security: sovereign-audit Phase 2 fixes — trustProxy, Docker hardening, banned-pattern overhaul
All checks were successful
Deploy to Production / deploy (push) Successful in 55s
All checks were successful
Deploy to Production / deploy (push) Successful in 55s
Five confirmed findings from the sovereign-audit pass, ordered by severity: Z3-001 CRITICAL — Fastify now trustProxy:true so req.ip resolves to the real visitor IP via X-Forwarded-For instead of always being the nginx / docker-bridge peer. Every per-IP rate-limit in the codebase was silently collapsed into one global counter; this restores them. Z1-001 CRITICAL — runner container hardening flags (--read-only, --cap-drop=ALL, --security-opt=no-new-privileges:true, --pids-limit=100, --memory=512m, --cpus=0.5, tmpfs /tmp) were sitting commented-out as a TODO despite /security promising them. Now applied unconditionally on production/staging; opt-out flag RUNNER_DISABLE_HARDENING=1 for Win-dev. Z2-001 + Z2-002 CRITICAL / MEDIUM — banned-pattern blacklist tightened (Function(...) without `new`, process.binding, process.dlopen, .constructor.constructor, _load, vm.runIn*Context, globalThis['..'], "system prompt override"). scanForInjection now also walks tool.name and every inputSchema property description, not only implementation + description — closes the prompt-injection-into-AI-client surface that downstream clients (Claude Desktop, Cursor) read verbatim. The duplicate BANNED_PATTERNS in apps/api/src/routes/servers.ts deleted in favour of the single shared scanForInjection export from @bmm/llm. Z4-001 HIGH — /v1/auth/magic-link gained the two-axis daily rate-limit the SMS endpoint already had: 10/IP/day + 5/email/day. Combined with the trustProxy fix above these are now real per-visitor limits. Z4-002 MEDIUM — magic-link callback URL no longer printed to stdout in production. In dev it still prints (so devs can click the link); in production we log only "issued, URL withheld" and a loud error if no email sender is wired (Resend integration is the actual launch blocker — left as a TODO). Z6-001 MEDIUM — /v1/builds/:id/stream WebSocket now refuses cross-origin upgrades. SameSite=Lax already mitigates in modern browsers; this is the defense-in-depth against browser bugs and non-browser clients. FALSE POSITIVES dismissed: slug path-traversal (schema regex ^[a-z][a-z0-9-]*$ in @bmm/types catches it); session-after-promote (getSession re-fetches isAdmin from DB on every request). DEFERRED (not blockers, tracked): - Z1-002 generated-server HTTPS — needs nginx wildcard subdomain TLS - Z1-003 docker image cleanup cron - Z2-001 v2 — real sandbox runtime (multi-week refactor) - Z3-002 rawBody-per-request memory — branch on webhook path only - Z5-001 multi-user org RBAC for billing — gated on Team feature - Email sender integration (Resend) — launch blocker Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,13 +36,27 @@ Rules:
|
||||
|
||||
Return JSON only. No explanation.`;
|
||||
|
||||
// Regex blacklist — explicitly NOT a security boundary, just an early-warning
|
||||
// for obviously-dangerous LLM output. The real defence is the Docker
|
||||
// hardening in apps/generator/src/lib/deploy.ts (--cap-drop=ALL etc.). A
|
||||
// determined attacker can bypass any of these with string concatenation
|
||||
// (`'chi'+'ld_process'`) or alternate APIs — that's why container isolation
|
||||
// has to hold even when this fails.
|
||||
const BANNED_PATTERNS = [
|
||||
/\beval\s*\(/,
|
||||
/\bnew\s+Function\s*\(/,
|
||||
/\bFunction\s*\(\s*['"`]/, // Function('...') without `new`
|
||||
/\brequire\s*\(\s*['"]child_process['"]/,
|
||||
/\bchild_process\b/,
|
||||
/\bprocess\.binding\b/,
|
||||
/\bprocess\.dlopen\b/,
|
||||
/\.constructor\s*\.\s*constructor\b/, // [].constructor.constructor('...')
|
||||
/\b_load\s*\(/,
|
||||
/\bvm\.runIn(This|New)Context\b/,
|
||||
/globalThis\s*\[\s*['"`]/, // globalThis['Fun'+'ction']
|
||||
/ignore\s+previous\s+instructions/i,
|
||||
/disregard\s+(the\s+)?(above|previous)/i,
|
||||
/system\s+prompt\s+override/i,
|
||||
];
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -325,11 +339,29 @@ function extractJson(text: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function scanForInjection(spec: GeneratorSpecT): void {
|
||||
/**
|
||||
* Public so other layers (the spec-edit merge in apps/api) can re-scan a
|
||||
* user-edited spec without duplicating the pattern list — single source of
|
||||
* truth for what counts as obviously-dangerous LLM output.
|
||||
*/
|
||||
export function scanForInjection(spec: GeneratorSpecT): void {
|
||||
for (const tool of spec.tools) {
|
||||
for (const pattern of BANNED_PATTERNS) {
|
||||
if (pattern.test(tool.implementation) || pattern.test(tool.description)) {
|
||||
throw new BannedPatternError(`banned_pattern_detected: ${pattern.source}`);
|
||||
// Collect every string the LLM could have planted a payload in. Downstream
|
||||
// AI clients (Claude Desktop, Cursor) read tool.name + every inputSchema
|
||||
// description verbatim, so an injection there can pivot the user's AI
|
||||
// session — not only the runtime code.
|
||||
const surfaces: string[] = [tool.name, tool.description, tool.implementation];
|
||||
for (const param of Object.values(tool.inputSchema)) {
|
||||
if (param && typeof param === 'object' && 'description' in param) {
|
||||
const d = (param as { description?: unknown }).description;
|
||||
if (typeof d === 'string') surfaces.push(d);
|
||||
}
|
||||
}
|
||||
for (const text of surfaces) {
|
||||
for (const pattern of BANNED_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
throw new BannedPatternError(`banned_pattern_detected: ${pattern.source}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user