fix(coderd): prevent cross-tenant workspace app rebinding (#26103)

This commit is contained in:
dylanhuff-at-coder
2026-06-11 15:02:41 -04:00
committed by GitHub
parent 112c921235
commit f3c7c23623
7 changed files with 751 additions and 2 deletions
+172
View File
@@ -7558,6 +7558,178 @@ func TestWorkspaceAgentNameUniqueTrigger(t *testing.T) {
})
}
func TestUpsertWorkspaceAppCannotRebindAcrossWorkspaces(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
ctx := testutil.Context(t, testutil.WaitShort)
// createWorkspace builds the owner -> template -> version -> workspace chain
// and returns the workspace plus its template version so callers can create
// additional builds (and thus agents) within the same workspace.
createWorkspace := func(t *testing.T) (database.WorkspaceTable, uuid.UUID) {
t.Helper()
user := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID},
OrganizationID: org.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
TemplateID: template.ID,
OwnerID: user.ID,
})
return workspace, version.ID
}
// addAgent creates a build, resource, and agent for the workspace. The
// build's JobID matches the resource's JobID so the upsert's
// agent -> resource -> workspace_builds(job_id) -> workspace_id traversal
// resolves to the workspace.
addAgent := func(t *testing.T, workspace database.WorkspaceTable, versionID uuid.UUID, buildNumber int32) database.WorkspaceAgent {
t.Helper()
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeWorkspaceBuild,
OrganizationID: org.ID,
})
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
BuildNumber: buildNumber,
JobID: job.ID,
WorkspaceID: workspace.ID,
TemplateVersionID: versionID,
})
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: job.ID,
})
return dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: resource.ID,
})
}
upsertApp := func(appID, agentID uuid.UUID, slug string) (database.WorkspaceApp, error) {
return db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: appID,
CreatedAt: dbtime.Now(),
AgentID: agentID,
Slug: slug,
DisplayName: "Code Server",
Icon: "/icon.png",
SharingLevel: database.AppSharingLevelOwner,
Health: database.WorkspaceAppHealthDisabled,
OpenIn: database.WorkspaceAppOpenInSlimWindow,
})
}
// Given: two independent workspaces, each with an agent that resolves to its
// own workspace.
workspaceA, versionA := createWorkspace(t)
workspaceB, versionB := createWorkspace(t)
agentA := addAgent(t, workspaceA, versionA, 1)
agentB := addAgent(t, workspaceB, versionB, 1)
gotA, err := db.GetWorkspaceByAgentID(ctx, agentA.ID)
require.NoError(t, err)
require.Equal(t, workspaceA.ID, gotA.ID)
gotB, err := db.GetWorkspaceByAgentID(ctx, agentB.ID)
require.NoError(t, err)
require.Equal(t, workspaceB.ID, gotB.ID)
appID := uuid.New()
const originalSlug = "code-server"
// Initial insert under workspace A's agent succeeds (no conflict).
app, err := upsertApp(appID, agentA.ID, originalSlug)
require.NoError(t, err)
require.Equal(t, appID, app.ID)
require.Equal(t, agentA.ID, app.AgentID)
require.Equal(t, originalSlug, app.Slug)
// Upserting the same app id onto workspace B's agent is rejected because the
// existing row and the incoming agent resolve to different workspaces. The
// guard updates zero rows, so the :one query returns sql.ErrNoRows.
_, err = upsertApp(appID, agentB.ID, "hijacked")
require.ErrorIs(t, err, sql.ErrNoRows)
// The app remains bound to workspace A's agent, unchanged.
appsA, err := db.GetWorkspaceAppsByAgentID(ctx, agentA.ID)
require.NoError(t, err)
require.Len(t, appsA, 1)
require.Equal(t, appID, appsA[0].ID)
require.Equal(t, agentA.ID, appsA[0].AgentID)
require.Equal(t, originalSlug, appsA[0].Slug)
// Workspace B's agent has no app.
appsB, err := db.GetWorkspaceAppsByAgentID(ctx, agentB.ID)
require.NoError(t, err)
require.Empty(t, appsB)
// A legitimate rebuild of workspace A produces a new agent (agent IDs are
// regenerated every build). Rebinding the persistent app to it succeeds
// because both agents resolve to workspace A.
agentA2 := addAgent(t, workspaceA, versionA, 2)
app, err = upsertApp(appID, agentA2.ID, "code-server-v2")
require.NoError(t, err)
require.Equal(t, agentA2.ID, app.AgentID)
require.Equal(t, "code-server-v2", app.Slug)
appsA2, err := db.GetWorkspaceAppsByAgentID(ctx, agentA2.ID)
require.NoError(t, err)
require.Len(t, appsA2, 1)
require.Equal(t, appID, appsA2[0].ID)
// Set up a template-import agent. It is intentionally not associated with
// a workspace build, so it resolves to no workspace.
importJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeTemplateVersionImport,
OrganizationID: org.ID,
})
importResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: importJob.ID,
})
importAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: importResource.ID,
})
_, err = db.GetWorkspaceByAgentID(ctx, importAgent.ID)
require.ErrorIs(t, err, sql.ErrNoRows, "import agent must not resolve to a workspace")
// An app that already belongs to a workspace cannot be rebound to a
// template-import agent. Otherwise a second update could move it from
// the import agent to a different workspace.
_, err = upsertApp(appID, importAgent.ID, "hijacked-by-import")
require.ErrorIs(t, err, sql.ErrNoRows)
appsA2, err = db.GetWorkspaceAppsByAgentID(ctx, agentA2.ID)
require.NoError(t, err)
require.Len(t, appsA2, 1)
require.Equal(t, appID, appsA2[0].ID)
require.Equal(t, agentA2.ID, appsA2[0].AgentID)
require.Equal(t, "code-server-v2", appsA2[0].Slug)
appsImport, err := db.GetWorkspaceAppsByAgentID(ctx, importAgent.ID)
require.NoError(t, err)
require.Empty(t, appsImport)
_, err = upsertApp(appID, agentB.ID, "hijacked-after-import")
require.ErrorIs(t, err, sql.ErrNoRows)
unownedAppID := uuid.New()
_, err = upsertApp(unownedAppID, importAgent.ID, "import-app")
require.NoError(t, err)
// An app whose existing agent belongs to a template-import job resolves to
// no workspace, so rebinding it is permitted. It is not a cross-tenant
// victim.
rebound, err := upsertApp(unownedAppID, agentA.ID, "import-app")
require.NoError(t, err)
require.Equal(t, agentA.ID, rebound.AgentID)
}
func TestGetWorkspaceAgentsByParentID(t *testing.T) {
t.Parallel()
+36
View File
@@ -33272,6 +33272,42 @@ ON CONFLICT (id) DO UPDATE SET
agent_id = EXCLUDED.agent_id,
slug = EXCLUDED.slug,
tooltip = EXCLUDED.tooltip
WHERE
-- Prevent cross-tenant/cross-workspace agent rebinding (SEC-91).
-- App IDs persist across builds of the same workspace, but agent IDs are
-- regenerated every build, so compare by the workspace that owns the agent
-- rather than by agent_id. Permit unowned apps to be claimed and permit
-- same-workspace rebuilds. If an existing app belongs to a workspace, block
-- moves to both different workspaces and template import or dry-run agents
-- that resolve to no workspace. The conflicting row is then left untouched,
-- and the :one query returns no row, which the caller treats as a
-- rejection.
NOT EXISTS (
SELECT 1
FROM workspace_agents AS existing_agent
INNER JOIN workspace_resources AS existing_resource
ON existing_agent.resource_id = existing_resource.id
INNER JOIN workspace_builds AS existing_build
ON existing_resource.job_id = existing_build.job_id
WHERE existing_agent.id = workspace_apps.agent_id
)
OR EXISTS (
SELECT 1
FROM workspace_agents AS existing_agent
INNER JOIN workspace_resources AS existing_resource
ON existing_agent.resource_id = existing_resource.id
INNER JOIN workspace_builds AS existing_build
ON existing_resource.job_id = existing_build.job_id
INNER JOIN workspace_agents AS incoming_agent
ON incoming_agent.id = EXCLUDED.agent_id
INNER JOIN workspace_resources AS incoming_resource
ON incoming_agent.resource_id = incoming_resource.id
INNER JOIN workspace_builds AS incoming_build
ON incoming_resource.job_id = incoming_build.job_id
WHERE
existing_agent.id = workspace_apps.agent_id
AND existing_build.workspace_id = incoming_build.workspace_id
)
RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group, tooltip
`
+36
View File
@@ -55,6 +55,42 @@ ON CONFLICT (id) DO UPDATE SET
agent_id = EXCLUDED.agent_id,
slug = EXCLUDED.slug,
tooltip = EXCLUDED.tooltip
WHERE
-- Prevent cross-tenant/cross-workspace agent rebinding (SEC-91).
-- App IDs persist across builds of the same workspace, but agent IDs are
-- regenerated every build, so compare by the workspace that owns the agent
-- rather than by agent_id. Permit unowned apps to be claimed and permit
-- same-workspace rebuilds. If an existing app belongs to a workspace, block
-- moves to both different workspaces and template import or dry-run agents
-- that resolve to no workspace. The conflicting row is then left untouched,
-- and the :one query returns no row, which the caller treats as a
-- rejection.
NOT EXISTS (
SELECT 1
FROM workspace_agents AS existing_agent
INNER JOIN workspace_resources AS existing_resource
ON existing_agent.resource_id = existing_resource.id
INNER JOIN workspace_builds AS existing_build
ON existing_resource.job_id = existing_build.job_id
WHERE existing_agent.id = workspace_apps.agent_id
)
OR EXISTS (
SELECT 1
FROM workspace_agents AS existing_agent
INNER JOIN workspace_resources AS existing_resource
ON existing_agent.resource_id = existing_resource.id
INNER JOIN workspace_builds AS existing_build
ON existing_resource.job_id = existing_build.job_id
INNER JOIN workspace_agents AS incoming_agent
ON incoming_agent.id = EXCLUDED.agent_id
INNER JOIN workspace_resources AS incoming_resource
ON incoming_agent.resource_id = incoming_resource.id
INNER JOIN workspace_builds AS incoming_build
ON incoming_resource.job_id = incoming_build.job_id
WHERE
existing_agent.id = workspace_apps.agent_id
AND existing_build.workspace_id = incoming_build.workspace_id
)
RETURNING *;
-- name: UpdateWorkspaceAppHealthByID :exec