fix(security): sovereign-audit hardening pass — RCE, multi-tenant, reliability

Reasoning-based audit fixes (all verified by typecheck, attack paths re-traced):

- build-time RCE: validate spec.dependencies to npm-registry semver only
  (no git/url/file specifiers) + --ignore-scripts in runner Dockerfile.
- container hardening fail-CLOSED: harden unless RUNNER_DISABLE_HARDENING=1,
  no longer gated on a fragile NODE_ENV string compare.
- secret env keys validated (UPPER_SNAKE, reject NODE_*/PATH/LD_*).
- cross-org image-tag collision: qualify tag with serverId.
- /iterate now enforces suspension + daily-build limits like /servers.
- preview SSE: clear keepalive in finally + on client close (timer/FD leak).
- SMS OTP: atomic attempt counter (lt(attempts,MAX) in UPDATE) — brute-force race.
- getSession orders membership by createdAt (deterministic primary org).
- template scopes aggregated from real tool scopes (was hardcoded mcp:read).
- template category filter pushed into WHERE (was applied after LIMIT).
- support admin reply/status: 404 on unknown ticket; status change now audited.
- build worker: queue defaultJobOptions, docker build/run/stop timeouts,
  old-container teardown in finally (no orphan on post-deploy DB failure).
- nginx: HSTS, X-Frame-Options DENY, nosniff, Referrer-Policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Marco Sadjadi
2026-05-29 20:56:30 +02:00
parent 092290bb38
commit 9d5386ccba
12 changed files with 338 additions and 136 deletions

View File

@@ -7,6 +7,8 @@ import {
eq,
gt,
isNull,
lt,
sql,
magicLinks,
memberships,
organizations,
@@ -272,12 +274,18 @@ export async function consumeSmsCode(
.orderBy(desc(smsCodes.createdAt))
.limit(1);
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts');
// Atomically claim one guess attempt. The increment is gated on
// `attempts < MAX` inside the same UPDATE, so the DB row-lock serialises
// concurrent verifies and at most MAX increments ever succeed for a code.
// The previous read-then-write (`row.attempts + 1`) let N parallel requests
// all pass a stale `attempts` read and brute-force the 6-digit code. (AUTH-001)
const slot = await db
.update(smsCodes)
.set({ attempts: sql`${smsCodes.attempts} + 1` })
.where(and(eq(smsCodes.id, row.id), lt(smsCodes.attempts, SMS_MAX_ATTEMPTS)))
.returning({ attempts: smsCodes.attempts });
if (slot.length === 0) throw new Error('too_many_attempts');
if (sha256(`${phone}:${code}`) !== row.codeHash) {
await db
.update(smsCodes)
.set({ attempts: row.attempts + 1 })
.where(eq(smsCodes.id, row.id));
throw new Error('invalid_code');
}
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));
@@ -344,10 +352,16 @@ export async function getSession(
.where(eq(sessions.tokenHash, hash))
.limit(1);
if (!row || row.expiresAt < new Date()) return null;
// Deterministic primary-org selection — must match the login flows
// (consumeMagicLink / loginWithPassword order by oldest membership). Without
// this orderBy, a user with >1 membership (once org-invites land) would get a
// nondeterministic org per request, silently scoping their reads/writes to a
// different tenant than they logged in as. (SRV-001)
const [membership] = await db
.select({ orgId: memberships.orgId, role: memberships.role })
.from(memberships)
.where(eq(memberships.userId, row.userId))
.orderBy(memberships.createdAt)
.limit(1);
if (!membership) return null;
return {