Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f1dc25e
fix(security): add fail-closed outbound target validation
reprewindai-dev Aug 24, 2026
d89e041
test(security): cover outbound target SSRF controls
reprewindai-dev Aug 24, 2026
39869cc
fix(security): validate MCP registration targets and sanitize errors
reprewindai-dev Aug 24, 2026
57efbc5
fix(security): validate OpenAPI outbound targets before fetch
reprewindai-dev Aug 24, 2026
4edfd20
fix(security): revalidate proxy destinations before outbound fetch
reprewindai-dev Aug 24, 2026
cbf8c51
fix(security): validate remote SSE MCP destination before connect
reprewindai-dev Aug 24, 2026
b09e340
docs(security): define hosted MCP outbound egress contract
reprewindai-dev Aug 24, 2026
c75017b
fix(security): constrain proxy headers and preserve upstream base paths
reprewindai-dev Aug 24, 2026
0bf6582
fix(security): disable remote SSE in production
reprewindai-dev Aug 31, 2026
7c47ac1
test(security): lock production remote SSE fail closed
reprewindai-dev Aug 31, 2026
d8549bb
test(security): use Vitest env stubbing
reprewindai-dev Aug 31, 2026
b43fd26
fix(security): tighten outbound IP classification
reprewindai-dev Aug 31, 2026
7c4cd17
test(security): cover special-use address boundaries
reprewindai-dev Aug 31, 2026
686c6d7
fix(security): bound outbound DNS resolution time
reprewindai-dev Aug 31, 2026
e6ef289
test(security): cover outbound DNS deadline
reprewindai-dev Aug 31, 2026
33f8b8b
fix(security): include DNS in proxy timeout budget
reprewindai-dev Aug 31, 2026
f95b9a6
security: expose validated outbound addresses for connection pinning
reprewindai-dev Aug 31, 2026
1333677
security: pin outbound requests to validated DNS addresses
reprewindai-dev Aug 31, 2026
09f1365
security: pin OpenAPI fetches to validated addresses
reprewindai-dev Aug 31, 2026
d479898
security: pin proxy sockets to validated upstream addresses
reprewindai-dev Aug 31, 2026
17e821f
test: preserve validated DNS answers for connection pinning
reprewindai-dev Aug 31, 2026
a7d4c33
fix: type pinned HTTPS SNI request options
reprewindai-dev Aug 31, 2026
32e755d
security: fail closed remote SSE until transport is address-pinned
reprewindai-dev Aug 31, 2026
5113e2b
test: keep remote SSE fail closed in every environment
reprewindai-dev Aug 31, 2026
584aba6
docs: record address-pinned outbound boundary
reprewindai-dev Aug 31, 2026
38d9b0b
testability: expose pinned socket lookup primitive
reprewindai-dev Aug 31, 2026
f14513e
test: prove socket lookup cannot re-resolve validated host
reprewindai-dev Aug 31, 2026
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
21 changes: 19 additions & 2 deletions docs/MCP_SECURITY_BOUNDARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 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

Expand All @@ -28,21 +32,34 @@ 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.
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.

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 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

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;
- 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.
40 changes: 36 additions & 4 deletions src/app/api/mcp/servers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand All @@ -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({
Expand All @@ -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,
});
}

Expand All @@ -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({
Expand All @@ -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);
}
}

Expand All @@ -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),
});
Expand Down
71 changes: 56 additions & 15 deletions src/app/api/proxy/[serverId]/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { timingSafeEqual } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { toolRegistry } from "@/lib/covenant/tool-registry";
import { OutboundTargetError } from "@/lib/security/outbound-target";
import { pinnedOutboundRequest } from "@/lib/security/pinned-outbound-request";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -55,21 +57,36 @@ async function handleProxy(
);
}

const startedAt = Date.now();
const remainingBudgetMs = () => Math.max(0, PROXY_TIMEOUT_MS - (Date.now() - startedAt));

const path = `/${pathParts.join("/")}`;
const targetUrl = `${server.base_url}${path}${req.nextUrl.search}`;
let targetUrl: URL;
try {
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 = baseUrl;
} catch (error) {
console.error("Proxy target URL construction 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.
// 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);
}

Expand All @@ -78,26 +95,39 @@ 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"
? 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,
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");
Expand All @@ -109,10 +139,21 @@ 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);
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 },
);
}
Expand Down
27 changes: 18 additions & 9 deletions src/lib/covenant/dynamic-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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";
import { pinnedOutboundRequest } from "@/lib/security/pinned-outbound-request";

// ---------------------------------------------------------------------------
// Minimal OpenAPI types we care about
Expand Down Expand Up @@ -96,12 +98,18 @@ export async function translateOpenApiToMcp(
openapiUrl: string,
baseUrl: string,
): Promise<DynamicTool[]> {
// 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}`);
const spec = (await res.json()) as OAPISpec;
// 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);

if (!spec.paths) throw new Error(`OpenAPI spec at ${openapiUrl} has no paths`);
const res = await pinnedOutboundRequest(openapiUrl, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`Failed to fetch OpenAPI spec: ${res.status}`);
const spec = res.json<OAPISpec>();

if (!spec.paths) throw new Error("OpenAPI specification has no paths");

const engine = getEngine();
const tools: DynamicTool[] = [];
Expand All @@ -126,7 +134,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,
Expand Down Expand Up @@ -164,11 +172,12 @@ export async function translateOpenApiToMcp(
}
}

// Store in registry
// Store only canonicalized, policy-validated destinations in the registry.
const validatedSpecUrl = await validateOutboundTarget(openapiUrl);
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(),
});

Expand Down
26 changes: 26 additions & 0 deletions src/lib/mcp/drivers/McpDriver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { McpDriver } from "./McpDriver";

afterEach(() => {
vi.unstubAllEnvs();
});

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-${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",
);
},
);
});
7 changes: 5 additions & 2 deletions src/lib/mcp/drivers/McpDriver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
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";

const LOCAL_PROCESS_ENV_ALLOWLIST = [
Expand Down Expand Up @@ -56,7 +55,11 @@ export class McpDriver {
env: buildLocalProcessEnv(descriptor),
});
} else if (descriptor.type === "remote-sse" && descriptor.serverUrl) {
transport = new SSEClientTransport(new URL(descriptor.serverUrl));
// 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}`);
}
Expand Down
Loading
Loading