@
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:
@@ -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 {
|
||||
|
||||
@@ -68,6 +68,33 @@ export const PromptSpec = z.object({
|
||||
});
|
||||
export type PromptSpec = z.infer<typeof PromptSpec>;
|
||||
|
||||
// Dependency specifiers come from untrusted LLM output and are merged into the
|
||||
// build's package.json, then `npm install`-ed inside `docker build` on the
|
||||
// SHARED host. Restrict to npm-registry semver ranges only: no `git+`, `http(s):`,
|
||||
// `file:` or tarball-URL specifiers (which fetch/checkout+run arbitrary code at
|
||||
// install time), and valid (optionally-scoped) npm package names. Combined with
|
||||
// `--ignore-scripts` in the runner Dockerfile this closes the build-time RCE. (GEN-001)
|
||||
const DepName = z
|
||||
.string()
|
||||
.max(214)
|
||||
.regex(/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i, 'invalid npm package name');
|
||||
const DepRange = z
|
||||
.string()
|
||||
.max(64)
|
||||
.regex(/^([\^~]?\d+(\.\d+){0,2}(-[\w.]+)?|\*|latest)$/, 'must be a plain semver range');
|
||||
export const DependencyMap = z.record(DepName, DepRange);
|
||||
|
||||
// Secret KEYS become `-e KEY=VALUE` docker run args and the runtime env of the
|
||||
// tenant container. Constrain to UPPER_SNAKE_CASE (matches requiredSecrets) and
|
||||
// reject names that could hijack the Node runtime/loader if the container
|
||||
// hardening ever regressed. Values stay free-form. (GEN-003)
|
||||
const RESERVED_ENV = new Set(['PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_ENV']);
|
||||
const SecretKey = z
|
||||
.string()
|
||||
.regex(/^[A-Z][A-Z0-9_]*$/, 'UPPER_SNAKE_CASE env var name required')
|
||||
.refine((k) => !RESERVED_ENV.has(k) && !k.startsWith('NODE_'), 'reserved env var name');
|
||||
export const SecretMap = z.record(SecretKey, z.string());
|
||||
|
||||
export const GeneratorSpec = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().max(2000).optional(),
|
||||
@@ -76,7 +103,7 @@ export const GeneratorSpec = z.object({
|
||||
prompts: z.array(PromptSpec).max(50).default([]),
|
||||
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
|
||||
scopes: z.array(z.string()).max(50).default([]),
|
||||
dependencies: z.record(z.string(), z.string()).default({}),
|
||||
dependencies: DependencyMap.default({}),
|
||||
});
|
||||
export type GeneratorSpec = z.infer<typeof GeneratorSpec>;
|
||||
|
||||
@@ -136,7 +163,7 @@ export const CreateServerInput = z.object({
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
|
||||
prompt: z.string().min(10).max(8000),
|
||||
secrets: z.record(z.string(), z.string()).default({}),
|
||||
secrets: SecretMap.default({}),
|
||||
previewId: z.string().min(1).max(64).optional(),
|
||||
specEdit: SpecEdit.optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
@@ -169,7 +196,7 @@ export type PreviewResult = z.infer<typeof PreviewResult>;
|
||||
|
||||
export const IterateServerInput = z.object({
|
||||
prompt: z.string().min(10).max(8000),
|
||||
secrets: z.record(z.string(), z.string()).default({}),
|
||||
secrets: SecretMap.default({}),
|
||||
});
|
||||
export type IterateServerInput = z.infer<typeof IterateServerInput>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user