Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 47 additions & 37 deletions src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,46 @@ function rpcResult(id: unknown, result: unknown): Response {
}

// ─── Security: scrub secrets from error messages ──────────────
// M-2 remediation (2026-04-10 audit): service binding names are now
// stripped so backend topology (AUTH_SERVICE, ENGINE, etc.) never reaches
// a caller via leaked error text. Without this, an attacker could probe
// each tool to trigger backend errors and map internal service names.
const SERVICE_BINDING_NAMES =
/\b(AUTH_SERVICE|IMG_FORGE|TAROTSCRIPT|ENGINE|DEPLOYER|VISUAL_QA|TRANSPILER|PLATFORM_EVENTS_QUEUE|OAUTH_KV|OAUTH_PROVIDER)\b/g;

function sanitizeError(message: string): string {
// Strip anything that looks like a token, key, or secret
// Strip tokens, keys, secrets, AND internal service binding names
return message
.replace(/sb_(live|test)_[a-zA-Z0-9]+/g, '[REDACTED_KEY]')
.replace(/Bearer\s+[^\s]+/gi, 'Bearer [REDACTED]')
.replace(/[a-f0-9]{32,}/gi, '[REDACTED_HASH]');
.replace(/[a-f0-9]{32,}/gi, '[REDACTED_HASH]')
.replace(SERVICE_BINDING_NAMES, '[BINDING]');
}

// ─── User-facing error helper (M-2 remediation) ───────────────
// Every tool error path that returns text to the client must flow through
// this helper. It sanitizes the message (stripping secrets + binding names),
// truncates to a fixed length so no backend can pad responses with internal
// state, appends the trace id for correlated support, and logs the FULL
// original error internally so operators can still debug. Before M-2, tool
// handlers interpolated `err.message` directly into JSON-RPC responses,
// leaking internal identifiers, DB ids, and service binding names.
const USER_ERROR_MAX_LEN = 200;

function userError(
prefix: string,
err: unknown,
traceId: string,
): { content: Array<{ type: 'text'; text: string }>; isError: true } {
const raw = err instanceof Error ? err.message : String(err);
const safe = sanitizeError(raw).slice(0, USER_ERROR_MAX_LEN);
// Full (unsanitized) error stays in worker logs, tied to the trace id
// so support can correlate without exposing anything to the caller.
console.error(`[${traceId}] ${prefix}: ${raw}`);
return {
content: [{ type: 'text', text: `${prefix}: ${safe} (trace: ${traceId})` }],
isError: true,
};
}

// ─── Audit helper: log + queue ─────────────────────────────────
Expand Down Expand Up @@ -170,6 +204,7 @@ async function proxyRestToolCall(
args: unknown,
session: GatewaySession,
env: GatewayEnv,
traceId: string,
): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> {
const a = (args ?? {}) as Record<string, unknown>;

Expand Down Expand Up @@ -614,10 +649,7 @@ async function proxyRestToolCall(
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
return {
content: [{ type: 'text', text: `scaffold_publish failed: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
return userError('scaffold_publish failed', err, traceId);
}
}

Expand Down Expand Up @@ -653,10 +685,7 @@ async function proxyRestToolCall(
isError: !res.ok,
};
} catch (err) {
return {
content: [{ type: 'text', text: `Deploy failed: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
return userError('scaffold_deploy failed', err, traceId);
}
}

Expand Down Expand Up @@ -704,10 +733,7 @@ async function proxyRestToolCall(
isError: !res.ok,
};
} catch (err) {
return {
content: [{ type: 'text', text: `Visual QA error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
return userError('visual_qa error', err, traceId);
}
}

Expand Down Expand Up @@ -739,10 +765,7 @@ async function proxyRestToolCall(
isError: !res.ok,
};
} catch (err) {
return {
content: [{ type: 'text', text: `Transpiler error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
return userError('scaffold_import error', err, traceId);
}
}

Expand Down Expand Up @@ -789,15 +812,12 @@ async function proxyToolCall(
// ── REST API backends (e.g. TarotScript) ──────────────────────
if (route.restApi) {
try {
const result = await proxyRestToolCall(binding, route, backendToolName, args, session, env);
const result = await proxyRestToolCall(binding, route, backendToolName, args, session, env, traceId);
audit({ ...auditBase, outcome: 'success', latency_ms: Date.now() - start }, env);
return result;
} catch (err) {
audit({ ...auditBase, outcome: 'backend_error', latency_ms: Date.now() - start }, env);
return {
content: [{ type: 'text', text: `${route.product} error: ${sanitizeError(err instanceof Error ? err.message : String(err))}` }],
isError: true,
};
return userError(`${route.product} error`, err, traceId);
}
}

Expand Down Expand Up @@ -833,10 +853,7 @@ async function proxyToolCall(
if (!response.ok) {
const text = await response.text().catch(() => 'unknown error');
audit({ ...auditBase, outcome: 'backend_error', latency_ms: Date.now() - start }, env);
return {
content: [{ type: 'text', text: `Backend error (${route.product}): ${sanitizeError(text).slice(0, 500)}` }],
isError: true,
};
return userError(`Backend error (${route.product})`, text, traceId);
}

// Backend may respond with JSON or SSE (Streamable HTTP transport).
Expand Down Expand Up @@ -880,10 +897,7 @@ async function proxyToolCall(

if (body.error) {
audit({ ...auditBase, outcome: 'backend_error', latency_ms: Date.now() - start }, env);
return {
content: [{ type: 'text', text: `${route.product} error: ${sanitizeError(body.error.message ?? 'unknown')}` }],
isError: true,
};
return userError(`${route.product} error`, body.error.message ?? 'unknown', traceId);
}

const fallback = { content: [{ type: 'text' as const, text: 'No result from backend' }], isError: true as const };
Expand Down Expand Up @@ -919,16 +933,12 @@ async function proxyToolCall(
if (err instanceof DOMException && err.name === 'TimeoutError') {
audit({ ...auditBase, outcome: 'backend_error', latency_ms: Date.now() - start }, env);
return {
content: [{ type: 'text', text: `Backend timeout (${route.product})` }],
content: [{ type: 'text', text: `Backend timeout (${route.product}) (trace: ${traceId})` }],
isError: true,
};
}
const msg = err instanceof Error ? err.message : String(err);
audit({ ...auditBase, outcome: 'error', latency_ms: Date.now() - start }, env);
return {
content: [{ type: 'text', text: `Gateway proxy error: ${sanitizeError(msg)}` }],
isError: true,
};
return userError('Gateway proxy error', err, traceId);
}
}

Expand Down
Loading
Loading