History

Revert "Cache DNS guard resolutions and harden the outbound URL guard (#1524)"

This reverts commit 6a924dd98de916d6ff8cea2329bf672f149b64f4.

Rhys Sullivan committed 3 months ago

Commit bc4fd981 parent 4c9e78a4 files · +173 −2167Browse files

Unified · Split

4 files
  1. packages/core/api/src/server/scoped-executor.ts +9 −8
  2. packages/core/sdk/src/host-internal.ts +0 −1
  3. packages/core/sdk/src/hosted-http-client.test.ts +39 −1509
  4. packages/core/sdk/src/hosted-http-client.ts +125 −649
packages/core/api/src/server/scoped-executor.ts+9−8
@@ -43,7 +43,11 @@ import { · 42 unmodified lines
4343 type ExecutorConfig,
4444 type StorageFailure,
4545 } from "@executor-js/sdk";
46−import { makeHostedHttp, touchSubject } from "@executor-js/sdk/host-internal";
46+import {
47+ makeHostedFetch,
48+ makeHostedHttpClientLayer,
49+ touchSubject,
50+} from "@executor-js/sdk/host-internal";
4751
4852 import { DbProvider } from "./executor-fuma-db";
4953
@@ -265,10 +269,8 @@ export const makeScopedExecutor = < · 215 unmodified lines
265269 const hostedHttpOptions = {
266270 allowLocalNetwork: config.allowLocalNetwork,
267271 };
268− // One resolution cache behind both adapters: the plugins take the raw
269− // fetch and the SDK takes the layer, so building them separately would
270− // resolve every hostname twice per session.
271− const { fetch: hostedFetch, httpClientLayer } = makeHostedHttp(hostedHttpOptions);
272+ const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions);
273+ const hostedFetch = makeHostedFetch(hostedHttpOptions);
272274
273275 // The org id is the tenant (catalog partition); the account id is the acting
274276 // subject (drives `owner: "user"` rows). `organizationName` is no longer part
@@ -361,15 +363,14 @@ export const makePlatformExecutor = ( · 86 unmodified lines
361363 Effect.withSpan("executor.platform.plugins.init"),
362364 );
363365 const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork };
364− const platformHttp = makeHostedHttp(hostedHttpOptions);
365366
366367 return yield* createExecutor({
367368 tenant: Tenant.make(organizationId),
368369 db,
369370 blobs,
370371 plugins,
371− httpClientLayer: platformHttp.httpClientLayer,
372− fetch: platformHttp.fetch,
372+ httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions),
373+ fetch: makeHostedFetch(hostedHttpOptions),
373374 onElicitation: "accept-all",
374375 platformView: true,
375376 }).pipe(Effect.withSpan("executor.platform.create_executor"));
packages/core/sdk/src/host-internal.ts+0−1
@@ -32,7 +32,6 @@ · 31 unmodified lines
3232 export {
3333 HostedOutboundRequestBlocked,
3434 makeHostedFetch,
35− makeHostedHttp,
3635 makeHostedHttpClientLayer,
3736 type HostedHttpClientOptions,
3837 } from "./hosted-http-client";
packages/core/sdk/src/hosted-http-client.test.ts+39−1509
@@ -1,575 +1,42 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Layer, Result } from "effect";
3−import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
4−import { createServer, type Server } from "node:http";
2+import { Effect, Predicate, Result } from "effect";
3+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
54
65 import {
76 type HostedHostnameResolver,
87 makeHostedFetch,
9− makeHostedHttp,
108 makeHostedHttpClientLayer,
119 validateHostedOutboundUrl,
1210 } from "./hosted-http-client";
1311
14−const publicResolver: HostedHostnameResolver = async () => [{ address: "93.184.216.34" }];
15−
16−// A stub `fetch` accepts any init, so the shape of what the adapter hands the
17−// transport is exactly what a stub cannot check — undici rejects a streamed
18−// body whose init omits `duplex`, and no fake will ever say so. This drives a
19−// real server so the transport's own requirements are the assertion.
20−const withServer = <A>(
21− f: (input: {
22− readonly baseUrl: string;
23− readonly received: Array<{ readonly method: string; readonly body: string }>;
24− }) => Promise<A>,
25−) =>
26− new Promise<A>((resolve, reject) => {
27− const received: Array<{ method: string; body: string }> = [];
28− const server: Server = createServer((request, response) => {
29− const chunks: Array<Buffer> = [];
30− request.on("data", (chunk: Buffer) => chunks.push(chunk));
31− request.on("end", () => {
32− received.push({
33− method: request.method ?? "",
34− body: Buffer.concat(chunks).toString("utf8"),
35− });
36− response.statusCode = 200;
37− response.end("ok");
38− });
39− });
40−
41− server.listen(0, "127.0.0.1", () => {
42− const address = server.address();
43− if (!address || typeof address === "string") {
44− server.close();
45− // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: Node listen callback is adapted into the test Promise failure path
46− reject(new Error("Server did not bind to a TCP port"));
47− return;
48− }
49− f({ baseUrl: `http://127.0.0.1:${address.port}`, received })
50− .then(resolve, reject)
51− .finally(() => server.close());
52− });
53− });
54−
55−// Reproduces the raw Promise rejection node:dns/promises produces at the
56−// adapter boundary under test. The rejection value is deliberately opaque:
57−// the guard discards it and maps every failure to the same tagged error, so
58−// carrying a getaddrinfo code here would imply a distinction that is not made.
59−const nodeDnsRejection = (): Promise<never> =>
60− // oxlint-disable-next-line executor/no-promise-reject -- boundary: fixture mirrors the raw Promise rejection node:dns produces
61− Promise.reject("getaddrinfo failure");
62−
63−// The guard error is thrown inside the fetch adapter, so the HttpClient
64−// surfaces it wrapped in a RequestError whose cause is the blocked error.
65−const expectBlocked = (result: Result.Result<unknown, unknown>, reason: string) => {
66− expect(result).toMatchObject({
67− _tag: "Failure",
68− failure: { cause: { _tag: "HostedOutboundRequestBlocked", reason } },
69− });
70−};
12+const publicResolver: HostedHostnameResolver = async () => [
13+ { address: "93.184.216.34", family: 4 },
14+];
7115
7216 describe("hosted outbound HTTP client", () => {
7317 it.effect("allows public HTTP and HTTPS URLs", () =>
7418 Effect.gen(function* () {
75− yield* validateHostedOutboundUrl("https://example.com/openapi.json", {
76− resolveHostname: publicResolver,
77− });
78− yield* validateHostedOutboundUrl("http://example.com/graphql", {
79− resolveHostname: publicResolver,
80− });
81− }),
82− );
83−
84− it.effect("rejects non-HTTP protocols", () =>
85− Effect.gen(function* () {
86− for (const url of ["file:///etc/passwd", "data:text/plain,x", "gopher://example.com/"]) {
87− const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
88− expect(error).toMatchObject({
89− _tag: "HostedOutboundRequestBlocked",
90− reason: "Only HTTP and HTTPS outbound requests are allowed",
91− });
92− }
19+ yield* validateHostedOutboundUrl("https://example.com/openapi.json");
20+ yield* validateHostedOutboundUrl("http://example.com/graphql");
9321 }),
9422 );
9523
96− // One representative per arm of the IPv4 blocklist, so deleting any range
97− // turns a test red: this-network, loopback, RFC1918 x3, CGNAT, link-local,
98− // 192.0.0.0/24, benchmark, multicast.
9924 it.effect("rejects local and private network URLs", () =>
10025 Effect.gen(function* () {
10126 for (const url of [
10227 "http://localhost:3000",
103− // The subdomain form is what a dev server or local reverse proxy
104− // actually hands out, and it never reaches the resolved-address check.
105− "http://app.localhost/",
106− // WHATWG keeps every trailing dot, so a hostname stripped of only one
107− // stays unequal to "localhost" and skips the whole name check while
108− // still resolving to the same host.
109− "http://localhost../",
110− "http://0.0.0.0/",
11128 "http://127.0.0.1:3000",
11229 "http://10.0.0.1/openapi.json",
113− "http://100.64.0.1/",
114− "http://169.254.1.1/",
11530 "http://172.16.0.1/graphql",
116− "http://192.0.0.1/",
11731 "http://192.168.1.10/mcp",
118− "http://198.18.0.1/",
119− "http://224.0.0.1/",
120− // The far edge of every range spanning more than one second octet.
121− // Without these, narrowing a range to the single value tested above
122− // leaves the suite green while a live private destination becomes
123− // reachable — the guard would still look covered.
124− "http://100.127.255.255/",
125− "http://172.31.255.255/",
126− "http://198.19.255.255/",
127− // 224.0.0.0-255.255.255.255 is one arm spanning four /4s: with only
128− // 224.0.0.1 above, narrowing it to `a === 224` — or to multicast plus
129− // one value — unblocks reserved 240.0.0.0/4 and the broadcast address
130− // with the suite still green.
131− "http://240.0.0.1/",
132− "http://255.255.255.255/",
133− ]) {
134− // The public resolver pins WHICH rule blocked: were these to slip
135− // past the pre-resolution check, resolution would succeed with a
136− // public address and the URL would be allowed, failing the test.
137− const error = yield* validateHostedOutboundUrl(url, {
138− resolveHostname: publicResolver,
139− }).pipe(Effect.flip);
140− expect(error).toMatchObject({
141− _tag: "HostedOutboundRequestBlocked",
142− reason: "Local and private network addresses are not allowed",
143− });
144− }
145− // The second entry is the same name with a second root dot appended:
146− // the resolver treats it identically, so stripping only one dot would
147− // let the metadata endpoint through by adding one character.
148− for (const url of [
14932 "http://169.254.169.254/latest/meta-data/",
150− "http://metadata.google.internal../computeMetadata/v1/",
151− // The metadata block runs ahead of the allowLocalNetwork gate, so its
152− // invariant is "never reachable" — and a comparison against the one
153− // dotted-decimal spelling cannot hold that. Every IPv6 form carrying
154− // the same destination has to take the same block, or the local and
155− // desktop hosts reach the endpoint by writing the address differently.
156− // Each of these normalizes to a distinct hostname — the WHATWG parser
157− // rewrites a dotted quad inside a literal, so ::ffff:169.254.169.254
158− // and ::ffff:a9fe:a9fe are one input, not two.
159− "http://[::ffff:169.254.169.254]/latest/meta-data/",
160− "http://[::169.254.169.254]/latest/meta-data/",
161− "http://[::ffff:0:a9fe:a9fe]/latest/meta-data/",
162− "http://[2002:a9fe:a9fe::]/latest/meta-data/",
163− "http://[64:ff9b::169.254.169.254]/latest/meta-data/",
164− ]) {
165− const metadataError = yield* validateHostedOutboundUrl(url, {
166− // allowLocalNetwork isolates the metadata arm: without it the
167− // local/private check would block the name first and the test would
168− // pass whether or not the trailing dots were handled.
169− allowLocalNetwork: true,
170− resolveHostname: publicResolver,
171− }).pipe(Effect.flip);
172− expect(metadataError).toMatchObject({
173− _tag: "HostedOutboundRequestBlocked",
174− reason: "Metadata service addresses are not allowed",
175− });
176− }
177− }),
178− );
179−
180− it.effect("rejects native IPv6 loopback, link-local, ULA and multicast URLs", () =>
181− Effect.gen(function* () {
182− for (const url of [
183− "http://[::1]/",
184− "http://[::]/",
185− "http://[fe80::1]/",
186− "http://[fd00::1]/",
187− "http://[ff02::1]/",
188− // Both classifiers match a masked range, so testing only its most
189− // canonical member leaves the mask width free: fc00::/7 tightened to
190− // fd00::/8, or fe80::/10 to the exact word, would unblock the other
191− // half of each range with the suite still green. These pin the width.
192− "http://[fc00::1]/",
193− "http://[febf::1]/",
194− // Site-local (fec0::/10) is deprecated but still routed by older
195− // stacks and still handed out by some equipment, and it sits in the
196− // half of fe80::/9 that a link-local-only mask leaves public.
197− "http://[fec0::1]/",
198− "http://[feff::1]/",
19933 ]) {
20034 const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
201− expect(error).toMatchObject({
202− _tag: "HostedOutboundRequestBlocked",
203− reason: "Local and private network addresses are not allowed",
204− });
205− }
206− }),
207− );
208−
209− // Every prefix that carries an IPv4 destination in its low 32 bits must be
210− // classified by that destination. Reading the first non-empty group instead
211− // would let "::169.254.1.1" — which starts with six zero words, not with
212− // 0xa9fe — straight through to the link-local range.
213− //
214− // These deliberately avoid 169.254.169.254: the metadata check runs ahead of
215− // this one and would answer for them, leaving the local/private classifier
216− // free to break with the suite green.
217− it.effect("rejects IPv6 literals that embed a private IPv4 address", () =>
218− Effect.gen(function* () {
219− for (const url of [
220− "http://[::169.254.1.1]/",
221− "http://[::127.0.0.1]/",
222− "http://[::10.0.0.1]/",
223− "http://[64:ff9b::169.254.1.1]/",
224− "http://[64:ff9b::127.0.0.1]/",
225− // RFC 6052 IPv4-translatable (::ffff:0:0:0/96), 6to4 (2002::/16), and
226− // RFC 8215 local-use NAT64 (64:ff9b:1::/48) each reach an embedded or
227− // locally translated IPv4 destination too. Being address literals they
228− // skip the resolved-address check entirely, so classifying them by
229− // their own prefix is the whole guard, not one layer of it.
230− "http://[::ffff:0:7f00:1]/",
231− "http://[::ffff:0:a9fe:101]/",
232− "http://[2002:a9fe:101::]/",
233− "http://[2002:7f00:1::]/",
234− "http://[64:ff9b:1::a9fe:a9fe]/",
235− ]) {
236− const error = yield* validateHostedOutboundUrl(url, {
237− resolveHostname: publicResolver,
238− }).pipe(Effect.flip);
239− expect(error).toMatchObject({
240− _tag: "HostedOutboundRequestBlocked",
241− reason: "Local and private network addresses are not allowed",
242− });
243− }
244− }),
245− );
246−
247− // The mirror of the case above: classification reads the leading word, so a
248− // public address keeps being allowed even when a *later* word spells a
249− // blocked prefix — "2001:db8::fe80" is public, not link-local — and a
250− // v4-compatible address to a public destination is allowed like its
251− // v4-mapped equivalent.
252− it.effect("allows IPv6 literals whose leading word is public", () =>
253− Effect.gen(function* () {
254− yield* validateHostedOutboundUrl("http://[2001:db8::fe80]/", {
255− resolveHostname: publicResolver,
256− });
257− yield* validateHostedOutboundUrl("http://[::93.184.216.34]/", {
258− resolveHostname: publicResolver,
259− });
260− // The added prefixes are classified by their embedded destination, not
261− // blocked wholesale — 93.184.216.34 is 5db8:d822.
262− yield* validateHostedOutboundUrl("http://[::ffff:0:5db8:d822]/", {
263− resolveHostname: publicResolver,
264− });
265− yield* validateHostedOutboundUrl("http://[2002:5db8:d822::]/", {
266− resolveHostname: publicResolver,
267− });
268− }),
269− );
270−
271− // A resolved address the classifiers cannot decode is not evidence that the
272− // destination is public — it is the guard admitting it cannot tell. Treating
273− // it as "not private" would route every address these parsers reject
274− // straight past the one check that exists to stop it.
275− it.effect("rejects resolved addresses it cannot classify", () =>
276− Effect.gen(function* () {
277− for (const address of ["not-an-address", "10.0.0.1.5", "1:2:3:4:5:6:7:8:9", "::gggg"]) {
278− const error = yield* validateHostedOutboundUrl("https://api.example/x", {
279− resolveHostname: async () => [{ address }],
280− }).pipe(Effect.flip);
281−
282− expect(error).toMatchObject({
283− _tag: "HostedOutboundRequestBlocked",
284− reason: "Resolved address is local or private",
285− });
286− }
287− }),
288− );
289−
290− // Where the parsers disagree with the platform resolver about what an
291− // address means, the guard reads one destination and the connection dials
292− // another. Both forms below are decoded by the parsers, not rejected by
293− // them, so they reach the classifiers wearing a public-looking leading word
294− // — the exact failure the "cannot classify" test above cannot catch.
295− //
296− // A dotted quad is legal only at the end of the whole address; permitting
297− // one at the end of the head half of a compressed literal puts 0x7f00 in
298− // the leading word, where no prefix or mask matches it. And a leading zero
299− // means octal to inet_aton, so "0177.0.0.1" is 127.0.0.1 to the resolver
300− // and 177.0.0.1 to a decimal-only parser.
301− it.effect("rejects resolved addresses whose syntax the platform reads differently", () =>
302− Effect.gen(function* () {
303− for (const address of [
304− "127.0.0.1::1",
305− "192.168.1.1::",
306− "10.0.0.1::0",
307− "169.254.169.254::1",
308− "0177.0.0.1",
309− ]) {
310− const error = yield* validateHostedOutboundUrl("https://api.example/x", {
311− resolveHostname: async () => [{ address }],
312− }).pipe(Effect.flip);
313−
314− expect(error).toMatchObject({
315− _tag: "HostedOutboundRequestBlocked",
316− reason: "Resolved address is local or private",
317− });
318− }
319−
320− // Guards against over-blocking: the trailing quad is still legal where
321− // it belongs, so tightening the rule must not reject the v4-compatible
322− // and v4-mapped forms of an ordinary public address.
323− for (const address of ["::93.184.216.34", "::ffff:93.184.216.34"]) {
324− yield* validateHostedOutboundUrl("https://api.example/x", {
325− resolveHostname: async () => [{ address }],
326− });
327− }
328− }),
329− );
330−
331− // node:dns can hand back a link-local address carrying its interface scope.
332− // The zone identifier is not part of the address and must not defeat the
333− // classification of the address it is attached to.
334− it.effect("rejects resolved IPv6 addresses that carry a zone identifier", () =>
335− Effect.gen(function* () {
336− const error = yield* validateHostedOutboundUrl("https://api.example/x", {
337− resolveHostname: async () => [{ address: "fe80::1%eth0" }],
338− }).pipe(Effect.flip);
339−
340− expect(error).toMatchObject({
341− _tag: "HostedOutboundRequestBlocked",
342− reason: "Resolved address is local or private",
343− });
344−
345− // The blocked case alone cannot tell "zone stripped, then classified
346− // link-local" from "parse failed, unclassifiable" — both reach the same
347− // reason. A public zoned address is only allowed when the zone is
348− // genuinely stripped before classification.
349− yield* validateHostedOutboundUrl("https://api.example/x", {
350− resolveHostname: async () => [{ address: "2001:db8::1%eth0" }],
351− });
352− }),
353− );
354−
355− // The tagged error is the guard's contract: a malformed URL has to arrive as
356− // a typed block, not as a raw TypeError escaping as an untyped defect.
357− it.effect("rejects input that is not a URL", () =>
358− Effect.gen(function* () {
359− const error = yield* validateHostedOutboundUrl("not a url").pipe(Effect.flip);
360− // The url field is asserted, not just the reason: it is the only part of
361− // the error telling an operator which destination was refused.
362− expect(error).toMatchObject({
363− _tag: "HostedOutboundRequestBlocked",
364− reason: "URL is invalid",
365− url: "not a url",
366− });
367− }),
368− );
369−
370− // The root dot is the same name to a resolver but a different string, so an
371− // exact-match blocklist that skipped canonicalization would be defeated by
372− // appending one character. This check sits above the allowLocalNetwork gate
373− // deliberately — it is the one rule that holds even in self-host mode, which
374− // is exactly where the bypass would be reachable.
375− it.effect("blocks metadata hostnames written with a trailing root dot", () =>
376− Effect.gen(function* () {
377− for (const hostname of ["metadata.google.internal.", "metadata.", "instance-data."]) {
378− const error = yield* validateHostedOutboundUrl(`http://${hostname}/latest/meta-data/`, {
379− allowLocalNetwork: true,
380− resolveHostname: async () => [{ address: "169.254.169.254" }],
381− }).pipe(Effect.flip);
382−
383− expect(error).toMatchObject({
384− _tag: "HostedOutboundRequestBlocked",
385− reason: "Metadata service addresses are not allowed",
386− });
387− }
388− }),
389− );
390−
391− // The name blocklist covers the published metadata hostnames, which is not
392− // the same as covering the endpoint: any name at all can carry an A record
393− // for it. Since the metadata rule holds regardless of allowLocalNetwork, the
394− // resolved-address check has to run in that mode too — gating the lookup on
395− // the flag left the endpoint one DNS record away on exactly the hosts that
396− // set it, with the name never resolved and so never classified.
397− it.effect("blocks a public name that resolves to the metadata endpoint", () =>
398− Effect.gen(function* () {
399− for (const allowLocalNetwork of [false, true]) {
400− const error = yield* validateHostedOutboundUrl(
401− "http://harmless.example/latest/meta-data/",
402− {
403− allowLocalNetwork,
404− resolveHostname: async () => [{ address: "169.254.169.254" }],
405− },
406− ).pipe(Effect.flip);
407−
408− expect(error).toMatchObject({
409− _tag: "HostedOutboundRequestBlocked",
410− reason: "Metadata service addresses are not allowed",
411− });
412− }
413− }),
414− );
415−
416− // The permissive mode allows local destinations by name; only the metadata
417− // rule applies to what comes back. Were the resolved-address check to reject
418− // local answers here, the flag would stop doing the one thing it exists for.
419− it.effect("allows a name resolving to a local address when local network is allowed", () =>
420− Effect.gen(function* () {
421− yield* validateHostedOutboundUrl("http://nas.local/openapi.json", {
422− allowLocalNetwork: true,
423− resolveHostname: async () => [{ address: "192.168.1.10" }],
424− });
425− }),
426− );
427−
428− // A name this resolver cannot see is fatal in the default mode and not in
429− // the permissive one: a self-host behind a tunnel resolves names remotely,
430− // so the transport reaches destinations the local resolver cannot, and that
431− // deployment is what the flag is for. Nothing resolved means nothing can be
432− // the metadata endpoint, and a name that truly does not exist fails at the
433− // transport with its own error.
434− it.effect("does not block on an unresolvable name when local network is allowed", () =>
435− Effect.gen(function* () {
436− const options = {
437− // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: a resolver is a Promise-returning platform seam, and a rejected lookup is what node:dns actually does
438− resolveHostname: async () => Promise.reject(new Error("EAI_AGAIN")),
439− };
440− yield* validateHostedOutboundUrl("http://tunnel.internal/mcp", {
441− ...options,
442− allowLocalNetwork: true,
443− });
444−
445− const error = yield* validateHostedOutboundUrl("http://tunnel.internal/mcp", options).pipe(
446− Effect.flip,
447− );
448− expect(error).toMatchObject({
449− _tag: "HostedOutboundRequestBlocked",
450− reason: "Hostname could not be resolved",
451− });
452− }),
453− );
454−
455− // The named metadata hostnames are the classic cloud SSRF vector; they are
456− // blocked before resolution, so no resolver is consulted.
457− it.effect("rejects metadata service hostnames without resolving them", () =>
458− Effect.gen(function* () {
459− for (const url of [
460− "http://metadata.google.internal/computeMetadata/v1/",
461− "http://metadata/",
462− "http://instance-data/",
463− ]) {
464− const resolved: string[] = [];
465− const error = yield* validateHostedOutboundUrl(url, {
466− resolveHostname: async (hostname) => {
467− resolved.push(hostname);
468− return [{ address: "93.184.216.34" }];
469− },
470− }).pipe(Effect.flip);
471− expect(resolved).toEqual([]);
472− expect(error).toMatchObject({
473− _tag: "HostedOutboundRequestBlocked",
474− reason: "Metadata service addresses are not allowed",
475− });
35+ expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
47636 }
47737 }),
47838 );
47939
480− it.effect("rejects hostnames that resolve to no addresses", () =>
481− Effect.gen(function* () {
482− const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {
483− resolveHostname: async () => [],
484− }).pipe(Effect.flip);
485−
486− expect(error).toMatchObject({
487− _tag: "HostedOutboundRequestBlocked",
488− reason: "Hostname did not resolve to an address",
489− });
490− }),
491− );
492−
493− // An empty answer is a failure, not a successful "no addresses". Only the
494− // error channel takes the zero failure TTL, so caching it as a success would
495− // stamp the full 60s window on one transient empty answer and blackhole the
496− // hostname for its duration — something the uncached code could never do.
497− it("re-resolves after a resolver returns no addresses", async () => {
498− let attempts = 0;
499− const hostedFetch = makeHostedFetch({
500− fetch: async () => new Response("ok"),
501− resolveHostname: async () => {
502− attempts++;
503− return attempts === 1 ? [] : [{ address: "93.184.216.34" }];
504− },
505− });
506−
507− await expect(hostedFetch("https://api.example/x")).rejects.toMatchObject({
508− _tag: "HostedOutboundRequestBlocked",
509− reason: "Hostname did not resolve to an address",
510− });
511−
512− // The second request must reach the resolver again rather than replaying
513− // the empty answer out of the cache.
514− const recovered = await hostedFetch("https://api.example/x");
515− expect(recovered.status).toBe(200);
516− expect(attempts).toBe(2);
517− });
518−
519− // Every host needs both adapters — plugins take the raw fetch, the SDK takes
520− // the layer — so building them separately would resolve each hostname twice
521− // per session and run two TTL windows side by side.
522− it.effect("shares one resolution cache between the fetch and the client layer", () =>
523− Effect.gen(function* () {
524− let resolutions = 0;
525− const fakeFetch: typeof globalThis.fetch = async () => new Response("ok", { status: 200 });
526− const hosted = makeHostedHttp({
527− fetch: fakeFetch,
528− resolveHostname: async () => {
529− resolutions++;
530− return [{ address: "93.184.216.34" }];
531− },
532− });
533−
534− yield* Effect.promise(() => hosted.fetch("https://api.example/one"));
535− yield* Effect.gen(function* () {
536− const client = yield* HttpClient.HttpClient;
537− return yield* client.execute(HttpClientRequest.get("https://api.example/two"));
538− }).pipe(Effect.provide(hosted.httpClientLayer));
539−
540− // The layer's request reuses what the fetch already resolved.
541− expect(resolutions).toBe(1);
542− }),
543− );
544−
545− // A failed lookup is stale under the zero TTL but still occupies a slot, so
546− // a run of dead hostnames would otherwise push the working ones out and put
547− // the resolver back on the hot path — the stall this cache exists to remove.
548− it("does not let failed lookups evict cached ones", async () => {
549− const resolved: Array<string> = [];
550− const hostedFetch = makeHostedFetch({
551− fetch: async () => new Response("ok"),
552− dnsCacheCapacity: 4,
553− resolveHostname: async (hostname) => {
554− resolved.push(hostname);
555− if (hostname.startsWith("dead")) return [];
556− return [{ address: "93.184.216.34" }];
557− },
558− });
559−
560− await hostedFetch("https://live.example/x");
561− expect(resolved).toEqual(["live.example"]);
562−
563− for (let index = 0; index < 20; index++) {
564− await expect(hostedFetch(`https://dead${index}.example/x`)).rejects.toMatchObject({
565− _tag: "HostedOutboundRequestBlocked",
566− });
567− }
568−
569− await hostedFetch("https://live.example/x");
570− expect(resolved.filter((hostname) => hostname === "live.example")).toHaveLength(1);
571− });
572−
57340 it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () =>
57441 Effect.gen(function* () {
57542 for (const url of [
@@ -577,110 +44,39 @@ describe("hosted outbound HTTP client", () => { · 1 unmodified line
57744 "http://[::ffff:10.0.0.1]/openapi.json",
57845 "http://[::ffff:172.16.0.1]/graphql",
57946 "http://[::ffff:192.168.1.10]/mcp",
580− "http://[::ffff:169.254.1.1]/",
47+ "http://[::ffff:169.254.169.254]/latest/meta-data/",
58148 ]) {
582− const error = yield* validateHostedOutboundUrl(url, {
583− resolveHostname: publicResolver,
584− }).pipe(Effect.flip);
585− expect(error).toMatchObject({
586− _tag: "HostedOutboundRequestBlocked",
587− reason: "Local and private network addresses are not allowed",
588− });
49+ const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
50+ expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
58951 }
59052 }),
59153 );
59254
593− // Guards against over-blocking, not under-blocking: an embedded-IPv4 prefix
594− // is only a container, so it must be classified by the address it carries
595− // rather than treated as suspicious in itself. Deleting embeddedIpv4 does
596− // NOT turn this red — these prefixes start with 0x0000/0x0064 and match none
597− // of the first-word masks — so this pins the allow behavior against a future
598− // change that blanket-blocks the prefix. The block path embeddedIpv4 does
599− // carry is covered by "rejects IPv6 literals that embed a private IPv4".
600− it.effect("allows IPv4-mapped IPv6 URLs for public addresses", () =>
601− validateHostedOutboundUrl("http://[::ffff:93.184.216.34]/openapi.json"),
602− );
603−
60455 it.effect("can allow local network URLs explicitly", () =>
605− validateHostedOutboundUrl("http://127.0.0.1:3000", { allowLocalNetwork: true }),
606− );
607−
608− it.effect("rejects hostnames that resolve to local or private addresses", () =>
609− Effect.gen(function* () {
610− const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {
611− resolveHostname: async () => [{ address: "10.0.0.10" }],
612− }).pipe(Effect.flip);
613−
614− expect(error).toMatchObject({
615− _tag: "HostedOutboundRequestBlocked",
616− reason: "Resolved address is local or private",
617− });
618− }),
619− );
620−
621− // A resolver returns whatever string the platform gives it, uncompressed
622− // forms included, and nothing normalizes it on the way in — unlike a URL
623− // literal, which the WHATWG parser always compresses. So this is the only
624− // path that reaches the eight-group branch of parseIpv6; a URL can never
625− // exercise it.
626− it.effect("classifies an uncompressed IPv6 address returned by the resolver", () =>
62756 Effect.gen(function* () {
628− const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {
629− resolveHostname: async () => [{ address: "0:0:0:0:0:0:0:1" }],
630− }).pipe(Effect.flip);
631−
632− expect(error).toMatchObject({
633− _tag: "HostedOutboundRequestBlocked",
634− reason: "Resolved address is local or private",
57+ yield* validateHostedOutboundUrl("http://127.0.0.1:3000", {
58+ allowLocalNetwork: true,
63559 });
63660 }),
63761 );
63862
639− // A hostname may hold several A records, and an attacker only needs one of
640− // them to point inside. Every address the name resolves to has to clear the
641− // check, not just the first one the resolver happened to order first —
642− // checking only `addresses[0]` leaves the rest of this file green.
643− it.effect("rejects a hostname whose records mix a public address with a private one", () =>
63+ it.effect("rejects hostnames that resolve to local or private addresses", () =>
64464 Effect.gen(function* () {
64565 const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {
646− resolveHostname: async () => [{ address: "93.184.216.34" }, { address: "169.254.1.1" }],
66+ resolveHostname: async () => [{ address: "10.0.0.10", family: 4 }],
64767 }).pipe(Effect.flip);
64868
649− expect(error).toMatchObject({
650− _tag: "HostedOutboundRequestBlocked",
651− reason: "Resolved address is local or private",
652− });
653− }),
654− );
655−
656− // "beef.cafe" is a registrable DNS name spelled entirely in hex digits and
657− // dots. It must go through resolution like any other hostname; treating it
658− // as an address literal would skip the resolved-address check entirely.
659− it.effect("resolves hex-digit hostnames instead of treating them as IP literals", () =>
660− Effect.gen(function* () {
661− const resolved: string[] = [];
662− const error = yield* validateHostedOutboundUrl("https://beef.cafe/x", {
663− resolveHostname: async (hostname) => {
664− resolved.push(hostname);
665− return [{ address: "169.254.1.1" }];
666− },
667− }).pipe(Effect.flip);
668−
669− expect(resolved).toEqual(["beef.cafe"]);
670− expect(error).toMatchObject({
671− _tag: "HostedOutboundRequestBlocked",
672− reason: "Resolved address is local or private",
673− });
69+ expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
67470 }),
67571 );
67672
67773 it.effect("checks DNS before the first fetch call", () =>
67874 Effect.gen(function* () {
67975 let calls = 0;
680− const fakeFetch: typeof globalThis.fetch = async () => {
76+ const fakeFetch: typeof globalThis.fetch = (async () => {
68177 calls++;
68278 return new Response("unexpected", { status: 200 });
683− };
79+ }) as typeof globalThis.fetch;
68480
68581 const result = yield* Effect.gen(function* () {
68682 const client = yield* HttpClient.HttpClient;
@@ -689,870 +85,39 @@ describe("hosted outbound HTTP client", () => { · 2 unmodified lines
68985 Effect.provide(
69086 makeHostedHttpClientLayer({
69187 fetch: fakeFetch,
692− resolveHostname: async () => [{ address: "169.254.1.1" }],
88+ resolveHostname: async () => [{ address: "169.254.169.254", family: 4 }],
69389 }),
69490 ),
69591 Effect.result,
69692 );
69793
698− expectBlocked(result, "Resolved address is local or private");
94+ expect(Result.isFailure(result)).toBe(true);
69995 expect(calls).toBe(0);
70096 }),
70197 );
70298
70399 it("applies the DNS guard to fetch callers", async () => {
704100 let calls = 0;
705− const underlying: typeof globalThis.fetch = async () => {
706− calls++;
707− return new Response("unexpected", { status: 200 });
708− };
709101 const hostedFetch = makeHostedFetch({
710− fetch: underlying,
711− resolveHostname: async () => [{ address: "10.0.0.20" }],
712− });
713−
714− await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
715− _tag: "HostedOutboundRequestBlocked",
716− reason: "Resolved address is local or private",
717− });
718− expect(calls).toBe(0);
719− });
720−
721− it("fails the request when the hostname cannot be resolved", async () => {
722− const hostedFetch = makeHostedFetch({
723− fetch: async () => new Response("unexpected"),
724− resolveHostname: () => nodeDnsRejection(),
725− });
726−
727− await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
728− _tag: "HostedOutboundRequestBlocked",
729− reason: "Hostname could not be resolved",
730− });
731− });
732−
733− // Omitting resolveHostname must not silently drop the resolved-address
734− // check: the default adapter is the one that ships. The label is longer
735− // than the 63 octets RFC 1035 allows, so getaddrinfo rejects it structurally
736− // without emitting a query — the assertion cannot be flipped by a resolver
737− // that wildcards NXDOMAIN, and it touches no network.
738− it("uses the node:dns resolver when none is supplied", async () => {
739− let calls = 0;
740− const hostedFetch = makeHostedFetch({
741− fetch: async () => {
102+ fetch: (async () => {
742103 calls++;
743− return new Response("unexpected");
744− },
104+ return new Response("unexpected", { status: 200 });
105+ }) as typeof globalThis.fetch,
106+ resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }],
745107 });
746− const overlongLabel = `${"a".repeat(300)}.example.com`;
747108
748− await expect(hostedFetch(`https://${overlongLabel}/x`)).rejects.toMatchObject({
109+ await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
749110 _tag: "HostedOutboundRequestBlocked",
750− reason: "Hostname could not be resolved",
751111 });
752112 expect(calls).toBe(0);
753113 });
754114
755− it("resolves each hostname once per adapter within the cache window", async () => {
756− const resolved: string[] = [];
757− const hostedFetch = makeHostedFetch({
758− fetch: async () => new Response("ok"),
759− resolveHostname: async (hostname) => {
760− resolved.push(hostname);
761− return [{ address: "93.184.216.34" }];
762− },
763− });
764−
765− await hostedFetch("https://api.example/one");
766− await hostedFetch("https://api.example/two");
767− await hostedFetch("https://cdn.example/blob");
768−
769− expect(resolved).toEqual(["api.example", "cdn.example"]);
770− });
771−
772− // The cache is per adapter on purpose (see withResolutionCache): the MCP host
773− // builds one per session, so a verdict reached in one session must not decide
774− // requests in another. Hoisting the Cache to module scope — the shape that
775− // makes this shared — keeps every other cache test green.
776− it("keeps one adapter's cached resolutions out of another's", async () => {
777− const makeCounting = () => {
778− const resolved: string[] = [];
779− const hostedFetch = makeHostedFetch({
780− fetch: async () => new Response("ok"),
781− resolveHostname: async (hostname) => {
782− resolved.push(hostname);
783− return [{ address: "93.184.216.34" }];
784− },
785− });
786− return { hostedFetch, resolved };
787− };
788−
789− const first = makeCounting();
790− const second = makeCounting();
791−
792− await first.hostedFetch("https://api.example/one");
793− await second.hostedFetch("https://api.example/two");
794−
795− expect(first.resolved).toEqual(["api.example"]);
796− expect(second.resolved).toEqual(["api.example"]);
797− });
798−
799− it("re-resolves a hostname after the cache window elapses", async () => {
800− const resolved: string[] = [];
801− const hostedFetch = makeHostedFetch({
802− fetch: async () => new Response("ok"),
803− resolveHostname: async (hostname) => {
804− resolved.push(hostname);
805− return [{ address: "93.184.216.34" }];
806− },
807− dnsCacheTtlMillis: 20,
808− });
809−
810− await hostedFetch("https://api.example/one");
811− await new Promise((resolve) => setTimeout(resolve, 40));
812− await hostedFetch("https://api.example/two");
813−
814− expect(resolved).toEqual(["api.example", "api.example"]);
815− });
816−
817− it("evicts the oldest hostname when the cache capacity is exceeded", async () => {
818− const resolved: string[] = [];
819− const hostedFetch = makeHostedFetch({
820− fetch: async () => new Response("ok"),
821− resolveHostname: async (hostname) => {
822− resolved.push(hostname);
823− return [{ address: "93.184.216.34" }];
824− },
825− dnsCacheCapacity: 1,
826− });
827−
828− await hostedFetch("https://api.example/one");
829− await hostedFetch("https://cdn.example/blob");
830− await hostedFetch("https://api.example/two");
831−
832− expect(resolved).toEqual(["api.example", "cdn.example", "api.example"]);
833− });
834−
835− it("does not cache failed resolutions", async () => {
836− let attempts = 0;
837− const hostedFetch = makeHostedFetch({
838− fetch: async () => new Response("ok"),
839− resolveHostname: async () => {
840− attempts++;
841− if (attempts === 1) return nodeDnsRejection();
842− return [{ address: "93.184.216.34" }];
843− },
844− });
845−
846− await expect(hostedFetch("https://api.example/one")).rejects.toMatchObject({
847− _tag: "HostedOutboundRequestBlocked",
848− });
849− const response = await hostedFetch("https://api.example/two");
850−
851− expect(response.status).toBe(200);
852− expect(attempts).toBe(2);
853− });
854−
855− it("shares one in-flight resolution across concurrent requests", async () => {
856− let resolutions = 0;
857− const gate = Promise.withResolvers<void>();
858− const entered = Promise.withResolvers<void>();
859− const hostedFetch = makeHostedFetch({
860− fetch: async () => new Response("ok"),
861− resolveHostname: async () => {
862− resolutions++;
863− entered.resolve();
864− await gate.promise;
865− return [{ address: "93.184.216.34" }];
866− },
867− // A zero TTL is what makes the count mean what this test says it means.
868− // Under the default 60s window a settled entry is reusable, so
869− // `resolutions === 1` is also satisfied by the second request simply
870− // arriving after the first finished and reading the cache — the test
871− // would keep passing while no longer testing in-flight sharing at all.
872− // With no settled entry to find, one resolution can only be a join.
873− dnsCacheTtlMillis: 0,
874− });
875−
876− // The second request is issued only once the first is provably inside the
877− // resolver, then given a turn of the event loop to reach the cache before
878− // the gate opens: issuance order alone does not put it there.
879− const first = hostedFetch("https://api.example/one");
880− await entered.promise;
881− const second = hostedFetch("https://api.example/two");
882− await new Promise((resolve) => setTimeout(resolve, 0));
883− gate.resolve();
884− await Promise.all([first, second]);
885−
886− expect(resolutions).toBe(1);
887− });
888−
889− // Concurrent callers share one resolution, so aborting one must not cancel
890− // the lookup the others are waiting on. The survivor's outcome is the whole
891− // point: before the shared lookup was detached from the requesting fiber it
892− // failed with an untyped "All fibers interrupted without error".
893− it("keeps concurrent requests alive when one caller aborts", async () => {
894− const gate = Promise.withResolvers<void>();
895− const entered = Promise.withResolvers<void>();
896− const hostedFetch = makeHostedFetch({
897− fetch: async () => new Response("ok"),
898− resolveHostname: async () => {
899− entered.resolve();
900− await gate.promise;
901− return [{ address: "93.184.216.34" }];
902− },
903− });
904−
905− const aborter = new AbortController();
906− const aborted = hostedFetch("https://api.example/one", { signal: aborter.signal });
907− const survivor = hostedFetch("https://api.example/two");
908− // Gate on the resolver itself having been entered, not on a microtask tick:
909− // the lookup sits behind runPromise plus forkDetach, so a bare `await` does
910− // not reach it and the abort would land before the work it must not kill.
911− await entered.promise;
912− aborter.abort();
913− gate.resolve();
914−
915− // Asserting the shape, not merely that it threw: the regression this test
916− // exists to exclude also throws, so a bare toThrow() would accept it.
917− await expect(aborted).rejects.toMatchObject({ name: "AbortError" });
918− // The survivor completing is the assertion. Its 200 does not prove it was
919− // still waiting on the shared lookup — under the default TTL a caller that
920− // arrived after the first resolution settled would read the cache and get
921− // the same 200 — so no resolution count is asserted here. That the lookup
922− // survives an abort at all is what fails without forkDetach: the survivor
923− // rejects with "All fibers interrupted without error". In-flight sharing
924− // itself is pinned by the zero-TTL test above.
925− expect((await survivor).status).toBe(200);
926− });
927−
928− // The guard's verdict is decided by the failure's cause, not by whether the
929− // caller's signal happens to be aborted. Branching on `signal.aborted`
930− // instead reclassifies a real SSRF block as an AbortError — which callers
931− // read as a transport failure and retry — and keeps every test above green.
932− it("reports a blocked URL as a guard failure even when the caller has aborted", async () => {
933− const hostedFetch = makeHostedFetch({
934− fetch: async () => new Response("ok"),
935− resolveHostname: async () => [{ address: "93.184.216.34" }],
936− });
937−
938− const aborter = new AbortController();
939− const blocked = hostedFetch("http://169.254.169.254/latest/meta-data", {
940− signal: aborter.signal,
941− });
942− aborter.abort();
943−
944− await expect(blocked).rejects.toMatchObject({
945− _tag: "HostedOutboundRequestBlocked",
946− reason: "Metadata service addresses are not allowed",
947− });
948− });
949−
950− // A pending cache entry carries no expiry, so without a bound on the lookup
951− // itself one stuck resolution would leave every later request for that
952− // hostname joining the same dead Deferred for the adapter's lifetime.
953− it("recovers from a resolution that never settles", async () => {
954− let attempts = 0;
955− const hostedFetch = makeHostedFetch({
956− fetch: async () => new Response("ok"),
957− resolveHostname: async () => {
958− attempts++;
959− if (attempts === 1) return new Promise<never>(() => {});
960− return [{ address: "93.184.216.34" }];
961− },
962− dnsResolutionTimeoutMillis: 20,
963− });
964−
965− await expect(hostedFetch("https://api.example/one")).rejects.toMatchObject({
966− _tag: "HostedOutboundRequestBlocked",
967− reason: "Hostname could not be resolved",
968− });
969− const response = await hostedFetch("https://api.example/two");
970−
971− expect(response.status).toBe(200);
972− expect(attempts).toBe(2);
973− });
974−
975− it("stops following redirects at the maxRedirects bound", async () => {
976− let calls = 0;
977− const alwaysRedirect: typeof globalThis.fetch = async () => {
978− calls++;
979− return new Response(null, { status: 302, headers: { location: "/next" } });
980− };
981− const hostedFetch = makeHostedFetch({
982− fetch: alwaysRedirect,
983− resolveHostname: publicResolver,
984− maxRedirects: 3,
985− });
986−
987− // The exhausted budget is a guard decision, so it arrives as one. Handing
988− // back the raw 302 would be indistinguishable from a successful final
989− // response to a caller that asked to follow redirects, and it may then
990− // follow the Location itself with no guard in front of it.
991− await expect(hostedFetch("https://api.example/start")).rejects.toMatchObject({
992− _tag: "HostedOutboundRequestBlocked",
993− reason: "Redirect limit of 3 exceeded",
994− });
995− expect(calls).toBe(4);
996− });
997−
998− it("rejects redirects whose Location header is not a valid URL", async () => {
999− const hostedFetch = makeHostedFetch({
1000− fetch: async () => new Response(null, { status: 302, headers: { location: "http://[bad" } }),
1001− resolveHostname: publicResolver,
1002− });
1003−
1004− await expect(hostedFetch("https://api.example/start")).rejects.toMatchObject({
1005− _tag: "HostedOutboundRequestBlocked",
1006− reason: "Redirect target is not a valid URL",
1007− });
1008− });
1009−
1010− // Every redirect rule below is downstream of one instruction: the adapter
1011− // must ask the transport not to follow 3xx itself. A real fetch left on its
1012− // default follows internally, so hops 2..n are never re-validated and none of
1013− // the stripping runs — the guard sees one request and an already-followed
1014− // response. The fakes here cannot show that, so the mode is asserted directly.
1015− it("tells the transport not to follow redirects on any hop", async () => {
1016− const modes: Array<RequestRedirect | undefined> = [];
1017− const underlying: typeof globalThis.fetch = async (input, init) => {
1018− modes.push(init?.redirect);
1019− if (String(input) === "https://api.example/start") {
1020− return new Response(null, { status: 302, headers: { location: "/moved" } });
1021− }
1022− return new Response("ok", { status: 200 });
1023− };
1024− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1025−
1026− await hostedFetch("https://api.example/start");
1027−
1028− expect(modes).toEqual(["manual", "manual"]);
1029− });
1030−
1031− it("demotes POST to GET and drops the body on a 302 redirect", async () => {
1032− const seen: Array<{
1033− url: string;
1034− method: string;
1035− hasBody: boolean;
1036− contentType: string | null;
1037− contentLength: string | null;
1038− contentEncoding: string | null;
1039− contentLanguage: string | null;
1040− }> = [];
1041− const underlying: typeof globalThis.fetch = async (input, init) => {
1042− const url = String(input);
1043− const headers = new Headers(init?.headers);
1044− seen.push({
1045− url,
1046− method: init?.method ?? "GET",
1047− hasBody: init?.body !== undefined && init?.body !== null,
1048− contentType: headers.get("content-type"),
1049− contentLength: headers.get("content-length"),
1050− contentEncoding: headers.get("content-encoding"),
1051− contentLanguage: headers.get("content-language"),
1052− });
1053− if (url === "https://api.example/start") {
1054− return new Response(null, { status: 302, headers: { location: "/see-here" } });
1055− }
1056− return new Response("ok", { status: 200 });
1057− };
1058− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1059−
1060− await hostedFetch("https://api.example/start", {
1061− method: "POST",
1062− headers: {
1063− "content-type": "application/x-www-form-urlencoded",
1064− "content-length": "25",
1065− "content-encoding": "gzip",
1066− "content-language": "en",
1067− },
1068− body: "client_secret=supersecret",
1069− });
1070−
1071− expect(seen).toHaveLength(2);
1072− // One representative per content header the demotion drops: a bodyless GET
1073− // still advertising content-length: 25 reads as a truncated request, and
1074− // the upstream 4xx gets blamed on the caller.
1075− expect(seen[1]).toMatchObject({
1076− url: "https://api.example/see-here",
1077− method: "GET",
1078− hasBody: false,
1079− contentType: null,
1080− contentLength: null,
1081− contentEncoding: null,
1082− contentLanguage: null,
1083− });
1084− });
1085−
1086− // Integrations render credentials into arbitrary header names, so a
1087− // cross-origin hop keeps only a known-safe set. A 302 demotes the POST
1088− // first, so by the time the hop is taken there is no body left to leak.
1089− it("strips non-safelisted headers and the body on cross-origin redirects", async () => {
1090− const seen: Array<{
1091− url: string;
1092− apiKey: string | null;
1093− accept: string | null;
1094− acceptLanguage: string | null;
1095− userAgent: string | null;
1096− hasBody: boolean;
1097− }> = [];
1098− const underlying: typeof globalThis.fetch = async (input, init) => {
1099− const url = String(input);
1100− const headers = new Headers(init?.headers);
1101− seen.push({
1102− url,
1103− apiKey: headers.get("x-api-key"),
1104− accept: headers.get("accept"),
1105− acceptLanguage: headers.get("accept-language"),
1106− userAgent: headers.get("user-agent"),
1107− hasBody: init?.body !== undefined && init?.body !== null,
1108− });
1109− if (url === "https://api.example/start") {
1110− return new Response(null, {
1111− status: 302,
1112− headers: { location: "https://evil.example/collect" },
1113− });
1114− }
1115− return new Response("ok", { status: 200 });
1116− };
1117− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1118−
1119− await hostedFetch("https://api.example/start", {
1120− method: "POST",
1121− headers: {
1122− "x-api-key": "rendered-credential",
1123− accept: "application/json",
1124− "accept-language": "en-GB",
1125− "user-agent": "executor-test",
1126− },
1127− body: "client_secret=supersecret",
1128− });
1129−
1130− expect(seen).toHaveLength(2);
1131− expect(seen[0]).toMatchObject({ apiKey: "rendered-credential", hasBody: true });
1132− // One representative per safelisted header: dropping user-agent on a hop
1133− // changes what the upstream serves — bot rules, content negotiation — so
1134− // the safelist is a contract, not an implementation detail.
1135− expect(seen[1]).toMatchObject({
1136− url: "https://evil.example/collect",
1137− apiKey: null,
1138− accept: "application/json",
1139− acceptLanguage: "en-GB",
1140− userAgent: "executor-test",
1141− hasBody: false,
1142− });
1143− });
1144−
1145− // The scrub is not only about headers. `referrer` and `credentials` are
1146− // credential-carrying init fields, and the platform renders `referrer` into
1147− // a real Referer header — the full URL, query included, under
1148− // referrerPolicy "unsafe-url". An OAuth callback whose query holds a token
1149− // would hand that token to the redirect target, past a header safelist that
1150− // looks like it covered this.
1151− it("drops credential-carrying init fields on cross-origin redirects", async () => {
1152− const seen: Array<RequestInit | undefined> = [];
1153− const underlying: typeof globalThis.fetch = async (input, init) => {
1154− seen.push(init);
1155− return String(input).startsWith("https://api.example/start")
1156− ? new Response(null, {
1157− status: 302,
1158− headers: { location: "https://evil.example/collect" },
1159− })
1160− : new Response("ok", { status: 200 });
1161− };
1162− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1163−
1164− await hostedFetch(
1165− new Request("https://api.example/start?code=authorization-code", {
1166− method: "GET",
1167− referrer: "https://api.example/start?code=authorization-code",
1168− referrerPolicy: "unsafe-url",
1169− credentials: "include",
1170− }),
1171− );
1172−
1173− expect(seen).toHaveLength(2);
1174− expect(seen[0]).toMatchObject({
1175− referrer: "https://api.example/start?code=authorization-code",
1176− });
1177− expect(seen[1]?.referrer).toBeUndefined();
1178− expect(seen[1]?.referrerPolicy).toBeUndefined();
1179− expect(seen[1]?.credentials).toBeUndefined();
1180− });
1181−
1182− // The guard pins the transport to `redirect: "manual"` for its own reasons,
1183− // which must not be mistaken for the caller's intent. A caller inspecting a
1184− // 3xx — the standard OAuth authorize probe — needs the unfollowed response
1185− // with its Location header intact.
1186− it("returns the unfollowed 3xx when the caller asks for manual redirects", async () => {
1187− const seen: Array<string> = [];
1188− const underlying: typeof globalThis.fetch = async (input) => {
1189− seen.push(String(input));
1190− return String(input) === "https://api.example/start"
1191− ? new Response(null, { status: 302, headers: { location: "https://api.example/moved" } })
1192− : new Response("followed", { status: 200 });
1193− };
1194− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1195−
1196− const response = await hostedFetch("https://api.example/start", { redirect: "manual" });
1197−
1198− expect(response.status).toBe(302);
1199− expect(response.headers.get("location")).toBe("https://api.example/moved");
1200− // The hop was never taken, so the guard never saw the second URL.
1201− expect(seen).toEqual(["https://api.example/start"]);
1202− });
1203−
1204− it("rejects a redirect when the caller asks for redirect: error", async () => {
1205− const underlying: typeof globalThis.fetch = async (input) =>
1206− String(input) === "https://api.example/start"
1207− ? new Response(null, { status: 302, headers: { location: "https://api.example/moved" } })
1208− : new Response("followed", { status: 200 });
1209− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1210−
1211− await expect(
1212− hostedFetch("https://api.example/start", { redirect: "error" }),
1213− ).rejects.toMatchObject({
1214− _tag: "HostedOutboundRequestBlocked",
1215− reason: "Redirect received while the caller requested redirect: error",
1216− });
1217− });
1218−
1219− // The mode travels on a Request the same way it travels on an init.
1220− it("honors a Request input's redirect mode", async () => {
1221− const underlying: typeof globalThis.fetch = async (input) =>
1222− String(input) === "https://api.example/start"
1223− ? new Response(null, { status: 302, headers: { location: "https://api.example/moved" } })
1224− : new Response("followed", { status: 200 });
1225− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1226−
1227− const response = await hostedFetch(
1228− new Request("https://api.example/start", { redirect: "manual" }),
1229− );
1230−
1231− expect(response.status).toBe(302);
1232− });
1233−
1234− // `fetch(request, {method: undefined})` keeps the Request's method — the
1235− // undefined member is absent, not an instruction to clear the field. Reading
1236− // the Request into an init and spreading over it would invert that, sending
1237− // a credential-bearing POST as an unauthenticated GET.
1238− it("ignores explicitly undefined init members over a Request input", async () => {
1239− const seen: Array<{ method: string; authorization: string | null }> = [];
1240− const underlying: typeof globalThis.fetch = async (_input, init) => {
1241− seen.push({
1242− method: init?.method ?? "GET",
1243− authorization: new Headers(init?.headers).get("authorization"),
1244− });
1245− return new Response("ok", { status: 200 });
1246− };
1247− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1248−
1249− await hostedFetch(
1250− new Request("https://api.example/x", {
1251− method: "POST",
1252− headers: { authorization: "Bearer token" },
1253− }),
1254− { method: undefined, headers: undefined },
1255− );
1256−
1257− expect(seen).toEqual([{ method: "POST", authorization: "Bearer token" }]);
1258− });
1259−
1260− // `integrity` is a check on what comes back, not a secret handed to the
1261− // target, so the scrub that drops credentials must not drop it too: the hop
1262− // that lands on a different origin is precisely the one whose bytes the
1263− // caller has least reason to trust unverified.
1264− it("keeps subresource integrity across a cross-origin redirect", async () => {
1265− const seen: Array<string | undefined> = [];
1266− const underlying: typeof globalThis.fetch = async (input, init) => {
1267− seen.push(init?.integrity);
1268− return String(input) === "https://api.example/start"
1269− ? new Response(null, {
1270− status: 302,
1271− headers: { location: "https://cdn.example/asset.js" },
1272− })
1273− : new Response("ok", { status: 200 });
1274− };
1275− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1276−
1277− await hostedFetch(new Request("https://api.example/start", { integrity: "sha256-abc" }));
1278−
1279− expect(seen).toEqual(["sha256-abc", "sha256-abc"]);
1280− });
1281−
1282− // Origin is scheme plus host, and the scheme half is the half that matters
1283− // here: comparing hosts alone would call an https -> http hop to the same
1284− // name same-origin and replay the credential headers over plaintext.
1285− it("treats a scheme downgrade to the same host as cross-origin", async () => {
1286− const seen: Array<{ url: string; apiKey: string | null }> = [];
1287− const underlying: typeof globalThis.fetch = async (input, init) => {
1288− const url = String(input);
1289− seen.push({ url, apiKey: new Headers(init?.headers).get("x-api-key") });
1290− if (url === "https://api.example/start") {
1291− return new Response(null, {
1292− status: 302,
1293− headers: { location: "http://api.example/moved" },
1294− });
1295− }
1296− return new Response("ok", { status: 200 });
1297− };
1298− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1299−
1300− await hostedFetch("https://api.example/start", {
1301− headers: { "x-api-key": "rendered-credential" },
1302− });
1303−
1304− expect(seen).toHaveLength(2);
1305− expect(seen[1]).toMatchObject({ url: "http://api.example/moved", apiKey: null });
1306− });
1307−
1308− // A 307/308 keeps the method, so stripping the body would send a bodyless
1309− // POST the caller never wrote and blame the upstream for the resulting 4xx.
1310− // The body cannot cross origins either, so the hop is refused outright.
1311− it("refuses a cross-origin redirect that would replay a body", async () => {
1312− let calls = 0;
1313− const underlying: typeof globalThis.fetch = async (input) => {
1314− calls++;
1315− if (String(input) === "https://api.example/start") {
1316− return new Response(null, {
1317− status: 307,
1318− headers: { location: "https://evil.example/collect" },
1319− });
1320− }
1321− return new Response("unexpected", { status: 200 });
1322− };
1323− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1324−
1325− await expect(
1326− hostedFetch("https://api.example/start", {
1327− method: "POST",
1328− headers: { "x-api-key": "rendered-credential" },
1329− body: "client_secret=supersecret",
1330− }),
1331− ).rejects.toMatchObject({
1332− _tag: "HostedOutboundRequestBlocked",
1333− reason: "Cross-origin redirect cannot replay a request body",
1334− });
1335− expect(calls).toBe(1);
1336− });
1337−
1338− // A stream reads once, so replaying the same object on the next hop sends an
1339− // empty body and still returns 200 — the caller's upload silently truncated.
1340− // The hop is refused rather than buffering every upload up front to make it
1341− // replayable: FetchHttpClient passes a ReadableStream for every streamed
1342− // body, so that buffer would land on every request, redirect or not.
1343− it("refuses to replay a streamed body across a redirect", async () => {
1344− const seen: Array<boolean> = [];
1345− const underlying: typeof globalThis.fetch = async (input, init) => {
1346− seen.push(init?.body instanceof ReadableStream);
1347− if (String(input) === "https://api.example/start") {
1348− return new Response(null, { status: 308, headers: { location: "/moved" } });
1349− }
1350− return new Response("ok");
1351− };
1352− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1353− const stream = new ReadableStream<Uint8Array>({
1354− start(controller) {
1355− controller.enqueue(new TextEncoder().encode("hello-streamed-body"));
1356− controller.close();
1357− },
1358− });
1359−
1360− await expect(
1361− hostedFetch("https://api.example/start", { method: "POST", body: stream }),
1362− ).rejects.toMatchObject({
1363− _tag: "HostedOutboundRequestBlocked",
1364− reason: "Redirect cannot replay a streamed request body",
1365− });
1366− // The first hop got the stream itself, unbuffered — the whole point of
1367− // refusing the replay rather than materializing it.
1368− expect(seen).toEqual([true]);
1369− });
1370−
1371− // The refusal is scoped to a replay. A 303 or 301 demotes to GET and drops
1372− // the body first, so there is no stream left to replay and the redirect is
1373− // followed normally — blocking those too would break every streamed upload
1374− // whose upstream answers with a see-other.
1375− it("follows a demoting redirect after a streamed body, since the body is dropped", async () => {
1376− const seen: Array<{ url: string; method: string; hasBody: boolean }> = [];
1377− const underlying: typeof globalThis.fetch = async (input, init) => {
1378− const url = String(input);
1379− seen.push({
1380− url,
1381− method: init?.method ?? "GET",
1382− hasBody: init?.body !== undefined && init?.body !== null,
1383− });
1384− if (url === "https://api.example/start") {
1385− return new Response(null, { status: 303, headers: { location: "/moved" } });
1386− }
1387− return new Response("final-body");
1388− };
1389− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1390− const stream = new ReadableStream<Uint8Array>({
1391− start(controller) {
1392− controller.enqueue(new TextEncoder().encode("hello-streamed-body"));
1393− controller.close();
1394− },
1395− });
1396−
1397− const response = await hostedFetch("https://api.example/start", {
1398− method: "POST",
1399− body: stream,
1400− });
1401−
1402− expect(response.status).toBe(200);
1403− // The body the last hop produced reaches the caller — the redirect loop
1404− // returns that response rather than a synthesized or drained stand-in.
1405− expect(await response.text()).toBe("final-body");
1406− expect(seen[1]).toMatchObject({
1407− url: "https://api.example/moved",
1408− method: "GET",
1409− hasBody: false,
1410− });
1411− });
1412−
1413− // One representative per arm of redirectDemotesToGet, mirroring the
1414− // arm-coverage rule this file states for the IPv4 blocklist: 303 demotes any
1415− // method, 301 demotes a non-GET, and 308 replays method and body untouched.
1416− it("applies the right method semantics per redirect status", async () => {
1417− const run = async (status: number, method: string) => {
1418− const seen: Array<{ method: string; hasBody: boolean }> = [];
1419− const underlying: typeof globalThis.fetch = async (input, init) => {
1420− seen.push({
1421− method: init?.method ?? "GET",
1422− hasBody: init?.body !== undefined && init?.body !== null,
1423− });
1424− if (String(input) === "https://api.example/start") {
1425− return new Response(null, { status, headers: { location: "/moved" } });
1426− }
1427− return new Response("ok", { status: 200 });
1428− };
1429− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1430− await hostedFetch(
1431− "https://api.example/start",
1432− method === "HEAD" ? { method } : { method, body: "secret=1" },
1433− );
1434− return seen[1];
1435− };
1436−
1437− expect(await run(303, "POST")).toMatchObject({ method: "GET", hasBody: false });
1438− expect(await run(301, "POST")).toMatchObject({ method: "GET", hasBody: false });
1439− expect(await run(308, "POST")).toMatchObject({ method: "POST", hasBody: true });
1440− // The demotion exempts GET and HEAD: turning a HEAD probe into a GET makes
1441− // the caller download a body it deliberately asked not to receive.
1442− expect(await run(301, "HEAD")).toMatchObject({ method: "HEAD" });
1443− // 301/302 demote POST alone, which is where a "any non-GET" reading
1444− // diverges from platform fetch and from every caller's expectation: a
1445− // rewritten DELETE never deletes and a rewritten PUT never writes, and
1446− // both hand back a 200 for a request that was never made. Verified against
1447− // Node's own fetch against a real server, which keeps the method here.
1448− expect(await run(301, "DELETE")).toMatchObject({ method: "DELETE", hasBody: true });
1449− expect(await run(302, "PUT")).toMatchObject({ method: "PUT", hasBody: true });
1450− // 303 is the mirror: it demotes any method except GET and HEAD.
1451− expect(await run(303, "DELETE")).toMatchObject({ method: "GET", hasBody: false });
1452− expect(await run(303, "HEAD")).toMatchObject({ method: "HEAD" });
1453− });
1454−
1455− it("preserves method and headers from a Request input across redirects", async () => {
1456− const seen: Array<{ url: string; method: string; accept: string | null }> = [];
1457− const underlying: typeof globalThis.fetch = async (input, init) => {
1458− const url = String(input);
1459− seen.push({
1460− url,
1461− method: init?.method ?? "GET",
1462− accept: new Headers(init?.headers).get("accept"),
1463− });
1464− if (url === "https://api.example/start") {
1465− return new Response(null, { status: 307, headers: { location: "/moved" } });
1466− }
1467− return new Response("ok", { status: 200 });
1468− };
1469− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1470−
1471− const response = await hostedFetch(
1472− new Request("https://api.example/start", {
1473− method: "DELETE",
1474− headers: { accept: "application/json" },
1475− }),
1476− );
1477−
1478− expect(response.status).toBe(200);
1479− expect(seen).toHaveLength(2);
1480− expect(seen[1]).toMatchObject({
1481− url: "https://api.example/moved",
1482− method: "DELETE",
1483− accept: "application/json",
1484− });
1485− });
1486−
1487− // A Request always exposes its body as a ReadableStream, whatever it was
1488− // constructed from, so a 307/308 after one hits the same one-shot problem as
1489− // an explicitly streamed body and is refused for the same reason. The
1490− // alternative is not "replay it" — the bytes are gone once the first hop
1491− // reads them — it is sending a bodyless POST under a 200 and calling that
1492− // success.
1493− // normalizeFetchInput turns every Request input into its ReadableStream
1494− // body, and undici refuses a streamed body whose init omits `duplex`. Both
1495− // of these reach the real transport as streams, so without it every
1496− // body-bearing Request and every streamed upload throws before a byte
1497− // leaves — a failure the whole rest of this file is structurally blind to.
1498− it("sends body-bearing requests through a real transport", async () => {
1499− await withServer(async ({ baseUrl, received }) => {
1500− const hostedFetch = makeHostedFetch({
1501− allowLocalNetwork: true,
1502− resolveHostname: publicResolver,
1503− });
1504−
1505− const fromRequest = await hostedFetch(
1506− new Request(`${baseUrl}/from-request`, { method: "POST", body: "payload" }),
1507− );
1508− expect(fromRequest.status).toBe(200);
1509− // The adapter's primary contract, and the one a status-only assertion
1510− // cannot see: the caller gets the upstream body back unread. Cancelling
1511− // or draining it anywhere in the guard would leave the status intact and
1512− // hand the caller nothing.
1513− expect(await fromRequest.text()).toBe("ok");
1514−
1515− const streamed = await hostedFetch(`${baseUrl}/streamed`, {
1516− method: "PUT",
1517− body: new ReadableStream<Uint8Array>({
1518− start(controller) {
1519− controller.enqueue(new TextEncoder().encode("streamed"));
1520− controller.close();
1521− },
1522− }),
1523− });
1524− expect(streamed.status).toBe(200);
1525− expect(await streamed.text()).toBe("ok");
1526−
1527− expect(received).toEqual([
1528− { method: "POST", body: "payload" },
1529− { method: "PUT", body: "streamed" },
1530− ]);
1531− });
1532− });
1533−
1534− it("refuses to replay a Request input's body across a redirect", async () => {
1535− const underlying: typeof globalThis.fetch = async (input) =>
1536− String(input) === "https://api.example/start"
1537− ? new Response(null, { status: 307, headers: { location: "/moved" } })
1538− : new Response("ok", { status: 200 });
1539− const hostedFetch = makeHostedFetch({ fetch: underlying, resolveHostname: publicResolver });
1540−
1541− await expect(
1542− hostedFetch(new Request("https://api.example/start", { method: "POST", body: "payload" })),
1543− ).rejects.toMatchObject({
1544− _tag: "HostedOutboundRequestBlocked",
1545− url: "https://api.example/moved",
1546− reason: "Redirect cannot replay a streamed request body",
1547− });
1548− });
1549−
1550115 it.effect("checks redirected URLs before following them", () =>
1551116 Effect.gen(function* () {
1552117 let calls = 0;
1553− const fakeFetch: typeof globalThis.fetch = async (input) => {
118+ const fakeFetch: typeof globalThis.fetch = (async (input) => {
1554119 calls++;
1555− const url = String(input);
120+ const url = input instanceof Request ? input.url : String(input);
1556121 if (url === "https://public.example/start") {
1557122 return new Response(null, {
1558123 status: 302,
@@ -1560,7 +125,7 @@ describe("hosted outbound HTTP client", () => { · 1 unmodified line
1560125 });
1561126 }
1562127 return new Response("unexpected", { status: 200 });
1563− };
128+ }) as typeof globalThis.fetch;
1564129 const result = yield* Effect.gen(function* () {
1565130 const client = yield* HttpClient.HttpClient;
1566131 return yield* client.execute(HttpClientRequest.get("https://public.example/start"));
@@ -1571,7 +136,7 @@ describe("hosted outbound HTTP client", () => { · 4 unmodified lines
1571136 Effect.result,
1572137 );
1573138
1574− expectBlocked(result, "Local and private network addresses are not allowed");
139+ expect(Result.isFailure(result)).toBe(true);
1575140 expect(calls).toBe(1);
1576141 }),
1577142 );
@@ -1583,8 +148,8 @@ describe("hosted outbound HTTP client", () => { · 5 unmodified lines
1583148 authorization: string | null;
1584149 cookie: string | null;
1585150 }> = [];
1586− const fakeFetch: typeof globalThis.fetch = async (input, init) => {
1587− const url = String(input);
151+ const fakeFetch: typeof globalThis.fetch = (async (input, init) => {
152+ const url = input instanceof Request ? input.url : String(input);
1588153 const headers = new Headers(init?.headers);
1589154 seen.push({
1590155 url,
@@ -1598,7 +163,7 @@ describe("hosted outbound HTTP client", () => { · 7 unmodified lines
1598163 });
1599164 }
1600165 return new Response("bytes", { status: 200 });
1601− };
166+ }) as typeof globalThis.fetch;
1602167
1603168 const response = yield* Effect.gen(function* () {
1604169 const client = yield* HttpClient.HttpClient;
@@ -1635,8 +200,8 @@ describe("hosted outbound HTTP client", () => { · 30 unmodified lines
1635200 it.effect("keeps credential headers on same-origin redirects", () =>
1636201 Effect.gen(function* () {
1637202 const seen: Array<{ url: string; authorization: string | null }> = [];
1638− const fakeFetch: typeof globalThis.fetch = async (input, init) => {
1639− const url = String(input);
203+ const fakeFetch: typeof globalThis.fetch = (async (input, init) => {
204+ const url = input instanceof Request ? input.url : String(input);
1640205 seen.push({
1641206 url,
1642207 authorization: new Headers(init?.headers).get("authorization"),
@@ -1648,7 +213,7 @@ describe("hosted outbound HTTP client", () => { · 5 unmodified lines
1648213 });
1649214 }
1650215 return new Response("ok", { status: 200 });
1651− };
216+ }) as typeof globalThis.fetch;
1652217
1653218 const response = yield* Effect.gen(function* () {
1654219 const client = yield* HttpClient.HttpClient;
@@ -1672,40 +237,12 @@ describe("hosted outbound HTTP client", () => { · 17 unmodified lines
1672237 }),
1673238 );
1674239
1675− // The layer has two branches and the ambient one is what ships when a
1676− // composition root provides FetchHttpClient.Fetch itself; leaving it
1677− // unguarded would be a complete SSRF bypass on the live path.
1678− it.effect("guards an ambient FetchHttpClient.Fetch when options.fetch is omitted", () =>
1679− Effect.gen(function* () {
1680− let calls = 0;
1681− const ambient: typeof globalThis.fetch = async () => {
1682− calls++;
1683− return new Response("unexpected", { status: 200 });
1684− };
1685−
1686− const result = yield* Effect.gen(function* () {
1687− const client = yield* HttpClient.HttpClient;
1688− return yield* client.execute(HttpClientRequest.get("https://api.example/start"));
1689− }).pipe(
1690− Effect.provide(
1691− makeHostedHttpClientLayer({
1692− resolveHostname: async () => [{ address: "169.254.1.1" }],
1693− }).pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(ambient))),
1694− ),
1695− Effect.result,
1696− );
1697−
1698− expectBlocked(result, "Resolved address is local or private");
1699− expect(calls).toBe(0);
1700− }),
1701− );
1702−
1703240 it.effect("rejects cross-origin redirects to private addresses", () =>
1704241 Effect.gen(function* () {
1705242 let calls = 0;
1706− const fakeFetch: typeof globalThis.fetch = async (input) => {
243+ const fakeFetch: typeof globalThis.fetch = (async (input) => {
1707244 calls++;
1708− const url = String(input);
245+ const url = input instanceof Request ? input.url : String(input);
1709246 if (url === "https://api.example/start") {
1710247 return new Response(null, {
1711248 status: 302,
@@ -1713,7 +250,7 @@ describe("hosted outbound HTTP client", () => { · 1 unmodified line
1713250 });
1714251 }
1715252 return new Response("unexpected", { status: 200 });
1716− };
253+ }) as typeof globalThis.fetch;
1717254
1718255 const result = yield* Effect.gen(function* () {
1719256 const client = yield* HttpClient.HttpClient;
@@ -1725,14 +262,7 @@ describe("hosted outbound HTTP client", () => { · 5 unmodified lines
1725262 Effect.result,
1726263 );
1727264
1728− expectBlocked(result, "Metadata service addresses are not allowed");
1729− // On a redirect block the reported url is genuinely ambiguous — the
1730− // request the caller made, or the hop that was actually refused. It is
1731− // the hop: naming the original would point an operator at a URL that is
1732− // fine and hide the destination the guard stopped.
1733− expect(result).toMatchObject({
1734− failure: { cause: { url: "http://169.254.169.254/latest/meta-data/" } },
1735− });
265+ expect(Result.isFailure(result)).toBe(true);
1736266 expect(calls).toBe(1);
1737267 }),
1738268 );
packages/core/sdk/src/hosted-http-client.ts+125−649
@@ -1,4 +1,4 @@
1−import { Cache, Cause, Duration, Effect, Exit, Fiber, Layer, Schema } from "effect";
1+import { Effect, Layer, Schema } from "effect";
22 import { FetchHttpClient, HttpClient } from "effect/unstable/http";
33
44 export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<HostedOutboundRequestBlocked>()(
@@ -9,16 +9,13 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted · 4 unmodified lines
99 },
1010 ) {}
1111
12−// Only the address is carried: every classifier decides from the address
13−// string alone, so a family field would be a second, unchecked source of
14−// truth for the same question.
1512 export interface HostedResolvedAddress {
1613 readonly address: string;
14+ readonly family?: 4 | 6;
1715 }
1816
1917 export type HostedHostnameResolver = (
2018 hostname: string,
21− signal?: AbortSignal,
2219 ) => Promise<ReadonlyArray<HostedResolvedAddress>>;
2320
2421 export interface HostedHttpClientOptions {
@@ -26,26 +23,14 @@ export interface HostedHttpClientOptions { · 1 unmodified line
2623 readonly maxRedirects?: number;
2724 readonly fetch?: typeof globalThis.fetch;
2825 readonly resolveHostname?: HostedHostnameResolver;
29− /** How long a successful guard resolution is reused, default 60s. */
30− readonly dnsCacheTtlMillis?: number;
31− /** How many hostnames the guard resolution cache holds, default 256. */
32− readonly dnsCacheCapacity?: number;
33− /** How long a single guard resolution may run before failing, default 60s. */
34− readonly dnsResolutionTimeoutMillis?: number;
3526 }
3627
37−// Octets are decimal-only and leading zeros are rejected. inet_aton reads a
38−// leading zero as octal, so accepting "0177.0.0.1" here would classify it as
39−// 177.0.0.1 (public) while the platform resolver dials 127.0.0.1 — the guard
40−// and the connection would disagree about the destination. WHATWG normalizes
41−// these forms, so the URL path never produces one, but a resolver answer
42−// reaches isAllowedResolvedAddress unnormalized.
4328 const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
4429 const parts = hostname.split(".");
4530 if (parts.length !== 4) return null;
4631 const parsed: number[] = [];
4732 for (const part of parts) {
48− if (!/^(?:0|[1-9]\d*)$/.test(part)) return null;
33+ if (!/^\d+$/.test(part)) return null;
4934 const value = Number(part);
5035 if (!Number.isInteger(value) || value < 0 || value > 255) return null;
5136 parsed.push(value);
@@ -53,53 +38,34 @@ const parseIpv4 = (hostname: string): readonly [number, number, number, number] · 1 unmodified line
5338 return parsed as [number, number, number, number];
5439 };
5540
56−// `allowTrailingQuad` is the caller's statement that this segment ends the
57−// whole address, not merely that it ends its own half. Deciding it from the
58−// segment alone would legalize a quad at the end of the head half of a
59−// compressed literal: "127.0.0.1::1" would parse, land 0x7f00 in the leading
60−// word where no prefix or mask matches it, and be classified public.
61−const parseIpv6Groups = (text: string, allowTrailingQuad: boolean): number[] | null => {
62− if (text === "") return [];
63− const parts = text.split(":");
64− const groups: number[] = [];
65− for (const [index, part] of parts.entries()) {
66− // A trailing dotted quad is the only place IPv4 syntax is legal.
67− if (index === parts.length - 1 && part.includes(".")) {
68− if (!allowTrailingQuad) return null;
69− const dotted = parseIpv4(part);
70− if (!dotted) return null;
71− groups.push((dotted[0] << 8) | dotted[1], (dotted[2] << 8) | dotted[3]);
72− continue;
73− }
74− if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
75− groups.push(Number.parseInt(part, 16));
41+const parseIpv4MappedIpv6 = (
42+ hostname: string,
43+): readonly [number, number, number, number] | null => {
44+ const prefix = "::ffff:";
45+ if (!hostname.startsWith(prefix)) return null;
46+ const embedded = hostname.slice(prefix.length);
47+ const dotted = parseIpv4(embedded);
48+ if (dotted) return dotted;
49+
50+ const parts = embedded.split(":");
51+ if (parts.length !== 2) return null;
52+
53+ const words = parts.map((part) => Number.parseInt(part, 16));
54+ if (
55+ words.some(
56+ (word, index) =>
57+ parts[index] === "" ||
58+ !/^[0-9a-f]+$/i.test(parts[index]) ||
59+ !Number.isInteger(word) ||
60+ word < 0 ||
61+ word > 0xffff,
62+ )
63+ ) {
64+ return null;
7665 }
77− return groups;
78−};
79−
80−// Expands an IPv6 literal into its eight 16-bit words, resolving the "::"
81−// run to its true position. Classification has to work from the real words:
82−// the leading group of a compressed literal is not the leading word —
83−// "::127.0.0.1" starts with six zero words, not with 0x7f00 — so reading the
84−// first non-empty group instead lets loopback and metadata addresses past a
85−// guard whose whole purpose is blocking them.
86−const parseIpv6 = (hostname: string): ReadonlyArray<number> | null => {
87− if (!hostname.includes(":")) return null;
88− // node:dns returns link-local addresses with their interface scope
89− // ("fe80::1%eth0"); the zone is not part of the address being classified.
90− const [head, tail, ...extra] = hostname.replace(/%.*$/, "").split("::");
91− if (extra.length > 0) return null;
92−
93− // The head half ends the address only when there is no "::" after it.
94− const high = parseIpv6Groups(head, tail === undefined);
95− if (high === null) return null;
96− if (tail === undefined) return high.length === 8 ? high : null;
9766
98− const low = parseIpv6Groups(tail, true);
99− if (low === null) return null;
100− const elided = 8 - high.length - low.length;
101− if (elided < 1) return null;
102− return [...high, ...Array<number>(elided).fill(0), ...low];
67+ const [high, low] = words;
68+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
10369 };
10470
10571 const isBlockedIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
@@ -114,247 +80,63 @@ const isBlockedIpv4 = ([a, b]: readonly [number, number, number, number]): boole · 8 unmodified lines
11480 (a === 198 && (b === 18 || b === 19)) ||
11581 a >= 224;
11682
117−const dotted = (high: number, low: number): readonly [number, number, number, number] => [
118− high >> 8,
119− high & 0xff,
120− low >> 8,
121− low & 0xff,
122−];
123−
124−// Prefixes that carry an IPv4 address at a fixed position, so the destination
125−// they actually reach is that address and not their own prefix: v4-mapped
126−// (::ffff:0:0/96), the deprecated v4-compatible form (::/96), RFC 6052's
127−// IPv4-translatable form (::ffff:0:0:0/96), the well-known NAT64 prefix
128−// (64:ff9b::/96), and 6to4 (2002::/16), which carries its address one word
129−// higher. Classifying by the prefix instead would let ::ffff:0:7f00:1 and
130−// 2002:a9fe:a9fe:: reach loopback and the metadata endpoint untouched.
131−const embeddedIpv4 = (
132− words: ReadonlyArray<number>,
133−): readonly [number, number, number, number] | null => {
134− const [w0, w1, w2, w3, w4, w5, w6, w7] = words;
135− const zeroThrough = (...parts: ReadonlyArray<number>) => parts.every((word) => word === 0);
136− if (w0 === 0x2002) return dotted(w1, w2);
137− const isV4Mapped = zeroThrough(w0, w1, w2, w3, w4) && w5 === 0xffff;
138− const isV4Compatible = zeroThrough(w0, w1, w2, w3, w4, w5);
139− const isV4Translatable = zeroThrough(w0, w1, w2, w3, w5) && w4 === 0xffff;
140− const isNat64 = w0 === 0x0064 && w1 === 0xff9b && zeroThrough(w2, w3, w4, w5);
141− if (!isV4Mapped && !isV4Compatible && !isV4Translatable && !isNat64) return null;
142− return dotted(w6, w7);
143−};
144−
145−// RFC 8215 reserves 64:ff9b:1::/48 for local-use NAT64. Where the IPv4 address
146−// sits inside one of these depends on the prefix length the local translator
147−// was configured with, which the address alone does not carry — so there is no
148−// embedded address to classify, and a translator that exists at all is by
149−// definition on the local network.
150−const isLocalUseNat64 = (words: ReadonlyArray<number>): boolean =>
151− words[0] === 0x0064 && words[1] === 0xff9b && words[2] === 0x0001;
152−
153−const isBlockedIpv6 = (words: ReadonlyArray<number>): boolean => {
154− if (isLocalUseNat64(words)) return true;
155− const embedded = embeddedIpv4(words);
156− if (embedded) return isBlockedIpv4(embedded);
157− const leading = words[0];
158− // fe80::/9 rather than fe80::/10: the upper half of it is site-local
159− // (fec0::/10), deprecated by RFC 3879 but still routed by stacks that
160− // predate the deprecation and still assigned by some equipment, so fec0::1
161− // is a local address that the /10 mask let through as public.
83+const isBlockedIpv6 = (hostname: string): boolean => {
84+ const normalized = hostname.toLowerCase();
85+ if (
86+ normalized === "::" ||
87+ normalized === "::1" ||
88+ normalized === "0:0:0:0:0:0:0:0" ||
89+ normalized === "0:0:0:0:0:0:0:1"
90+ ) {
91+ return true;
92+ }
93+ const firstWordText = normalized.split(":").find((part) => part.length > 0);
94+ if (!firstWordText || !/^[0-9a-f]{1,4}$/.test(firstWordText)) return false;
95+ const firstWord = Number.parseInt(firstWordText, 16);
16296 return (
163− (leading & 0xff80) === 0xfe80 || (leading & 0xfe00) === 0xfc00 || (leading & 0xff00) === 0xff00
97+ (firstWord & 0xffc0) === 0xfe80 ||
98+ (firstWord & 0xfe00) === 0xfc00 ||
99+ (firstWord & 0xff00) === 0xff00
164100 );
165101 };
166102
167−// Every hostname check runs against this form, so each one sees the same name.
168−// The trailing root dot matters: "metadata.google.internal." is the same name
169−// to the resolver but a different string, and an exact-match blocklist that
170−// skipped this would be defeated by appending one character.
171−const canonicalHostname = (hostname: string): string =>
172− hostname
173− .toLowerCase()
174− .replace(/^\[|\]$/g, "")
175− .replace(/\.+$/, "");
176−
177−const isMetadataAddress = ([a, b, c, d]: readonly [number, number, number, number]): boolean =>
178− a === 169 && b === 254 && c === 169 && d === 254;
179−
180−// The metadata endpoint is blocked unconditionally, ahead of the
181−// allowLocalNetwork gate, so the invariant is "never reachable" — which a
182−// string comparison cannot hold. It only ever matched the one dotted-decimal
183−// spelling, so under allowLocalNetwork (the local and desktop hosts) every
184−// IPv6 form carrying the same destination — ::ffff:169.254.169.254, the 6to4
185−// 2002:a9fe:a9fe::, the NAT64 64:ff9b::169.254.169.254 — reached it untouched.
186−// The address decides now, by the same parsers the rest of the guard uses, so
187−// every spelling of it takes the same block.
188−const isBlockedMetadataAddress = (normalized: string): boolean => {
189− const ipv4 = parseIpv4(normalized);
190− if (ipv4) return isMetadataAddress(ipv4);
191− const ipv6 = parseIpv6(normalized);
192− if (!ipv6) return false;
193− const embedded = embeddedIpv4(ipv6);
194− return embedded !== null && isMetadataAddress(embedded);
103+const isBlockedMetadataHostname = (hostname: string): boolean => {
104+ const normalized = hostname.toLowerCase();
105+ return (
106+ normalized === "metadata.google.internal" ||
107+ normalized === "metadata" ||
108+ normalized === "instance-data" ||
109+ normalized === "169.254.169.254"
110+ );
195111 };
196112
197−const isBlockedMetadataHostname = (normalized: string): boolean =>
198− normalized === "metadata.google.internal" ||
199− normalized === "metadata" ||
200− normalized === "instance-data" ||
201− isBlockedMetadataAddress(normalized);
202−
203−const isLocalOrPrivateHostname = (normalized: string): boolean => {
113+const isLocalOrPrivateHostname = (hostname: string): boolean => {
114+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
204115 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
205116 const ipv4 = parseIpv4(normalized);
206117 if (ipv4) return isBlockedIpv4(ipv4);
207− const ipv6 = parseIpv6(normalized);
208− if (ipv6) return isBlockedIpv6(ipv6);
209− return false;
118+ const mappedIpv4 = parseIpv4MappedIpv6(normalized);
119+ if (mappedIpv4) return isBlockedIpv4(mappedIpv4);
120+ return isBlockedIpv6(normalized);
210121 };
211122
212−// A resolved address is known to be an IP, so failing to decode it is not
213−// "this is a public name" — it is "the guard cannot classify this". Reusing
214−// isLocalOrPrivateHostname there would collapse both into `false` and let an
215−// address these parsers reject through the one check that exists to stop it.
216−const isAllowedResolvedAddress = (address: string): boolean => {
217− const normalized = address.toLowerCase().replace(/^\[|\]$/g, "");
218− const ipv4 = parseIpv4(normalized);
219− if (ipv4) return !isBlockedIpv4(ipv4);
220− const ipv6 = parseIpv6(normalized);
221− if (ipv6) return !isBlockedIpv6(ipv6);
222− return false;
123+const isAddressLiteral = (hostname: string): boolean => {
124+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
125+ return parseIpv4(normalized) !== null || /^[0-9a-f:.]+$/i.test(normalized);
223126 };
224127
225−// A URL hostname is an IP literal only in canonical shapes: the WHATWG parser
226−// rewrites every IPv4 form (hex, octal, integer) to dotted decimal, and an
227−// IPv6 literal always contains a colon. A character-class test is not enough
228−// here: registrable names spelled entirely in hex digits ("beef.cafe") must
229−// count as DNS names, or they would skip the resolved-address check below.
230−const isAddressLiteral = (normalized: string): boolean =>
231− parseIpv4(normalized) !== null || normalized.includes(":");
232−
233−// `empty` separates "the resolver answered, with nothing" from "the resolver
234−// did not answer". Both fail closed and both must stay out of the success
235−// cache, but they are different operator diagnoses, so the block reason keeps
236−// them apart rather than collapsing into one message.
237−class HostedHostnameResolutionFailed extends Schema.TaggedErrorClass<HostedHostnameResolutionFailed>()(
238− "HostedHostnameResolutionFailed",
239− {
240− hostname: Schema.String,
241− empty: Schema.Boolean,
242− },
243−) {}
244−
245128 const resolveHostnameWithNodeDns: HostedHostnameResolver = async (hostname) => {
246129 const { lookup } = await import("node:dns/promises");
247130 const addresses = await lookup(hostname, { all: true, verbatim: true });
248− return addresses.map(({ address }) => ({ address }));
249−};
250−
251−type GuardResolver = (
252− hostname: string,
253−) => Effect.Effect<ReadonlyArray<HostedResolvedAddress>, HostedHostnameResolutionFailed>;
254−
255−// An empty answer becomes a failure at the resolver boundary, not at the call
256−// site, so it reaches every caller the same way: the cached path needs it in
257−// the error channel for the zero failure TTL to drop it, and putting it here
258−// rather than inside the cache keeps the uncached path — the exported
259−// validateHostedOutboundUrl — from having a second copy of the rule.
260−const toGuardResolver =
261− (resolve: HostedHostnameResolver): GuardResolver =>
262− (hostname) =>
263− Effect.tryPromise({
264− try: (signal) => resolve(hostname, signal),
265− catch: () => new HostedHostnameResolutionFailed({ hostname, empty: false }),
266− }).pipe(
267− Effect.flatMap((addresses) =>
268− addresses.length === 0
269− ? Effect.fail(new HostedHostnameResolutionFailed({ hostname, empty: true }))
270− : Effect.succeed(addresses),
271− ),
272− );
273−
274−const DNS_CACHE_TTL_MILLIS = 60_000;
275−const DNS_CACHE_CAPACITY = 256;
276−const DNS_RESOLUTION_TIMEOUT_MILLIS = 60_000;
277−
278−// The cache is what keeps the guard off the request hot path: without it,
279−// every outbound request pays a full system-resolver round trip before the
280−// fetch, and on networks whose resolver drops AAAA queries (common behind
281−// IPv4-only home routers) that round trip stalls for the resolver timeout,
282−// several seconds per request. Only the first request per hostname per TTL
283−// window pays it now; concurrent requests share one in-flight resolution and
284−// failed resolutions are not retained.
285−//
286−// The TTL does widen an existing hole rather than opening one. The guard hands
287−// the underlying fetch a hostname, never the addresses it checked, so the fetch
288−// resolves again on its own and the verdict is already advisory: an attacker
289−// serving a TTL-0 record defeats it in the unresolved gap with or without this
290−// cache. Caching lengthens that gap from milliseconds to the TTL, and only
291−// closing it — pinning the connection to the checked address — makes either
292−// window matter. Until then a zero TTL would buy no real protection while
293−// restoring the per-request stall this exists to remove.
294−//
295−// Scope is the guarded-fetch adapter, deliberately: the MCP host builds one
296−// adapter per session, so the window spans a session there; the per-request
297−// HTTP API path only dedupes hops within one request. A process-wide cache
298−// would need a layer seam through every composition root.
299−const withResolutionCache = (
300− resolve: GuardResolver,
301− options: HostedHttpClientOptions,
302−): GuardResolver => {
303− const ttl = Duration.millis(options.dnsCacheTtlMillis ?? DNS_CACHE_TTL_MILLIS);
304− // The cached effect is bounded because a pending entry is not subject to the
305− // TTL: Cache stamps expiry only when the lookup settles, so a resolution
306− // that never settles would leave every later request for that hostname
307− // joining the same dead Deferred for the adapter's lifetime — the hostname
308− // would be permanently unreachable. Timing out turns that into an ordinary
309− // typed failure, which the zero failure TTL then drops so the next request
310− // resolves again.
311− //
312− // It is a liveness backstop, not a latency policy, so it sits far above any
313− // real resolver: a resolver that answers slowly still answers, and blocking
314− // the request instead is a worse outcome than the wait. Callers that will
315− // not wait that long abort their own fetch, which the detached lookup
316− // survives on behalf of everyone else waiting on it.
317− //
318− // An empty answer is already a failure by the time it reaches the cache —
319− // toGuardResolver raises it — so the zero failure TTL drops it. Were it a
320− // success, one transient empty answer would stamp the full TTL on "no
321− // addresses" and blackhole the hostname for the rest of the window; the
322− // uncached code re-resolved every request and never could.
323− const bounded: GuardResolver = (hostname) =>
324− Effect.timeoutOrElse(resolve(hostname), {
325− duration: Duration.millis(
326− options.dnsResolutionTimeoutMillis ?? DNS_RESOLUTION_TIMEOUT_MILLIS,
327− ),
328− orElse: () => Effect.fail(new HostedHostnameResolutionFailed({ hostname, empty: false })),
329− });
330− const cache = Effect.runSync(
331− Cache.makeWith(bounded, {
332− capacity: options.dnsCacheCapacity ?? DNS_CACHE_CAPACITY,
333− timeToLive: (exit) => (Exit.isSuccess(exit) ? ttl : Duration.zero),
334− }),
335− );
336− // A zero TTL makes a failed entry stale, not absent: it still holds a slot
337− // until something evicts it. Failures are the unbounded input here — one
338− // agent walking a list of dead hostnames mints a fresh key per name — so at
339− // capacity they evict the live entries this cache exists to keep, and the
340− // per-request resolver stall comes back for every hostname that still works.
341− // Dropping a failed entry on the way out keeps capacity for answers.
342− const forget = (hostname: string) =>
343− Effect.onExit(Cache.get(cache, hostname), (exit) =>
344− Exit.isSuccess(exit) ? Effect.void : Cache.invalidate(cache, hostname),
345− );
346− // The lookup runs on a detached fiber that no caller owns, and each caller
347− // only joins it. A cache entry is shared, so running it on the requesting
348− // fiber would make one caller's abort interrupt the resolution every other
349− // caller is waiting on — they would fail with an untyped interrupt instead
350− // of their own answer.
351− return (hostname) => Effect.flatMap(Effect.forkDetach(forget(hostname)), Fiber.join);
131+ return addresses.map(({ address, family }) => ({
132+ address,
133+ family: family === 6 ? 6 : 4,
134+ }));
352135 };
353136
354−const validateOutboundUrl = (
137+export const validateHostedOutboundUrl = (
355138 value: string,
356− options: HostedHttpClientOptions,
357− resolve: GuardResolver,
139+ options: HostedHttpClientOptions = {},
358140 ): Effect.Effect<void, HostedOutboundRequestBlocked> =>
359141 Effect.gen(function* () {
360142 const url = yield* Effect.try({
@@ -373,419 +155,113 @@ const validateOutboundUrl = ( · 12 unmodified lines
373155 });
374156 }
375157
376− const normalizedHostname = canonicalHostname(url.hostname);
377−
378− if (isBlockedMetadataHostname(normalizedHostname)) {
158+ if (isBlockedMetadataHostname(url.hostname)) {
379159 return yield* new HostedOutboundRequestBlocked({
380160 url: value,
381161 reason: "Metadata service addresses are not allowed",
382162 });
383163 }
384164
385− if (!options.allowLocalNetwork && isLocalOrPrivateHostname(normalizedHostname)) {
165+ if (!options.allowLocalNetwork && isLocalOrPrivateHostname(url.hostname)) {
386166 return yield* new HostedOutboundRequestBlocked({
387167 url: value,
388168 reason: "Local and private network addresses are not allowed",
389169 });
390170 }
391171
392− // The resolved-address check runs whether or not the local network is
393− // allowed, because the metadata block above is only as good as the names it
394− // sees. Gating the whole lookup on `!allowLocalNetwork` left the endpoint
395− // one DNS record away on exactly the hosts that set the flag: a name with
396− // an A record for 169.254.169.254 is not an address literal, so nothing
397− // resolved it and nothing classified it. What the flag changes is the
398− // verdict, not whether the lookup happens — with it set, only the metadata
399− // rule applies to the answers, so a local address stays reachable by name
400− // as intended. The lookup this adds is the cached one, so a repeat name
401− // costs nothing after the first.
402− if (isAddressLiteral(normalizedHostname)) return;
403−
404− // The empty answer is already a failure by the time it arrives here —
405− // the cached lookup raises it, so it takes the zero failure TTL instead
406− // of being retained as a successful "no addresses" for the full window.
407− //
408− // A lookup that fails is fatal in the default mode and not in the
409− // permissive one. Blocking on it there would newly reject names this
410− // resolver cannot see but the transport can — a self-host behind a tunnel
411− // that resolves remotely is the case, and it is the deployment the flag
412− // exists for. No address came back, so none of them can be the metadata
413− // endpoint; a name that truly does not resolve fails at the transport a
414− // moment later, with its own error rather than a guard verdict.
415− const addresses = yield* resolve(normalizedHostname).pipe(
416− Effect.catchTag("HostedHostnameResolutionFailed", (error) =>
417− options.allowLocalNetwork
418− ? Effect.succeed([])
419− : Effect.fail(
420− new HostedOutboundRequestBlocked({
421− url: value,
422− reason: error.empty
423− ? "Hostname did not resolve to an address"
424− : "Hostname could not be resolved",
425− }),
426− ),
427− ),
428− );
172+ const normalizedHostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
173+ if (!options.allowLocalNetwork && options.resolveHostname && !isAddressLiteral(url.hostname)) {
174+ const addresses = yield* Effect.tryPromise({
175+ try: () => options.resolveHostname!(normalizedHostname),
176+ catch: () =>
177+ new HostedOutboundRequestBlocked({
178+ url: value,
179+ reason: "Hostname could not be resolved",
180+ }),
181+ });
429182
430− for (const { address } of addresses) {
431− if (isBlockedMetadataAddress(address.toLowerCase().replace(/^\[|\]$/g, ""))) {
183+ if (addresses.length === 0) {
432184 return yield* new HostedOutboundRequestBlocked({
433185 url: value,
434− reason: "Metadata service addresses are not allowed",
186+ reason: "Hostname did not resolve to an address",
435187 });
436188 }
437− if (!options.allowLocalNetwork && !isAllowedResolvedAddress(address)) {
438− return yield* new HostedOutboundRequestBlocked({
439− url: value,
440− reason: "Resolved address is local or private",
441− });
189+
190+ for (const { address } of addresses) {
191+ if (isLocalOrPrivateHostname(address)) {
192+ return yield* new HostedOutboundRequestBlocked({
193+ url: value,
194+ reason: "Resolved address is local or private",
195+ });
196+ }
442197 }
443198 }
444199 });
445200
446−// Defaults to the same node:dns resolver guardFetch uses so both public
447−// entry points enforce the same guard; omitting the resolver must not
448−// silently drop the resolved-address check.
449−export const validateHostedOutboundUrl = (
450− value: string,
451− options: HostedHttpClientOptions = {},
452−): Effect.Effect<void, HostedOutboundRequestBlocked> =>
453− validateOutboundUrl(
454− value,
455− options,
456− toGuardResolver(options.resolveHostname ?? resolveHostnameWithNodeDns),
457− );
458−
459−// Cross-origin redirects keep only headers that cannot carry credentials.
460−// A blocklist of well-known credential names is unsound here: integrations
461−// render credentials into arbitrary header names (X-Api-Key and friends),
462−// so everything not on this list is dropped on a cross-origin hop. The body
463−// is dropped with them — a credential-bearing POST body must not replay to
464−// a redirect target whose headers were just scrubbed.
465−const SAFE_REDIRECT_HEADERS = ["accept", "accept-language", "user-agent"] as const;
201+const CREDENTIAL_HEADERS = ["authorization", "proxy-authorization", "cookie"] as const;
466202
467−const retainSafeRedirectHeaders = (init: RequestInit | undefined): RequestInit => {
203+const stripCredentialHeaders = (init: RequestInit | undefined): RequestInit => {
468204 const headers = new Headers(init?.headers);
469− const kept = new Headers();
470− for (const name of SAFE_REDIRECT_HEADERS) {
471− const value = headers.get(name);
472− if (value !== null) kept.set(name, value);
473− }
474− // Rebuilt field by field rather than spread from the original: `referrer`
475− // and `credentials` are credential-carrying too, and spreading would carry
476− // them past the header scrub. The platform turns `referrer` into a real
477− // Referer header — the whole URL, query string included, once the caller
478− // asked for `referrerPolicy: "unsafe-url"` — so an OAuth callback whose
479− // query holds a token would hand that token to the redirect target. Only
480− // the transport-shaped fields survive.
481− //
482− // `integrity` is kept because it is a check on the response, not a secret
483− // sent to the target, and the cross-origin hop is exactly where it earns its
484− // keep: dropping it would leave the caller believing the bytes were verified
485− // while whatever the redirect target served went unchecked. Platform fetch
486− // carries it across redirects for the same reason.
487− return {
488− headers: kept,
489− body: undefined,
490− method: init?.method ?? "GET",
491− signal: init?.signal,
492− cache: init?.cache,
493− integrity: init?.integrity,
494− keepalive: init?.keepalive,
495− mode: init?.mode,
496− redirect: init?.redirect,
497− };
498−};
499−
500−const CONTENT_HEADERS = [
501− "content-type",
502− "content-length",
503− "content-encoding",
504− "content-language",
505−] as const;
506−
507−// Platform redirect semantics (fetch, curl): 303 always demotes to GET, and
508−// 301/302 demote any non-GET/HEAD method to GET; the body and its content
509−// headers go with it. Only 307/308 replay the method and body.
510−const demoteToGet = (init: RequestInit | undefined): RequestInit => {
511− const headers = new Headers(init?.headers);
512− for (const name of CONTENT_HEADERS) headers.delete(name);
513− return { ...init, method: "GET", body: undefined, headers };
514−};
515−
516−// The WHATWG rule, which is narrower than "3xx that is not 307/308": 303
517−// demotes everything except GET and HEAD, and 301/302 demote POST alone.
518−// Demoting on 301/302 for any non-GET rewrote a DELETE or a PUT into a GET —
519−// the delete never happened, the write never happened, and the caller got a
520−// 200 for a request it did not make. A HEAD meeting a 303 was turned into a
521−// GET the same way, downloading a body the caller asked not to receive.
522−const redirectDemotesToGet = (status: number, method: string): boolean =>
523− (status === 303 && method !== "GET" && method !== "HEAD") ||
524− ((status === 301 || status === 302) && method === "POST");
525−
526−// A stream can only be read once, so a hop that would replay it sends an empty
527−// body and still reports success — the caller's upload silently truncated. The
528−// stream is passed through untouched and the replay is refused instead:
529−// buffering it up front to make the rare redirect replayable would materialize
530−// every streamed upload in memory, and FetchHttpClient passes a ReadableStream
531−// for every streamed body, so that cost lands on the common path.
532−const isStreamedBody = (init: RequestInit | undefined): boolean =>
533− init?.body instanceof ReadableStream;
534−
535−// Every hop goes out through here, so this is the one place the transport's
536−// requirements are met. `redirect: "manual"` is what makes the guard see each
537−// hop at all — without it the platform follows 3xx internally and hops 2..n
538−// are never re-validated. `duplex: "half"` is required by undici whenever the
539−// body is a stream, and rejects the request outright when it is missing; since
540−// normalizeFetchInput turns every Request input into its ReadableStream body,
541−// omitting it would kill every body-bearing Request and every streamed upload.
542−const withStreamingDuplex = (init: RequestInit | undefined): RequestInit => ({
543− ...init,
544− redirect: "manual",
545− // @ts-expect-error — undici/Bun extension, absent from the DOM RequestInit
546− duplex: isStreamedBody(init) ? "half" : undefined,
547−});
548−
549−// `fetch(request, init)` treats a member the caller left undefined as absent,
550−// so the Request's own value stands. A plain spread does not: an explicit
551−// `{ method: undefined }` would overwrite the Request's POST with undefined,
552−// which the transport then reads as GET, and `{ headers: undefined }` would
553−// erase the Authorization header — a credential-bearing call would go out as a
554−// bare unauthenticated GET. Dropping the undefined members restores the
555−// platform's own merge.
556−const definedMembers = (init: RequestInit | undefined): RequestInit =>
557− Object.fromEntries(Object.entries(init ?? {}).filter(([, value]) => value !== undefined));
558−
559−// A Request input is read into url + init up front so redirect hops keep the
560−// request shape instead of silently degrading to a bare GET.
561−const normalizeFetchInput = (
562− input: Parameters<typeof globalThis.fetch>[0],
563− init: RequestInit | undefined,
564−): { readonly url: string; readonly init: RequestInit | undefined } => {
565− if (!(input instanceof Request)) {
566− return { url: String(input), init };
567− }
568− const normalized: RequestInit = {
569− method: input.method,
570− headers: new Headers(input.headers),
571− signal: input.signal,
572− credentials: input.credentials,
573− cache: input.cache,
574− integrity: input.integrity,
575− keepalive: input.keepalive,
576− mode: input.mode,
577− redirect: input.redirect,
578− referrer: input.referrer,
579− referrerPolicy: input.referrerPolicy,
580− ...definedMembers(init),
581− };
582− if (input.body !== null && normalized.body === undefined) {
583− normalized.body = input.body;
584− }
585− return { url: input.url, init: normalized };
205+ for (const name of CREDENTIAL_HEADERS) headers.delete(name);
206+ return { ...init, headers };
586207 };
587208
588−// An abort during the guard would otherwise surface as the interrupt Effect
589−// raises when its fiber is cancelled ("All fibers interrupted without error"),
590−// which callers branching on `error.name === "AbortError"` read as a transport
591−// failure and retry. This is a Fetch adapter, so it rejects the way fetch does.
592−const rejectAsAbort = (signal: AbortSignal): never => {
593− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Fetch-compatible adapter must reject with the caller's own abort reason
594− if (signal.reason !== undefined) throw signal.reason;
595− // oxlint-disable-next-line executor/no-error-constructor -- boundary: Fetch-compatible adapter must reject with an AbortError-shaped value
596− const error = new Error("The operation was aborted");
597− error.name = "AbortError";
598− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Fetch-compatible adapter must reject with an AbortError-shaped value
599− throw error;
600−};
601−
602−// One place the guard's verdict becomes a rejected Promise. Each call site had
603−// its own `Effect.runPromise(Effect.fail(...))`, which is a fresh fiber per
604−// verdict carrying no context and no signal — six runtime entries for what is
605−// one decision repeated. The adapter must return `Promise<Response>`, so the
606−// rejection itself is the contract; this keeps the tagged error the single
607−// currency and the entry in one reviewable spot.
608−const rejectBlocked = (url: string, reason: string): Promise<never> =>
609− Effect.runPromise(Effect.fail(new HostedOutboundRequestBlocked({ url, reason })));
610−
611−// The cached resolver for one composition. Built here rather than inside
612−// guardFetch so several adapters over the same options can share a single
613−// cache instead of one each.
614−const makeSharedResolver = (options: HostedHttpClientOptions): GuardResolver =>
615− withResolutionCache(
616− toGuardResolver(options.resolveHostname ?? resolveHostnameWithNodeDns),
617− options,
618− );
619−
620209 const guardFetch = (
621210 underlying: typeof globalThis.fetch,
622211 options: HostedHttpClientOptions,
623− sharedResolve?: GuardResolver,
624−): typeof globalThis.fetch => {
625− const resolve = sharedResolve ?? makeSharedResolver(options);
626− const maxRedirects = Math.max(0, options.maxRedirects ?? 10);
627− // SAFETY: narrowed to the one thing a closure cannot express — Bun's fetch
628− // type carries a static `preconnect` property, which packages built against
629− // @types/bun require. The call signature is checked by the annotations, and
630− // the return type is `Promise<Response>` on every path.
631− return (async (input, init) => {
632− const normalized = normalizeFetchInput(input, init);
633− let currentUrl = normalized.url;
634− let currentInit = normalized.init;
635− const signal = currentInit?.signal ?? undefined;
636− // Read once, before the loop rewrites `currentInit` on each hop.
637− const redirectMode = currentInit?.redirect ?? "follow";
638− for (let redirects = 0; ; redirects++) {
639− const guarded = await Effect.runPromiseExit(
640− validateOutboundUrl(currentUrl, options, resolve),
641− { signal },
642− );
643− if (Exit.isFailure(guarded)) {
644− // Both halves are required. The cause decides that this was an
645− // interrupt rather than a guard verdict — an aborted signal alone
646− // would bury a real SSRF block under a retryable AbortError. The
647− // signal's own state decides that the caller asked for it: an
648− // interrupt from anywhere else must not be reported as the caller's
649− // abort, which would fabricate a reason that never happened.
650− if (signal?.aborted && Cause.hasInterruptsOnly(guarded.cause)) rejectAsAbort(signal);
651− return await Effect.runPromise(Effect.failCause(guarded.cause));
652− }
653− const response = await underlying(currentUrl, withStreamingDuplex(currentInit));
654− const isRedirect =
655− response.status >= 300 && response.status < 400 && response.headers.has("location");
656− // The guard pins the transport to `redirect: "manual"` so it sees every
657− // hop, which is a separate question from what the caller asked for.
658− // Following anyway would answer a caller who asked to inspect a 3xx with
659− // the followed response and no Location header — the OAuth authorize
660− // probe reads exactly that header — and would turn a requested rejection
661− // into a 200. Neither mode is a security concession: not following is
662− // strictly safer than following, so the caller's intent stands.
663− if (isRedirect && redirectMode === "error") {
664− return await rejectBlocked(
665− currentUrl,
666− "Redirect received while the caller requested redirect: error",
667− );
668− }
669− if (isRedirect && redirectMode === "manual") return response;
670− if (isRedirect && redirects < maxRedirects) {
671− // The 3xx is abandoned here. Undici keeps a connection out of the
672− // pool until an unread body is consumed or cancelled, so a
673− // redirect-heavy integration would leak one connection per hop. A
674− // cancel that itself fails — a 3xx declaring Content-Length whose
675− // connection dies mid-body rejects with "terminated" — is connection
676− // hygiene failing, not the request: the redirect still proceeds.
677− const body = response.body;
678− if (body) {
679− await Effect.runPromise(
680− Effect.tryPromise({
681− try: () => body.cancel(),
682− catch: (error) => error,
683− }).pipe(Effect.ignore),
684− );
685− }
686− const location = response.headers.get("location")!;
687− // A malformed Location header is a rejected redirect target, not a
688− // client defect, so it surfaces as the tagged guard error.
689− const parsed = URL.parse(location, currentUrl);
690− if (parsed === null) {
691− return await rejectBlocked(location, "Redirect target is not a valid URL");
692− }
693− const next = parsed;
694− if (redirectDemotesToGet(response.status, currentInit?.method ?? "GET")) {
695− currentInit = demoteToGet(currentInit);
696− }
697− // A demotion has already dropped the body, so what is left here is a
698− // 307/308 that would replay it. A stream cannot be replayed — the
699− // first hop drained it — and sending the drained object would deliver
700− // an empty body under a 200, so the hop is refused instead.
701− if (isStreamedBody(currentInit)) {
702− return await rejectBlocked(
703− next.toString(),
704− "Redirect cannot replay a streamed request body",
705− );
706− }
212+): typeof globalThis.fetch =>
213+ (async (input, init) => {
214+ const guardOptions = {
215+ ...options,
216+ resolveHostname: options.resolveHostname ?? resolveHostnameWithNodeDns,
217+ };
218+ const maxRedirects = options.maxRedirects ?? 10;
219+ let current: Parameters<typeof globalThis.fetch>[0] | URL = input;
220+ let currentInit = init;
221+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
222+ const url = current instanceof Request ? current.url : String(current);
223+ await Effect.runPromise(validateHostedOutboundUrl(url, guardOptions));
224+ const response = await underlying(current, {
225+ ...currentInit,
226+ redirect: "manual",
227+ });
228+ if (
229+ response.status >= 300 &&
230+ response.status < 400 &&
231+ response.headers.has("location") &&
232+ redirects < maxRedirects
233+ ) {
234+ const next = new URL(response.headers.get("location")!, url);
707235 // Cross-origin redirects are followed (the loop re-validates every
708− // hop), but credentials minted for the original origin — in any
709− // header or in the body — must not leak to the redirect target.
710− if (next.origin !== new URL(currentUrl).origin) {
711− // A 307/308 replays the method, so a request still carrying a body
712− // here would go out as a bodyless POST/PUT once the body is
713− // stripped — a different request than the caller made, whose 4xx
714− // would misattribute the cause. The hop is refused instead, so the
715− // caller sees the guard decision rather than an upstream symptom.
716− if (currentInit?.body !== undefined && currentInit?.body !== null) {
717− return await rejectBlocked(
718− next.toString(),
719− "Cross-origin redirect cannot replay a request body",
720− );
721− }
722− currentInit = retainSafeRedirectHeaders(currentInit);
236+ // hop), but credentials minted for the original origin must not leak
237+ // to the redirect target — same as fetch/curl behavior.
238+ if (next.origin !== new URL(url).origin) {
239+ currentInit = stripCredentialHeaders(currentInit);
723240 }
724− currentUrl = next.toString();
241+ current = next.toString();
725242 continue;
726243 }
727− // A 3xx still here means the budget ran out: the manual and error modes
728− // returned above, so a "follow" caller would otherwise receive the raw
729− // 3xx as if it were the final response and might follow it itself,
730− // unguarded. Every sibling anomaly in this loop is a typed rejection, so
731− // this one is too.
732− if (isRedirect) {
733− return await rejectBlocked(
734− currentUrl,
735− `Redirect limit of ${String(maxRedirects)} exceeded`,
736− );
737− }
738244 return response;
739245 }
246+ return await underlying(current, { ...currentInit, redirect: "manual" });
740247 }) as typeof globalThis.fetch;
741−};
742248
743249 export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch =>
744250 // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch
745251 guardFetch(options.fetch ?? globalThis.fetch, options);
746252
747−/**
748− * The guarded fetch and the guarded HttpClient layer over ONE resolution
749− * cache.
750− *
751− * Calling `makeHostedFetch` and `makeHostedHttpClientLayer` separately builds
752− * a cache each, so a composition that needs both — every host does, since
753− * plugins take the raw fetch and the SDK takes the layer — resolves each
754− * hostname twice and runs two TTL windows that drift apart. That halves the
755− * benefit the cache exists for, on the path where it matters most. Prefer this
756− * over constructing the two independently.
757− */
758−export const makeHostedHttp = (
253+export const makeHostedHttpClientLayer = (
759254 options: HostedHttpClientOptions = {},
760−): {
761− readonly fetch: typeof globalThis.fetch;
762− readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
763−} => {
764− const resolve = makeSharedResolver(options);
765− return {
766− // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch
767− fetch: guardFetch(options.fetch ?? globalThis.fetch, options, resolve),
768− httpClientLayer: hostedHttpClientLayer(options, resolve),
769− };
770−};
771−
772−const hostedHttpClientLayer = (
773− options: HostedHttpClientOptions,
774− resolve?: GuardResolver,
775255 ): Layer.Layer<HttpClient.HttpClient> =>
776256 FetchHttpClient.layer.pipe(
777257 Layer.provide(
778258 options.fetch
779− ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options, resolve))
259+ ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options))
780260 : Layer.effect(
781261 FetchHttpClient.Fetch,
782262 Effect.map(Effect.service(FetchHttpClient.Fetch), (underlying) =>
783− guardFetch(underlying, options, resolve),
263+ guardFetch(underlying, options),
784264 ),
785265 ),
786266 ),
787267 );
788−
789−export const makeHostedHttpClientLayer = (
790− options: HostedHttpClientOptions = {},
791−): Layer.Layer<HttpClient.HttpClient> => hostedHttpClientLayer(options);