Skip to content

Commit 8067ef3

Browse files
committed
fix(sdk,openapi): block SSRF targets when fetching integration specs by URL
1 parent fff7ed6 commit 8067ef3

8 files changed

Lines changed: 529 additions & 2 deletions

File tree

.changeset/egress-guard.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@executor-js/sdk": patch
3+
"@executor-js/plugin-openapi": patch
4+
---
5+
6+
fix: block SSRF targets when fetching integration specs by URL
7+
8+
Adding an OpenAPI (or other URL-based) integration fetched the spec URL
9+
server-side with no egress filtering. A crafted URL pointing at cloud
10+
metadata (169.254.169.254), loopback, RFC1918, or link-local addresses let
11+
the fetch feature reach internal state on hosted deployments.
12+
13+
A shared egress guard (`assertFetchable`) now validates every spec-fetch
14+
target before connecting: it normalizes DNS-encoding tricks (decimal/octal/
15+
hex integer IPv4, trailing dots), resolves hostnames, and fails closed if
16+
any resolved address is loopback, RFC1918, link-local, carrier-grade NAT,
17+
IPv6 link-local/ULA, or IPv4-mapped private. The resolved address is pinned
18+
for the connect (no second resolution, so DNS rebinding cannot swap in a
19+
private target), and the original host is preserved in the Host header.
20+
Rejections are coarse ("blocked by egress policy") and never echo internal
21+
addresses.

e2e/setup/cloud.globalsetup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const optionalCloudEnv = (): Record<string, string> => {
3232
const env: Record<string, string> = {
3333
SENTRY_OTEL_VERIFY: "true",
3434
SENTRY_OTEL_LOG_PAYLOAD: "true",
35+
// The e2e cloud stack serves integration specs from a loopback fixture
36+
// server — the egress guard's loopback block must be explicitly trusted
37+
// here. Production never sets this.
38+
EXECUTOR_ALLOW_LOOPBACK_SPECS: "1",
3539
// Boot the BROWSER crash reporter too, so what the frontend actually
3640
// reports is observable to a scenario. Production always has this set;
3741
// without it the reporter the app wires into ExecutorProvider is a no-op
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import { assertFetchable, isBlockedAddress } from "./egress";
5+
6+
// ---------------------------------------------------------------------------
7+
// Focused tests — egress-guard classification boundaries and the pinned
8+
// resolve/connect contract.
9+
//
10+
// assertFetchable is pure (DNS injected as a lookup fn), so these tests need
11+
// no executor harness, no DB, no scope. Encoded-host permutations and
12+
// redirect-chain behavior are covered by property tests elsewhere; these
13+
// pin the classification boundaries and the pin-return contract.
14+
// ---------------------------------------------------------------------------
15+
16+
const publicLookup = async (hostname: string): Promise<string[]> =>
17+
hostname === "petstore3.swagger.io" ? ["104.18.16.10"] : ["93.184.216.34"];
18+
19+
const run = (url: string, lookup = publicLookup) => Effect.runPromise(assertFetchable(url, lookup));
20+
21+
describe("isBlockedAddress (pure classification)", () => {
22+
it("blocks metadata, loopback, RFC1918, CGNAT, link-local", () => {
23+
expect(isBlockedAddress("169.254.169.254")).toBe(true); // cloud metadata
24+
expect(isBlockedAddress("127.0.0.1")).toBe(true);
25+
expect(isBlockedAddress("10.0.0.1")).toBe(true);
26+
expect(isBlockedAddress("192.168.1.1")).toBe(true);
27+
expect(isBlockedAddress("172.16.0.1")).toBe(true);
28+
expect(isBlockedAddress("100.64.0.1")).toBe(true); // CGNAT
29+
expect(isBlockedAddress("0.0.0.0")).toBe(true);
30+
});
31+
32+
it("allows public addresses", () => {
33+
expect(isBlockedAddress("8.8.8.8")).toBe(false);
34+
expect(isBlockedAddress("104.18.16.10")).toBe(false);
35+
expect(isBlockedAddress("93.184.216.34")).toBe(false);
36+
});
37+
38+
it("blocks IPv6 loopback, link-local, ULA, and IPv4-mapped private", () => {
39+
expect(isBlockedAddress("::1")).toBe(true);
40+
expect(isBlockedAddress("fe80::1")).toBe(true);
41+
expect(isBlockedAddress("fc00::1")).toBe(true);
42+
expect(isBlockedAddress("fd00::1")).toBe(true);
43+
expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true); // mapped loopback
44+
expect(isBlockedAddress("::ffff:169.254.169.254")).toBe(true); // mapped metadata
45+
});
46+
47+
it("fails closed on unparseable input", () => {
48+
expect(isBlockedAddress("not-an-ip")).toBe(true);
49+
expect(isBlockedAddress("")).toBe(true);
50+
});
51+
});
52+
53+
describe("assertFetchable (allowLoopback trust mode)", () => {
54+
it("allows a loopback literal when the option is set", async () => {
55+
const pinned = await Effect.runPromise(
56+
assertFetchable("http://127.0.0.1:8787/spec.json", { allowLoopback: true }),
57+
);
58+
expect(pinned.resolvedAddress).toBe("127.0.0.1");
59+
});
60+
61+
it("passes the hostname through when the resolver yields nothing (trusted resolver)", async () => {
62+
const emptyLookup = async (): Promise<string[]> => [];
63+
const pinned = await Effect.runPromise(
64+
assertFetchable("http://fixture.local:8787/spec.json", emptyLookup, { allowLoopback: true }),
65+
);
66+
expect(pinned.hostname).toBe("fixture.local");
67+
expect(pinned.resolvedAddress).toBe("fixture.local");
68+
});
69+
70+
it("still blocks an unresolvable hostname without the option (fail closed)", async () => {
71+
const emptyLookup = async (): Promise<string[]> => [];
72+
await expect(
73+
Effect.runPromise(assertFetchable("http://fixture.local:8787/spec.json", emptyLookup)),
74+
).rejects.toMatchObject({ _tag: "EgressError" });
75+
});
76+
});
77+
78+
describe("assertFetchable (resolve + classify + pin)", () => {
79+
it("accepts a public hostname and returns the pinned resolved address", async () => {
80+
const pinned = await run("https://petstore3.swagger.io/api/v3/openapi.json");
81+
expect(pinned.hostname).toBe("petstore3.swagger.io");
82+
expect(pinned.resolvedAddress).toBe("104.18.16.10");
83+
expect(pinned.url).toBe("https://petstore3.swagger.io/api/v3/openapi.json");
84+
});
85+
86+
it("rejects a metadata literal without DNS (fail closed)", async () => {
87+
await expect(run("http://169.254.169.254/latest/meta-data/")).rejects.toMatchObject({
88+
_tag: "EgressError",
89+
});
90+
});
91+
92+
it("rejects a decimal-encoded metadata IP (2852039166 = 169.254.169.254)", async () => {
93+
await expect(run("http://2852039166/latest/meta-data/")).rejects.toMatchObject({
94+
_tag: "EgressError",
95+
});
96+
});
97+
98+
it("rejects a hex-encoded loopback (0x7f000001 = 127.0.0.1)", async () => {
99+
await expect(run("http://0x7f000001/")).rejects.toMatchObject({
100+
_tag: "EgressError",
101+
});
102+
});
103+
104+
it("rejects an octal-encoded loopback (0177.0.0.1 = 127.0.0.1)", async () => {
105+
await expect(run("http://0177.0.0.1/")).rejects.toMatchObject({
106+
_tag: "EgressError",
107+
});
108+
});
109+
110+
it("rejects a hostname that resolves to a private address (DNS-pinned check)", async () => {
111+
const privateResolvingLookup = async (): Promise<string[]> => ["10.0.0.5"];
112+
await expect(run("http://evil.example.com/", privateResolvingLookup)).rejects.toMatchObject({
113+
_tag: "EgressError",
114+
});
115+
});
116+
117+
it("rejects a hostname that resolves to ANY private address among public ones", async () => {
118+
const mixedLookup = async (): Promise<string[]> => ["104.18.16.10", "169.254.169.254"];
119+
await expect(run("http://evil.example.com/", mixedLookup)).rejects.toMatchObject({
120+
_tag: "EgressError",
121+
});
122+
});
123+
124+
it("rejects non-http(s) schemes and userinfo", async () => {
125+
await expect(run("file:///etc/passwd")).rejects.toMatchObject({ _tag: "EgressError" });
126+
await expect(run("ftp://example.com/")).rejects.toMatchObject({ _tag: "EgressError" });
127+
await expect(run("http://user:pass@example.com/")).rejects.toMatchObject({
128+
_tag: "EgressError",
129+
});
130+
});
131+
});

0 commit comments

Comments
 (0)