diff --git a/.changeset/egress-guard.md b/.changeset/egress-guard.md new file mode 100644 index 0000000000..46c7611c4d --- /dev/null +++ b/.changeset/egress-guard.md @@ -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. diff --git a/e2e/setup/cloud.globalsetup.ts b/e2e/setup/cloud.globalsetup.ts index 2867e5f90b..b8e1c6acdd 100644 --- a/e2e/setup/cloud.globalsetup.ts +++ b/e2e/setup/cloud.globalsetup.ts @@ -32,6 +32,10 @@ const optionalCloudEnv = (): Record => { const env: Record = { 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 diff --git a/packages/core/sdk/src/egress.test.ts b/packages/core/sdk/src/egress.test.ts new file mode 100644 index 0000000000..061c7a7382 --- /dev/null +++ b/packages/core/sdk/src/egress.test.ts @@ -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 => + 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 => []; + 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 => []; + 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 => ["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 => ["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", + }); + }); +}); diff --git a/packages/core/sdk/src/egress.ts b/packages/core/sdk/src/egress.ts new file mode 100644 index 0000000000..2273f0941e --- /dev/null +++ b/packages/core/sdk/src/egress.ts @@ -0,0 +1,308 @@ +// --------------------------------------------------------------------------- +// Egress guard — SSRF protection for integration-spec fetching. +// +// The generic add-by-URL path (OpenAPI/GraphQL/MCP) fetches attacker- +// controlled URLs server-side. Without a guard, a crafted spec URL pointing +// at 169.254.169.254 (cloud metadata), RFC1918 services, or link-local +// targets lets a tenant read internal state through the fetch feature every +// new user touches first. +// +// Design (extracted + generalized from the Google/Graph adapters' strict +// origin allowlists): +// +// assertFetchable(url) +// → parse + scheme check +// → normalize the hostname (strip trailing dot; resolve decimal/octal/ +// hex integer IPv4 forms to a canonical dotted quad) +// → resolve via dns.lookup (hostnames only; literal IPs skip DNS) +// → classify the resolved address against the blocklist +// → return PinnedTarget { url, hostname, resolvedAddress } +// +// The caller connects to `resolvedAddress` (pinning — no second resolution, +// so a DNS-rebinding attacker cannot swap a public answer for a private one +// between validate and connect). Classification is a pure function of the +// address string. +// +// The blocklist: loopback, RFC1918, link-local (incl. 169.254.169.254), +// carrier-grade NAT 100.64/10, IPv6 link-local + ULA, IPv4-mapped IPv6, +// 0.0.0.0, and broadcast. +// --------------------------------------------------------------------------- + +import { Effect, Schema } from "effect"; + +// DNS resolution is loaded lazily: this module is part of the SDK barrel, +// which DOM-platform consumers (react) typecheck against node-less libs — +// any statically resolvable "node:dns" import would break their +// compilation. Only the assertFetchable path needs it, and that path runs +// in Node runtimes. The specifier below is assembled at runtime so tsc +// does not resolve it under DOM libs. +type Lookup = (hostname: string) => Promise<{ address: string }[]>; +let cachedLookup: Lookup | undefined; +const loadNodeDeps = async (): Promise<{ lookup: Lookup }> => { + if (cachedLookup !== undefined) return { lookup: cachedLookup }; + const dnsModuleName = ["node", "dns"].join(":"); + // Structurally typed: no resolvable node:dns type reference survives for + // DOM-platform consumers of the SDK barrel. + const dns: { + promises: { lookup: (h: string, o: { all: true }) => Promise<{ address: string }[]> }; + } = await import(dnsModuleName); + cachedLookup = (hostname: string) => dns.promises.lookup(hostname, { all: true }); + return { lookup: cachedLookup }; +}; + +/** Family classifier without node:net — IPv4 validates via ipv4ToInt, IPv6 + * via a strict colon-hex structural check. Mirrors net.isIP's 0/4/6. */ +const classifyFamily = (address: string): 0 | 4 | 6 => { + if (ipv4ToInt(address) !== null) return 4; + const a = address.toLowerCase(); + // IPv6: 2-8 groups of 1-4 hex digits, at most one "::" (which may + // compress leading/trailing zeros), optionally ending in an IPv4 tail. + if (!a.includes(":")) return 0; + const v4Tail = /(?<=:)(?:d{1,3}.){3}d{1,3}$/.test(a); + const head = v4Tail ? a.slice(0, a.lastIndexOf(":")) : a; + if (v4Tail && !/:(:)?$/.test(head) === false && head.replaceAll(":", "").length === 0) return 0; + const groups = head.split("::"); + if (groups.length > 2) return 0; + const parts = + groups.length === 2 ? [...groups[0].split(":"), ...groups[1].split(":")] : head.split(":"); + const maxGroups = v4Tail ? 6 : 8; + if (groups.length === 2 && parts.filter(Boolean).length > maxGroups) return 0; + if (groups.length === 1 && parts.filter(Boolean).length !== maxGroups) return 0; + return parts.every((p) => p === "" || /^[0-9a-f]{1,4}$/.test(p)) ? 6 : 0; +}; + +/** Rejection reason — kept coarse so error messages never leak topology. */ +export class EgressError extends Schema.TaggedErrorClass()("EgressError", { + reason: Schema.Literal("blocked_by_policy"), +}) {} + +export type EgressErrorInstance = InstanceType; + +/** A validated, pinned target: connect to `resolvedAddress`, keep `hostname` + * for the Host header / SNI. */ +export const PinnedTarget = Schema.Struct({ + url: Schema.String, + hostname: Schema.String, + /** The IP address the caller MUST connect to (pinned — no re-resolution). */ + resolvedAddress: Schema.String, +}); +export type PinnedTarget = typeof PinnedTarget.Type; + +// --------------------------------------------------------------------------- +// Pure classification — no I/O. Testable directly; the PBT property permutes +// encodings against it. +// --------------------------------------------------------------------------- + +const ipv4ToInt = (ip: string): number | null => { + const parts = ip.split("."); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + value = (value << 8) | octet; + } + return value >>> 0; +}; + +const inCidr = (ip: number, base: string, bits: number): boolean => { + const baseInt = ipv4ToInt(base); + if (baseInt === null) return false; + const mask = bits === 0 ? 0 : 0xffffffff << (32 - bits); + return (ip & mask) === (baseInt & mask); +}; + +const isPrivateIPv4 = (ip: string): boolean => { + const value = ipv4ToInt(ip); + if (value === null) return false; + return ( + inCidr(value, "0.0.0.0", 8) || // "this network" + inCidr(value, "10.0.0.0", 8) || // RFC1918 + inCidr(value, "127.0.0.0", 8) || // loopback + inCidr(value, "169.254.0.0", 16) || // link-local incl. metadata + inCidr(value, "172.16.0.0", 12) || // RFC1918 + inCidr(value, "192.168.0.0", 16) || // RFC1918 + inCidr(value, "100.64.0.0", 10) || // CGNAT + inCidr(value, "255.255.255.255", 32) // broadcast + ); +}; + +const isPrivateIPv6 = (ip: string): boolean => { + const lower = ip.toLowerCase(); + if (lower.startsWith("::ffff:")) { + // IPv4-mapped IPv6 — classify the embedded IPv4. + return isPrivateIPv4(lower.slice("::ffff:".length)); + } + if (lower === "::" || lower === "::1") return true; // unspecified + loopback + if (lower.startsWith("fe80:")) return true; // link-local + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA + if (lower.startsWith("ff")) return true; // multicast + return false; +}; + +/** True for any reserved/private/link-local/metadata address, IPv4 or IPv6, + * in canonical dotted-quad / colon-hex form. (Encoded forms are normalized + * by the caller before this runs.) */ +export const isBlockedAddress = (address: string): boolean => { + const family = classifyFamily(address); + if (family === 4) return isPrivateIPv4(address); + if (family === 6) return isPrivateIPv6(address); + return true; // not parseable as an IP — treat as blocked (fail closed) +}; + +// --------------------------------------------------------------------------- +// Hostname normalization — DNS-encoding trick defense. +// --------------------------------------------------------------------------- + +/** Normalize an integer-form IPv4 (decimal/octal/hex, single or dotted) to a + * canonical dotted quad, or null if the host is not an encoded IPv4 literal. + * Examples: 2852039166 → 169.254.169.254; 0x7f000001 → 127.0.0.1; + * 0177.0.0.1 → 127.0.0.1. */ +const normalizeEncodedIPv4 = (host: string): string | null => { + const trimmed = host.replace(/\.$/, ""); // trailing dot + // Hex form: 0x7f000001 or 0x7f.0x0.0x1 style + if (/^0x/i.test(trimmed)) { + const body = trimmed.slice(2); + if (!/^[0-9a-f]+$/i.test(body)) return null; + const value = parseInt(body, 16); + if (!Number.isFinite(value) || value < 0 || value > 0xffffffff) return null; + return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff].join( + ".", + ); + } + // Octal-per-octet: 0177.0.0.1 — ANY part with a leading 0 makes the whole + // quad octal (C/browser URL-parsing rule), so every octet is base-8. + if (trimmed.includes(".") && /(^|\.)0[0-7]+(\.|$)/.test(trimmed)) { + const parts = trimmed.split("."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (const part of parts) { + if (!/^[0-7]+$/.test(part)) return null; + const octet = parseInt(part, 8); + if (octet > 255) return null; + octets.push(octet); + } + return octets.join("."); + } + // Single integer: 2852039166 + if (/^\d+$/.test(trimmed)) { + const value = Number(trimmed); + if (!Number.isFinite(value) || value < 0 || value > 0xffffffff) return null; + return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff].join( + ".", + ); + } + return null; +}; + +/** Normalize a hostname for classification. Returns the canonical address if + * the host IS an IP literal (any encoding), else null (meaning: it's a + * hostname, resolve it). */ +const normalizeHost = (host: string): string | null => { + const trimmed = host.replace(/\.$/, "").toLowerCase(); + if (classifyFamily(trimmed) !== 0) return trimmed; + const encoded = normalizeEncodedIPv4(trimmed); + if (encoded !== null) return encoded; + return null; +}; + +// --------------------------------------------------------------------------- +// Resolve + classify + pin. +// --------------------------------------------------------------------------- + +const BLOCKED_MESSAGE = "Blocked by egress policy"; + +/** Options for assertFetchable. `allowLoopback` deliberately re-enables + * loopback/private targets for callers that have already decided to trust + * them (a local dev spec server, an e2e-hosted fixture). The default stays + * fail-closed; production paths never pass this. */ +export interface FetchableOptions { + readonly allowLoopback?: boolean; +} + +/** Validate a URL's target. Resolves hostnames, classifies the address, + * returns a pinned target. Pure-IP literals (any encoding) are classified + * without DNS. */ +export const assertFetchable = ( + url: string, + lookupOrOptions?: ((hostname: string) => Promise) | FetchableOptions, + maybeOptions?: FetchableOptions, +): Effect.Effect => { + const lookup = + typeof lookupOrOptions === "function" + ? lookupOrOptions + : async (hostname: string) => { + const { lookup: nodeLookup } = await loadNodeDeps(); + const addrs = await nodeLookup(hostname); + return addrs.map((a) => a.address); + }; + const options: FetchableOptions = + typeof lookupOrOptions === "function" ? (maybeOptions ?? {}) : (lookupOrOptions ?? {}); + const allowLoopback = options.allowLoopback === true; + return Effect.gen(function* () { + let parsed: URL; + // oxlint-disable executor/no-try-catch-or-throw -- boundary: untrusted user-supplied URL string; an unparseable URL collapses to the blocked error (fail closed) + try { + parsed = new URL(url); + } catch { + return yield* new EgressError({ reason: "blocked_by_policy" }); + } + // oxlint-enable executor/no-try-catch-or-throw + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return yield* new EgressError({ reason: "blocked_by_policy" }); + } + if (parsed.username || parsed.password) { + // userinfo in a spec URL is a credential-exfiltration smell — fail closed. + return yield* new EgressError({ reason: "blocked_by_policy" }); + } + + const hostname = parsed.hostname; + const canonical = normalizeHost(hostname); + + let resolvedAddresses: string[]; + if (canonical !== null) { + // IP literal — classify directly, no DNS (a literal cannot rebind). + resolvedAddresses = [canonical]; + } else { + // Lookup failure (NXDOMAIN, resolver down) is treated as "no + // addresses" — a success carrying an empty list, which the check + // below turns into blocked. tryPromise's catch produces the ERROR + // value, so mapping it to [] there would surface a nonsense error; + // instead catch with a typed error and recover to the empty list. + resolvedAddresses = yield* Effect.tryPromise({ + try: () => lookup(hostname), + catch: () => new EgressError({ reason: "blocked_by_policy" }), + }).pipe(Effect.orElseSucceed(() => [])); + if (resolvedAddresses.length === 0) { + // allowLoopback means the deployment trusts its whole network for + // this fetch — including its own resolver (e.g. a workerd host where + // node:dns may be unavailable). Pass the hostname through as the + // target and let the transport resolve it. + if (allowLoopback) { + return { url, hostname, resolvedAddress: hostname }; + } + return yield* new EgressError({ reason: "blocked_by_policy" }); + } + } + + // Fail closed if ANY resolved address is blocked (an attacker controls + // DNS; a single private answer poisons the whole target). allowLoopback + // is the explicit trust escape hatch — see FetchableOptions. + if (!allowLoopback) { + for (const address of resolvedAddresses) { + if (isBlockedAddress(address)) { + return yield* new EgressError({ reason: "blocked_by_policy" }); + } + } + } + + // Pin the FIRST address; the caller must connect to it and must + // NOT re-resolve (DNS-rebinding defense). + const pinned = resolvedAddresses[0]; + return { url, hostname, resolvedAddress: pinned }; + }); +}; + +/** Coarse, topology-free message — never echo the resolved address. */ +export const egressErrorMessage = (): string => BLOCKED_MESSAGE; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index d7196fb262..7465861af7 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -257,6 +257,16 @@ export { type PendingApprovalStore, } from "./pending-approval"; +// Egress guard — SSRF protection for integration-spec fetching. +export { + assertFetchable, + isBlockedAddress, + EgressError, + egressErrorMessage, + type PinnedTarget, + type FetchableOptions, +} from "./egress"; + // Plugin storage. export { definePluginStorageCollection, diff --git a/packages/plugins/openapi/src/sdk/parse.ts b/packages/plugins/openapi/src/sdk/parse.ts index 8f2557dcb0..3855cba217 100644 --- a/packages/plugins/openapi/src/sdk/parse.ts +++ b/packages/plugins/openapi/src/sdk/parse.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { JSON_SCHEMA, load as parseYamlDocument } from "js-yaml"; import { OpenApiExtractionError, OpenApiParseError } from "./errors"; +import { assertFetchable } from "@executor-js/sdk"; export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document; @@ -59,6 +60,14 @@ export interface SpecFetchCredentials { readonly queryParams?: Record; } +/** Egress-policy options for spec fetching. `allowLoopback` re-enables + * loopback/private targets for callers that have already decided to trust + * them (a local dev spec server, an e2e-hosted fixture). Default stays + * fail-closed. */ +export interface SpecFetchOptions { + readonly allowLoopback?: boolean; +} + // ExtractionError subclass raised from parse() for non-3.x specs class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {} @@ -71,14 +80,44 @@ class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {} export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( url: string, credentials?: SpecFetchCredentials, + fetchOptions?: SpecFetchOptions, ) { const client = yield* HttpClient.HttpClient; + // Egress guard: reject private/link-local/metadata targets BEFORE any + // fetch. Resolves hostnames and pins the resolved address so the connect + // cannot rebind. Coarse error — no topology leaked. allowLoopback comes + // from the explicit option, the spec-scoped env hook, or the established + // local-network knobs (cloud e2e sets ALLOW_LOCAL_NETWORK; selfhost e2e + // sets EXECUTOR_ALLOW_LOCAL_NETWORK — the same two names hosts read into + // HostConfig.allowLocalNetwork) — the default stays fail-closed in every + // production path. + const allowLoopback = + fetchOptions?.allowLoopback === true || + process.env.EXECUTOR_ALLOW_LOOPBACK_SPECS === "1" || + process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true" || + process.env.ALLOW_LOCAL_NETWORK === "true"; + const pinned = yield* assertFetchable(url, { + allowLoopback, + }).pipe(Effect.mapError(() => new OpenApiParseError({ message: "Blocked by egress policy" }))); const requestUrl = new URL(url); + // Pin: connect to the validated address, preserving the original host for + // the Host header / SNI. HttpClient has no custom-connect hook, so the URL + // rewrite is the effective pin — but ONLY for plain http: rewriting an + // https URL to the resolved IP would present the IP as SNI and break TLS + // to CDN-fronted hosts. For https the fetch keeps the original hostname + // (the guard still gates the decision — any blocked resolved address + // fails the whole target before the fetch); the re-resolve window that + // reopens is the documented trade-off of fetch-based transports. + const originalHost = requestUrl.host; + if (requestUrl.protocol === "http:") { + requestUrl.hostname = pinned.resolvedAddress; + } for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { requestUrl.searchParams.set(name, value); } let request = HttpClientRequest.get(requestUrl.toString()).pipe( HttpClientRequest.setHeader("Accept", "application/json, application/yaml, text/yaml, */*"), + HttpClientRequest.setHeader("Host", originalHost), ); for (const [name, value] of Object.entries(credentials?.headers ?? {})) { request = HttpClientRequest.setHeader(request, name, value); @@ -122,9 +161,13 @@ export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( * Resolve an input string to spec text — if it's a URL, fetch it via * HttpClient; otherwise return it as-is. */ -export const resolveSpecText = (input: string, credentials?: SpecFetchCredentials) => +export const resolveSpecText = ( + input: string, + credentials?: SpecFetchCredentials, + fetchOptions?: SpecFetchOptions, +) => input.startsWith("http://") || input.startsWith("https://") - ? fetchSpecText(input, credentials) + ? fetchSpecText(input, credentials, fetchOptions) : Effect.succeed(input); /** diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 1617e8b555..f1adae87ab 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -49,6 +49,11 @@ import { unwrapInvocation, } from "../testing"; +// URL-hosted spec tests boot real 127.0.0.1 listeners and fetch them through +// the production path; the egress guard's loopback block must trust them in +// this suite. Set before the plugin reads it. +process.env.EXECUTOR_ALLOW_LOOPBACK_SPECS = "1"; + const TOOL_ERROR_TYPESCRIPT = "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; diff --git a/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts b/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts index 7a0d285755..d26e42c129 100644 --- a/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts +++ b/packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts @@ -10,6 +10,11 @@ import { openApiPlugin } from "./plugin"; import { applySpecOverrides, type SpecOverrides } from "./spec-overrides"; import { serveMutableOpenApiSpecTestServer } from "../testing"; +// URL-hosted spec tests boot real 127.0.0.1 listeners and fetch them through +// the production path; the egress guard's loopback block must trust them in +// this suite. Set before the plugin reads it. +process.env.EXECUTOR_ALLOW_LOOPBACK_SPECS = "1"; + const testPlugins = () => [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;