fix(security): template integration sovereign audit + critical fixes

P0 — three critical issues found by tracing every attack vector on the template
publish + fork + render path. All three fixed and verified with attack tests.

FIX A — Takedown actually stops malicious containers
  PATCH /v1/admin/templates with status=takedown previously only updated
  mcp_servers.status to 'paused' in the DB. The Docker container kept running
  and serving traffic on its allocated port — takedown was cosmetic. Now the
  endpoint enumerates every fork's container, calls 'docker rm -f' on each,
  clears container_id/public_url/host_port in the DB, and returns the
  stoppedContainers count. New apps/api/src/lib/docker.ts owns the stop logic.
  Verified: takedown stopped container f5632962, port 4109 connection refused.

FIX B — Reject specEdit on fork
  A hand-crafted POST /v1/servers with {templateId, previewId, specEdit} would
  enter the spec-edit branch, merge edits into the cached spec, but the worker
  reads the pre-built template code (separate cache key), ignoring the merged
  spec entirely. User thinks they changed something; deployed container behaves
  as the original. Now returns 400 spec_edit_forbidden_on_fork with an explainer
  pointing to the Iterate flow.

FIX C — templateId validation via Redis fork-ref
  templateId on POST /v1/servers was user-controlled and unvalidated:
  fork_count of any template could be pumped, mcp_servers got garbage
  template_id rows, takedown cascade would miss the bogus rows. Fork endpoint
  now writes a Redis key fork-ref:<previewId> -> templateId (5min TTL).
  Server-create requires the ref to exist AND match the submitted templateId.
  Verified attack: fake templateId without fork-ref returns 410 fork_ref_expired.

DEFENSE-IN-DEPTH — Hardened static checks

  Banned patterns (added):
    Function\s*\(['"`]    — Function('code')() form, no 'new' needed
    \bimport\s*\(           — dynamic import escapes bundle scope
    \bsetTimeout\s*\(['"`] — setTimeout('code', ms) eval form
    \bsetInterval\s*\(['"`]
    \bfs\s*\.\s*(unlink|rmdir|rm)\b
    \bprocess\s*\.\s*kill\b
    you are now in (developer|jailbreak|dan) mode — extra jailbreak markers

  Hardcoded-credential patterns (new — scanForLeakedSecrets):
    sk-ant-(api|sid)…  — Anthropic
    sk-…               — OpenAI
    sk_(live|test)_…   — Stripe
    ghp_…              — GitHub PAT
    github_pat_…       — GitHub fine-grained
    xox[bpoasr]-…      — Slack
    AKIA[0-9A-Z]{16}   — AWS
    -----BEGIN…PRIVATE KEY----- — RSA / SSH / GPG
  Triggered when a publisher pasted their key into the prompt and Claude
  embedded it literally in the generated code. Publish-blocking.
  Verified attack: smuggled 'Function("return 1")' into a build's
  generated_code, attempted publish → 422 publish_blocked.

  Slug regex tightened — fork + detail routes now require
  ^[a-z0-9][a-z0-9-]{0,63}$ (was loose min(1).max(64) — letting through
  '../admin', long strings, mixed case).

  UI warning — Publish-as-template form now shows an amber callout listing
  what's scanned and explicitly stating egress allowlisting is roadmap, not
  enforced today (was misleading: the field was collected, never enforced).

  TEMPLATE_SECURITY_AUDIT.md added — documents all 20 audited vectors with
  severity, status, and rationale for what's deferred.

UI polish
  globals.css — select/input/textarea/button get color-scheme: dark + custom
  chevron + option styling so Chrome's native popdown stops rendering as a
  white OS-themed widget on dark pages. The /templates category dropdown was
  the immediate trigger; same rule applies system-wide.
This commit is contained in:
Marco Sadjadi
2026-05-19 23:35:45 +02:00
parent 8334de13a8
commit 2ad4a7e34c
6 changed files with 359 additions and 11 deletions

View File

@@ -0,0 +1,34 @@
import { spawn } from 'node:child_process';
/**
* Stop and remove a generated MCP container by container id.
* Resolves regardless of outcome — failures are logged but never blocking.
* Production: should be moved to a Coolify HTTP-API call.
*/
export async function stopContainer(containerId: string): Promise<{ ok: boolean; detail: string }> {
if (!containerId || containerId.length < 4) {
return { ok: false, detail: 'invalid_container_id' };
}
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
const child = spawn('docker', ['rm', '-f', containerId], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let out = '';
let err = '';
child.stdout?.on('data', (d: Buffer) => {
out += d.toString();
});
child.stderr?.on('data', (d: Buffer) => {
err += d.toString();
});
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
child.on('close', (code) => {
if (code === 0) {
resolve({ ok: true, detail: out.trim() });
} else {
resolve({ ok: false, detail: err.trim() || `exit ${code}` });
}
});
});
}

View File

@@ -16,6 +16,7 @@ import { getBuildQueue } from '../lib/queue.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { encryptSecret } from '../lib/crypto.js';
import { audit } from '../lib/audit.js';
import { getForkRefTemplate } from './templates.js';
import { config } from '../config.js';
const db = createDb();
@@ -77,6 +78,35 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
const { name, slug, prompt, secrets: secretValues, previewId, specEdit, templateId } = parsed.data;
// ---- Template-fork validation ----
// templateId is user-controlled. To prevent fork_count manipulation + garbage
// template_id rows, the user MUST have hit POST /v1/templates/:slug/fork,
// which created a Redis fork-ref keyed by previewId. We verify both exist and match.
let validatedTemplateId: string | null = null;
if (templateId) {
if (!previewId) {
return reply.code(400).send({ error: 'preview_id_required_for_fork' });
}
const refTemplateId = await getForkRefTemplate(previewId);
if (!refTemplateId) {
return reply.code(410).send({
error: 'fork_ref_expired',
detail: 'Re-open the template and click Fork again.',
});
}
if (refTemplateId !== templateId) {
return reply.code(400).send({ error: 'fork_ref_mismatch' });
}
if (specEdit) {
return reply.code(400).send({
error: 'spec_edit_forbidden_on_fork',
detail:
'Forked templates ship pre-built code. Iterate after build to change tool behavior.',
});
}
validatedTemplateId = templateId;
}
// If the user edited the spec in step 2 of the wizard, merge their edits into
// the cached spec (keeping the original tool implementations untouched).
if (specEdit) {
@@ -113,15 +143,21 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const [server] = await db
.insert(mcpServers)
.values({ orgId: user.orgId, slug, name, status: 'queued', templateId: templateId ?? null })
.values({
orgId: user.orgId,
slug,
name,
status: 'queued',
templateId: validatedTemplateId,
})
.returning();
if (!server) return reply.code(500).send({ error: 'create_failed' });
if (templateId) {
if (validatedTemplateId) {
await db
.update(templates)
.set({ forkCount: sql`${templates.forkCount} + 1`, updatedAt: new Date() })
.where(eq(templates.id, templateId));
.where(eq(templates.id, validatedTemplateId));
}
for (const [key, value] of Object.entries(secretValues)) {

View File

@@ -19,15 +19,37 @@ import { GeneratorSpec } from '@bmm/types';
import { requireAuth, requireAdmin } from '../plugins/session.js';
import { audit } from '../lib/audit.js';
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
import { getRedis } from '../lib/redis.js';
import { stopContainer } from '../lib/docker.js';
const db = createDb();
const BANNED_PATTERNS = [
/\beval\s*\(/,
/\bnew\s+Function\s*\(/,
/\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed
/\bimport\s*\(/, // dynamic import (escape from bundle scope)
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
/\bsetInterval\s*\(\s*['"`]/,
/\bchild_process\b/,
/\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
/\bprocess\s*\.\s*kill\b/,
/ignore\s+previous\s+instructions/i,
/disregard\s+(the\s+)?(above|previous)/i,
/you\s+are\s+now\s+(in\s+)?(developer|jailbreak|dan)\s+mode/i,
];
// Hardcoded-credential patterns. If Claude embedded a literal API key into the
// generated code (publisher pasted it into the prompt), block the publish.
const SECRET_PATTERNS = [
{ name: 'anthropic_key', re: /\bsk-ant-(?:api|sid)\d+-[A-Za-z0-9_-]{20,}/ },
{ name: 'openai_key', re: /\bsk-[A-Za-z0-9_-]{30,}/ },
{ name: 'stripe_secret', re: /\bsk_(live|test)_[A-Za-z0-9]{20,}/ },
{ name: 'github_pat', re: /\bghp_[A-Za-z0-9]{30,}/ },
{ name: 'github_fine_grained', re: /\bgithub_pat_[A-Za-z0-9_]{30,}/ },
{ name: 'slack_token', re: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/ },
{ name: 'aws_access_key', re: /\bAKIA[0-9A-Z]{16}\b/ },
{ name: 'rsa_private_key', re: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/i },
];
function scanForInjection(code: string): void {
@@ -36,6 +58,29 @@ function scanForInjection(code: string): void {
}
}
function scanForLeakedSecrets(code: string): void {
for (const { name, re } of SECRET_PATTERNS) {
if (re.test(code)) {
throw new Error(
`hardcoded_${name}_detected: a literal credential was found in the generated code; remove it before publishing`,
);
}
}
}
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
// Per-fork link: ties a previewId back to the template it came from. Set during
// fork, consumed by the create-server endpoint to prove the user actually went
// through the fork flow before we accept templateId or bump forkCount.
const FORK_REF_TTL_SECONDS = 5 * 60;
async function setForkRef(previewId: string, templateId: string): Promise<void> {
await getRedis().set(`fork-ref:${previewId}`, templateId, 'EX', FORK_REF_TTL_SECONDS);
}
export async function getForkRefTemplate(previewId: string): Promise<string | null> {
return (await getRedis().get(`fork-ref:${previewId}`)) ?? null;
}
const CATEGORIES = [
'productivity',
'developer-tools',
@@ -100,11 +145,12 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'no_generated_code' });
}
// Re-validate code against banned patterns (catch any drift since build)
// Re-validate code against banned patterns AND hardcoded secrets
try {
scanForInjection(build.generatedCode);
scanForLeakedSecrets(build.generatedCode);
} catch (err) {
return reply.code(422).send({ error: 'banned_pattern', detail: (err as Error).message });
return reply.code(422).send({ error: 'publish_blocked', detail: (err as Error).message });
}
// Build a unique template slug
@@ -226,7 +272,7 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
// ---- Detail ----
app.get('/v1/templates/:slug', async (req, reply) => {
const Params = z.object({ slug: z.string().min(1).max(64) });
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_slug' });
@@ -273,7 +319,7 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
// ---- Fork (returns previewId so wizard can complete with user's secrets) ----
app.post('/v1/templates/:slug/fork', { preHandler: requireAuth }, async (req, reply) => {
const Params = z.object({ slug: z.string().min(1).max(64) });
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_slug' });
@@ -322,6 +368,9 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
// Persist the pre-rendered code under the same previewId so the worker uses it
// verbatim instead of re-rendering (which would lose the template's per-tool impls).
await cachePrebuiltCode(previewId, template.generatedCode);
// Record the fork→template link so /v1/servers can verify the user actually
// went through this endpoint before accepting templateId.
await setForkRef(previewId, template.id);
return reply.send({
previewId,
@@ -382,11 +431,31 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.set({ ...b.data, updatedAt: new Date() })
.where(eq(templates.id, p.data.id));
// If takedown, also pause any forked servers — they ran code we no longer trust
// Takedown cascade: stop every fork's container, then mark them paused.
// Just flipping the DB status leaves the container running and serving
// traffic; we MUST hard-stop them or the takedown is cosmetic.
let stoppedContainers = 0;
if (b.data.status === 'takedown') {
const forkedServers = await db
.select({ id: mcpServers.id, containerId: mcpServers.containerId })
.from(mcpServers)
.where(eq(mcpServers.templateId, p.data.id));
for (const fork of forkedServers) {
if (fork.containerId) {
const result = await stopContainer(fork.containerId);
if (result.ok) stoppedContainers++;
else app.log.warn({ containerId: fork.containerId, detail: result.detail }, 'takedown: stop failed');
}
}
await db
.update(mcpServers)
.set({ status: 'paused', updatedAt: new Date() })
.set({
status: 'paused',
containerId: null,
publicUrl: null,
hostPort: null,
updatedAt: new Date(),
})
.where(eq(mcpServers.templateId, p.data.id));
}
@@ -396,10 +465,10 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
action: 'admin.template.update',
resourceType: 'template',
resourceId: p.data.id,
metadata: b.data,
metadata: { ...b.data, stoppedContainers },
ipAddress: req.ip,
});
return reply.send({ ok: true });
return reply.send({ ok: true, stoppedContainers });
});
// unused-import guard