@
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:
@@ -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})`));
|
||||
});
|
||||
|
||||
@@ -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}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user