buildmymcpserver/apps/web/app/(dashboard)/servers/[id]/page.tsx
Marco Sadjadi 2ad4a7e34c 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.
2026-05-19 23:35:45 +02:00

541 lines
18 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { StatusPill } from '@/components/status-pill';
import { CodeBlock } from '@/components/code-block';
import { InstallSnippets } from '@/components/install-snippets';
import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { Input, Textarea, Label } from '@/components/input';
import { cn } from '@/lib/cn';
import type { ToolSpec } from '@bmm/types';
interface ServerDetail {
id: string;
name: string;
slug: string;
status: string;
publicUrl: string | null;
toolsSchema: ToolSpec[] | null;
currentVersion: number;
oauthEnabled: boolean;
createdAt: string;
updatedAt: string;
}
interface BuildSummary {
id: string;
version: number;
status: string;
startedAt: string | null;
finishedAt: string | null;
errorMessage: string | null;
}
type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate' | 'publish';
export default function ServerDetailPage() {
const params = useParams<{ id: string }>();
const [server, setServer] = useState<ServerDetail | null>(null);
const [builds, setBuilds] = useState<BuildSummary[]>([]);
const [tab, setTab] = useState<Tab>('overview');
const [iteratePrompt, setIteratePrompt] = useState('');
const [latestBuildId, setLatestBuildId] = useState<string | null>(null);
async function refresh() {
const r = await apiFetch<{ server: ServerDetail; builds: BuildSummary[] }>(
`/v1/servers/${params.id}`,
);
setServer(r.server);
setBuilds(r.builds);
if (r.builds.length > 0 && !latestBuildId) {
setLatestBuildId(r.builds[0]!.id);
}
}
useEffect(() => {
refresh();
const t = setInterval(refresh, 4000);
return () => clearInterval(t);
}, [params.id]);
async function onIterate() {
if (!server) return;
const res = await apiFetch<{ build: { id: string } }>(
`/v1/servers/${server.id}/iterate`,
{ method: 'POST', body: JSON.stringify({ prompt: iteratePrompt, secrets: {} }) },
);
setLatestBuildId(res.build.id);
setIteratePrompt('');
setTab('logs');
}
if (!server) {
return (
<div className="mx-auto max-w-7xl px-6 py-8 text-[12.5px] text-[--color-fg-muted]">Loading</div>
);
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'tools', label: 'Tools' },
{ id: 'logs', label: 'Logs' },
{ id: 'metrics', label: 'Metrics' },
{ id: 'secrets', label: 'Secrets' },
{ id: 'iterate', label: 'Iterate' },
{ id: 'publish', label: 'Publish' },
];
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="flex items-baseline justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-[22px] font-semibold tracking-tight">{server.name}</h1>
<StatusPill status={server.status as never} />
</div>
<div className="mt-1 flex items-center gap-3 text-[12.5px] text-[--color-fg-muted]">
<span className="mono">{server.slug}</span>
<span>·</span>
<span>v{server.currentVersion}</span>
{server.publicUrl && (
<>
<span>·</span>
<a
href={`${server.publicUrl}/mcp`}
target="_blank"
rel="noreferrer"
className="mono hover:text-[--color-fg]"
>
{server.publicUrl}/mcp
</a>
</>
)}
</div>
</div>
</div>
<div className="mt-6 flex gap-1 border-b border-[--color-border]">
{tabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
'relative px-3 py-2 text-[12.5px] transition-colors duration-200 ease-out',
tab === t.id
? 'text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{t.label}
{tab === t.id && (
<span className="absolute inset-x-0 -bottom-px h-px bg-[--color-fg]" />
)}
</button>
))}
</div>
<div className="mt-6">
{tab === 'overview' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Endpoint
</div>
<div className="mt-3">
{server.publicUrl ? (
<CodeBlock code={`${server.publicUrl}/mcp`} />
) : (
<div className="text-[12.5px] text-[--color-fg-muted]">Not deployed yet.</div>
)}
</div>
<div className="mt-4 flex items-center gap-2 text-[12px] text-[--color-fg-muted]">
<span className="size-1.5 rounded-full bg-emerald-400" />
OAuth 2.1 enforced · Streamable HTTP
</div>
</div>
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Builds
</div>
<div className="mt-3 space-y-2">
{builds.slice(0, 5).map((b) => (
<div key={b.id} className="flex items-center justify-between text-[12.5px]">
<span className="mono text-[--color-fg-muted]">v{b.version}</span>
<StatusPill status={b.status as never} />
<span className="mono text-[11px] text-[--color-fg-subtle]">
{b.startedAt ? new Date(b.startedAt).toLocaleString() : '—'}
</span>
</div>
))}
</div>
</div>
{server.publicUrl && (
<div className="panel p-4 md:col-span-2">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Install
</div>
<div className="mt-3">
<InstallSnippets
input={{ name: server.name, slug: server.slug, publicUrl: server.publicUrl }}
/>
</div>
</div>
)}
</div>
)}
{tab === 'tools' && (
<div className="panel p-4">
{!server.toolsSchema || server.toolsSchema.length === 0 ? (
<div className="text-[12.5px] text-[--color-fg-muted]">No tools yet.</div>
) : (
<div className="space-y-3">
{server.toolsSchema.map((tool) => (
<div key={tool.name} className="rounded-md border border-[--color-border] p-3">
<div className="flex items-baseline gap-2">
<span className="mono text-[13px] font-semibold">{tool.name}</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
{Object.keys(tool.inputSchema ?? {}).length > 0 && (
<div className="mt-2">
<CodeBlock
label="input schema"
code={JSON.stringify(tool.inputSchema, null, 2)}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{tab === 'logs' && (
latestBuildId ? (
<StreamingLogs buildId={latestBuildId} />
) : (
<div className="panel p-4 text-[12.5px] text-[--color-fg-muted]">
No builds to stream yet.
</div>
)
)}
{tab === 'metrics' && (
<div className="panel p-4">
<div className="grid gap-4 md:grid-cols-3">
<Metric label="Calls (24h)" value="0" />
<Metric label="P95 latency" value="—" />
<Metric label="Error rate" value="0%" />
</div>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
Live metrics begin streaming after the first tool call.
</p>
</div>
)}
{tab === 'secrets' && (
<div className="panel p-4 text-[12.5px] text-[--color-fg-muted]">
Secrets are AES-256-GCM encrypted at rest. They are injected as environment variables
into your container and are never echoed back. Use the wizard or CLI to rotate them.
</div>
)}
{tab === 'iterate' && (
<div className="panel p-4">
<Label hint="A new build will be queued; rolling deploy keeps current version live until ready.">
Extend the server
</Label>
<Textarea
rows={4}
className="mt-2"
value={iteratePrompt}
onChange={(e) => setIteratePrompt(e.target.value)}
placeholder="Add a tool: create_page that POSTs to /v1/pages with title and body."
/>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
size="md"
disabled={iteratePrompt.length < 10}
onClick={onIterate}
>
Queue rebuild
</Button>
</div>
</div>
)}
{tab === 'publish' && <PublishPanel serverId={server.id} serverStatus={server.status} />}
</div>
</div>
);
}
const CATEGORIES = [
'productivity',
'developer-tools',
'data',
'communication',
'finance',
'crm',
'analytics',
'devops',
'demo',
'other',
];
interface SecretHint {
key: string;
description: string;
howToGetUrl: string;
}
function PublishPanel({ serverId, serverStatus }: { serverId: string; serverStatus: string }) {
const [title, setTitle] = useState('');
const [category, setCategory] = useState('other');
const [shortDescription, setShortDescription] = useState('');
const [longDescription, setLongDescription] = useState('');
const [secretHints, setSecretHints] = useState<SecretHint[]>([]);
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [publishedSlug, setPublishedSlug] = useState<string | null>(null);
if (serverStatus !== 'live') {
return (
<div className="panel p-4">
<p className="text-[13px] text-[--color-fg-muted]">
Server must be live before publishing as a template. Current status:{' '}
<span className="mono">{serverStatus}</span>.
</p>
</div>
);
}
function addHint() {
setSecretHints((h) => [...h, { key: '', description: '', howToGetUrl: '' }]);
}
function updateHint(i: number, patch: Partial<SecretHint>) {
setSecretHints((h) => {
const next = [...h];
const cur = next[i];
if (!cur) return h;
next[i] = { ...cur, ...patch };
return next;
});
}
function removeHint(i: number) {
setSecretHints((h) => h.filter((_, j) => j !== i));
}
async function submit() {
setError(null);
if (title.length < 3) {
setError('Title needs at least 3 characters.');
return;
}
if (shortDescription.length < 10) {
setError('Short description needs at least 10 characters.');
return;
}
// Validate secret hints
for (const h of secretHints) {
if (!h.key) {
setError('Empty secret key — remove the row or fill it in.');
return;
}
if (!/^[A-Z][A-Z0-9_]*$/.test(h.key)) {
setError(`Secret key "${h.key}" must be UPPER_SNAKE_CASE.`);
return;
}
if (h.description.length < 1) {
setError(`Add a description for ${h.key}.`);
return;
}
}
setState('submitting');
try {
const res = await apiFetch<{ template: { slug: string } }>('/v1/templates', {
method: 'POST',
body: JSON.stringify({
serverId,
title,
category,
shortDescription,
longDescription: longDescription || undefined,
secretHints: secretHints.map((h) => ({
key: h.key,
description: h.description,
...(h.howToGetUrl ? { howToGetUrl: h.howToGetUrl } : {}),
})),
}),
});
setPublishedSlug(res.template.slug);
setState('success');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setState('error');
}
}
if (state === 'success' && publishedSlug) {
return (
<div className="panel p-4">
<div className="text-[14px] font-semibold tracking-tight">Published 🎉</div>
<p className="mt-2 text-[12.5px] text-[--color-fg-muted]">
Your template is live on the marketplace.
</p>
<div className="mt-3 flex gap-2">
<a
href={`/templates/${publishedSlug}`}
className="inline-flex h-8 items-center rounded-md bg-[--color-accent] px-3 text-[12.5px] font-medium text-white transition-colors hover:bg-[#5557e8]"
>
Open in marketplace
</a>
</div>
</div>
);
}
return (
<div className="panel p-4 space-y-4">
<div>
<h3 className="text-[14px] font-semibold tracking-tight">Publish as template</h3>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
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]">
<div className="space-y-1.5">
<Label htmlFor="t-title">Title</Label>
<Input
id="t-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Notion Reader"
maxLength={128}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-cat">Category</Label>
<select
id="t-cat"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[13px] focus:border-[--color-accent] focus:outline-none"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-short" hint={`${shortDescription.length}/280`}>
Short description
</Label>
<Input
id="t-short"
value={shortDescription}
onChange={(e) => setShortDescription(e.target.value)}
placeholder="Search and read pages from a Notion workspace."
maxLength={280}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-long">Long description (optional)</Label>
<Textarea
id="t-long"
rows={4}
value={longDescription}
onChange={(e) => setLongDescription(e.target.value)}
placeholder="What does it do, what doesn't it do, what should the user know before forking?"
/>
</div>
<div className="space-y-2">
<Label hint="Help future users understand which credentials they need and where to get them">
Credential hints
</Label>
{secretHints.length === 0 && (
<p className="text-[11.5px] text-[--color-fg-muted]">
None. This server doesn&apos;t need any credentials.
</p>
)}
{secretHints.map((h, i) => (
<div key={i} className="panel-subtle p-2.5 space-y-2">
<div className="grid grid-cols-[200px_1fr_auto] gap-2">
<Input
placeholder="NOTION_API_KEY"
value={h.key}
onChange={(e) => updateHint(i, { key: e.target.value.toUpperCase() })}
className="mono"
/>
<Input
placeholder="What is this credential? One sentence."
value={h.description}
onChange={(e) => updateHint(i, { description: e.target.value })}
/>
<Button variant="ghost" size="md" onClick={() => removeHint(i)}>
Remove
</Button>
</div>
<Input
placeholder="https://notion.so/my-integrations (optional 'How to get one' link)"
value={h.howToGetUrl}
onChange={(e) => updateHint(i, { howToGetUrl: e.target.value })}
/>
</div>
))}
<Button variant="ghost" size="sm" onClick={addHint}>
+ Add credential hint
</Button>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between border-t border-[--color-border] pt-3">
<p className="text-[11px] text-[--color-fg-subtle]">
By publishing, you agree the generated code may be inspected by others.
</p>
<Button
variant="primary"
size="md"
onClick={submit}
disabled={state === 'submitting'}
>
{state === 'submitting' ? 'Publishing…' : 'Publish'}
</Button>
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className="mt-1 text-[22px] font-semibold tracking-tight">{value}</div>
</div>
);
}