Files
buildmymcpserver/apps/generator/src/worker.ts
Marco Sadjadi 9d5386ccba @
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>
@
2026-05-29 20:56:30 +02:00

240 lines
8.6 KiB
TypeScript

import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { GeneratorSpec } from '@bmm/types';
import { Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { config } from './config.js';
import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js';
import { generateSpec } from './lib/claude.js';
import {
allocatePort,
computePublicUrl,
deployContainer,
dockerAvailable,
stopContainer,
} from './lib/deploy.js';
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
import { renderServerCode } from './lib/render.js';
const db = createDb();
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
const cacheReader = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
interface JobData {
buildId: string;
serverId: string;
orgId: string;
prompt: string;
version: number;
slug: string;
serverName: string;
secrets: Record<string, string>;
previewId?: string;
}
async function loadCachedSpec(previewId: string): Promise<GeneratorSpec | null> {
const raw = await cacheReader.get(`preview:${previewId}`);
if (!raw) return null;
try {
const parsed = GeneratorSpec.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
async function loadPrebuiltCode(previewId: string): Promise<string | null> {
return (await cacheReader.get(`prebuilt:${previewId}`)) ?? null;
}
export const worker = new Worker<JobData>(
'build',
async (job) => {
const { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
// Capture the container currently serving this server (if any) BEFORE the
// build mutates the row. On an iterate (version > 1) we deploy the new
// container, then tear this old one down — rolling-deploy, no orphan.
const [priorState] = await db
.select({ containerId: mcpServers.containerId })
.from(mcpServers)
.where(eq(mcpServers.id, serverId))
.limit(1);
const oldContainerId = priorState?.containerId ?? null;
try {
await db
.update(builds)
.set({ status: 'generating', startedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'generating', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'generating');
let spec: GeneratorSpec | null = null;
let source: 'claude' | 'glm' | 'mock' | 'cached' = 'mock';
if (previewId) {
spec = await loadCachedSpec(previewId);
if (spec) {
source = 'cached';
await log('info', `Re-using preview spec ${previewId} (skipping Claude call)`);
} else {
await log('warn', `Preview ${previewId} cache miss — regenerating`);
}
}
if (!spec) {
await log('info', 'Generating MCP server spec...');
const result = await generateSpec(prompt);
spec = result.spec;
source = result.source;
}
await log('info', `Spec ready via ${source} (${spec.tools.length} tool(s))`);
// Forks supply pre-rendered code via Redis. If present, use it verbatim.
let generatedCode: string;
const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null;
if (prebuilt) {
await log(
'info',
`Using pre-rendered template code (${prebuilt.length} chars) — skipping render`,
);
generatedCode = prebuilt;
} else {
generatedCode = renderServerCode(spec);
}
await db
.update(builds)
.set({ generatedSpec: spec, generatedCode })
.where(eq(builds.id, buildId));
await db.update(builds).set({ status: 'building' }).where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'building');
await log('info', 'Preparing build context...');
const { contextDir, imageTag } = await prepareBuildContext(
serverId,
version,
slug,
generatedCode,
spec,
);
await log('info', `Build context at ${contextDir}`);
await log('info', 'Running static checks...');
await staticCheck(contextDir);
await log('info', 'Static checks passed.');
const hasDocker = await dockerAvailable();
if (!hasDocker) {
await log('warn', 'Docker not available — skipping build/deploy. Server marked draft.');
await db
.update(builds)
.set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'failed', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitDone(buildId, 'failed', serverId, null);
return;
}
await log('info', `Building Docker image ${imageTag}...`);
await dockerBuild(contextDir, imageTag, (line) => {
emitLog(buildId, 'info', line).catch(() => undefined);
});
await log('info', 'Image built.');
await db.update(builds).set({ status: 'deploying' }).where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'deploying', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'deploying');
const port = await allocatePort();
// The container's PUBLIC_URL must match what end-users (and Claude
// Desktop's DCR client) actually reach. When MCP_DOMAIN is set we
// route via https://<MCP_DOMAIN>/<slug>; the hardcoded loopback URL
// we used to inject caused the runner to advertise an unreachable
// resource_metadata URL in its WWW-Authenticate header, killing OAuth
// discovery from any external MCP client.
const publicUrl = computePublicUrl(slug, port);
const envVars: Record<string, string> = {
...secrets,
PUBLIC_URL: publicUrl,
CONTROL_PLANE_URL: config.CONTROL_PLANE_URL,
OAUTH_ISSUER: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
PORT: '3000',
SERVER_ID: serverId,
};
const handle = await deployContainer({ serverId, slug, hostPort: port, imageTag, envVars });
await log(
'info',
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
);
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');
await emitDone(buildId, 'success', serverId, handle.publicUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[worker] build failed:', err);
await db
.update(builds)
.set({ status: 'failed', errorMessage: msg, finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'failed', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitError(buildId, msg);
await emitDone(buildId, 'failed', serverId, null);
}
},
{ connection, concurrency: 2 },
);
worker.on('ready', () => console.log('[generator] worker ready'));
worker.on('failed', (job, err) => console.error('[generator] job failed', job?.id, err?.message));
worker.on('error', (err) => console.error('[generator] worker error', err.message));