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

@ -1,5 +1,8 @@
# ---- Core ---- # ---- Core ----
NODE_ENV=development NODE_ENV=development
# Local dev only: skip runner container hardening (--read-only etc. break on
# Windows Docker Desktop). NEVER set this in .env.production. (GEN-002)
RUNNER_DISABLE_HARDENING=1
# ---- Database ---- # ---- Database ----
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm

View File

@ -17,7 +17,14 @@ let queue: Queue<BuildJobData> | null = null;
export function getBuildQueue(): Queue<BuildJobData> { export function getBuildQueue(): Queue<BuildJobData> {
if (!queue) { if (!queue) {
queue = new Queue<BuildJobData>('build', { connection: getRedis() }); queue = new Queue<BuildJobData>('build', {
connection: getRedis(),
// Explicit job lifecycle. attempts:1 because a build is non-idempotent
// (allocates a host port, runs a container, spends an LLM call) — a blind
// BullMQ retry would double-spend; users re-run via /iterate instead.
// removeOnComplete/Fail caps Redis growth. (GEN-007)
defaultJobOptions: { attempts: 1, removeOnComplete: 100, removeOnFail: 500 },
});
} }
return queue; return queue;
} }

View File

@ -219,7 +219,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) { if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
return reply.code(409).send({ return reply.code(409).send({
error: 'streaming_unavailable', error: 'streaming_unavailable',
detail: 'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.', detail:
'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
}); });
} }
@ -254,7 +255,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// open as long as bytes flow; comments are SSE-noop but count as bytes. // open as long as bytes flow; comments are SSE-noop but count as bytes.
const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000); const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
const abort = new AbortController(); const abort = new AbortController();
req.raw.on('close', () => abort.abort()); req.raw.on('close', () => {
abort.abort();
clearInterval(keepalive);
});
// `resolved` is set inside the awaited handlers below — by the time // `resolved` is set inside the awaited handlers below — by the time
// streamSpecFromAnthropic returns, exactly one of onSpec/onError will // streamSpecFromAnthropic returns, exactly one of onSpec/onError will
@ -263,95 +267,105 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// ended without either handler running (which would be a programming // ended without either handler running (which would be a programming
// bug, not a runtime path). // bug, not a runtime path).
let resolved = false; let resolved = false;
await streamSpecFromAnthropic( try {
parsed.data.prompt, await streamSpecFromAnthropic(
{ parsed.data.prompt,
apiKey: config.ANTHROPIC_API_KEY, {
model: choice.model, apiKey: config.ANTHROPIC_API_KEY,
maxTokens: choice.maxTokens, model: choice.model,
signal: abort.signal, maxTokens: choice.maxTokens,
}, signal: abort.signal,
{ },
onText: (delta) => send('text', delta), {
onSpec: async ({ spec, source }) => { onText: (delta) => send('text', delta),
const previewId = await cacheSpec(spec); onSpec: async ({ spec, source }) => {
send('spec', { const previewId = await cacheSpec(spec);
previewId, send('spec', {
source,
plan,
modelDisplayName: choice.displayName,
modelBadge: choice.displayBadge,
upgradeHint: plan === 'hobby',
spec: {
name: spec.name,
description: spec.description,
tools: spec.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
requiredSecrets: spec.requiredSecrets,
scopes: spec.scopes,
},
});
app.log.info(
{
previewId, previewId,
tools: spec.tools.length, source,
prompt: parsed.data.prompt.slice(0, 200), plan,
model: choice.displayName, modelDisplayName: choice.displayName,
}, modelBadge: choice.displayBadge,
'preview_spec_ready', upgradeHint: plan === 'hobby',
); spec: {
resolved = true; name: spec.name,
}, description: spec.description,
onError: (err) => { tools: spec.tools.map((t) => ({
if (err instanceof SpecTruncatedError) { name: t.name,
app.log.warn( description: t.description,
inputSchema: t.inputSchema,
})),
requiredSecrets: spec.requiredSecrets,
scopes: spec.scopes,
},
});
app.log.info(
{ {
reason: err.message, previewId,
tools: spec.tools.length,
prompt: parsed.data.prompt.slice(0, 200), prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName, model: choice.displayName,
}, },
'preview_spec_truncated', 'preview_spec_ready',
); );
send('error', { resolved = true;
error: 'spec_too_large', },
detail: onError: (err) => {
'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.', if (err instanceof SpecTruncatedError) {
}); app.log.warn(
} else if (err instanceof SpecValidationError) { {
app.log.warn( reason: err.message,
{ prompt: parsed.data.prompt.slice(0, 200),
zod_message: err.message, model: choice.displayName,
prompt: parsed.data.prompt.slice(0, 200), },
model: choice.displayName, 'preview_spec_truncated',
}, );
'preview_spec_invalid', send('error', {
); error: 'spec_too_large',
send('error', { error: 'spec_invalid', detail: err.message }); detail:
} else if (err instanceof BannedPatternError) { 'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.',
send('error', { error: 'banned_pattern', detail: err.message }); });
} else if (err instanceof SpecTimeoutError) { } else if (err instanceof SpecValidationError) {
send('error', { app.log.warn(
error: 'preview_timeout', {
detail: 'Spec generation took too long. Try a shorter, more specific prompt.', zod_message: err.message,
}); prompt: parsed.data.prompt.slice(0, 200),
} else { model: choice.displayName,
app.log.error(err); },
send('error', { error: 'preview_failed', detail: err.message }); 'preview_spec_invalid',
} );
resolved = true; send('error', { error: 'spec_invalid', detail: err.message });
} else if (err instanceof BannedPatternError) {
send('error', { error: 'banned_pattern', detail: err.message });
} else if (err instanceof SpecTimeoutError) {
send('error', {
error: 'preview_timeout',
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
});
} else {
app.log.error(err);
send('error', { error: 'preview_failed', detail: err.message });
}
resolved = true;
},
}, },
}, );
);
if (!resolved) { if (!resolved) {
app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved'); app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
send('error', { error: 'preview_failed', detail: 'stream ended without a final event' }); send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
}
} catch (err) {
// If the stream itself rejects (e.g. cacheSpec/Redis throws inside onSpec,
// or a network error before either handler runs) we must still tear down
// the keepalive timer and close the socket — otherwise the interval keeps
// writing to a dead connection forever, leaking a timer + FD per failure. (SRV-004)
app.log.error({ err, prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_threw');
if (!resolved) send('error', { error: 'preview_failed', detail: 'spec generation failed' });
} finally {
clearInterval(keepalive);
reply.raw.end();
} }
clearInterval(keepalive);
reply.raw.end();
}); });
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => { app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
@ -574,6 +588,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.limit(1); .limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' }); if (!server) return reply.code(404).send({ error: 'not_found' });
// iterate queues a full paid LLM build exactly like POST /v1/servers, so it
// must enforce the same suspension + daily-build gates. Without these a
// suspended (non-paying) or rate-capped org could generate unlimited builds
// by hitting iterate instead of create. (SRV-003)
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const iterateRl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[billing.plan]);
if (!iterateRl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily build limit reached for plan "${billing.plan}" (${BUILD_DAILY_LIMIT[billing.plan]}/day). Resets in ${Math.ceil(iterateRl.resetIn / 3600)}h.`,
plan: billing.plan,
limit: BUILD_DAILY_LIMIT[billing.plan],
resetIn: iterateRl.resetIn,
});
}
const nextVersion = server.currentVersion + 1; const nextVersion = server.currentVersion + 1;
const [build] = await db const [build] = await db
.insert(builds) .insert(builds)

View File

@ -250,6 +250,15 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
const body = NewMessageBody.safeParse(req.body); const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
// Confirm the ticket exists first — otherwise the insert below hits a raw
// FK violation (500) instead of a clean 404. (SUP-002)
const [ticket] = await db
.select({ id: supportTickets.id })
.from(supportTickets)
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db.insert(supportMessages).values({ await db.insert(supportMessages).values({
ticketId: parsed.data.id, ticketId: parsed.data.id,
authorUserId: user.userId, authorUserId: user.userId,
@ -282,12 +291,22 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
'/v1/admin/support/tickets/:id/status', '/v1/admin/support/tickets/:id/status',
{ preHandler: requireAdmin }, { preHandler: requireAdmin },
async (req, reply) => { async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() }); const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params); const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = StatusBody.safeParse(req.body); const body = StatusBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
// 404 on unknown ticket instead of a silent no-op `UPDATE ... WHERE id=?`
// that returns ok:true and masks the bad id. (SUP-002)
const [ticket] = await db
.select({ id: supportTickets.id })
.from(supportTickets)
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db await db
.update(supportTickets) .update(supportTickets)
.set({ .set({
@ -297,6 +316,17 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
}) })
.where(eq(supportTickets.id, parsed.data.id)); .where(eq(supportTickets.id, parsed.data.id));
// Status changes were previously unaudited, unlike admin replies — close
// the compliance-trail gap. (SUP-002)
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.status_changed',
resourceType: 'support_ticket',
resourceId: parsed.data.id,
metadata: { status: body.data.status },
});
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
); );

View File

@ -194,10 +194,15 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
toolsSchema: server.toolsSchema, toolsSchema: server.toolsSchema,
generatedCode: build.generatedCode, generatedCode: build.generatedCode,
requiredSecrets: parsed.data.secretHints, requiredSecrets: parsed.data.secretHints,
scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce<string[]>( // Aggregate the distinct scopes actually declared by the server's tools
() => ['mcp:read'], // (deduped), falling back to read-only. The previous reduce ignored its
[], // input and hardcoded ['mcp:read'] for every template regardless of what
), // its tools did. (TPL-003)
scopes: (() => {
const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
return all.length > 0 ? all : ['mcp:read'];
})(),
allowedDomains: parsed.data.allowedDomains ?? null, allowedDomains: parsed.data.allowedDomains ?? null,
}) })
.returning(); .returning();
@ -310,13 +315,19 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.from(templates) .from(templates)
.leftJoin(users, eq(users.id, templates.ownerUserId)) .leftJoin(users, eq(users.id, templates.ownerUserId))
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId)) .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
.where(eq(templates.status, 'public')) // Category filter belongs in the WHERE, BEFORE limit — filtering in JS
// after `.limit(50)` meant `?category=x` searched only the 50 newest
// public templates (any category), returning far fewer than `limit`. (TPL-008)
.where(
and(
eq(templates.status, 'public'),
parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
),
)
.orderBy(desc(templates.createdAt)) .orderBy(desc(templates.createdAt))
.limit(parsed.data.limit); .limit(parsed.data.limit);
const filtered = parsed.data.category const filtered = rows;
? rows.filter((r) => r.template.category === parsed.data.category)
: rows;
// Single grouped query — was N+1 (one COUNT per template). On a 100-row // Single grouped query — was N+1 (one COUNT per template). On a 100-row
// listing that's 101 round-trips → p95 latency cliff once the marketplace // listing that's 101 round-trips → p95 latency cliff once the marketplace

View File

@ -39,7 +39,10 @@ export async function prepareBuildContext(
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies }; pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
const imageTag = `bmm-mcp-${slug}:v${version}`; // Include serverId in the tag: `slug` is unique only per-org, so two orgs
// sharing a slug at the same version would otherwise collide on one global
// image tag and run each other's code. Matches the contextDir scheme. (GEN-009)
const imageTag = `bmm-mcp-${serverId.slice(0, 8)}-${slug}:v${version}`;
return { contextDir, imageTag }; return { contextDir, imageTag };
} }
@ -70,12 +73,21 @@ export async function staticCheck(contextDir: string): Promise<void> {
} }
} }
// A hung `docker build` (stalled npm install, wedged daemon) must not pin a
// worker slot forever — concurrency is 2, so two stuck builds = zero throughput
// with no alarm. Kill and fail the build past this ceiling. (GEN-008)
const BUILD_TIMEOUT_MS = 10 * 60 * 1000;
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> { export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn('docker', ['build', '-t', imageTag, '.'], { const child = spawn('docker', ['build', '-t', imageTag, '.'], {
cwd: contextDir, cwd: contextDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`docker_build_timeout (exceeded ${BUILD_TIMEOUT_MS / 1000}s)`));
}, BUILD_TIMEOUT_MS);
child.stdout.on('data', (d) => { child.stdout.on('data', (d) => {
for (const line of d.toString().split(/\r?\n/)) { for (const line of d.toString().split(/\r?\n/)) {
if (line.trim()) onLog(line.trim()); if (line.trim()) onLog(line.trim());
@ -86,8 +98,12 @@ export async function dockerBuild(contextDir: string, imageTag: string, onLog: (
if (line.trim()) onLog(line.trim()); if (line.trim()) onLog(line.trim());
} }
}); });
child.on('error', (e) => reject(e)); child.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
child.on('close', (code) => { child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve(); if (code === 0) resolve();
else reject(new Error(`docker_build_failed (exit ${code})`)); else reject(new Error(`docker_build_failed (exit ${code})`));
}); });

View File

@ -81,13 +81,26 @@ const HARDENING_FLAGS = [
]; ];
function shouldHarden(): boolean { function shouldHarden(): boolean {
// Explicit opt-out for local dev on Windows where --read-only conflicts // Fail-CLOSED: harden by default everywhere. The only opt-out is the explicit
// with how Docker Desktop binds volumes. Production must always harden. // RUNNER_DISABLE_HARDENING=1 flag (local Windows Docker Desktop, where
if (process.env.RUNNER_DISABLE_HARDENING === '1') return false; // --read-only conflicts with how volumes bind). The previous NODE_ENV gate was
const env = process.env.NODE_ENV; // fail-OPEN — a missing/typo'd NODE_ENV silently ran tenant containers as root
return env === 'production' || env === 'staging'; // with full caps on the shared host, which is the one defense the LLM
// static-check explicitly is NOT. (GEN-002)
if (process.env.RUNNER_DISABLE_HARDENING === '1') {
console.warn(
'[deploy] container hardening DISABLED via RUNNER_DISABLE_HARDENING=1 — never set this in production',
);
return false;
}
return true;
} }
// docker run / rm should return in seconds; cap them so a wedged daemon can't
// hang a worker slot indefinitely. (GEN-008)
const DOCKER_RUN_TIMEOUT_MS = 60 * 1000;
const DOCKER_STOP_TIMEOUT_MS = 60 * 1000;
const db = createDb(); const db = createDb();
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> { async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
@ -157,14 +170,24 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }); const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; let out = '';
let err = ''; let err = '';
// `docker run -d` returns promptly; if it hangs (wedged daemon) don't pin a
// worker slot forever. (GEN-008)
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('docker_run_timeout'));
}, DOCKER_RUN_TIMEOUT_MS);
child.stdout.on('data', (d) => { child.stdout.on('data', (d) => {
out += d.toString(); out += d.toString();
}); });
child.stderr.on('data', (d) => { child.stderr.on('data', (d) => {
err += d.toString(); err += d.toString();
}); });
child.on('error', (e) => reject(e)); child.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
child.on('close', async (code) => { child.on('close', async (code) => {
clearTimeout(timer);
if (code !== 0) { if (code !== 0) {
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`)); reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
return; return;
@ -207,13 +230,21 @@ export async function stopContainer(
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
let err = ''; let err = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ ok: false, detail: 'stop_timeout' });
}, DOCKER_STOP_TIMEOUT_MS);
child.stderr?.on('data', (d: Buffer) => { child.stderr?.on('data', (d: Buffer) => {
err += d.toString(); err += d.toString();
}); });
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' })); child.on('error', () => {
child.on('close', (code) => clearTimeout(timer);
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }), resolve({ ok: false, detail: 'spawn_failed' });
); });
child.on('close', (code) => {
clearTimeout(timer);
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` });
});
}); });
} }

View File

@ -184,30 +184,34 @@ export const worker = new Worker<JobData>(
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`, `Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
); );
await db try {
.update(builds) await db
.set({ status: 'success', finishedAt: new Date() }) .update(builds)
.where(eq(builds.id, buildId)); .set({ status: 'success', finishedAt: new Date() })
await db .where(eq(builds.id, buildId));
.update(mcpServers) await db
.set({ .update(mcpServers)
status: 'live', .set({
currentVersion: version, status: 'live',
publicUrl: handle.publicUrl, currentVersion: version,
updatedAt: new Date(), publicUrl: handle.publicUrl,
}) updatedAt: new Date(),
.where(eq(mcpServers.id, serverId)); })
.where(eq(mcpServers.id, serverId));
// Rolling deploy: the new container is live — now retire the previous one. } finally {
// Without this every iterate would leave an orphan holding a host port. // Rolling deploy: retire the previous container even if the success DB
if (oldContainerId && oldContainerId !== handle.containerId) { // writes above threw — otherwise a DB hiccup after a healthy deploy
const stopped = await stopContainer(oldContainerId); // leaves the old container orphaned, holding its host port. The new
await log( // container is already live and its id is persisted in deployContainer. (GEN-007)
stopped.ok ? 'info' : 'warn', if (oldContainerId && oldContainerId !== handle.containerId) {
stopped.ok const stopped = await stopContainer(oldContainerId);
? `Retired previous container ${oldContainerId.slice(0, 12)}` await log(
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`, stopped.ok ? 'info' : 'warn',
); stopped.ok
? `Retired previous container ${oldContainerId.slice(0, 12)}`
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
);
}
} }
await emitStatus(buildId, 'success'); await emitStatus(buildId, 'success');

View File

@ -1,7 +1,11 @@
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm install --no-save tsx@4.19.2 typescript@5.7.2 # --ignore-scripts: generated package.json carries LLM/user-chosen dependencies.
# Without this, a malicious dependency's postinstall lifecycle script would run
# at `docker build` time on the shared host. Specifiers are also validated to
# registry semver ranges at the API boundary (DependencyMap). (GEN-001)
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund && npm install --no-save --ignore-scripts tsx@4.19.2 typescript@5.7.2
FROM node:20-alpine AS runtime FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app

View File

@ -21,6 +21,14 @@ server {
client_max_body_size 12M; client_max_body_size 12M;
# Security headers (INF-002). Cloudflare sits in front — if it also injects
# HSTS, drop that line here to avoid duplication. CSP intentionally omitted
# for now (a wrong policy breaks Next/Tailwind inline) — track separately.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / { location / {
proxy_pass http://127.0.0.1:4001; proxy_pass http://127.0.0.1:4001;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -48,6 +56,13 @@ server {
client_max_body_size 12M; client_max_body_size 12M;
# Security headers (INF-002). nosniff matters for the JSON API; XFO/HSTS are
# belt-and-suspenders for any HTML the API might ever serve.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade # Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade
# headers and a long read timeout; buffering off so frames are not held. # headers and a long read timeout; buffering off so frames are not held.
location /v1/builds/ { location /v1/builds/ {

View File

@ -7,6 +7,8 @@ import {
eq, eq,
gt, gt,
isNull, isNull,
lt,
sql,
magicLinks, magicLinks,
memberships, memberships,
organizations, organizations,
@ -272,12 +274,18 @@ export async function consumeSmsCode(
.orderBy(desc(smsCodes.createdAt)) .orderBy(desc(smsCodes.createdAt))
.limit(1); .limit(1);
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code'); 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) { 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'); throw new Error('invalid_code');
} }
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id)); 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)) .where(eq(sessions.tokenHash, hash))
.limit(1); .limit(1);
if (!row || row.expiresAt < new Date()) return null; 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 const [membership] = await db
.select({ orgId: memberships.orgId, role: memberships.role }) .select({ orgId: memberships.orgId, role: memberships.role })
.from(memberships) .from(memberships)
.where(eq(memberships.userId, row.userId)) .where(eq(memberships.userId, row.userId))
.orderBy(memberships.createdAt)
.limit(1); .limit(1);
if (!membership) return null; if (!membership) return null;
return { return {

View File

@ -68,6 +68,33 @@ export const PromptSpec = z.object({
}); });
export type PromptSpec = z.infer<typeof PromptSpec>; 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({ export const GeneratorSpec = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
@ -76,7 +103,7 @@ export const GeneratorSpec = z.object({
prompts: z.array(PromptSpec).max(50).default([]), prompts: z.array(PromptSpec).max(50).default([]),
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]), requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
scopes: z.array(z.string()).max(50).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>; export type GeneratorSpec = z.infer<typeof GeneratorSpec>;
@ -136,7 +163,7 @@ export const CreateServerInput = z.object({
.max(64) .max(64)
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'), .regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
prompt: z.string().min(10).max(8000), 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(), previewId: z.string().min(1).max(64).optional(),
specEdit: SpecEdit.optional(), specEdit: SpecEdit.optional(),
templateId: z.string().uuid().optional(), templateId: z.string().uuid().optional(),
@ -169,7 +196,7 @@ export type PreviewResult = z.infer<typeof PreviewResult>;
export const IterateServerInput = z.object({ export const IterateServerInput = z.object({
prompt: z.string().min(10).max(8000), 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>; export type IterateServerInput = z.infer<typeof IterateServerInput>;