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

@@ -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>;