fix(store): scope SetDomainStatus by project (IDOR); scheduler reuses DeriveStatus
handleCheck's error branch wrote last_check_status via an id-only UPDATE, so an authenticated caller's own valid project id paired with a foreign domain id in the URL could flip a stranger's domain to "error" even though Check itself is project-scoped and would 404/error out first. Add project_id to the WHERE clause (queries/domains.sql + generated db/domains.sql.go), thread projectID through Store/TenantStore/SchedStore SetDomainStatus, and pass pid from context at both call sites in handleCheck plus the scheduler. Also collapse checkDomain's inline status derivation in scheduler.go into a call to service.DeriveStatus, the same helper handleCheck already uses, so there's a single source of truth for "drift vs in_sync" instead of two copies that could drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -218,16 +218,17 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams)
|
||||
}
|
||||
|
||||
const setDomainStatus = `-- name: SetDomainStatus :exec
|
||||
UPDATE domains SET last_check_status = $2 WHERE id = $1
|
||||
UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3
|
||||
`
|
||||
|
||||
type SetDomainStatusParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LastCheckStatus string `json:"last_check_status"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDomainStatus(ctx context.Context, arg SetDomainStatusParams) error {
|
||||
_, err := q.db.Exec(ctx, setDomainStatus, arg.ID, arg.LastCheckStatus)
|
||||
_, err := q.db.Exec(ctx, setDomainStatus, arg.ID, arg.LastCheckStatus, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ WHERE d.id = $1 AND d.project_id = $2;
|
||||
SELECT last_check_status FROM domains WHERE id = $1;
|
||||
|
||||
-- name: SetDomainStatus :exec
|
||||
UPDATE domains SET last_check_status = $2 WHERE id = $1;
|
||||
UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3;
|
||||
|
||||
-- name: CountDriftDomains :one
|
||||
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
||||
|
||||
@@ -246,7 +246,7 @@ func TestDomainStatus_RoundTrip(t *testing.T) {
|
||||
t.Fatalf("expected default status 'unknown', got %q", status)
|
||||
}
|
||||
|
||||
if err := s.SetDomainStatus(ctx, d.ID, "ok"); err != nil {
|
||||
if err := s.SetDomainStatus(ctx, d.ID, p.ID, "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = s.GetDomainStatus(ctx, d.ID)
|
||||
@@ -265,3 +265,56 @@ func TestDomainStatus_RoundTrip(t *testing.T) {
|
||||
t.Fatalf("expected ListDomains to reflect updated status: %+v", domains)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDomainStatus_ScopedByProject_ForeignProjectIsNoOp covers the IDOR
|
||||
// fix: SetDomainStatus is called with a valid domain ID but a projectID that
|
||||
// does NOT own it (e.g. an authenticated caller's own pid, paired with
|
||||
// another tenant's did in the URL). The WHERE id = $1 AND project_id = $3
|
||||
// clause must match zero rows — no error, but the foreign domain's status
|
||||
// must remain untouched, never "error"/"drift"/whatever was passed in.
|
||||
func TestSetDomainStatus_ScopedByProject_ForeignProjectIsNoOp(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
_, owner, err := s.RegisterUser(ctx, "domain-status-owner@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, attacker, err := s.RegisterUser(ctx, "domain-status-attacker@example.com", "argon2-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acc, err := s.CreateAccount(ctx, owner.ID, "selectel", "enc-blob", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := s.CreateDomain(ctx, owner.ID, acc.ID, "example.com", "zone-1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// attacker's own (valid) project ID, paired with owner's domain ID —
|
||||
// mirrors the exact request shape an authenticated attacker could send.
|
||||
if err := s.SetDomainStatus(ctx, d.ID, attacker.ID, "error"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := s.GetDomainStatus(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "unknown" {
|
||||
t.Fatalf("expected foreign-project SetDomainStatus to be a no-op, but status changed to %q", status)
|
||||
}
|
||||
|
||||
// The legitimate owner can still update it — proves the no-op above was
|
||||
// due to project scoping, not some unrelated write failure.
|
||||
if err := s.SetDomainStatus(ctx, d.ID, owner.ID, "error"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = s.GetDomainStatus(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "error" {
|
||||
t.Fatalf("expected owner's SetDomainStatus to apply, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +253,13 @@ func (s *Store) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string
|
||||
}
|
||||
|
||||
// SetDomainStatus records the outcome of the most recent check/apply run for
|
||||
// a domain (e.g. "ok", "drift", "error").
|
||||
func (s *Store) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error {
|
||||
return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status})
|
||||
// a domain (e.g. "ok", "drift", "error"). Scoped by projectID — a domain ID
|
||||
// belonging to another tenant's project is left untouched (matches zero
|
||||
// rows) rather than being overwritten, closing an IDOR-on-write where a
|
||||
// caller's own valid pid + a foreign did could otherwise flip a stranger's
|
||||
// domain status.
|
||||
func (s *Store) SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error {
|
||||
return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status, ProjectID: projectID})
|
||||
}
|
||||
|
||||
// CountDriftDomains returns the current number of domains system-wide whose
|
||||
|
||||
Reference in New Issue
Block a user