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
@@ -1704,6 +1704,7 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro
slog.F("transition", transition))
if err := InsertWorkspaceResource(ctx, db, jobID, transition, resource, telemetrySnapshot); err != nil {
s.warnWorkspaceAppRebindRejected(ctx, jobID, err)
return xerrors.Errorf("insert resource: %w", err)
}
}
@@ -2122,6 +2123,7 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
InsertWorkspaceResourceWithAgentIDsFromProto(),
)
if err != nil {
s.warnWorkspaceAppRebindRejected(ctx, jobID, err)
return xerrors.Errorf("insert provisioner job: %w", err)
}
}
@@ -2590,6 +2592,7 @@ func (s *server) completeTemplateDryRunJob(ctx context.Context, job database.Pro
err := InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, telemetrySnapshot)
if err != nil {
s.warnWorkspaceAppRebindRejected(ctx, jobID, err)
return xerrors.Errorf("insert resource: %w", err)
}
}
@@ -3614,6 +3617,32 @@ func insertAgentScriptsAndLogSources(ctx context.Context, db database.Store, age
return nil
}
type workspaceAppRebindError struct {
slug string
appID uuid.UUID
agentID uuid.UUID
}
func (e *workspaceAppRebindError) Error() string {
return fmt.Sprintf("workspace app slug %q with ID %q is already bound to a workspace-owned agent and cannot be rebound to an agent in another workspace or to an agent without a workspace; refusing to rebind to agent ID %q", e.slug, e.appID, e.agentID)
}
func (s *server) warnWorkspaceAppRebindRejected(ctx context.Context, jobID uuid.UUID, err error) {
slog.Helper()
var rebindErr *workspaceAppRebindError
if !errors.As(err, &rebindErr) {
return
}
s.Logger.Warn(ctx, "workspace app rebind rejected by SQL guard",
slog.F("job_id", jobID.String()),
slog.F("app_id", rebindErr.appID.String()),
slog.F("agent_id", rebindErr.agentID.String()),
slog.F("app_slug", rebindErr.slug),
)
}
func insertAgentApp(ctx context.Context, db database.Store, agentID uuid.UUID, app *sdkproto.App, appSlugs map[string]struct{}, snapshot *telemetry.Snapshot) error {
// Similar logic is duplicated in terraform/resources.go.
slug := app.Slug
@@ -3702,6 +3731,17 @@ func insertAgentApp(ctx context.Context, db database.Store, agentID uuid.UUID, a
Tooltip: app.Tooltip,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// The upsert's ON CONFLICT guard refused to rebind an app
// owned by a workspace to an agent outside that workspace,
// including agents from import or dry-run jobs that resolve
// to no workspace (SEC-91).
return &workspaceAppRebindError{
slug: slug,
appID: id,
agentID: agentID,
}
}
return xerrors.Errorf("upsert app: %w", err)
}
@@ -26,6 +26,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"storj.io/drpc"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd"
@@ -2348,6 +2349,109 @@ func TestCompleteJob(t *testing.T) {
})
}
})
t.Run("WorkspaceBuild_CrossWorkspaceAppRebindRejected", func(t *testing.T) {
t.Parallel()
logSink := &recordingSlogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
srv, db, _, pd := setup(t, false, &overrides{provisionerdLogger: &logger})
// Given: a victim workspace whose agent owns an app with a known UUID.
victimAppID, victimAgentID, victimSlug := setupWorkspaceAppRebindVictim(
t, db, pd.OrganizationID,
)
// Given: an attacker workspace with a running build job acquired by the
// provisioner daemon.
attackerUser := dbgen.User(t, db, database.User{})
attackerTemplate := dbgen.Template(t, db, database.Template{
CreatedBy: attackerUser.ID,
OrganizationID: pd.OrganizationID,
})
attackerVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: attackerUser.ID,
OrganizationID: pd.OrganizationID,
TemplateID: uuid.NullUUID{UUID: attackerTemplate.ID, Valid: true},
JobID: uuid.New(),
})
attackerWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: attackerTemplate.ID,
OwnerID: attackerUser.ID,
OrganizationID: pd.OrganizationID,
})
attackerBuildID := uuid.New()
attackerJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
InitiatorID: attackerUser.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: attackerBuildID,
})),
OrganizationID: pd.OrganizationID,
})
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
ID: attackerBuildID,
JobID: attackerJob.ID,
WorkspaceID: attackerWorkspace.ID,
TemplateVersionID: attackerVersion.ID,
InitiatorID: attackerUser.ID,
Transition: database.WorkspaceTransitionStart,
Reason: database.BuildReasonInitiator,
})
_, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
OrganizationID: pd.OrganizationID,
WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true},
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
ProvisionerTags: must(json.Marshal(attackerJob.Tags)),
})
require.NoError(t, err)
// When: the attacker's build completes with an app that reuses the
// victim's app UUID but points at the attacker's (new) agent.
attackerAgent := &sdkproto.Agent{
Id: uuid.NewString(),
Name: "dev",
Auth: &sdkproto.Agent_Token{Token: uuid.NewString()},
Apps: []*sdkproto.App{{
Id: victimAppID.String(),
Slug: "attacker-app",
}},
}
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
JobId: attackerJob.ID.String(),
Type: &proto.CompletedJob_WorkspaceBuild_{
WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{
State: []byte{},
Resources: []*sdkproto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*sdkproto.Agent{attackerAgent},
}},
},
},
})
// Then: the build is rejected with the cross-tenant rebind error.
require.Error(t, err)
require.ErrorContains(t, err, "already bound to a workspace-owned agent")
assertWorkspaceAppRebindWarning(
t,
logSink,
workspaceAppRebindWarning{
jobID: attackerJob.ID,
appID: victimAppID,
slug: "attacker-app",
agentID: attackerAgent.Id,
},
)
// And: the victim's app remains bound to the victim agent, unchanged.
victimApps, err := db.GetWorkspaceAppsByAgentID(ctx, victimAgentID)
require.NoError(t, err)
require.Len(t, victimApps, 1)
require.Equal(t, victimAppID, victimApps[0].ID)
require.Equal(t, victimAgentID, victimApps[0].AgentID)
require.Equal(t, victimSlug, victimApps[0].Slug)
})
t.Run("TemplateDryRun", func(t *testing.T) {
t.Parallel()
srv, db, _, pd := setup(t, false, &overrides{})
@@ -2398,6 +2502,161 @@ func TestCompleteJob(t *testing.T) {
require.NoError(t, err)
})
t.Run("TemplateDryRun_CrossWorkspaceAppRebindRejected", func(t *testing.T) {
t.Parallel()
logSink := &recordingSlogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
srv, db, _, pd := setup(t, false, &overrides{provisionerdLogger: &logger})
victimAppID, victimAgentID, victimSlug := setupWorkspaceAppRebindVictim(
t, db, pd.OrganizationID,
)
user := dbgen.User(t, db, database.User{})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
OrganizationID: pd.OrganizationID,
JobID: uuid.New(),
})
job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
ID: version.JobID,
Provisioner: database.ProvisionerTypeEcho,
Type: database.ProvisionerJobTypeTemplateVersionDryRun,
StorageMethod: database.ProvisionerStorageMethodFile,
Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{
TemplateVersionID: version.ID,
})),
OrganizationID: pd.OrganizationID,
Tags: pd.Tags,
})
require.NoError(t, err)
_, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true},
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
StartedAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
OrganizationID: pd.OrganizationID,
ProvisionerTags: must(json.Marshal(job.Tags)),
})
require.NoError(t, err)
dryRunAgent := &sdkproto.Agent{
Name: "dev",
Auth: &sdkproto.Agent_Token{Token: uuid.NewString()},
Apps: []*sdkproto.App{{
Id: victimAppID.String(),
Slug: "dry-run-app",
}},
}
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
JobId: job.ID.String(),
Type: &proto.CompletedJob_TemplateDryRun_{
TemplateDryRun: &proto.CompletedJob_TemplateDryRun{
Resources: []*sdkproto.Resource{{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{dryRunAgent},
}},
},
},
})
require.Error(t, err)
require.ErrorContains(t, err, "already bound to a workspace-owned agent")
assertWorkspaceAppRebindWarning(
t,
logSink,
workspaceAppRebindWarning{
jobID: job.ID,
appID: victimAppID,
slug: "dry-run-app",
},
)
victimApps, err := db.GetWorkspaceAppsByAgentID(ctx, victimAgentID)
require.NoError(t, err)
require.Len(t, victimApps, 1)
require.Equal(t, victimAppID, victimApps[0].ID)
require.Equal(t, victimAgentID, victimApps[0].AgentID)
require.Equal(t, victimSlug, victimApps[0].Slug)
})
t.Run("TemplateImport_CrossWorkspaceAppRebindRejected", func(t *testing.T) {
t.Parallel()
logSink := &recordingSlogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
srv, db, _, pd := setup(t, false, &overrides{provisionerdLogger: &logger})
victimAppID, victimAgentID, victimSlug := setupWorkspaceAppRebindVictim(
t, db, pd.OrganizationID,
)
user := dbgen.User(t, db, database.User{})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
OrganizationID: pd.OrganizationID,
JobID: uuid.New(),
})
job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
ID: version.JobID,
Provisioner: database.ProvisionerTypeEcho,
Type: database.ProvisionerJobTypeTemplateVersionImport,
StorageMethod: database.ProvisionerStorageMethodFile,
Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{
TemplateVersionID: version.ID,
})),
OrganizationID: pd.OrganizationID,
Tags: pd.Tags,
})
require.NoError(t, err)
_, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true},
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
StartedAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
OrganizationID: pd.OrganizationID,
ProvisionerTags: must(json.Marshal(job.Tags)),
})
require.NoError(t, err)
importAgent := &sdkproto.Agent{
Name: "dev",
Auth: &sdkproto.Agent_Token{Token: uuid.NewString()},
Apps: []*sdkproto.App{{
Id: victimAppID.String(),
Slug: "import-app",
}},
}
_, err = srv.CompleteJob(ctx, &proto.CompletedJob{
JobId: job.ID.String(),
Type: &proto.CompletedJob_TemplateImport_{
TemplateImport: &proto.CompletedJob_TemplateImport{
StartResources: []*sdkproto.Resource{{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{importAgent},
}},
Plan: []byte("{}"),
},
},
})
require.Error(t, err)
require.ErrorContains(t, err, "already bound to a workspace-owned agent")
assertWorkspaceAppRebindWarning(
t,
logSink,
workspaceAppRebindWarning{
jobID: job.ID,
appID: victimAppID,
slug: "import-app",
},
)
victimApps, err := db.GetWorkspaceAppsByAgentID(ctx, victimAgentID)
require.NoError(t, err)
require.Len(t, victimApps, 1)
require.Equal(t, victimAppID, victimApps[0].ID)
require.Equal(t, victimAgentID, victimApps[0].AgentID)
require.Equal(t, victimSlug, victimApps[0].Slug)
})
t.Run("Modules", func(t *testing.T) {
t.Parallel()
@@ -3383,6 +3642,59 @@ func TestCompleteJob(t *testing.T) {
})
}
func setupWorkspaceAppRebindVictim(
t *testing.T,
db database.Store,
organizationID uuid.UUID,
) (appID uuid.UUID, agentID uuid.UUID, slug string) {
t.Helper()
victimUser := dbgen.User(t, db, database.User{})
victimTemplate := dbgen.Template(t, db, database.Template{
CreatedBy: victimUser.ID,
OrganizationID: organizationID,
})
victimVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: victimUser.ID,
OrganizationID: organizationID,
TemplateID: uuid.NullUUID{UUID: victimTemplate.ID, Valid: true},
})
victimWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: victimTemplate.ID,
OwnerID: victimUser.ID,
OrganizationID: organizationID,
})
victimJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeWorkspaceBuild,
OrganizationID: organizationID,
StartedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
CompletedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
})
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
JobID: victimJob.ID,
WorkspaceID: victimWorkspace.ID,
TemplateVersionID: victimVersion.ID,
InitiatorID: victimUser.ID,
Transition: database.WorkspaceTransitionStart,
Reason: database.BuildReasonInitiator,
})
victimResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: victimJob.ID,
})
victimAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: victimResource.ID,
})
victimAppID := uuid.New()
const victimSlug = "code-server"
dbgen.WorkspaceApp(t, db, database.WorkspaceApp{
ID: victimAppID,
AgentID: victimAgent.ID,
Slug: victimSlug,
})
return victimAppID, victimAgent.ID, victimSlug
}
type mockPrebuildsOrchestrator struct {
agplprebuilds.ReconciliationOrchestrator
@@ -4781,6 +5093,70 @@ func TestServer_ExpirePrebuildsSessionToken(t *testing.T) {
require.ErrorIs(t, err, sql.ErrNoRows, "api key for prebuilds user should be deleted")
}
type workspaceAppRebindWarning struct {
jobID uuid.UUID
appID uuid.UUID
slug string
agentID string
}
func assertWorkspaceAppRebindWarning(t *testing.T, logSink *recordingSlogSink, want workspaceAppRebindWarning) {
t.Helper()
for _, entry := range logSink.Entries() {
if entry.Message != "workspace app rebind rejected by SQL guard" {
continue
}
require.Equal(t, slog.LevelWarn, entry.Level)
require.Contains(t, entry.File, "coderd/provisionerdserver/provisionerdserver.go")
require.NotContains(t, entry.Func, "warnWorkspaceAppRebindRejected")
fields := slogFieldsByName(entry.Fields)
require.Equal(t, want.jobID.String(), fields["job_id"])
require.Equal(t, want.appID.String(), fields["app_id"])
require.Equal(t, want.slug, fields["app_slug"])
agentID, ok := fields["agent_id"].(string)
require.True(t, ok)
require.NotEqual(t, uuid.Nil.String(), agentID)
if want.agentID != "" {
require.Equal(t, want.agentID, agentID)
} else {
_, err := uuid.Parse(agentID)
require.NoError(t, err)
}
return
}
require.Fail(t, "expected workspace app rebind warning")
}
type recordingSlogSink struct {
mu sync.Mutex
entries []slog.SinkEntry
}
func (s *recordingSlogSink) LogEntry(_ context.Context, entry slog.SinkEntry) {
s.mu.Lock()
defer s.mu.Unlock()
s.entries = append(s.entries, entry)
}
func (*recordingSlogSink) Sync() {}
func (s *recordingSlogSink) Entries() []slog.SinkEntry {
s.mu.Lock()
defer s.mu.Unlock()
return append([]slog.SinkEntry(nil), s.entries...)
}
func slogFieldsByName(fields []slog.Field) map[string]any {
byName := make(map[string]any, len(fields))
for _, field := range fields {
byName[field.Name] = field.Value
}
return byName
}
type overrides struct {
ctx context.Context
deploymentValues *codersdk.DeploymentValues
@@ -4795,6 +5171,7 @@ type overrides struct {
auditor audit.Auditor
notificationEnqueuer notifications.Enqueuer
prebuildsOrchestrator agplprebuilds.ReconciliationOrchestrator
provisionerdLogger *slog.Logger
}
func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisionerDaemonServer, database.Store, pubsub.Pubsub, database.ProvisionerDaemon) {
@@ -4871,6 +5248,10 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi
} else {
notifEnq = notifications.NewNoopEnqueuer()
}
provisionerdLogger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors})
if ov.provisionerdLogger != nil {
provisionerdLogger = *ov.provisionerdLogger
}
daemon, err := db.UpsertProvisionerDaemon(ov.ctx, database.UpsertProvisionerDaemonParams{
Name: "test",
@@ -4902,7 +5283,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi
&url.URL{},
daemon.ID,
defOrg.ID,
slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}),
provisionerdLogger,
[]database.ProvisionerType{database.ProvisionerTypeEcho},
provisionerdserver.Tags(daemon.Tags),
serverDB,