mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(coderd): prevent cross-tenant workspace app rebinding (#26103)
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Generated
+36
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user