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
5 changes: 5 additions & 0 deletions .changeset/ssrf-safe-outbound-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add SSRF protection to outbound requests whose host comes from author, tenant, or model input. A new safe-fetch wrapper resolves the target host and refuses to connect to private, loopback (when hardened), link-local, or cloud-metadata addresses (e.g. `169.254.169.254`), pins the transport to https (loopback http allowed for local dev), re-validates every redirect hop, and drops credentials on cross-origin redirects. It now guards the OpenAPI spec and operation calls, the `web_fetch` tool, remote-agent dispatch, OIDC discovery and JWKS, MCP connections, and session callbacks. Session callbacks and non-loopback OpenAPI/callback URLs now require https.
19 changes: 19 additions & 0 deletions docs/concepts/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ Credential brokering gives the model _authenticated_ network access from inside

[Connection](../connections) tokens (MCP and OpenAPI) come from either `getToken()` or an interactive OAuth flow, and eve injects the resolved token into every outbound request. The token is cached per step and never serialized to durable state.

## Outbound requests are SSRF-guarded

Several eve surfaces make outbound HTTP requests to a host that comes from author, tenant, or model input rather than being hardcoded: [OpenAPI](../connections/openapi) spec fetches and credentialed operation calls, the built-in `web_fetch` tool, [MCP](../connections/mcp) connections, remote [subagent](../subagents) dispatch, OIDC discovery, and session callbacks. Left unguarded, any of these could be steered at an internal-only service or a cloud metadata endpoint (`169.254.169.254`) — and because some carry the connection's credentials, that would make an authenticated request on an attacker's behalf.

This protection is on by default; the author configures nothing. For every request eve issues on those surfaces, it:

- **Pins the transport to `https`.** Plain `http` is allowed only for loopback hosts, so local development still works but a credentialed call never runs over cleartext.
- **Resolves the host and blocks private ranges.** The hostname is DNS-resolved and rejected if it is — or resolves to — a private (RFC 1918), carrier-grade-NAT, link-local (including cloud metadata), or otherwise reserved address. A public-looking hostname that resolves into a private range is caught, not just IP literals.
- **Re-validates every redirect hop.** Redirects are followed manually and each hop runs the same checks, and `Authorization`/`Cookie` are dropped when a redirect crosses origin, so a safe first hop can't bounce the request (and its credentials) to an internal host.
- **Bounds the request** with a timeout and a response-size cap.

Two things to know:

- **Loopback is allowed by default.** `localhost` and `127.0.0.0/8` are treated as same-host, not a network pivot, because local development depends on them. The high-value target — link-local cloud metadata — is still blocked.
- **Your own code is not automatically covered.** The guard applies to eve's built-in request paths. A custom [tool](../tools) or handler that calls `fetch()` directly reaches whatever host it is given; validate untrusted URLs yourself before fetching them.

## Channel verification

A [channel](../channels/overview) is your agent's front door, so authenticating inbound traffic is its job. The built-in platform channels follow two rules, and so must any channel you write yourself:
Expand Down Expand Up @@ -94,6 +110,9 @@ Before exposing an agent to real traffic:
shouldn't have open egress; use credential brokering for authenticated egress.
- [ ] Don't surface untrusted text as markup. Model- or user-controlled
strings rendered into a channel UI should be escaped for that surface.
- [ ] Validate outbound URLs in your own tools. eve's built-in request paths
are SSRF-guarded, but a custom tool that fetches an author-, tenant-, or
model-supplied URL must validate it before fetching.

## What to read next

Expand Down
2 changes: 2 additions & 0 deletions docs/connections/openapi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Each operation becomes `<connection>__<operationId>`, for example `petstore__get

`spec` can be a URL that eve fetches at runtime, or an inline parsed OpenAPI object. A spec URL must use `https` (plain `http` is allowed only for loopback hosts such as `localhost` during local development), and eve re-checks the transport after any redirects. Prefer a URL when the provider owns the contract and updates it; prefer an inline object for private APIs, generated specs you pin in source control, or small hand-authored contracts.

The spec URL and the resolved base URL must use `https` (plain `http` is allowed only for loopback hosts during local development). Because operation calls carry the connection's credentials, eve also refuses to connect to hosts that are — or that resolve to — a private, link-local, or cloud-metadata address (for example `169.254.169.254`), and re-validates every redirect hop against the same rules.

## Base URL and servers

eve resolves operation paths against `baseUrl` when you provide one. Otherwise, it derives the base URL from the spec:
Expand Down
16 changes: 7 additions & 9 deletions packages/eve/src/execution/remote-agent-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,13 @@ describe("startRemoteAgentSession", () => {
});

expect(childSessionId).toBe("remote-session-header");
expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/eve/v1/session", {
body: expect.any(String),
headers: {
authorization: "Bearer remote-token",
"content-type": "application/json",
"x-static": "yes",
},
method: "POST",
});
const [calledUrl, init] = fetchMock.mock.calls[0]!;
expect(calledUrl).toBe("https://remote.example.com/eve/v1/session");
expect(init?.method).toBe("POST");
const sentHeaders = new Headers(init?.headers);
expect(sentHeaders.get("authorization")).toBe("Bearer remote-token");
expect(sentHeaders.get("content-type")).toBe("application/json");
expect(sentHeaders.get("x-static")).toBe("yes");
expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual({
callback: {
callId: "call-remote",
Expand Down
9 changes: 7 additions & 2 deletions packages/eve/src/execution/remote-agent-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EVE_SESSION_ID_HEADER } from "#protocol/message.js";
import { createEveCallbackRoutePath } from "#protocol/routes.js";
import { createWorkflowCallbackUrl } from "#execution/workflow-callback-url.js";
import { formatSubagentInput } from "#execution/subagent-invocation.js";
import { readBodyTextSafe, safeFetch } from "#shared/safe-fetch.js";
import type { HarnessSession } from "#harness/types.js";
import type { RuntimeRemoteAgentCallActionRequest } from "#runtime/actions/types.js";
import type { RuntimeSubagentRegistry } from "#runtime/subagents/registry.js";
Expand All @@ -23,7 +24,8 @@ export async function startRemoteAgentSession(input: {
}

const headers = await resolveRemoteAgentRequestHeaders(input.remote);
const response = await fetch(createRemoteAgentSessionUrl(input.remote), {
// Credentialed POST — safeFetch guards the remote URL against SSRF.
const response = await safeFetch(createRemoteAgentSessionUrl(input.remote), {
body: JSON.stringify({
callback: {
callId: input.action.callId,
Expand Down Expand Up @@ -58,7 +60,10 @@ export async function startRemoteAgentSession(input: {
}

try {
const body = (await response.json()) as { readonly sessionId?: unknown };
// Bounded read — the body only carries a small `{ sessionId }`.
const body = JSON.parse(await readBodyTextSafe(response, { maxBytes: 1024 * 1024 })) as {
readonly sessionId?: unknown;
};
if (typeof body.sessionId === "string" && body.sessionId.length > 0) {
return body.sessionId;
}
Expand Down
24 changes: 8 additions & 16 deletions packages/eve/src/execution/session-callback-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ describe("fireSessionCallbackStep", () => {
sessionId: "remote-session",
subagentName: "research",
}),
headers: {
"content-type": "application/json",
},
headers: expect.any(Headers),
method: "POST",
redirect: "error",
redirect: "manual",
signal: expect.any(AbortSignal),
});
expect(errorSpy).not.toHaveBeenCalled();
Expand All @@ -85,11 +83,9 @@ describe("fireSessionCallbackStep", () => {
sessionId: "remote-session",
subagentName: "research",
}),
headers: {
"content-type": "application/json",
},
headers: expect.any(Headers),
method: "POST",
redirect: "error",
redirect: "manual",
signal: expect.any(AbortSignal),
});
});
Expand All @@ -114,11 +110,9 @@ describe("fireSessionCallbackStep", () => {
subagentName: "research",
usage: USAGE,
}),
headers: {
"content-type": "application/json",
},
headers: expect.any(Headers),
method: "POST",
redirect: "error",
redirect: "manual",
signal: expect.any(AbortSignal),
});
expect(errorSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -173,11 +167,9 @@ describe("fireSessionCallbackStep", () => {
sessionId: "remote-session",
subagentName: "research",
}),
headers: {
"content-type": "application/json",
},
headers: expect.any(Headers),
method: "POST",
redirect: "error",
redirect: "manual",
signal: expect.any(AbortSignal),
});
});
Expand Down
10 changes: 5 additions & 5 deletions packages/eve/src/execution/session-callback-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { parseSessionCallback } from "#channel/session-callback.js";
import { SessionCallbackKey } from "#context/keys.js";
import { createLogger } from "#internal/logging.js";
import { toErrorMessage } from "#shared/errors.js";
import { safeFetch } from "#shared/safe-fetch.js";
import type { TokenUsage } from "#shared/token-usage.js";

const SESSION_CALLBACK_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -58,16 +59,15 @@ export async function fireSessionCallbackStep(input: {
subagentName: callback.subagentName,
};

const response = await fetch(callback.url, {
const response = await safeFetch(callback.url, {
body: JSON.stringify(body),
headers: {
"content-type": "application/json",
},
method: "POST",
// Do not follow redirects: a validated callback host could otherwise
// 3xx-bounce the framework to an internal/metadata address after the
// path/token check has already passed.
redirect: "error",
// Reject redirects so a validated host can't 3xx-bounce to metadata;
// safeFetch also DNS-resolves the host (the schema guard only sees IP literals).
maxRedirects: 0,
signal: AbortSignal.timeout(SESSION_CALLBACK_TIMEOUT_MS),
});

Expand Down
8 changes: 4 additions & 4 deletions packages/eve/src/execution/web-fetch/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,9 @@ describe("executeWebFetchTool", () => {

const retryCall = fetchSpy.mock.calls[1];
expect(retryCall).toBeDefined();
const retryHeaders = (retryCall![1] as RequestInit).headers as Record<string, string>;
const retryHeaders = new Headers((retryCall![1] as RequestInit).headers);

expect(retryHeaders["User-Agent"]).toBe(EVE_PACKAGE_NAME);
expect(retryHeaders.get("User-Agent")).toBe(EVE_PACKAGE_NAME);
});

it("sends format-aware Accept headers", async () => {
Expand All @@ -257,9 +257,9 @@ describe("executeWebFetchTool", () => {

const firstCall = fetchSpy.mock.calls[0];
expect(firstCall).toBeDefined();
const headers = (firstCall![1] as RequestInit).headers as Record<string, string>;
const headers = new Headers((firstCall![1] as RequestInit).headers);

expect(headers.Accept).toContain("text/markdown");
expect(headers.get("Accept")).toContain("text/markdown");
});

it("truncates large bodies to the shared tool-output budget", async () => {
Expand Down
28 changes: 14 additions & 14 deletions packages/eve/src/execution/web-fetch/tool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EVE_PACKAGE_NAME } from "#internal/package-name.js";
import { truncateHead } from "#execution/sandbox/truncate-output.js";
import { convertHtmlToMarkdown, extractTextFromHtml } from "#execution/web-fetch/html.js";
import { readBodyTextSafe, safeFetch } from "#shared/safe-fetch.js";

const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5 MB
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds
Expand Down Expand Up @@ -69,23 +70,28 @@ export async function executeWebFetchTool(
MAX_TIMEOUT_MS,
);

const timeoutSignal = AbortSignal.timeout(timeoutMs);
const headers = buildHeaders(format);
// Shared deadline so the initial request and the Cloudflare retry together
// stay within timeoutMs, not one budget each.
const deadline = AbortSignal.timeout(timeoutMs);
const signal =
options?.abortSignal === undefined
? timeoutSignal
: AbortSignal.any([options.abortSignal, timeoutSignal]);
const headers = buildHeaders(format);
? deadline
: AbortSignal.any([options.abortSignal, deadline]);
// Model-supplied URL — the top SSRF target. allowHttp since web pages are
// still often http.
const fetchOptions = { allowHttp: true, headers, signal, timeoutMs };

const initial = await fetch(url, { headers, signal });
const initial = await safeFetch(url, fetchOptions);

// Cloudflare may reject browser-like UA strings from Node.js runtimes
// because the TLS fingerprint does not match a real browser. Retry with
// an honest UA to bypass the challenge.
const response =
initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
? await fetch(url, {
? await safeFetch(url, {
...fetchOptions,
headers: { ...headers, "User-Agent": EVE_PACKAGE_NAME },
signal,
})
: initial;

Expand All @@ -99,15 +105,9 @@ export async function executeWebFetchTool(
throw new Error("Response too large (exceeds 5 MB limit).");
}

const buffer = await response.arrayBuffer();

if (buffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5 MB limit).");
}

const contentType = response.headers.get("content-type") ?? "";
const isHtml = contentType.includes("text/html");
const body = new TextDecoder().decode(buffer);
const body = await readBodyTextSafe(response, { maxBytes: MAX_RESPONSE_SIZE });

let rawContent: string;

Expand Down
6 changes: 6 additions & 0 deletions packages/eve/src/internal/testing/unit-guard.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { vi } from "vitest";

import { setSafeFetchLookupOverride } from "#shared/safe-fetch.js";

/**
* Vitest setup that makes unit-tier boundary violations fail loudly.
*/

// safeFetch DNS-resolves before connecting; unit tests are hermetic, so resolve
// every host to a fixed public IP. SSRF rejection is covered in safe-fetch.test.ts.
setSafeFetchLookupOverride(async () => [{ address: "93.184.216.34", family: 4 }]);

const INTEGRATION_GUIDANCE =
"Move this test to `src/**/*.integration.test.ts` or `test/scenarios/*.scenario.test.ts`.";

Expand Down
6 changes: 6 additions & 0 deletions packages/eve/src/runtime/connections/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ResolvedConnectionDefinition } from "#runtime/types.js";
import { evictScopedToken, resolveScopedToken } from "#runtime/connections/scoped-authorization.js";
import { resolveConnectionAuthorization } from "#runtime/connections/resolve-authorization.js";
import { isObject } from "#shared/guards.js";
import { assertUrlSafeToFetch } from "#shared/safe-fetch.js";
import type {
AuthorizationDefinition,
ConnectionClient,
Expand Down Expand Up @@ -70,6 +71,11 @@ export class McpConnectionClient implements ConnectionClient {
const headers = await resolveHeaders(this.#connection);
const url = this.#connection.url;

// @ai-sdk/mcp owns the socket, so eve can only preflight the URL for SSRF.
if (typeof url === "string") {
await assertUrlSafeToFetch(url);
}

try {
return await createMCPClient({
transport: { type: "http", url, headers },
Expand Down
Loading
Loading