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:
@@ -2,6 +2,37 @@ import net from 'node:net';
|
||||
import { createDb, eq, isNotNull, mcpServers } from '@bmm/db';
|
||||
import { config } from '../config.js';
|
||||
|
||||
/**
|
||||
* Container hardening flags applied on every runner deployment on Linux
|
||||
* production hosts. Skipped only when explicitly disabled (dev/Windows
|
||||
* Docker Desktop, which doesn't fully honour --read-only on bind mounts).
|
||||
*
|
||||
* Without these, a tenant container runs as root with full capabilities on
|
||||
* the shared host — combined with the LLM static-check being a regex
|
||||
* blacklist (Z2-001), this would let a malicious tenant execute arbitrary
|
||||
* code on the host. With them, the blast radius collapses to "within the
|
||||
* container", which holds only that tenant's own decrypted secrets.
|
||||
*/
|
||||
const HARDENING_FLAGS = [
|
||||
'--read-only',
|
||||
'--cap-drop=ALL',
|
||||
'--security-opt=no-new-privileges:true',
|
||||
'--pids-limit=100',
|
||||
'--memory=512m',
|
||||
'--memory-swap=512m',
|
||||
'--cpus=0.5',
|
||||
// /tmp needs writable space — runner-template uses it for build/cache.
|
||||
'--tmpfs=/tmp:rw,nosuid,nodev,size=64m',
|
||||
];
|
||||
|
||||
function shouldHarden(): boolean {
|
||||
// Explicit opt-out for local dev on Windows where --read-only conflicts
|
||||
// with how Docker Desktop binds volumes. Production must always harden.
|
||||
if (process.env.RUNNER_DISABLE_HARDENING === '1') return false;
|
||||
const env = process.env.NODE_ENV;
|
||||
return env === 'production' || env === 'staging';
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
|
||||
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||
@@ -46,16 +77,9 @@ export interface DeployInput {
|
||||
envVars: Record<string, string>;
|
||||
}
|
||||
|
||||
// Production-only flags documented but unused in dev for Windows Docker Desktop compat:
|
||||
// '--read-only',
|
||||
// '--cap-drop=ALL',
|
||||
// '--security-opt=no-new-privileges',
|
||||
// '--cpus=0.5',
|
||||
// '--memory=512m',
|
||||
|
||||
export async function deployContainer(input: DeployInput): Promise<DeployHandle> {
|
||||
// In a future iteration this calls docker engine API directly via UNIX socket / named pipe.
|
||||
// For Sprint 1-3 we shell out via the bound docker CLI which is portable on win/mac/linux.
|
||||
// Docker CLI is portable across linux/mac/win — sufficient for now; future
|
||||
// iteration will switch to the engine API via UNIX socket.
|
||||
const { spawn } = await import('node:child_process');
|
||||
const containerName = `bmm-mcp-${input.slug}-${Date.now().toString(36)}`;
|
||||
const args = [
|
||||
@@ -66,6 +90,9 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
||||
'-p',
|
||||
`${input.hostPort}:3000`,
|
||||
];
|
||||
if (shouldHarden()) {
|
||||
args.push(...HARDENING_FLAGS);
|
||||
}
|
||||
for (const [k, v] of Object.entries(input.envVars)) {
|
||||
args.push('-e', `${k}=${v}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user