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

View File

@@ -411,6 +411,13 @@ function PublishPanel({ serverId, serverStatus }: { serverId: string; serverStat
Share this server&apos;s spec on the public marketplace. Others fork in one click they
run their own container with their own credentials. Your secrets are never shared.
</p>
<p className="mt-2 rounded-md border border-amber-400/30 bg-amber-400/5 p-2 text-[11.5px] text-amber-200/90">
Publishing re-scans your generated code for banned patterns (eval, child_process,
dynamic import, setTimeout-eval) AND for hardcoded credentials (API keys, tokens,
private keys). Templates with detected leaks are rejected before they reach the
marketplace. Network egress allowlisting is on the roadmap for now any template
can reach any URL its code names.
</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_220px]">

View File

@@ -46,6 +46,28 @@
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Force dark native UI for form controls — Chrome popdown otherwise reverts to OS light theme */
select,
input,
textarea,
button {
color-scheme: dark;
}
/* Style native select arrow + ensure the dropdown popdown uses our dark token */
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding-right: 26px !important;
}
select option {
background: var(--color-bg-elevated);
color: var(--color-fg);
}
/* Scrollbars */
::-webkit-scrollbar {
width: 10px;