Skip to content

Commit b8c44b3

Browse files
authored
fix(gui): expire single-use GUI intent tokens after 30s (#54612)
### What does this PR do? Gives the single-use GUI "intent token" (used by `agent launch-gui` and the systray Configure action to bootstrap a GUI session) a 30 second TTL instead of letting it stay valid indefinitely until first use. ### Motivation Security finding [VULN-92705](https://datadoghq.atlassian.net/browse/VULN-92705): `launch-gui` (and the Windows systray Configure path) pass the intent token to the OS URL-opener (`xdg-open`/`open`/`rundll32`) as part of a URL query string. That URL lands in the opener's argv, and via the browser process it spawns can remain visible there for a much longer time — readable by any co-resident local user via `/proc/<pid>/cmdline` on hosts where `hidepid` isn't set. `/auth?intent=` performs no caller-identity check, so whoever presents a valid intent token first is granted the (by default never-expiring) `accessToken` session cookie. Intent tokens previously had no expiration and remained valid in memory until first use, leaving an unbounded window for this argv-leak race. This change bounds that window to 30 seconds, which is generous for normal browser-launch latency while meaningfully limiting the exploitable exposure. (The token remains single-use regardless of outcome — an expired token is still consumed on presentation, closing any residual re-use race.) ### Describe how you validated your changes - Added unit tests in `comp/core/gui/impl/gui_test.go` covering: valid unexpired token grants access, expired token is rejected *and* consumed, a token cannot be redeemed twice, and expired entries are purged from the map when a new token is issued. - `dda inv test --targets=./comp/core/gui/impl/...` and `bazel test //comp/core/gui/impl:impl_test` pass. - `dda inv linter.go --targets=./comp/core/gui/impl/...` passes. - Built the agent locally (`dda inv agent.build`) to confirm `launch-gui` still compiles end-to-end. ### Additional Notes No release note: this is an internal hardening change with no user-facing behavior change (the GUI login flow is unaffected under normal use; only the far edge case of a stale, un-redeemed launch attempt now expires after 30s instead of forever). [VULN-92705]: https://datadoghq.atlassian.net/browse/VULN-92705?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: louis.coquerelle <louis.coquerelle@datadoghq.com>
1 parent 2ffb147 commit b8c44b3

3 files changed

Lines changed: 114 additions & 9 deletions

File tree

comp/core/gui/impl/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ dd_agent_go_test(
7171
"agent_test.go",
7272
"auth_test.go",
7373
"checks_test.go",
74+
"gui_test.go",
7475
"platform_darwin_test.go",
7576
"platform_nix_test.go",
7677
"platform_windows_test.go",

comp/core/gui/impl/gui.go

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ import (
4141
"github.com/DataDog/datadog-agent/pkg/util/system"
4242
)
4343

44+
// intentTokenTTL bounds how long a single-use intent token stays valid. Intent
45+
// tokens are handed to the OS URL-opener as part of a query string and can end
46+
// up exposed in a child process's argv (e.g. /proc/<pid>/cmdline); a short TTL
47+
// limits how long that exposure is exploitable.
48+
const intentTokenTTL = 30 * time.Second
49+
4450
type gui struct {
4551
logger log.Component
4652

@@ -49,7 +55,7 @@ type gui struct {
4955
router *http.ServeMux
5056

5157
auth authenticator
52-
intentTokens map[string]bool
58+
intentTokens map[string]time.Time // token -> expiration time
5359
intentMu sync.Mutex
5460

5561
sysprobeConfig sysprobeconfig.Component
@@ -112,7 +118,7 @@ func NewComponent(deps Requires) Provides {
112118
g := gui{
113119
address: net.JoinHostPort(guiHost, guiPort),
114120
logger: deps.Log,
115-
intentTokens: make(map[string]bool),
121+
intentTokens: make(map[string]time.Time),
116122
sysprobeConfig: deps.SysprobeConfig,
117123
}
118124

@@ -195,10 +201,21 @@ func (g *gui) getIntentToken(w http.ResponseWriter, _ *http.Request) {
195201
token := base64.RawURLEncoding.EncodeToString(key)
196202
g.intentMu.Lock()
197203
defer g.intentMu.Unlock()
198-
g.intentTokens[token] = true
204+
g.purgeExpiredIntentTokensLocked()
205+
g.intentTokens[token] = time.Now().Add(intentTokenTTL)
199206
w.Write([]byte(token))
200207
}
201208

209+
// purgeExpiredIntentTokensLocked removes expired intent tokens. Callers must hold intentMu.
210+
func (g *gui) purgeExpiredIntentTokensLocked() {
211+
now := time.Now()
212+
for token, expiresAt := range g.intentTokens {
213+
if now.After(expiresAt) {
214+
delete(g.intentTokens, token)
215+
}
216+
}
217+
}
218+
202219
func (g *gui) renderIndexPage(w http.ResponseWriter, _ *http.Request) {
203220
data, err := templatesFS.ReadFile("views/templates/index.tmpl")
204221
if err != nil {
@@ -270,15 +287,14 @@ func (g *gui) getAccessToken(w http.ResponseWriter, r *http.Request) {
270287
return
271288
}
272289
g.intentMu.Lock()
273-
_, ok := g.intentTokens[intentToken]
274-
if !ok {
275-
g.intentMu.Unlock()
290+
expiresAt, ok := g.intentTokens[intentToken]
291+
// Remove single use token from map (atomic with validation), whether or not it's expired
292+
delete(g.intentTokens, intentToken)
293+
g.intentMu.Unlock()
294+
if !ok || time.Now().After(expiresAt) {
276295
http.Error(w, "invalid intentToken", http.StatusUnauthorized)
277296
return
278297
}
279-
// Remove single use token from map (atomic with validation)
280-
delete(g.intentTokens, intentToken)
281-
g.intentMu.Unlock()
282298

283299
// generate accessToken
284300
accessToken := g.auth.GenerateAccessToken()

comp/core/gui/impl/gui_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2026-present Datadog, Inc.
5+
6+
package guiimpl
7+
8+
import (
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func Test_getAccessToken_intentTokenExpiry(t *testing.T) {
19+
g := &gui{
20+
auth: newAuthenticator("test-auth-token", time.Hour),
21+
intentTokens: make(map[string]time.Time),
22+
}
23+
24+
t.Run("valid unexpired token grants access", func(t *testing.T) {
25+
g.intentMu.Lock()
26+
g.intentTokens["valid"] = time.Now().Add(time.Minute)
27+
g.intentMu.Unlock()
28+
29+
req := httptest.NewRequest(http.MethodGet, "/auth?intent=valid", nil)
30+
rr := httptest.NewRecorder()
31+
g.getAccessToken(rr, req)
32+
33+
assert.Equal(t, http.StatusFound, rr.Code)
34+
})
35+
36+
t.Run("expired token is rejected and consumed", func(t *testing.T) {
37+
g.intentMu.Lock()
38+
g.intentTokens["expired"] = time.Now().Add(-time.Second)
39+
g.intentMu.Unlock()
40+
41+
req := httptest.NewRequest(http.MethodGet, "/auth?intent=expired", nil)
42+
rr := httptest.NewRecorder()
43+
g.getAccessToken(rr, req)
44+
45+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
46+
47+
g.intentMu.Lock()
48+
_, stillPresent := g.intentTokens["expired"]
49+
g.intentMu.Unlock()
50+
assert.False(t, stillPresent, "an expired token must still be consumed on use, closing any reuse race")
51+
})
52+
53+
t.Run("token cannot be redeemed twice", func(t *testing.T) {
54+
g.intentMu.Lock()
55+
g.intentTokens["single-use"] = time.Now().Add(time.Minute)
56+
g.intentMu.Unlock()
57+
58+
g.getAccessToken(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/auth?intent=single-use", nil))
59+
60+
rr := httptest.NewRecorder()
61+
g.getAccessToken(rr, httptest.NewRequest(http.MethodGet, "/auth?intent=single-use", nil))
62+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
63+
})
64+
}
65+
66+
func Test_getIntentToken_setsExpiryAndPurgesStale(t *testing.T) {
67+
g := &gui{
68+
intentTokens: make(map[string]time.Time),
69+
}
70+
g.intentTokens["stale"] = time.Now().Add(-time.Minute)
71+
72+
rr := httptest.NewRecorder()
73+
g.getIntentToken(rr, httptest.NewRequest(http.MethodGet, "/gui/intent", nil))
74+
75+
require.Equal(t, http.StatusOK, rr.Code)
76+
token := rr.Body.String()
77+
require.NotEmpty(t, token)
78+
79+
g.intentMu.Lock()
80+
defer g.intentMu.Unlock()
81+
82+
_, staleStillPresent := g.intentTokens["stale"]
83+
assert.False(t, staleStillPresent, "expired intent tokens should be purged whenever a new one is issued")
84+
85+
expiresAt, ok := g.intentTokens[token]
86+
require.True(t, ok)
87+
assert.WithinDuration(t, time.Now().Add(intentTokenTTL), expiresAt, 2*time.Second)
88+
}

0 commit comments

Comments
 (0)