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

@@ -39,7 +39,10 @@ export async function prepareBuildContext(
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
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 };
}
@@ -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> {
await new Promise<void>((resolve, reject) => {
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
cwd: contextDir,
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) => {
for (const line of d.toString().split(/\r?\n/)) {
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());
}
});
child.on('error', (e) => reject(e));
child.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`docker_build_failed (exit ${code})`));
});

View File

@@ -81,13 +81,26 @@ const HARDENING_FLAGS = [
];
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';
// Fail-CLOSED: harden by default everywhere. The only opt-out is the explicit
// RUNNER_DISABLE_HARDENING=1 flag (local Windows Docker Desktop, where
// --read-only conflicts with how volumes bind). The previous NODE_ENV gate was
// fail-OPEN — a missing/typo'd NODE_ENV silently ran tenant containers as root
// 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();
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'] });
let out = '';
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) => {
out += d.toString();
});
child.stderr.on('data', (d) => {
err += d.toString();
});
child.on('error', (e) => reject(e));
child.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
child.on('close', async (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
return;
@@ -207,13 +230,21 @@ export async function stopContainer(
stdio: ['ignore', 'pipe', 'pipe'],
});
let err = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ ok: false, detail: 'stop_timeout' });
}, DOCKER_STOP_TIMEOUT_MS);
child.stderr?.on('data', (d: Buffer) => {
err += d.toString();
});
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
child.on('close', (code) =>
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }),
);
child.on('error', () => {
clearTimeout(timer);
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}`,
);
await db
.update(builds)
.set({ status: 'success', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({
status: 'live',
currentVersion: version,
publicUrl: handle.publicUrl,
updatedAt: new Date(),
})
.where(eq(mcpServers.id, serverId));
// Rolling deploy: the new container is live — now retire the previous one.
// Without this every iterate would leave an orphan holding a host port.
if (oldContainerId && oldContainerId !== handle.containerId) {
const stopped = await stopContainer(oldContainerId);
await log(
stopped.ok ? 'info' : 'warn',
stopped.ok
? `Retired previous container ${oldContainerId.slice(0, 12)}`
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
);
try {
await db
.update(builds)
.set({ status: 'success', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({
status: 'live',
currentVersion: version,
publicUrl: handle.publicUrl,
updatedAt: new Date(),
})
.where(eq(mcpServers.id, serverId));
} finally {
// Rolling deploy: retire the previous container even if the success DB
// writes above threw — otherwise a DB hiccup after a healthy deploy
// leaves the old container orphaned, holding its host port. The new
// container is already live and its id is persisted in deployContainer. (GEN-007)
if (oldContainerId && oldContainerId !== handle.containerId) {
const stopped = await stopContainer(oldContainerId);
await log(
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');