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
21 changes: 21 additions & 0 deletions .changeset/egress-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@executor-js/sdk": patch
"@executor-js/plugin-openapi": patch
---

fix: block SSRF targets when fetching integration specs by URL

Adding an OpenAPI (or other URL-based) integration fetched the spec URL
server-side with no egress filtering. A crafted URL pointing at cloud
metadata (169.254.169.254), loopback, RFC1918, or link-local addresses let
the fetch feature reach internal state on hosted deployments.

A shared egress guard (`assertFetchable`) now validates every spec-fetch
target before connecting: it normalizes DNS-encoding tricks (decimal/octal/
hex integer IPv4, trailing dots), resolves hostnames, and fails closed if
any resolved address is loopback, RFC1918, link-local, carrier-grade NAT,
IPv6 link-local/ULA, or IPv4-mapped private. The resolved address is pinned
for the connect (no second resolution, so DNS rebinding cannot swap in a
private target), and the original host is preserved in the Host header.
Rejections are coarse ("blocked by egress policy") and never echo internal
addresses.
4 changes: 4 additions & 0 deletions e2e/setup/cloud.globalsetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const optionalCloudEnv = (): Record<string, string> => {
const env: Record<string, string> = {
SENTRY_OTEL_VERIFY: "true",
SENTRY_OTEL_LOG_PAYLOAD: "true",
// The e2e cloud stack serves integration specs from a loopback fixture
// server — the egress guard's loopback block must be explicitly trusted
// here. Production never sets this.
EXECUTOR_ALLOW_LOOPBACK_SPECS: "1",
// Boot the BROWSER crash reporter too, so what the frontend actually
// reports is observable to a scenario. Production always has this set;
// without it the reporter the app wires into ExecutorProvider is a no-op
Expand Down
131 changes: 131 additions & 0 deletions packages/core/sdk/src/egress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { assertFetchable, isBlockedAddress } from "./egress";

// ---------------------------------------------------------------------------
// Focused tests — egress-guard classification boundaries and the pinned
// resolve/connect contract.
//
// assertFetchable is pure (DNS injected as a lookup fn), so these tests need
// no executor harness, no DB, no scope. Encoded-host permutations and
// redirect-chain behavior are covered by property tests elsewhere; these
// pin the classification boundaries and the pin-return contract.
// ---------------------------------------------------------------------------

const publicLookup = async (hostname: string): Promise<string[]> =>
hostname === "petstore3.swagger.io" ? ["104.18.16.10"] : ["93.184.216.34"];

const run = (url: string, lookup = publicLookup) => Effect.runPromise(assertFetchable(url, lookup));

describe("isBlockedAddress (pure classification)", () => {
it("blocks metadata, loopback, RFC1918, CGNAT, link-local", () => {
expect(isBlockedAddress("169.254.169.254")).toBe(true); // cloud metadata
expect(isBlockedAddress("127.0.0.1")).toBe(true);
expect(isBlockedAddress("10.0.0.1")).toBe(true);
expect(isBlockedAddress("192.168.1.1")).toBe(true);
expect(isBlockedAddress("172.16.0.1")).toBe(true);
expect(isBlockedAddress("100.64.0.1")).toBe(true); // CGNAT
expect(isBlockedAddress("0.0.0.0")).toBe(true);
});

it("allows public addresses", () => {
expect(isBlockedAddress("8.8.8.8")).toBe(false);
expect(isBlockedAddress("104.18.16.10")).toBe(false);
expect(isBlockedAddress("93.184.216.34")).toBe(false);
});

it("blocks IPv6 loopback, link-local, ULA, and IPv4-mapped private", () => {
expect(isBlockedAddress("::1")).toBe(true);
expect(isBlockedAddress("fe80::1")).toBe(true);
expect(isBlockedAddress("fc00::1")).toBe(true);
expect(isBlockedAddress("fd00::1")).toBe(true);
expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true); // mapped loopback
expect(isBlockedAddress("::ffff:169.254.169.254")).toBe(true); // mapped metadata
});

it("fails closed on unparseable input", () => {
expect(isBlockedAddress("not-an-ip")).toBe(true);
expect(isBlockedAddress("")).toBe(true);
});
});

describe("assertFetchable (allowLoopback trust mode)", () => {
it("allows a loopback literal when the option is set", async () => {
const pinned = await Effect.runPromise(
assertFetchable("http://127.0.0.1:8787/spec.json", { allowLoopback: true }),
);
expect(pinned.resolvedAddress).toBe("127.0.0.1");
});

it("passes the hostname through when the resolver yields nothing (trusted resolver)", async () => {
const emptyLookup = async (): Promise<string[]> => [];
const pinned = await Effect.runPromise(
assertFetchable("http://fixture.local:8787/spec.json", emptyLookup, { allowLoopback: true }),
);
expect(pinned.hostname).toBe("fixture.local");
expect(pinned.resolvedAddress).toBe("fixture.local");
});

it("still blocks an unresolvable hostname without the option (fail closed)", async () => {
const emptyLookup = async (): Promise<string[]> => [];
await expect(
Effect.runPromise(assertFetchable("http://fixture.local:8787/spec.json", emptyLookup)),
).rejects.toMatchObject({ _tag: "EgressError" });
});
});

describe("assertFetchable (resolve + classify + pin)", () => {
it("accepts a public hostname and returns the pinned resolved address", async () => {
const pinned = await run("https://petstore3.swagger.io/api/v3/openapi.json");
expect(pinned.hostname).toBe("petstore3.swagger.io");
expect(pinned.resolvedAddress).toBe("104.18.16.10");
expect(pinned.url).toBe("https://petstore3.swagger.io/api/v3/openapi.json");
});

it("rejects a metadata literal without DNS (fail closed)", async () => {
await expect(run("http://169.254.169.254/latest/meta-data/")).rejects.toMatchObject({
_tag: "EgressError",
});
});

it("rejects a decimal-encoded metadata IP (2852039166 = 169.254.169.254)", async () => {
await expect(run("http://2852039166/latest/meta-data/")).rejects.toMatchObject({
_tag: "EgressError",
});
});

it("rejects a hex-encoded loopback (0x7f000001 = 127.0.0.1)", async () => {
await expect(run("http://0x7f000001/")).rejects.toMatchObject({
_tag: "EgressError",
});
});

it("rejects an octal-encoded loopback (0177.0.0.1 = 127.0.0.1)", async () => {
await expect(run("http://0177.0.0.1/")).rejects.toMatchObject({
_tag: "EgressError",
});
});

it("rejects a hostname that resolves to a private address (DNS-pinned check)", async () => {
const privateResolvingLookup = async (): Promise<string[]> => ["10.0.0.5"];
await expect(run("http://evil.example.com/", privateResolvingLookup)).rejects.toMatchObject({
_tag: "EgressError",
});
});

it("rejects a hostname that resolves to ANY private address among public ones", async () => {
const mixedLookup = async (): Promise<string[]> => ["104.18.16.10", "169.254.169.254"];
await expect(run("http://evil.example.com/", mixedLookup)).rejects.toMatchObject({
_tag: "EgressError",
});
});

it("rejects non-http(s) schemes and userinfo", async () => {
await expect(run("file:///etc/passwd")).rejects.toMatchObject({ _tag: "EgressError" });
await expect(run("ftp://example.com/")).rejects.toMatchObject({ _tag: "EgressError" });
await expect(run("http://user:pass@example.com/")).rejects.toMatchObject({
_tag: "EgressError",
});
});
});
Loading
Loading