From f1dc25e73b08fb3f2fce1369ef48d75d4f3acebf Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:14:08 -0400 Subject: [PATCH 01/27] fix(security): add fail-closed outbound target validation --- src/lib/security/outbound-target.ts | 151 ++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/lib/security/outbound-target.ts diff --git a/src/lib/security/outbound-target.ts b/src/lib/security/outbound-target.ts new file mode 100644 index 0000000..28093af --- /dev/null +++ b/src/lib/security/outbound-target.ts @@ -0,0 +1,151 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +export class OutboundTargetError extends Error { + constructor( + public readonly code: string, + message = "Outbound target is not allowed", + ) { + super(message); + this.name = "OutboundTargetError"; + } +} + +type Resolver = (hostname: string) => Promise; + +export interface OutboundTargetOptions { + allowedHosts?: string[]; + production?: boolean; + resolver?: Resolver; +} + +async function defaultResolver(hostname: string): Promise { + const records = await lookup(hostname, { all: true, verbatim: true }); + return records.map((record) => record.address); +} + +function configuredAllowedHosts(): string[] { + return (process.env.CAPI_MCP_ALLOWED_HOSTS ?? "") + .split(",") + .map((value) => value.trim().toLowerCase().replace(/\.$/, "")) + .filter(Boolean); +} + +function normalizeHostname(hostname: string): string { + return hostname + .trim() + .toLowerCase() + .replace(/^\[/, "") + .replace(/\]$/, "") + .replace(/\.$/, ""); +} + +function ipv4Octets(address: string): number[] | null { + if (isIP(address) !== 4) return null; + return address.split(".").map(Number); +} + +export function isUnsafeOutboundAddress(address: string): boolean { + const normalized = normalizeHostname(address); + const octets = ipv4Octets(normalized); + + if (octets) { + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 192 && b === 0 && octets[2] === 2) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && octets[2] === 100) || + (a === 203 && b === 0 && octets[2] === 113) || + a >= 224 + ); + } + + if (isIP(normalized) === 6) { + const value = normalized.toLowerCase(); + return ( + value === "::" || + value === "::1" || + value.startsWith("fc") || + value.startsWith("fd") || + /^fe[89ab]/.test(value) || + value.startsWith("ff") || + value.startsWith("::ffff:") + ); + } + + return false; +} + +function isForbiddenHostname(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + hostname === "metadata.google.internal" + ); +} + +export async function validateOutboundTarget( + input: string, + options: OutboundTargetOptions = {}, +): Promise { + let url: URL; + try { + url = new URL(input); + } catch { + throw new OutboundTargetError("OUTBOUND_URL_INVALID"); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new OutboundTargetError("OUTBOUND_SCHEME_FORBIDDEN"); + } + if (url.username || url.password) { + throw new OutboundTargetError("OUTBOUND_CREDENTIALS_FORBIDDEN"); + } + if (url.hash) { + throw new OutboundTargetError("OUTBOUND_FRAGMENT_FORBIDDEN"); + } + + const hostname = normalizeHostname(url.hostname); + if (!hostname || isForbiddenHostname(hostname)) { + throw new OutboundTargetError("OUTBOUND_HOST_FORBIDDEN"); + } + + const production = options.production ?? process.env.NODE_ENV === "production"; + const allowedHosts = (options.allowedHosts ?? configuredAllowedHosts()).map(normalizeHostname); + if (production && allowedHosts.length === 0) { + throw new OutboundTargetError("OUTBOUND_ALLOWLIST_UNCONFIGURED"); + } + if (allowedHosts.length > 0 && !allowedHosts.includes(hostname)) { + throw new OutboundTargetError("OUTBOUND_HOST_NOT_ALLOWLISTED"); + } + + const resolver = options.resolver ?? defaultResolver; + let addresses: string[]; + if (isIP(hostname)) { + addresses = [hostname]; + } else { + try { + addresses = await resolver(hostname); + } catch { + throw new OutboundTargetError("OUTBOUND_DNS_UNAVAILABLE"); + } + } + + if (addresses.length === 0) { + throw new OutboundTargetError("OUTBOUND_DNS_EMPTY"); + } + if (addresses.some(isUnsafeOutboundAddress)) { + throw new OutboundTargetError("OUTBOUND_ADDRESS_FORBIDDEN"); + } + + return url; +} From d89e0413f25efce03eee4616232ec5602f5a5c4b Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:14:24 -0400 Subject: [PATCH 02/27] test(security): cover outbound target SSRF controls --- src/lib/security/outbound-target.test.ts | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/lib/security/outbound-target.test.ts diff --git a/src/lib/security/outbound-target.test.ts b/src/lib/security/outbound-target.test.ts new file mode 100644 index 0000000..b82c231 --- /dev/null +++ b/src/lib/security/outbound-target.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { validateOutboundTarget } from "./outbound-target"; + +const publicResolver = async () => ["93.184.216.34"]; + +describe("validateOutboundTarget", () => { + it("accepts an explicitly allowlisted public HTTPS host", async () => { + const url = await validateOutboundTarget("https://api.example.com/v1", { + production: true, + allowedHosts: ["api.example.com"], + resolver: publicResolver, + }); + expect(url.hostname).toBe("api.example.com"); + }); + + it("fails closed in production when no host allowlist is configured", async () => { + await expect(validateOutboundTarget("https://api.example.com", { + production: true, + allowedHosts: [], + resolver: publicResolver, + })).rejects.toMatchObject({ code: "OUTBOUND_ALLOWLIST_UNCONFIGURED" }); + }); + + it.each([ + "http://127.0.0.1:8080", + "http://169.254.169.254/latest/meta-data", + "http://10.1.2.3", + "http://192.168.1.2", + "http://[::1]/", + ])("rejects local or private literal address %s", async (target) => { + await expect(validateOutboundTarget(target, { + production: false, + allowedHosts: [], + })).rejects.toMatchObject({ code: "OUTBOUND_ADDRESS_FORBIDDEN" }); + }); + + it("rejects a public-looking hostname that resolves to a private address", async () => { + await expect(validateOutboundTarget("https://api.example.com", { + production: true, + allowedHosts: ["api.example.com"], + resolver: async () => ["10.0.0.8"], + })).rejects.toMatchObject({ code: "OUTBOUND_ADDRESS_FORBIDDEN" }); + }); + + it("rejects non-allowlisted hosts", async () => { + await expect(validateOutboundTarget("https://other.example.com", { + production: true, + allowedHosts: ["api.example.com"], + resolver: publicResolver, + })).rejects.toMatchObject({ code: "OUTBOUND_HOST_NOT_ALLOWLISTED" }); + }); + + it.each([ + "file:///etc/passwd", + "https://user:pass@api.example.com/", + "https://api.example.com/#fragment", + "http://localhost:3000/", + ])("rejects unsafe URL form %s", async (target) => { + await expect(validateOutboundTarget(target, { + production: false, + allowedHosts: [], + resolver: publicResolver, + })).rejects.toBeTruthy(); + }); +}); From 39869cc91df1698b8b962434a488f264cbb8e90f Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:14:47 -0400 Subject: [PATCH 03/27] fix(security): validate MCP registration targets and sanitize errors --- src/app/api/mcp/servers/route.ts | 40 ++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/app/api/mcp/servers/route.ts b/src/app/api/mcp/servers/route.ts index 300478e..957f12e 100644 --- a/src/app/api/mcp/servers/route.ts +++ b/src/app/api/mcp/servers/route.ts @@ -11,6 +11,7 @@ import { translateOpenApiToMcp } from "@/lib/covenant/dynamic-mcp"; import { toolRegistry } from "@/lib/covenant/tool-registry"; import { mcpOrchestrator } from "@/lib/mcp/orchestrator"; import type { McpServerDescriptor } from "@/lib/mcp/schema"; +import { OutboundTargetError, validateOutboundTarget } from "@/lib/security/outbound-target"; export const dynamic = "force-dynamic"; @@ -24,6 +25,20 @@ function localProcessAllowed(): boolean { return process.env.NODE_ENV !== "production" && process.env.CAPI_ALLOW_LOCAL_PROCESS_MCP === "true"; } +function safeRegistryError(error: unknown): NextResponse { + if (error instanceof OutboundTargetError) { + return NextResponse.json( + { ok: false, error: "Remote MCP target is not permitted", code: error.code }, + { status: 403 }, + ); + } + console.error("MCP registry operation failed", error); + return NextResponse.json( + { ok: false, error: "MCP registry operation failed" }, + { status: 502 }, + ); +} + export async function POST(req: NextRequest) { const authError = requireRegistryAuth(req); if (authError) return authError; @@ -49,6 +64,22 @@ export async function POST(req: NextRequest) { ); } + // The current driver implements SSE, not Streamable HTTP. Do not accept + // descriptors that are guaranteed to fail later in the execution path. + if (descriptor.type === "remote-http") { + return NextResponse.json( + { error: "remote-http MCP is not implemented; use a supported governed transport" }, + { status: 400 }, + ); + } + + if (descriptor.type === "remote-sse") { + if (!descriptor.serverUrl) { + return NextResponse.json({ error: "serverUrl is required for remote-sse MCP" }, { status: 400 }); + } + await validateOutboundTarget(descriptor.serverUrl); + } + const instance = await mcpOrchestrator.startServer(descriptor); return NextResponse.json({ @@ -57,7 +88,7 @@ export async function POST(req: NextRequest) { status: instance.status, tools_registered: instance.tools.length, tool_names: instance.tools.map((t) => t.name), - error: instance.error, + error: instance.status === "error" ? "MCP server failed to start" : undefined, }); } @@ -70,6 +101,8 @@ export async function POST(req: NextRequest) { ); } + await validateOutboundTarget(openapi_url); + await validateOutboundTarget(base_url); const tools = await translateOpenApiToMcp(server_id, openapi_url, base_url); return NextResponse.json({ @@ -79,8 +112,7 @@ export async function POST(req: NextRequest) { tool_names: tools.map((t) => t.name), }); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return NextResponse.json({ ok: false, error: message }, { status: 500 }); + return safeRegistryError(err); } } @@ -99,7 +131,7 @@ export async function GET(req: NextRequest) { type: inst.descriptor.type, status: inst.status, tool_count: inst.tools.length, - error: inst.error, + error: inst.status === "error" ? "MCP server unavailable" : undefined, })), total_tools: toolRegistry.getAllTools().length + nativeInstances.reduce((sum, inst) => sum + inst.tools.length, 0), }); From 57efbc517ec8ef82d59542084ef3ad01ab648500 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:15:04 -0400 Subject: [PATCH 04/27] fix(security): validate OpenAPI outbound targets before fetch --- src/lib/covenant/dynamic-mcp.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/lib/covenant/dynamic-mcp.ts b/src/lib/covenant/dynamic-mcp.ts index 22ac063..fd4ed41 100644 --- a/src/lib/covenant/dynamic-mcp.ts +++ b/src/lib/covenant/dynamic-mcp.ts @@ -16,6 +16,7 @@ import { randomUUID } from "crypto"; import { getEngine } from "./engine"; import { toolRegistry, type DynamicTool } from "./tool-registry"; import type { CapabilityIdentity } from "./types"; +import { validateOutboundTarget } from "@/lib/security/outbound-target"; // --------------------------------------------------------------------------- // Minimal OpenAPI types we care about @@ -96,12 +97,20 @@ export async function translateOpenApiToMcp( openapiUrl: string, baseUrl: string, ): Promise { - // Fetch the spec - const res = await fetch(openapiUrl, { headers: { Accept: "application/json" } }); - if (!res.ok) throw new Error(`Failed to fetch OpenAPI spec: ${res.status} ${openapiUrl}`); + // Validate both the specification source and the execution destination. + // Redirects are disabled for the spec fetch so a public allowlisted URL + // cannot redirect cAPI into a private or metadata address. + const validatedSpecUrl = await validateOutboundTarget(openapiUrl); + const validatedBaseUrl = await validateOutboundTarget(baseUrl); + + const res = await fetch(validatedSpecUrl, { + headers: { Accept: "application/json" }, + redirect: "error", + }); + if (!res.ok) throw new Error(`Failed to fetch OpenAPI spec: ${res.status}`); const spec = (await res.json()) as OAPISpec; - if (!spec.paths) throw new Error(`OpenAPI spec at ${openapiUrl} has no paths`); + if (!spec.paths) throw new Error("OpenAPI specification has no paths"); const engine = getEngine(); const tools: DynamicTool[] = []; @@ -126,7 +135,7 @@ export async function translateOpenApiToMcp( inputSchema: buildInputSchema(op), _meta: { server_id: serverId, - base_url: baseUrl, + base_url: validatedBaseUrl.toString(), path, method: rawMethod.toUpperCase() as DynamicTool["_meta"]["method"], capability_id: capabilityId, @@ -164,11 +173,11 @@ export async function translateOpenApiToMcp( } } - // Store in registry + // Store only canonicalized, policy-validated destinations in the registry. toolRegistry.set(serverId, tools, { server_id: serverId, - base_url: baseUrl, - openapi_url: openapiUrl, + base_url: validatedBaseUrl.toString(), + openapi_url: validatedSpecUrl.toString(), registered_at: new Date().toISOString(), }); From 4edfd205f114b8524141db67ae95bd76d1a68542 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:15:22 -0400 Subject: [PATCH 05/27] fix(security): revalidate proxy destinations before outbound fetch --- .../api/proxy/[serverId]/[...path]/route.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/app/api/proxy/[serverId]/[...path]/route.ts b/src/app/api/proxy/[serverId]/[...path]/route.ts index cf1ac22..449fba5 100644 --- a/src/app/api/proxy/[serverId]/[...path]/route.ts +++ b/src/app/api/proxy/[serverId]/[...path]/route.ts @@ -8,6 +8,7 @@ import { timingSafeEqual } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { toolRegistry } from "@/lib/covenant/tool-registry"; +import { OutboundTargetError, validateOutboundTarget } from "@/lib/security/outbound-target"; export const dynamic = "force-dynamic"; @@ -56,7 +57,21 @@ async function handleProxy( } const path = `/${pathParts.join("/")}`; - const targetUrl = `${server.base_url}${path}${req.nextUrl.search}`; + let targetUrl: URL; + try { + targetUrl = await validateOutboundTarget( + new URL(`${path}${req.nextUrl.search}`, server.base_url).toString(), + ); + } catch (error) { + if (error instanceof OutboundTargetError) { + return NextResponse.json( + { error: "Registered upstream target is not permitted", code: error.code }, + { status: 403 }, + ); + } + console.error("Proxy target validation failed", error); + return NextResponse.json({ error: "Registered upstream target is unavailable" }, { status: 502 }); + } // Forward caller-supplied upstream headers, but never leak the cAPI internal // credential or reverse-proxy internals to the registered destination. @@ -91,6 +106,9 @@ async function handleProxy( headers: forwardHeaders, body: body ?? null, signal: controller.signal, + // A previously validated public target must not be allowed to redirect + // the proxy to a private or metadata destination. + redirect: "error", }); clearTimeout(timer); @@ -111,8 +129,9 @@ async function handleProxy( clearTimeout(timer); const message = err instanceof Error ? err.message : String(err); const isTimeout = message.includes("abort") || message.includes("timeout"); + console.error("MCP proxy upstream request failed", err); return NextResponse.json( - { error: isTimeout ? "Upstream request timed out" : `Proxy error: ${message}` }, + { error: isTimeout ? "Upstream request timed out" : "Upstream request failed" }, { status: isTimeout ? 504 : 502 }, ); } From cbf8c514f3294b59a4dfdbe1107ef85203180303 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:15:31 -0400 Subject: [PATCH 06/27] fix(security): validate remote SSE MCP destination before connect --- src/lib/mcp/drivers/McpDriver.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/mcp/drivers/McpDriver.ts b/src/lib/mcp/drivers/McpDriver.ts index ecbb9cd..9f1cf13 100644 --- a/src/lib/mcp/drivers/McpDriver.ts +++ b/src/lib/mcp/drivers/McpDriver.ts @@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { McpServerDescriptor } from "../schema"; +import { validateOutboundTarget } from "@/lib/security/outbound-target"; const LOCAL_PROCESS_ENV_ALLOWLIST = [ "PATH", @@ -56,7 +57,8 @@ export class McpDriver { env: buildLocalProcessEnv(descriptor), }); } else if (descriptor.type === "remote-sse" && descriptor.serverUrl) { - transport = new SSEClientTransport(new URL(descriptor.serverUrl)); + const validatedUrl = await validateOutboundTarget(descriptor.serverUrl); + transport = new SSEClientTransport(validatedUrl); } else { throw new Error(`Unsupported or misconfigured MCP descriptor type: ${descriptor.type}`); } From b09e340cb5d7537155e11fb2d047ddd350184e56 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Sun, 23 Aug 2026 23:16:04 -0400 Subject: [PATCH 07/27] docs(security): define hosted MCP outbound egress contract --- docs/MCP_SECURITY_BOUNDARY.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/MCP_SECURITY_BOUNDARY.md b/docs/MCP_SECURITY_BOUNDARY.md index 8858a8d..ed55620 100644 --- a/docs/MCP_SECURITY_BOUNDARY.md +++ b/docs/MCP_SECURITY_BOUNDARY.md @@ -2,7 +2,7 @@ Status: **source contract; deployed runtime must be verified separately** -This document records the security boundary introduced by cAPI commit `eb38524268cc3b4bcc767b4c8ce7794c91c777c9` so future changes do not accidentally restore ambient host execution or unauthenticated direct forwarding. +This document records the hosted MCP security boundary so future changes do not accidentally restore ambient host execution, unauthenticated direct forwarding, or arbitrary authenticated outbound network access. ## Responsibility @@ -16,6 +16,10 @@ cAPI is the governed connection/discovery/capability-negotiation layer. It is ** 4. Missing internal auth configuration fails closed; it must never silently make the proxy public. 5. cAPI internal authentication credentials are not forwarded to registered upstream services. 6. A spawned development MCP child must never inherit the complete cAPI service environment. +7. Authentication does not make an arbitrary remote URL safe. Remote MCP/OpenAPI destinations must pass the outbound egress policy before registration and immediately before cAPI-controlled outbound requests. +8. Production remote targets require an explicit server-controlled `CAPI_MCP_ALLOWED_HOSTS` allowlist. Loopback, private, link-local, metadata, reserved, and DNS-to-private destinations are rejected. +9. cAPI-controlled OpenAPI/proxy fetches must not follow an unvalidated redirect to a different destination. Redirects are disabled unless every redirect hop is independently revalidated. +10. Unsupported remote transport types fail closed rather than registering an endpoint that the execution driver cannot honor. ## Non-production local-process MCP @@ -28,12 +32,18 @@ When enabled, the child receives only a minimal runtime environment needed to st ## Remote MCP -Hosted cAPI should use remote MCP transports for actual service connections. Discovery/connection does not itself grant permission for a consequential operation. +Hosted cAPI may use remote MCP transports only under the outbound egress policy above. Discovery/connection does not itself grant permission for a consequential operation. + +The remote URL is not authority. It is untrusted input even when supplied by an authenticated administrator. A target must be canonicalized, matched against the server-controlled allowlist, resolved, and rejected if any resolved address is local/private/link-local/metadata/reserved. Client-visible errors must remain sanitized; transport/DNS details belong in server-side diagnostics. + +The current source validates the initial remote-SSE destination. Deployment verification must additionally prove that the SDK transport cannot redirect a validated public endpoint to a forbidden address. Until that redirect behavior is proven fail-closed (or remote SSE is constrained accordingly), the remote-SSE redirect boundary remains **NOT_VERIFIED**. ## Direct proxy rule The direct proxy is a transport helper, not an authority boundary. Because a direct call does not itself prove that CAPPO authorized the consequence, it is restricted to authenticated internal traffic and must not become a public alternate execution API. +A stored proxy destination is revalidated immediately before the outbound fetch. This is required because DNS and registry state can change after initial registration. + ## Required deployment verification After any deployment affecting these paths, verify at minimum: @@ -41,8 +51,13 @@ After any deployment affecting these paths, verify at minimum: - unauthenticated `GET /api/mcp/servers` is rejected; - unauthenticated `POST /api/mcp/servers` is rejected before any server start; - authenticated production registration of `local-process` is rejected; +- unsupported `remote-http` registration is rejected until a governed implementation exists; +- production remote registration fails closed when `CAPI_MCP_ALLOWED_HOSTS` is not configured; +- loopback/private/link-local/metadata targets and DNS-to-private targets are rejected; +- OpenAPI and direct-proxy redirects cannot pivot to forbidden destinations; - direct proxy requests without the internal key are rejected; - valid internal proxy calls do not forward the internal cAPI key upstream; +- client-visible registry/proxy errors do not disclose internal hostnames, ports, filesystem paths, or raw transport errors; - no alternate public route re-exposes MCP registration or direct forwarding. Do not infer deployed safety from the default branch alone. Record the exact deployed commit and negative-test results before marking the boundary verified live. From c75017b1f3caaa3c7957065387a8f73dc40c8c94 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 24 Aug 2026 04:21:00 -0400 Subject: [PATCH 08/27] fix(security): constrain proxy headers and preserve upstream base paths --- .../api/proxy/[serverId]/[...path]/route.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/app/api/proxy/[serverId]/[...path]/route.ts b/src/app/api/proxy/[serverId]/[...path]/route.ts index 449fba5..d25d27d 100644 --- a/src/app/api/proxy/[serverId]/[...path]/route.ts +++ b/src/app/api/proxy/[serverId]/[...path]/route.ts @@ -59,9 +59,12 @@ async function handleProxy( const path = `/${pathParts.join("/")}`; let targetUrl: URL; try { - targetUrl = await validateOutboundTarget( - new URL(`${path}${req.nextUrl.search}`, server.base_url).toString(), - ); + const baseUrl = new URL(server.base_url); + const basePath = baseUrl.pathname.replace(/\/+$/, ""); + const requestPath = path.replace(/^\/+/, ""); + baseUrl.pathname = `${basePath}/${requestPath}`.replace(/\/{2,}/g, "/"); + baseUrl.search = req.nextUrl.search; + targetUrl = await validateOutboundTarget(baseUrl.toString()); } catch (error) { if (error instanceof OutboundTargetError) { return NextResponse.json( @@ -73,18 +76,19 @@ async function handleProxy( return NextResponse.json({ error: "Registered upstream target is unavailable" }, { status: 502 }); } - // Forward caller-supplied upstream headers, but never leak the cAPI internal - // credential or reverse-proxy internals to the registered destination. + // Forward only headers that are explicitly safe for registered upstreams. + // Caller credentials, cookies, proxy credentials, internal keys, response-only + // headers, and hop-by-hop headers must never leave cAPI through this route. const forwardHeaders = new Headers(); - const blockedHeaders = new Set([ - "host", - "x-forwarded-host", - "x-forwarded-proto", - "x-api-key", - "x-covenant-admin-token", + const allowedRequestHeaders = new Set([ + "accept", + "content-type", + "if-match", + "if-none-match", + "range", ]); for (const [key, value] of req.headers.entries()) { - if (blockedHeaders.has(key.toLowerCase())) continue; + if (!allowedRequestHeaders.has(key.toLowerCase())) continue; forwardHeaders.set(key, value); } From 0bf6582c52ed80886e6d1ddc79f8de3f203e7a73 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 12:22:17 -0400 Subject: [PATCH 09/27] fix(security): disable remote SSE in production --- src/lib/mcp/drivers/McpDriver.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/mcp/drivers/McpDriver.ts b/src/lib/mcp/drivers/McpDriver.ts index 9f1cf13..dc15157 100644 --- a/src/lib/mcp/drivers/McpDriver.ts +++ b/src/lib/mcp/drivers/McpDriver.ts @@ -17,6 +17,13 @@ function localProcessAllowed(): boolean { return process.env.NODE_ENV !== "production" && process.env.CAPI_ALLOW_LOCAL_PROCESS_MCP === "true"; } +function remoteSseAllowed(): boolean { + // The MCP SDK owns redirects, reconnects, and message POSTs for SSE. Until + // every network operation can be bound to the same validated destination + // policy, hosted cAPI must not expose this transport in production. + return process.env.NODE_ENV !== "production"; +} + function buildLocalProcessEnv(descriptor: McpServerDescriptor): Record { const env: Record = {}; @@ -57,6 +64,10 @@ export class McpDriver { env: buildLocalProcessEnv(descriptor), }); } else if (descriptor.type === "remote-sse" && descriptor.serverUrl) { + if (!remoteSseAllowed()) { + throw new Error("remote-sse MCP is disabled in production until outbound policy enforcement covers every transport operation"); + } + const validatedUrl = await validateOutboundTarget(descriptor.serverUrl); transport = new SSEClientTransport(validatedUrl); } else { From 7c47ac1b8644e270795c22350729038dd786b8fa Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 12:22:35 -0400 Subject: [PATCH 10/27] test(security): lock production remote SSE fail closed --- src/lib/mcp/drivers/McpDriver.test.ts | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/lib/mcp/drivers/McpDriver.test.ts diff --git a/src/lib/mcp/drivers/McpDriver.test.ts b/src/lib/mcp/drivers/McpDriver.test.ts new file mode 100644 index 0000000..8774036 --- /dev/null +++ b/src/lib/mcp/drivers/McpDriver.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { McpDriver } from "./McpDriver"; + +const originalNodeEnv = process.env.NODE_ENV; + +afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } +}); + +describe("McpDriver production transport policy", () => { + it("fails closed for remote-sse until every SDK network operation is policy-bound", async () => { + process.env.NODE_ENV = "production"; + + await expect( + McpDriver.connect({ + id: "remote-sse-production", + displayName: "Remote SSE production probe", + type: "remote-sse", + serverUrl: "https://example.com/mcp", + }), + ).rejects.toThrow( + "remote-sse MCP is disabled in production until outbound policy enforcement covers every transport operation", + ); + }); +}); From d8549bbe87d92539d1f9a4201738f5008fee6515 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 12:24:23 -0400 Subject: [PATCH 11/27] test(security): use Vitest env stubbing --- src/lib/mcp/drivers/McpDriver.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/lib/mcp/drivers/McpDriver.test.ts b/src/lib/mcp/drivers/McpDriver.test.ts index 8774036..4eb589f 100644 --- a/src/lib/mcp/drivers/McpDriver.test.ts +++ b/src/lib/mcp/drivers/McpDriver.test.ts @@ -1,19 +1,13 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { McpDriver } from "./McpDriver"; -const originalNodeEnv = process.env.NODE_ENV; - afterEach(() => { - if (originalNodeEnv === undefined) { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = originalNodeEnv; - } + vi.unstubAllEnvs(); }); describe("McpDriver production transport policy", () => { it("fails closed for remote-sse until every SDK network operation is policy-bound", async () => { - process.env.NODE_ENV = "production"; + vi.stubEnv("NODE_ENV", "production"); await expect( McpDriver.connect({ From b43fd26a107e0aa58821b158cc1b90a9c604e0db Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 13:17:20 -0400 Subject: [PATCH 12/27] fix(security): tighten outbound IP classification --- src/lib/security/outbound-target.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/security/outbound-target.ts b/src/lib/security/outbound-target.ts index 28093af..95b0dc8 100644 --- a/src/lib/security/outbound-target.ts +++ b/src/lib/security/outbound-target.ts @@ -58,7 +58,7 @@ export function isUnsafeOutboundAddress(address: string): boolean { (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 0) || + (a === 192 && b === 0 && octets[2] === 0) || (a === 192 && b === 168) || (a === 192 && b === 0 && octets[2] === 2) || (a === 198 && (b === 18 || b === 19)) || @@ -76,6 +76,7 @@ export function isUnsafeOutboundAddress(address: string): boolean { value.startsWith("fc") || value.startsWith("fd") || /^fe[89ab]/.test(value) || + /^fe[cdef]/.test(value) || value.startsWith("ff") || value.startsWith("::ffff:") ); From 7c4cd1741b93803d9bea6af7c12b087ba9db1f91 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 13:17:31 -0400 Subject: [PATCH 13/27] test(security): cover special-use address boundaries --- src/lib/security/outbound-target.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/security/outbound-target.test.ts b/src/lib/security/outbound-target.test.ts index b82c231..fc28935 100644 --- a/src/lib/security/outbound-target.test.ts +++ b/src/lib/security/outbound-target.test.ts @@ -27,6 +27,7 @@ describe("validateOutboundTarget", () => { "http://10.1.2.3", "http://192.168.1.2", "http://[::1]/", + "http://[fec0::1]/", ])("rejects local or private literal address %s", async (target) => { await expect(validateOutboundTarget(target, { production: false, @@ -34,6 +35,14 @@ describe("validateOutboundTarget", () => { })).rejects.toMatchObject({ code: "OUTBOUND_ADDRESS_FORBIDDEN" }); }); + it("does not reject the entire public 192.0/16 range", async () => { + const url = await validateOutboundTarget("http://192.0.10.1/", { + production: false, + allowedHosts: [], + }); + expect(url.hostname).toBe("192.0.10.1"); + }); + it("rejects a public-looking hostname that resolves to a private address", async () => { await expect(validateOutboundTarget("https://api.example.com", { production: true, From 686c6d7b74b266ac62f28238bc8c602bc69f7245 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 14:16:29 -0400 Subject: [PATCH 14/27] fix(security): bound outbound DNS resolution time --- src/lib/security/outbound-target.ts | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lib/security/outbound-target.ts b/src/lib/security/outbound-target.ts index 95b0dc8..3559cf5 100644 --- a/src/lib/security/outbound-target.ts +++ b/src/lib/security/outbound-target.ts @@ -17,6 +17,7 @@ export interface OutboundTargetOptions { allowedHosts?: string[]; production?: boolean; resolver?: Resolver; + resolverTimeoutMs?: number; } async function defaultResolver(hostname: string): Promise { @@ -94,6 +95,31 @@ function isForbiddenHostname(hostname: string): boolean { ); } +async function resolveWithTimeout( + resolver: Resolver, + hostname: string, + timeoutMs: number, +): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new OutboundTargetError("OUTBOUND_DNS_TIMEOUT"); + } + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + resolver(hostname), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new OutboundTargetError("OUTBOUND_DNS_TIMEOUT")), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + export async function validateOutboundTarget( input: string, options: OutboundTargetOptions = {}, @@ -130,13 +156,15 @@ export async function validateOutboundTarget( } const resolver = options.resolver ?? defaultResolver; + const resolverTimeoutMs = options.resolverTimeoutMs ?? 5_000; let addresses: string[]; if (isIP(hostname)) { addresses = [hostname]; } else { try { - addresses = await resolver(hostname); - } catch { + addresses = await resolveWithTimeout(resolver, hostname, resolverTimeoutMs); + } catch (error) { + if (error instanceof OutboundTargetError) throw error; throw new OutboundTargetError("OUTBOUND_DNS_UNAVAILABLE"); } } From e6ef289713e8ced65f4ed0a4896daa1ffeaa9018 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 14:16:41 -0400 Subject: [PATCH 15/27] test(security): cover outbound DNS deadline --- src/lib/security/outbound-target.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/security/outbound-target.test.ts b/src/lib/security/outbound-target.test.ts index fc28935..d50d88c 100644 --- a/src/lib/security/outbound-target.test.ts +++ b/src/lib/security/outbound-target.test.ts @@ -51,6 +51,15 @@ describe("validateOutboundTarget", () => { })).rejects.toMatchObject({ code: "OUTBOUND_ADDRESS_FORBIDDEN" }); }); + it("fails closed when DNS resolution exceeds its deadline", async () => { + await expect(validateOutboundTarget("https://api.example.com", { + production: true, + allowedHosts: ["api.example.com"], + resolver: () => new Promise(() => undefined), + resolverTimeoutMs: 5, + })).rejects.toMatchObject({ code: "OUTBOUND_DNS_TIMEOUT" }); + }); + it("rejects non-allowlisted hosts", async () => { await expect(validateOutboundTarget("https://other.example.com", { production: true, From 33f8b8bf56688e95e4d65067eb3b030de67b7fb7 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 14:17:22 -0400 Subject: [PATCH 16/27] fix(security): include DNS in proxy timeout budget --- .../api/proxy/[serverId]/[...path]/route.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/app/api/proxy/[serverId]/[...path]/route.ts b/src/app/api/proxy/[serverId]/[...path]/route.ts index d25d27d..9e39de0 100644 --- a/src/app/api/proxy/[serverId]/[...path]/route.ts +++ b/src/app/api/proxy/[serverId]/[...path]/route.ts @@ -56,6 +56,9 @@ async function handleProxy( ); } + const startedAt = Date.now(); + const remainingBudgetMs = () => Math.max(0, PROXY_TIMEOUT_MS - (Date.now() - startedAt)); + const path = `/${pathParts.join("/")}`; let targetUrl: URL; try { @@ -64,12 +67,18 @@ async function handleProxy( const requestPath = path.replace(/^\/+/, ""); baseUrl.pathname = `${basePath}/${requestPath}`.replace(/\/{2,}/g, "/"); baseUrl.search = req.nextUrl.search; - targetUrl = await validateOutboundTarget(baseUrl.toString()); + targetUrl = await validateOutboundTarget(baseUrl.toString(), { + resolverTimeoutMs: remainingBudgetMs(), + }); } catch (error) { if (error instanceof OutboundTargetError) { + const timedOut = error.code === "OUTBOUND_DNS_TIMEOUT"; return NextResponse.json( - { error: "Registered upstream target is not permitted", code: error.code }, - { status: 403 }, + { + error: timedOut ? "Upstream request timed out" : "Registered upstream target is not permitted", + code: error.code, + }, + { status: timedOut ? 504 : 403 }, ); } console.error("Proxy target validation failed", error); @@ -97,8 +106,13 @@ async function handleProxy( forwardHeaders.set("X-Forwarded-Path", path); forwardHeaders.set("X-Request-Time", new Date().toISOString()); + const remaining = remainingBudgetMs(); + if (remaining <= 0) { + return NextResponse.json({ error: "Upstream request timed out" }, { status: 504 }); + } + const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), remaining); try { const body = req.method !== "GET" && req.method !== "HEAD" From f95b9a68d78d570ece5b9e9af151404fb9310102 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:16:41 -0400 Subject: [PATCH 17/27] security: expose validated outbound addresses for connection pinning --- src/lib/security/outbound-target.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/lib/security/outbound-target.ts b/src/lib/security/outbound-target.ts index 3559cf5..3a8964b 100644 --- a/src/lib/security/outbound-target.ts +++ b/src/lib/security/outbound-target.ts @@ -20,6 +20,11 @@ export interface OutboundTargetOptions { resolverTimeoutMs?: number; } +export interface ValidatedOutboundTarget { + url: URL; + addresses: readonly string[]; +} + async function defaultResolver(hostname: string): Promise { const records = await lookup(hostname, { all: true, verbatim: true }); return records.map((record) => record.address); @@ -120,10 +125,10 @@ async function resolveWithTimeout( } } -export async function validateOutboundTarget( +export async function validateOutboundTargetWithAddresses( input: string, options: OutboundTargetOptions = {}, -): Promise { +): Promise { let url: URL; try { url = new URL(input); @@ -176,5 +181,13 @@ export async function validateOutboundTarget( throw new OutboundTargetError("OUTBOUND_ADDRESS_FORBIDDEN"); } - return url; + return { url, addresses: Object.freeze([...addresses]) }; +} + +export async function validateOutboundTarget( + input: string, + options: OutboundTargetOptions = {}, +): Promise { + const validated = await validateOutboundTargetWithAddresses(input, options); + return validated.url; } From 1333677a0ac3ee1b0f1e3a5b1d46234c16f439ab Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:16:53 -0400 Subject: [PATCH 18/27] security: pin outbound requests to validated DNS addresses --- src/lib/security/pinned-outbound-request.ts | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/lib/security/pinned-outbound-request.ts diff --git a/src/lib/security/pinned-outbound-request.ts b/src/lib/security/pinned-outbound-request.ts new file mode 100644 index 0000000..5f96e25 --- /dev/null +++ b/src/lib/security/pinned-outbound-request.ts @@ -0,0 +1,113 @@ +import { request as httpRequest, type IncomingHttpHeaders, type RequestOptions } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; +import { + OutboundTargetError, + validateOutboundTargetWithAddresses, + type OutboundTargetOptions, +} from "./outbound-target"; + +export interface PinnedOutboundRequestOptions extends OutboundTargetOptions { + method?: string; + headers?: Headers | Record; + body?: ArrayBuffer | Uint8Array | string | null; + signal?: AbortSignal; +} + +export interface PinnedOutboundResponse { + status: number; + statusText: string; + headers: Headers; + body: Uint8Array; + ok: boolean; + json(): T; + arrayBuffer(): ArrayBuffer; +} + +function headerRecord(headers?: Headers | Record): Record { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + return { ...headers }; +} + +function responseHeaders(input: IncomingHttpHeaders): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(input)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else if (value !== undefined) { + headers.set(name, String(value)); + } + } + return headers; +} + +export async function pinnedOutboundRequest( + input: string | URL, + options: PinnedOutboundRequestOptions = {}, +): Promise { + const validated = await validateOutboundTargetWithAddresses(input.toString(), options); + const pinnedAddress = validated.addresses[0]; + const family = isIP(pinnedAddress); + if (family !== 4 && family !== 6) { + throw new OutboundTargetError("OUTBOUND_DNS_UNAVAILABLE"); + } + + const headers = headerRecord(options.headers); + const requestOptions: RequestOptions = { + method: options.method ?? "GET", + headers, + signal: options.signal, + lookup: (_hostname, _lookupOptions, callback) => { + callback(null, pinnedAddress, family); + }, + }; + + // Keep the original hostname for Host and TLS SNI while the custom lookup + // forces the socket to the exact address that passed policy validation. + if (validated.url.protocol === "https:") { + requestOptions.servername = validated.url.hostname; + } + + const requestImpl = validated.url.protocol === "https:" ? httpsRequest : httpRequest; + + return await new Promise((resolve, reject) => { + const req = requestImpl(validated.url, requestOptions, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + const body = Buffer.concat(chunks); + const status = res.statusCode ?? 502; + const statusText = res.statusMessage ?? ""; + const headers = responseHeaders(res.headers); + resolve({ + status, + statusText, + headers, + body, + ok: status >= 200 && status < 300, + json(): T { + return JSON.parse(body.toString("utf8")) as T; + }, + arrayBuffer(): ArrayBuffer { + return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer; + }, + }); + }); + res.on("error", reject); + }); + + req.on("error", reject); + + if (options.body !== undefined && options.body !== null) { + req.write( + typeof options.body === "string" + ? options.body + : Buffer.from(options.body instanceof Uint8Array ? options.body : new Uint8Array(options.body)), + ); + } + req.end(); + }); +} From 09f136541753e26b093873a2063a87e38778dafd Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:17:14 -0400 Subject: [PATCH 19/27] security: pin OpenAPI fetches to validated addresses --- src/lib/covenant/dynamic-mcp.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/covenant/dynamic-mcp.ts b/src/lib/covenant/dynamic-mcp.ts index fd4ed41..78eb435 100644 --- a/src/lib/covenant/dynamic-mcp.ts +++ b/src/lib/covenant/dynamic-mcp.ts @@ -17,6 +17,7 @@ import { getEngine } from "./engine"; import { toolRegistry, type DynamicTool } from "./tool-registry"; import type { CapabilityIdentity } from "./types"; import { validateOutboundTarget } from "@/lib/security/outbound-target"; +import { pinnedOutboundRequest } from "@/lib/security/pinned-outbound-request"; // --------------------------------------------------------------------------- // Minimal OpenAPI types we care about @@ -97,18 +98,16 @@ export async function translateOpenApiToMcp( openapiUrl: string, baseUrl: string, ): Promise { - // Validate both the specification source and the execution destination. - // Redirects are disabled for the spec fetch so a public allowlisted URL - // cannot redirect cAPI into a private or metadata address. - const validatedSpecUrl = await validateOutboundTarget(openapiUrl); + // Validate the execution destination before storing it. The OpenAPI source is + // fetched through pinnedOutboundRequest so the socket cannot perform a second + // attacker-controlled DNS resolution after validation. const validatedBaseUrl = await validateOutboundTarget(baseUrl); - const res = await fetch(validatedSpecUrl, { + const res = await pinnedOutboundRequest(openapiUrl, { headers: { Accept: "application/json" }, - redirect: "error", }); if (!res.ok) throw new Error(`Failed to fetch OpenAPI spec: ${res.status}`); - const spec = (await res.json()) as OAPISpec; + const spec = res.json(); if (!spec.paths) throw new Error("OpenAPI specification has no paths"); @@ -174,6 +173,7 @@ export async function translateOpenApiToMcp( } // Store only canonicalized, policy-validated destinations in the registry. + const validatedSpecUrl = await validateOutboundTarget(openapiUrl); toolRegistry.set(serverId, tools, { server_id: serverId, base_url: validatedBaseUrl.toString(), From d479898291de948a87b0e80b3519bad162705e63 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:17:33 -0400 Subject: [PATCH 20/27] security: pin proxy sockets to validated upstream addresses --- .../api/proxy/[serverId]/[...path]/route.ts | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/app/api/proxy/[serverId]/[...path]/route.ts b/src/app/api/proxy/[serverId]/[...path]/route.ts index 9e39de0..a7eb324 100644 --- a/src/app/api/proxy/[serverId]/[...path]/route.ts +++ b/src/app/api/proxy/[serverId]/[...path]/route.ts @@ -8,7 +8,8 @@ import { timingSafeEqual } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { toolRegistry } from "@/lib/covenant/tool-registry"; -import { OutboundTargetError, validateOutboundTarget } from "@/lib/security/outbound-target"; +import { OutboundTargetError } from "@/lib/security/outbound-target"; +import { pinnedOutboundRequest } from "@/lib/security/pinned-outbound-request"; export const dynamic = "force-dynamic"; @@ -67,21 +68,9 @@ async function handleProxy( const requestPath = path.replace(/^\/+/, ""); baseUrl.pathname = `${basePath}/${requestPath}`.replace(/\/{2,}/g, "/"); baseUrl.search = req.nextUrl.search; - targetUrl = await validateOutboundTarget(baseUrl.toString(), { - resolverTimeoutMs: remainingBudgetMs(), - }); + targetUrl = baseUrl; } catch (error) { - if (error instanceof OutboundTargetError) { - const timedOut = error.code === "OUTBOUND_DNS_TIMEOUT"; - return NextResponse.json( - { - error: timedOut ? "Upstream request timed out" : "Registered upstream target is not permitted", - code: error.code, - }, - { status: timedOut ? 504 : 403 }, - ); - } - console.error("Proxy target validation failed", error); + console.error("Proxy target URL construction failed", error); return NextResponse.json({ error: "Registered upstream target is unavailable" }, { status: 502 }); } @@ -119,21 +108,26 @@ async function handleProxy( ? await req.arrayBuffer() : undefined; - const upstream = await fetch(targetUrl, { + const upstream = await pinnedOutboundRequest(targetUrl, { method: req.method, headers: forwardHeaders, body: body ?? null, signal: controller.signal, - // A previously validated public target must not be allowed to redirect - // the proxy to a private or metadata destination. - redirect: "error", + resolverTimeoutMs: remainingBudgetMs(), }); clearTimeout(timer); - const responseBody = await upstream.arrayBuffer(); + // Node's request primitive does not follow redirects. Reject them rather + // than exposing Location to a caller that might automatically follow a + // private or metadata redirect target. + if (upstream.status >= 300 && upstream.status < 400) { + return NextResponse.json({ error: "Upstream redirect is not permitted" }, { status: 502 }); + } + + const responseBody = upstream.arrayBuffer(); const responseHeaders = new Headers(); upstream.headers.forEach((value, key) => { - if (["transfer-encoding", "connection", "keep-alive"].includes(key.toLowerCase())) return; + if (["transfer-encoding", "connection", "keep-alive", "location"].includes(key.toLowerCase())) return; responseHeaders.set(key, value); }); responseHeaders.set("X-Covenant-Proxy", "cAPI/1.0"); @@ -145,6 +139,16 @@ async function handleProxy( }); } catch (err: unknown) { clearTimeout(timer); + if (err instanceof OutboundTargetError) { + const timedOut = err.code === "OUTBOUND_DNS_TIMEOUT"; + return NextResponse.json( + { + error: timedOut ? "Upstream request timed out" : "Registered upstream target is not permitted", + code: err.code, + }, + { status: timedOut ? 504 : 403 }, + ); + } const message = err instanceof Error ? err.message : String(err); const isTimeout = message.includes("abort") || message.includes("timeout"); console.error("MCP proxy upstream request failed", err); From 17e821f499e0364f8a28009ad973a358df8bd673 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:17:57 -0400 Subject: [PATCH 21/27] test: preserve validated DNS answers for connection pinning --- src/lib/security/outbound-target.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/security/outbound-target.test.ts b/src/lib/security/outbound-target.test.ts index d50d88c..9e8ab39 100644 --- a/src/lib/security/outbound-target.test.ts +++ b/src/lib/security/outbound-target.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateOutboundTarget } from "./outbound-target"; +import { validateOutboundTarget, validateOutboundTargetWithAddresses } from "./outbound-target"; const publicResolver = async () => ["93.184.216.34"]; @@ -13,6 +13,18 @@ describe("validateOutboundTarget", () => { expect(url.hostname).toBe("api.example.com"); }); + it("returns the exact vetted DNS answers for socket pinning", async () => { + const validated = await validateOutboundTargetWithAddresses("https://api.example.com/v1", { + production: true, + allowedHosts: ["api.example.com"], + resolver: async () => ["93.184.216.34", "93.184.216.35"], + }); + + expect(validated.url.hostname).toBe("api.example.com"); + expect(validated.addresses).toEqual(["93.184.216.34", "93.184.216.35"]); + expect(Object.isFrozen(validated.addresses)).toBe(true); + }); + it("fails closed in production when no host allowlist is configured", async () => { await expect(validateOutboundTarget("https://api.example.com", { production: true, From a7d4c33de2bc79d79551583b1fd54f8b5df42445 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:19:09 -0400 Subject: [PATCH 22/27] fix: type pinned HTTPS SNI request options --- src/lib/security/pinned-outbound-request.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/security/pinned-outbound-request.ts b/src/lib/security/pinned-outbound-request.ts index 5f96e25..c75c41a 100644 --- a/src/lib/security/pinned-outbound-request.ts +++ b/src/lib/security/pinned-outbound-request.ts @@ -24,6 +24,8 @@ export interface PinnedOutboundResponse { arrayBuffer(): ArrayBuffer; } +type PinnedRequestOptions = RequestOptions & { servername?: string }; + function headerRecord(headers?: Headers | Record): Record { if (!headers) return {}; if (headers instanceof Headers) return Object.fromEntries(headers.entries()); @@ -54,7 +56,7 @@ export async function pinnedOutboundRequest( } const headers = headerRecord(options.headers); - const requestOptions: RequestOptions = { + const requestOptions: PinnedRequestOptions = { method: options.method ?? "GET", headers, signal: options.signal, From 32e755d4b74aa365fdf59a2bd16dddc3ba01efb4 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:19:29 -0400 Subject: [PATCH 23/27] security: fail closed remote SSE until transport is address-pinned --- src/lib/mcp/drivers/McpDriver.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/lib/mcp/drivers/McpDriver.ts b/src/lib/mcp/drivers/McpDriver.ts index dc15157..a0e356c 100644 --- a/src/lib/mcp/drivers/McpDriver.ts +++ b/src/lib/mcp/drivers/McpDriver.ts @@ -1,8 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import type { McpServerDescriptor } from "../schema"; -import { validateOutboundTarget } from "@/lib/security/outbound-target"; const LOCAL_PROCESS_ENV_ALLOWLIST = [ "PATH", @@ -17,13 +15,6 @@ function localProcessAllowed(): boolean { return process.env.NODE_ENV !== "production" && process.env.CAPI_ALLOW_LOCAL_PROCESS_MCP === "true"; } -function remoteSseAllowed(): boolean { - // The MCP SDK owns redirects, reconnects, and message POSTs for SSE. Until - // every network operation can be bound to the same validated destination - // policy, hosted cAPI must not expose this transport in production. - return process.env.NODE_ENV !== "production"; -} - function buildLocalProcessEnv(descriptor: McpServerDescriptor): Record { const env: Record = {}; @@ -64,12 +55,11 @@ export class McpDriver { env: buildLocalProcessEnv(descriptor), }); } else if (descriptor.type === "remote-sse" && descriptor.serverUrl) { - if (!remoteSseAllowed()) { - throw new Error("remote-sse MCP is disabled in production until outbound policy enforcement covers every transport operation"); - } - - const validatedUrl = await validateOutboundTarget(descriptor.serverUrl); - transport = new SSEClientTransport(validatedUrl); + // SSEClientTransport owns initial connection, reconnect, redirect, and + // message-POST networking. Until every one of those operations can be + // forced through the validated-address pinning boundary, fail closed in + // every environment instead of leaving development hosts SSRF-capable. + throw new Error("remote-sse MCP is disabled until every transport operation is bound to validated outbound addresses"); } else { throw new Error(`Unsupported or misconfigured MCP descriptor type: ${descriptor.type}`); } From 5113e2b8919883dc052b409f86a30b3de5cc0aab Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:19:36 -0400 Subject: [PATCH 24/27] test: keep remote SSE fail closed in every environment --- src/lib/mcp/drivers/McpDriver.test.ts | 31 +++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/lib/mcp/drivers/McpDriver.test.ts b/src/lib/mcp/drivers/McpDriver.test.ts index 4eb589f..b5f81c9 100644 --- a/src/lib/mcp/drivers/McpDriver.test.ts +++ b/src/lib/mcp/drivers/McpDriver.test.ts @@ -5,19 +5,22 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("McpDriver production transport policy", () => { - it("fails closed for remote-sse until every SDK network operation is policy-bound", async () => { - vi.stubEnv("NODE_ENV", "production"); +describe("McpDriver remote transport policy", () => { + it.each(["production", "development"])( + "fails closed for remote-sse in %s until every SDK network operation is address-pinned", + async (nodeEnv) => { + vi.stubEnv("NODE_ENV", nodeEnv); - await expect( - McpDriver.connect({ - id: "remote-sse-production", - displayName: "Remote SSE production probe", - type: "remote-sse", - serverUrl: "https://example.com/mcp", - }), - ).rejects.toThrow( - "remote-sse MCP is disabled in production until outbound policy enforcement covers every transport operation", - ); - }); + await expect( + McpDriver.connect({ + id: `remote-sse-${nodeEnv}`, + displayName: "Remote SSE policy probe", + type: "remote-sse", + serverUrl: "https://example.com/mcp", + }), + ).rejects.toThrow( + "remote-sse MCP is disabled until every transport operation is bound to validated outbound addresses", + ); + }, + ); }); From 584aba67f8b07c40bc258842c3f4c091af6a71a4 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:20:17 -0400 Subject: [PATCH 25/27] docs: record address-pinned outbound boundary --- docs/MCP_SECURITY_BOUNDARY.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/MCP_SECURITY_BOUNDARY.md b/docs/MCP_SECURITY_BOUNDARY.md index ed55620..ccb74b9 100644 --- a/docs/MCP_SECURITY_BOUNDARY.md +++ b/docs/MCP_SECURITY_BOUNDARY.md @@ -18,8 +18,8 @@ cAPI is the governed connection/discovery/capability-negotiation layer. It is ** 6. A spawned development MCP child must never inherit the complete cAPI service environment. 7. Authentication does not make an arbitrary remote URL safe. Remote MCP/OpenAPI destinations must pass the outbound egress policy before registration and immediately before cAPI-controlled outbound requests. 8. Production remote targets require an explicit server-controlled `CAPI_MCP_ALLOWED_HOSTS` allowlist. Loopback, private, link-local, metadata, reserved, and DNS-to-private destinations are rejected. -9. cAPI-controlled OpenAPI/proxy fetches must not follow an unvalidated redirect to a different destination. Redirects are disabled unless every redirect hop is independently revalidated. -10. Unsupported remote transport types fail closed rather than registering an endpoint that the execution driver cannot honor. +9. cAPI-controlled OpenAPI/proxy requests must bind their socket lookup to an address that passed policy validation and must not follow an unvalidated redirect. +10. Unsupported or not-yet-policy-bindable remote transport types fail closed rather than registering an endpoint that the execution driver cannot safely honor. ## Non-production local-process MCP @@ -32,17 +32,17 @@ When enabled, the child receives only a minimal runtime environment needed to st ## Remote MCP -Hosted cAPI may use remote MCP transports only under the outbound egress policy above. Discovery/connection does not itself grant permission for a consequential operation. - The remote URL is not authority. It is untrusted input even when supplied by an authenticated administrator. A target must be canonicalized, matched against the server-controlled allowlist, resolved, and rejected if any resolved address is local/private/link-local/metadata/reserved. Client-visible errors must remain sanitized; transport/DNS details belong in server-side diagnostics. -The current source validates the initial remote-SSE destination. Deployment verification must additionally prove that the SDK transport cannot redirect a validated public endpoint to a forbidden address. Until that redirect behavior is proven fail-closed (or remote SSE is constrained accordingly), the remote-SSE redirect boundary remains **NOT_VERIFIED**. +OpenAPI discovery and direct proxy requests use the validated DNS result for the actual socket lookup. The original hostname remains the HTTP Host/TLS SNI identity, but a later attacker-controlled DNS answer cannot replace the address that passed policy validation. + +`remote-sse` is currently disabled in every environment. The MCP SDK owns initial SSE connection, reconnect, redirect, and message-POST networking; until every one of those operations can be forced through the same address-pinning boundary, cAPI must fail closed rather than expose an incompletely governed transport. ## Direct proxy rule The direct proxy is a transport helper, not an authority boundary. Because a direct call does not itself prove that CAPPO authorized the consequence, it is restricted to authenticated internal traffic and must not become a public alternate execution API. -A stored proxy destination is revalidated immediately before the outbound fetch. This is required because DNS and registry state can change after initial registration. +A stored proxy destination is validated immediately before the outbound request, and the resulting vetted address is pinned into the connection lookup. This is required because DNS and registry state can change after initial registration. Redirect responses are rejected and their `Location` header is not relayed to callers. ## Required deployment verification @@ -51,9 +51,11 @@ After any deployment affecting these paths, verify at minimum: - unauthenticated `GET /api/mcp/servers` is rejected; - unauthenticated `POST /api/mcp/servers` is rejected before any server start; - authenticated production registration of `local-process` is rejected; +- `remote-sse` execution fails closed until all SDK network operations are policy-bound; - unsupported `remote-http` registration is rejected until a governed implementation exists; - production remote registration fails closed when `CAPI_MCP_ALLOWED_HOSTS` is not configured; - loopback/private/link-local/metadata targets and DNS-to-private targets are rejected; +- OpenAPI and direct-proxy connections use only the vetted DNS address and cannot pivot through a second DNS resolution; - OpenAPI and direct-proxy redirects cannot pivot to forbidden destinations; - direct proxy requests without the internal key are rejected; - valid internal proxy calls do not forward the internal cAPI key upstream; From 38d9b0b322d8dc26ba1666e7ff970d35d57739f9 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:20:37 -0400 Subject: [PATCH 26/27] testability: expose pinned socket lookup primitive --- src/lib/security/pinned-outbound-request.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/lib/security/pinned-outbound-request.ts b/src/lib/security/pinned-outbound-request.ts index c75c41a..0201494 100644 --- a/src/lib/security/pinned-outbound-request.ts +++ b/src/lib/security/pinned-outbound-request.ts @@ -25,6 +25,7 @@ export interface PinnedOutboundResponse { } type PinnedRequestOptions = RequestOptions & { servername?: string }; +type Lookup = NonNullable; function headerRecord(headers?: Headers | Record): Record { if (!headers) return {}; @@ -44,25 +45,30 @@ function responseHeaders(input: IncomingHttpHeaders): Headers { return headers; } +export function createPinnedLookup(pinnedAddress: string): Lookup { + const family = isIP(pinnedAddress); + if (family !== 4 && family !== 6) { + throw new OutboundTargetError("OUTBOUND_DNS_UNAVAILABLE"); + } + + return (_hostname, _lookupOptions, callback) => { + callback(null, pinnedAddress, family); + }; +} + export async function pinnedOutboundRequest( input: string | URL, options: PinnedOutboundRequestOptions = {}, ): Promise { const validated = await validateOutboundTargetWithAddresses(input.toString(), options); const pinnedAddress = validated.addresses[0]; - const family = isIP(pinnedAddress); - if (family !== 4 && family !== 6) { - throw new OutboundTargetError("OUTBOUND_DNS_UNAVAILABLE"); - } const headers = headerRecord(options.headers); const requestOptions: PinnedRequestOptions = { method: options.method ?? "GET", headers, signal: options.signal, - lookup: (_hostname, _lookupOptions, callback) => { - callback(null, pinnedAddress, family); - }, + lookup: createPinnedLookup(pinnedAddress), }; // Keep the original hostname for Host and TLS SNI while the custom lookup From f14513e141351ca8c36022916fe805b897ece4d2 Mon Sep 17 00:00:00 2001 From: reprewindai-dev Date: Mon, 31 Aug 2026 16:20:44 -0400 Subject: [PATCH 27/27] test: prove socket lookup cannot re-resolve validated host --- .../security/pinned-outbound-request.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/lib/security/pinned-outbound-request.test.ts diff --git a/src/lib/security/pinned-outbound-request.test.ts b/src/lib/security/pinned-outbound-request.test.ts new file mode 100644 index 0000000..5e0c660 --- /dev/null +++ b/src/lib/security/pinned-outbound-request.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { createPinnedLookup } from "./pinned-outbound-request"; + +describe("createPinnedLookup", () => { + it("returns only the vetted address even when the connection asks to resolve the hostname again", async () => { + const lookup = createPinnedLookup("93.184.216.34"); + + const result = await new Promise<{ address: string; family: number }>((resolve, reject) => { + lookup( + "api.example.com", + { family: 0, hints: 0, all: false }, + ((error: NodeJS.ErrnoException | null, address: string, family: number) => { + if (error) { + reject(error); + return; + } + resolve({ address, family }); + }) as never, + ); + }); + + expect(result).toEqual({ address: "93.184.216.34", family: 4 }); + expect(result.address).not.toBe("10.0.0.8"); + }); +});