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
43 type ExecutorConfig,43 type ExecutorConfig,
44 type StorageFailure,44 type StorageFailure,
45} from "@executor-js/sdk";45} from "@executor-js/sdk";
46import { makeHostedHttp, touchSubject } from "@executor-js/sdk/host-internal";46import {
47 makeHostedFetch,
48 makeHostedHttpClientLayer,
49 touchSubject,
50} from "@executor-js/sdk/host-internal";
47 51
48import { DbProvider } from "./executor-fuma-db";52import { DbProvider } from "./executor-fuma-db";
49 53
@@ -265,10 +269,8 @@ export const makeScopedExecutor = < · 215 unmodified lines
265 const hostedHttpOptions = {269 const hostedHttpOptions = {
266 allowLocalNetwork: config.allowLocalNetwork,270 allowLocalNetwork: config.allowLocalNetwork,
267 };271 };
268 // One resolution cache behind both adapters: the plugins take the raw272 const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions);
269 // fetch and the SDK takes the layer, so building them separately would273 const hostedFetch = makeHostedFetch(hostedHttpOptions);
270 // resolve every hostname twice per session.
271 const { fetch: hostedFetch, httpClientLayer } = makeHostedHttp(hostedHttpOptions);
272 274
273 // The org id is the tenant (catalog partition); the account id is the acting275 // The org id is the tenant (catalog partition); the account id is the acting
274 // subject (drives `owner: "user"` rows). `organizationName` is no longer part276 // subject (drives `owner: "user"` rows). `organizationName` is no longer part
@@ -361,15 +363,14 @@ export const makePlatformExecutor = ( · 86 unmodified lines
361 Effect.withSpan("executor.platform.plugins.init"),363 Effect.withSpan("executor.platform.plugins.init"),
362 );364 );
363 const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork };365 const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork };
364 const platformHttp = makeHostedHttp(hostedHttpOptions);
365 366
366 return yield* createExecutor({367 return yield* createExecutor({
367 tenant: Tenant.make(organizationId),368 tenant: Tenant.make(organizationId),
368 db,369 db,
369 blobs,370 blobs,
370 plugins,371 plugins,
371 httpClientLayer: platformHttp.httpClientLayer,372 httpClientLayer: makeHostedHttpClientLayer(hostedHttpOptions),
372 fetch: platformHttp.fetch,373 fetch: makeHostedFetch(hostedHttpOptions),
373 onElicitation: "accept-all",374 onElicitation: "accept-all",
374 platformView: true,375 platformView: true,
375 }).pipe(Effect.withSpan("executor.platform.create_executor"));376 }).pipe(Effect.withSpan("executor.platform.create_executor"));
packages/core/sdk/src/host-internal.ts+0−1
@@ -32,7 +32,6 @@ · 31 unmodified lines
32export {32export {
33 HostedOutboundRequestBlocked,33 HostedOutboundRequestBlocked,
34 makeHostedFetch,34 makeHostedFetch,
35 makeHostedHttp,
36 makeHostedHttpClientLayer,35 makeHostedHttpClientLayer,
37 type HostedHttpClientOptions,36 type HostedHttpClientOptions,
38} from "./hosted-http-client";37} from "./hosted-http-client";
packages/core/sdk/src/hosted-http-client.test.ts+39−1509
@@ -1,575 +1,42 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer, Result } from "effect";2import { Effect, Predicate, Result } from "effect";
3import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";3import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4import { createServer, type Server } from "node:http";
5 4
6import {5import {
7 type HostedHostnameResolver,6 type HostedHostnameResolver,
8 makeHostedFetch,7 makeHostedFetch,
9 makeHostedHttp,
10 makeHostedHttpClientLayer,8 makeHostedHttpClientLayer,
11 validateHostedOutboundUrl,9 validateHostedOutboundUrl,
12} from "./hosted-http-client";10} from "./hosted-http-client";
13 11
14const publicResolver: HostedHostnameResolver = async () => [{ address: "93.184.216.34" }];12const publicResolver: HostedHostnameResolver = async () => [
15 13 { address: "93.184.216.34", family: 4 },
16// A stub `fetch` accepts any init, so the shape of what the adapter hands the14];
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.
20const 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.
59const 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.
65const expectBlocked = (result: Result.Result<unknown, unknown>, reason: string) => {
66 expect(result).toMatchObject({
67 _tag: "Failure",
68 failure: { cause: { _tag: "HostedOutboundRequestBlocked", reason } },
69 });
70};
71 15
72describe("hosted outbound HTTP client", () => {16describe("hosted outbound HTTP client", () => {
73 it.effect("allows public HTTP and HTTPS URLs", () =>17 it.effect("allows public HTTP and HTTPS URLs", () =>
74 Effect.gen(function* () {18 Effect.gen(function* () {
75 yield* validateHostedOutboundUrl("https://example.com/openapi.json", {19 yield* validateHostedOutboundUrl("https://example.com/openapi.json");
76 resolveHostname: publicResolver,20 yield* validateHostedOutboundUrl("http://example.com/graphql");
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 }
93 }),21 }),
94 );22 );
95 23
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.
99 it.effect("rejects local and private network URLs", () =>24 it.effect("rejects local and private network URLs", () =>
100 Effect.gen(function* () {25 Effect.gen(function* () {
101 for (const url of [26 for (const url of [
102 "http://localhost:3000",27 "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/",
111 "http://127.0.0.1:3000",28 "http://127.0.0.1:3000",
112 "http://10.0.0.1/openapi.json",29 "http://10.0.0.1/openapi.json",
113 "http://100.64.0.1/",
114 "http://169.254.1.1/",
115 "http://172.16.0.1/graphql",30 "http://172.16.0.1/graphql",
116 "http://192.0.0.1/",
117 "http://192.168.1.10/mcp",31 "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 [
149 "http://169.254.169.254/latest/meta-data/",32 "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]/",
199 ]) {33 ]) {
200 const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);34 const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
201 expect(error).toMatchObject({35 expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
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 });
476 }36 }
477 }),37 }),
478 );38 );
479 39
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
573 it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () =>40 it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () =>
574 Effect.gen(function* () {41 Effect.gen(function* () {
575 for (const url of [42 for (const url of [
@@ -577,110 +44,39 @@ describe("hosted outbound HTTP client", () => { · 1 unmodified line
577 "http://[::ffff:10.0.0.1]/openapi.json",44 "http://[::ffff:10.0.0.1]/openapi.json",
578 "http://[::ffff:172.16.0.1]/graphql",45 "http://[::ffff:172.16.0.1]/graphql",
579 "http://[::ffff:192.168.1.10]/mcp",46 "http://[::ffff:192.168.1.10]/mcp",
580 "http://[::ffff:169.254.1.1]/",47 "http://[::ffff:169.254.169.254]/latest/meta-data/",
581 ]) {48 ]) {
582 const error = yield* validateHostedOutboundUrl(url, {49 const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
583 resolveHostname: publicResolver,50 expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
584 }).pipe(Effect.flip);
585 expect(error).toMatchObject({
586 _tag: "HostedOutboundRequestBlocked",
587 reason: "Local and private network addresses are not allowed",
588 });
589 }51 }
590 }),52 }),
591 );53 );
592 54
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
604 it.effect("can allow local network URLs explicitly", () =>55 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", () =>
627 Effect.gen(function* () {56 Effect.gen(function* () {
628 const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {57 yield* validateHostedOutboundUrl("http://127.0.0.1:3000", {
629 resolveHostname: async () => [{ address: "0:0:0:0:0:0:0:1" }],58 allowLocalNetwork: true,
630 }).pipe(Effect.flip);
631
632 expect(error).toMatchObject({
633 _tag: "HostedOutboundRequestBlocked",
634 reason: "Resolved address is local or private",
635 });59 });
636 }),60 }),
637 );61 );
638 62
639 // A hostname may hold several A records, and an attacker only needs one of63 it.effect("rejects hostnames that resolve to local or private addresses", () =>
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", () =>
644 Effect.gen(function* () {64 Effect.gen(function* () {
645 const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {65 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 }],
647 }).pipe(Effect.flip);67 }).pipe(Effect.flip);
648 68
649 expect(error).toMatchObject({69 expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
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 });
674 }),70 }),
675 );71 );
676 72
677 it.effect("checks DNS before the first fetch call", () =>73 it.effect("checks DNS before the first fetch call", () =>
678 Effect.gen(function* () {74 Effect.gen(function* () {
679 let calls = 0;75 let calls = 0;
680 const fakeFetch: typeof globalThis.fetch = async () => {76 const fakeFetch: typeof globalThis.fetch = (async () => {
681 calls++;77 calls++;
682 return new Response("unexpected", { status: 200 });78 return new Response("unexpected", { status: 200 });
683 };79 }) as typeof globalThis.fetch;
684 80
685 const result = yield* Effect.gen(function* () {81 const result = yield* Effect.gen(function* () {
686 const client = yield* HttpClient.HttpClient;82 const client = yield* HttpClient.HttpClient;
@@ -689,870 +85,39 @@ describe("hosted outbound HTTP client", () => { · 2 unmodified lines
689 Effect.provide(85 Effect.provide(
690 makeHostedHttpClientLayer({86 makeHostedHttpClientLayer({
691 fetch: fakeFetch,87 fetch: fakeFetch,
692 resolveHostname: async () => [{ address: "169.254.1.1" }],88 resolveHostname: async () => [{ address: "169.254.169.254", family: 4 }],
693 }),89 }),
694 ),90 ),
695 Effect.result,91 Effect.result,
696 );92 );
697 93
698 expectBlocked(result, "Resolved address is local or private");94 expect(Result.isFailure(result)).toBe(true);
699 expect(calls).toBe(0);95 expect(calls).toBe(0);
700 }),96 }),
701 );97 );
702 98
703 it("applies the DNS guard to fetch callers", async () => {99 it("applies the DNS guard to fetch callers", async () => {
704 let calls = 0;100 let calls = 0;
705 const underlying: typeof globalThis.fetch = async () => {
706 calls++;
707 return new Response("unexpected", { status: 200 });
708 };
709 const hostedFetch = makeHostedFetch({101 const hostedFetch = makeHostedFetch({
710 fetch: underlying,102 fetch: (async () => {
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 () => {
742 calls++;103 calls++;
743 return new Response("unexpected");104 return new Response("unexpected", { status: 200 });
744 },105 }) as typeof globalThis.fetch,
106 resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }],
745 });107 });
746 const overlongLabel = `${"a".repeat(300)}.example.com`;
747 108
748 await expect(hostedFetch(`https://${overlongLabel}/x`)).rejects.toMatchObject({109 await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
749 _tag: "HostedOutboundRequestBlocked",110 _tag: "HostedOutboundRequestBlocked",
750 reason: "Hostname could not be resolved",
751 });111 });
752 expect(calls).toBe(0);112 expect(calls).toBe(0);
753 });113 });
754 114
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
1550 it.effect("checks redirected URLs before following them", () =>115 it.effect("checks redirected URLs before following them", () =>
1551 Effect.gen(function* () {116 Effect.gen(function* () {
1552 let calls = 0;117 let calls = 0;
1553 const fakeFetch: typeof globalThis.fetch = async (input) => {118 const fakeFetch: typeof globalThis.fetch = (async (input) => {
1554 calls++;119 calls++;
1555 const url = String(input);120 const url = input instanceof Request ? input.url : String(input);
1556 if (url === "https://public.example/start") {121 if (url === "https://public.example/start") {
1557 return new Response(null, {122 return new Response(null, {
1558 status: 302,123 status: 302,
@@ -1560,7 +125,7 @@ describe("hosted outbound HTTP client", () => { · 1 unmodified line
1560 });125 });
1561 }126 }
1562 return new Response("unexpected", { status: 200 });127 return new Response("unexpected", { status: 200 });
1563 };128 }) as typeof globalThis.fetch;
1564 const result = yield* Effect.gen(function* () {129 const result = yield* Effect.gen(function* () {
1565 const client = yield* HttpClient.HttpClient;130 const client = yield* HttpClient.HttpClient;
1566 return yield* client.execute(HttpClientRequest.get("https://public.example/start"));131 return yield* client.execute(HttpClientRequest.get("https://public.example/start"));
@@ -1571,7 +136,7 @@ describe("hosted outbound HTTP client", () => { · 4 unmodified lines
1571 Effect.result,136 Effect.result,
1572 );137 );
1573 138
1574 expectBlocked(result, "Local and private network addresses are not allowed");139 expect(Result.isFailure(result)).toBe(true);
1575 expect(calls).toBe(1);140 expect(calls).toBe(1);
1576 }),141 }),
1577 );142 );
@@ -1583,8 +148,8 @@ describe("hosted outbound HTTP client", () => { · 5 unmodified lines
1583 authorization: string | null;148 authorization: string | null;
1584 cookie: string | null;149 cookie: string | null;
1585 }> = [];150 }> = [];
1586 const fakeFetch: typeof globalThis.fetch = async (input, init) => {151 const fakeFetch: typeof globalThis.fetch = (async (input, init) => {
1587 const url = String(input);152 const url = input instanceof Request ? input.url : String(input);
1588 const headers = new Headers(init?.headers);153 const headers = new Headers(init?.headers);
1589 seen.push({154 seen.push({
1590 url,155 url,
@@ -1598,7 +163,7 @@ describe("hosted outbound HTTP client", () => { · 7 unmodified lines
1598 });163 });
1599 }164 }
1600 return new Response("bytes", { status: 200 });165 return new Response("bytes", { status: 200 });
1601 };166 }) as typeof globalThis.fetch;
1602 167
1603 const response = yield* Effect.gen(function* () {168 const response = yield* Effect.gen(function* () {
1604 const client = yield* HttpClient.HttpClient;169 const client = yield* HttpClient.HttpClient;
@@ -1635,8 +200,8 @@ describe("hosted outbound HTTP client", () => { · 30 unmodified lines
1635 it.effect("keeps credential headers on same-origin redirects", () =>200 it.effect("keeps credential headers on same-origin redirects", () =>
1636 Effect.gen(function* () {201 Effect.gen(function* () {
1637 const seen: Array<{ url: string; authorization: string | null }> = [];202 const seen: Array<{ url: string; authorization: string | null }> = [];
1638 const fakeFetch: typeof globalThis.fetch = async (input, init) => {203 const fakeFetch: typeof globalThis.fetch = (async (input, init) => {
1639 const url = String(input);204 const url = input instanceof Request ? input.url : String(input);
1640 seen.push({205 seen.push({
1641 url,206 url,
1642 authorization: new Headers(init?.headers).get("authorization"),207 authorization: new Headers(init?.headers).get("authorization"),
@@ -1648,7 +213,7 @@ describe("hosted outbound HTTP client", () => { · 5 unmodified lines
1648 });213 });
1649 }214 }
1650 return new Response("ok", { status: 200 });215 return new Response("ok", { status: 200 });
1651 };216 }) as typeof globalThis.fetch;
1652 217
1653 const response = yield* Effect.gen(function* () {218 const response = yield* Effect.gen(function* () {
1654 const client = yield* HttpClient.HttpClient;219 const client = yield* HttpClient.HttpClient;
@@ -1672,40 +237,12 @@ describe("hosted outbound HTTP client", () => { · 17 unmodified lines
1672 }),237 }),
1673 );238 );
1674 239
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
1703 it.effect("rejects cross-origin redirects to private addresses", () =>240 it.effect("rejects cross-origin redirects to private addresses", () =>
1704 Effect.gen(function* () {241 Effect.gen(function* () {
1705 let calls = 0;242 let calls = 0;
1706 const fakeFetch: typeof globalThis.fetch = async (input) => {243 const fakeFetch: typeof globalThis.fetch = (async (input) => {
1707 calls++;244 calls++;
1708 const url = String(input);245 const url = input instanceof Request ? input.url : String(input);
1709 if (url === "https://api.example/start") {246 if (url === "https://api.example/start") {
1710 return new Response(null, {247 return new Response(null, {
1711 status: 302,248 status: 302,
@@ -1713,7 +250,7 @@ describe("hosted outbound HTTP client", () => { · 1 unmodified line
1713 });250 });
1714 }251 }
1715 return new Response("unexpected", { status: 200 });252 return new Response("unexpected", { status: 200 });
1716 };253 }) as typeof globalThis.fetch;
1717 254
1718 const result = yield* Effect.gen(function* () {255 const result = yield* Effect.gen(function* () {
1719 const client = yield* HttpClient.HttpClient;256 const client = yield* HttpClient.HttpClient;
@@ -1725,14 +262,7 @@ describe("hosted outbound HTTP client", () => { · 5 unmodified lines
1725 Effect.result,262 Effect.result,
1726 );263 );
1727 264
1728 expectBlocked(result, "Metadata service addresses are not allowed");265 expect(Result.isFailure(result)).toBe(true);
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 });
1736 expect(calls).toBe(1);266 expect(calls).toBe(1);
1737 }),267 }),
1738 );268 );
packages/core/sdk/src/hosted-http-client.ts+125−649
@@ -1,4 +1,4 @@
1import { Cache, Cause, Duration, Effect, Exit, Fiber, Layer, Schema } from "effect";1import { Effect, Layer, Schema } from "effect";
2import { FetchHttpClient, HttpClient } from "effect/unstable/http";2import { FetchHttpClient, HttpClient } from "effect/unstable/http";
3 3
4export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<HostedOutboundRequestBlocked>()(4export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<HostedOutboundRequestBlocked>()(
@@ -9,16 +9,13 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted · 4 unmodified lines
9 },9 },
10) {}10) {}
11 11
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.
15export interface HostedResolvedAddress {12export interface HostedResolvedAddress {
16 readonly address: string;13 readonly address: string;
14 readonly family?: 4 | 6;
17}15}
18 16
19export type HostedHostnameResolver = (17export type HostedHostnameResolver = (
20 hostname: string,18 hostname: string,
21 signal?: AbortSignal,
22) => Promise<ReadonlyArray<HostedResolvedAddress>>;19) => Promise<ReadonlyArray<HostedResolvedAddress>>;
23 20
24export interface HostedHttpClientOptions {21export interface HostedHttpClientOptions {
@@ -26,26 +23,14 @@ export interface HostedHttpClientOptions { · 1 unmodified line
26 readonly maxRedirects?: number;23 readonly maxRedirects?: number;
27 readonly fetch?: typeof globalThis.fetch;24 readonly fetch?: typeof globalThis.fetch;
28 readonly resolveHostname?: HostedHostnameResolver;25 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;
35}26}
36 27
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.
43const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {28const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
44 const parts = hostname.split(".");29 const parts = hostname.split(".");
45 if (parts.length !== 4) return null;30 if (parts.length !== 4) return null;
46 const parsed: number[] = [];31 const parsed: number[] = [];
47 for (const part of parts) {32 for (const part of parts) {
48 if (!/^(?:0|[1-9]\d*)$/.test(part)) return null;33 if (!/^\d+$/.test(part)) return null;
49 const value = Number(part);34 const value = Number(part);
50 if (!Number.isInteger(value) || value < 0 || value > 255) return null;35 if (!Number.isInteger(value) || value < 0 || value > 255) return null;
51 parsed.push(value);36 parsed.push(value);
@@ -53,53 +38,34 @@ const parseIpv4 = (hostname: string): readonly [number, number, number, number] · 1 unmodified line
53 return parsed as [number, number, number, number];38 return parsed as [number, number, number, number];
54};39};
55 40
56// `allowTrailingQuad` is the caller's statement that this segment ends the41const parseIpv4MappedIpv6 = (
57// whole address, not merely that it ends its own half. Deciding it from the42 hostname: string,
58// segment alone would legalize a quad at the end of the head half of a43): readonly [number, number, number, number] | null => {
59// compressed literal: "127.0.0.1::1" would parse, land 0x7f00 in the leading44 const prefix = "::ffff:";
60// word where no prefix or mask matches it, and be classified public.45 if (!hostname.startsWith(prefix)) return null;
61const parseIpv6Groups = (text: string, allowTrailingQuad: boolean): number[] | null => {46 const embedded = hostname.slice(prefix.length);
62 if (text === "") return [];47 const dotted = parseIpv4(embedded);
63 const parts = text.split(":");48 if (dotted) return dotted;
64 const groups: number[] = [];49
65 for (const [index, part] of parts.entries()) {50 const parts = embedded.split(":");
66 // A trailing dotted quad is the only place IPv4 syntax is legal.51 if (parts.length !== 2) return null;
67 if (index === parts.length - 1 && part.includes(".")) {52
68 if (!allowTrailingQuad) return null;53 const words = parts.map((part) => Number.parseInt(part, 16));
69 const dotted = parseIpv4(part);54 if (
70 if (!dotted) return null;55 words.some(
71 groups.push((dotted[0] << 8) | dotted[1], (dotted[2] << 8) | dotted[3]);56 (word, index) =>
72 continue;57 parts[index] === "" ||
73 }58 !/^[0-9a-f]+$/i.test(parts[index]) ||
74 if (!/^[0-9a-f]{1,4}$/.test(part)) return null;59 !Number.isInteger(word) ||
75 groups.push(Number.parseInt(part, 16));60 word < 0 ||
61 word > 0xffff,
62 )
63 ) {
64 return null;
76 }65 }
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.
86const 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;
97 66
98 const low = parseIpv6Groups(tail, true);67 const [high, low] = words;
99 if (low === null) return null;68 return [high >> 8, high & 0xff, low >> 8, low & 0xff];
100 const elided = 8 - high.length - low.length;
101 if (elided < 1) return null;
102 return [...high, ...Array<number>(elided).fill(0), ...low];
103};69};
104 70
105const isBlockedIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>71const 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
114 (a === 198 && (b === 18 || b === 19)) ||80 (a === 198 && (b === 18 || b === 19)) ||
115 a >= 224;81 a >= 224;
116 82
117const dotted = (high: number, low: number): readonly [number, number, number, number] => [83const isBlockedIpv6 = (hostname: string): boolean => {
118 high >> 8,84 const normalized = hostname.toLowerCase();
119 high & 0xff,85 if (
120 low >> 8,86 normalized === "::" ||
121 low & 0xff,87 normalized === "::1" ||
122];88 normalized === "0:0:0:0:0:0:0:0" ||
123 89 normalized === "0:0:0:0:0:0:0:1"
124// Prefixes that carry an IPv4 address at a fixed position, so the destination90 ) {
125// they actually reach is that address and not their own prefix: v4-mapped91 return true;
126// (::ffff:0:0/96), the deprecated v4-compatible form (::/96), RFC 6052's92 }
127// IPv4-translatable form (::ffff:0:0:0/96), the well-known NAT64 prefix93 const firstWordText = normalized.split(":").find((part) => part.length > 0);
128// (64:ff9b::/96), and 6to4 (2002::/16), which carries its address one word94 if (!firstWordText || !/^[0-9a-f]{1,4}$/.test(firstWordText)) return false;
129// higher. Classifying by the prefix instead would let ::ffff:0:7f00:1 and95 const firstWord = Number.parseInt(firstWordText, 16);
130// 2002:a9fe:a9fe:: reach loopback and the metadata endpoint untouched.
131const 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.
150const isLocalUseNat64 = (words: ReadonlyArray<number>): boolean =>
151 words[0] === 0x0064 && words[1] === 0xff9b && words[2] === 0x0001;
152
153const 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.
162 return (96 return (
163 (leading & 0xff80) === 0xfe80 || (leading & 0xfe00) === 0xfc00 || (leading & 0xff00) === 0xff0097 (firstWord & 0xffc0) === 0xfe80 ||
98 (firstWord & 0xfe00) === 0xfc00 ||
99 (firstWord & 0xff00) === 0xff00
164 );100 );
165};101};
166 102
167// Every hostname check runs against this form, so each one sees the same name.103const isBlockedMetadataHostname = (hostname: string): boolean => {
168// The trailing root dot matters: "metadata.google.internal." is the same name104 const normalized = hostname.toLowerCase();
169// to the resolver but a different string, and an exact-match blocklist that105 return (
170// skipped this would be defeated by appending one character.106 normalized === "metadata.google.internal" ||
171const canonicalHostname = (hostname: string): string =>107 normalized === "metadata" ||
172 hostname108 normalized === "instance-data" ||
173 .toLowerCase()109 normalized === "169.254.169.254"
174 .replace(/^\[|\]$/g, "")110 );
175 .replace(/\.+$/, "");
176
177const 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.
188const 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);
195};111};
196 112
197const isBlockedMetadataHostname = (normalized: string): boolean =>113const isLocalOrPrivateHostname = (hostname: string): boolean => {
198 normalized === "metadata.google.internal" ||114 const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
199 normalized === "metadata" ||
200 normalized === "instance-data" ||
201 isBlockedMetadataAddress(normalized);
202
203const isLocalOrPrivateHostname = (normalized: string): boolean => {
204 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;115 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
205 const ipv4 = parseIpv4(normalized);116 const ipv4 = parseIpv4(normalized);
206 if (ipv4) return isBlockedIpv4(ipv4);117 if (ipv4) return isBlockedIpv4(ipv4);
207 const ipv6 = parseIpv6(normalized);118 const mappedIpv4 = parseIpv4MappedIpv6(normalized);
208 if (ipv6) return isBlockedIpv6(ipv6);119 if (mappedIpv4) return isBlockedIpv4(mappedIpv4);
209 return false;120 return isBlockedIpv6(normalized);
210};121};
211 122
212// A resolved address is known to be an IP, so failing to decode it is not123const isAddressLiteral = (hostname: string): boolean => {
213// "this is a public name" — it is "the guard cannot classify this". Reusing124 const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
214// isLocalOrPrivateHostname there would collapse both into `false` and let an125 return parseIpv4(normalized) !== null || /^[0-9a-f:.]+$/i.test(normalized);
215// address these parsers reject through the one check that exists to stop it.
216const 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;
223};126};
224 127
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.
230const 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.
237class HostedHostnameResolutionFailed extends Schema.TaggedErrorClass<HostedHostnameResolutionFailed>()(
238 "HostedHostnameResolutionFailed",
239 {
240 hostname: Schema.String,
241 empty: Schema.Boolean,
242 },
243) {}
244
245const resolveHostnameWithNodeDns: HostedHostnameResolver = async (hostname) => {128const resolveHostnameWithNodeDns: HostedHostnameResolver = async (hostname) => {
246 const { lookup } = await import("node:dns/promises");129 const { lookup } = await import("node:dns/promises");
247 const addresses = await lookup(hostname, { all: true, verbatim: true });130 const addresses = await lookup(hostname, { all: true, verbatim: true });
248 return addresses.map(({ address }) => ({ address }));131 return addresses.map(({ address, family }) => ({
249};132 address,
250 133 family: family === 6 ? 6 : 4,
251type GuardResolver = (134 }));
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.
260const 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
274const DNS_CACHE_TTL_MILLIS = 60_000;
275const DNS_CACHE_CAPACITY = 256;
276const 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.
299const 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);
352};135};
353 136
354const validateOutboundUrl = (137export const validateHostedOutboundUrl = (
355 value: string,138 value: string,
356 options: HostedHttpClientOptions,139 options: HostedHttpClientOptions = {},
357 resolve: GuardResolver,
358): Effect.Effect<void, HostedOutboundRequestBlocked> =>140): Effect.Effect<void, HostedOutboundRequestBlocked> =>
359 Effect.gen(function* () {141 Effect.gen(function* () {
360 const url = yield* Effect.try({142 const url = yield* Effect.try({
@@ -373,419 +155,113 @@ const validateOutboundUrl = ( · 12 unmodified lines
373 });155 });
374 }156 }
375 157
376 const normalizedHostname = canonicalHostname(url.hostname);158 if (isBlockedMetadataHostname(url.hostname)) {
377
378 if (isBlockedMetadataHostname(normalizedHostname)) {
379 return yield* new HostedOutboundRequestBlocked({159 return yield* new HostedOutboundRequestBlocked({
380 url: value,160 url: value,
381 reason: "Metadata service addresses are not allowed",161 reason: "Metadata service addresses are not allowed",
382 });162 });
383 }163 }
384 164
385 if (!options.allowLocalNetwork && isLocalOrPrivateHostname(normalizedHostname)) {165 if (!options.allowLocalNetwork && isLocalOrPrivateHostname(url.hostname)) {
386 return yield* new HostedOutboundRequestBlocked({166 return yield* new HostedOutboundRequestBlocked({
387 url: value,167 url: value,
388 reason: "Local and private network addresses are not allowed",168 reason: "Local and private network addresses are not allowed",
389 });169 });
390 }170 }
391 171
392 // The resolved-address check runs whether or not the local network is172 const normalizedHostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
393 // allowed, because the metadata block above is only as good as the names it173 if (!options.allowLocalNetwork && options.resolveHostname && !isAddressLiteral(url.hostname)) {
394 // sees. Gating the whole lookup on `!allowLocalNetwork` left the endpoint174 const addresses = yield* Effect.tryPromise({
395 // one DNS record away on exactly the hosts that set the flag: a name with175 try: () => options.resolveHostname!(normalizedHostname),
396 // an A record for 169.254.169.254 is not an address literal, so nothing176 catch: () =>
397 // resolved it and nothing classified it. What the flag changes is the177 new HostedOutboundRequestBlocked({
398 // verdict, not whether the lookup happens — with it set, only the metadata178 url: value,
399 // rule applies to the answers, so a local address stays reachable by name179 reason: "Hostname could not be resolved",
400 // as intended. The lookup this adds is the cached one, so a repeat name180 }),
401 // costs nothing after the first.181 });
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 );
429 182
430 for (const { address } of addresses) {183 if (addresses.length === 0) {
431 if (isBlockedMetadataAddress(address.toLowerCase().replace(/^\[|\]$/g, ""))) {
432 return yield* new HostedOutboundRequestBlocked({184 return yield* new HostedOutboundRequestBlocked({
433 url: value,185 url: value,
434 reason: "Metadata service addresses are not allowed",186 reason: "Hostname did not resolve to an address",
435 });187 });
436 }188 }
437 if (!options.allowLocalNetwork && !isAllowedResolvedAddress(address)) {189
438 return yield* new HostedOutboundRequestBlocked({190 for (const { address } of addresses) {
439 url: value,191 if (isLocalOrPrivateHostname(address)) {
440 reason: "Resolved address is local or private",192 return yield* new HostedOutboundRequestBlocked({
441 });193 url: value,
194 reason: "Resolved address is local or private",
195 });
196 }
442 }197 }
443 }198 }
444 });199 });
445 200
446// Defaults to the same node:dns resolver guardFetch uses so both public201const CREDENTIAL_HEADERS = ["authorization", "proxy-authorization", "cookie"] as const;
447// entry points enforce the same guard; omitting the resolver must not
448// silently drop the resolved-address check.
449export 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.
465const SAFE_REDIRECT_HEADERS = ["accept", "accept-language", "user-agent"] as const;
466 202
467const retainSafeRedirectHeaders = (init: RequestInit | undefined): RequestInit => {203const stripCredentialHeaders = (init: RequestInit | undefined): RequestInit => {
468 const headers = new Headers(init?.headers);204 const headers = new Headers(init?.headers);
469 const kept = new Headers();205 for (const name of CREDENTIAL_HEADERS) headers.delete(name);
470 for (const name of SAFE_REDIRECT_HEADERS) {206 return { ...init, 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
500const 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.
510const 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.
522const 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.
532const 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.
542const 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.
556const 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.
561const 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 };
586};207};
587 208
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.
592const 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.
608const 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.
614const makeSharedResolver = (options: HostedHttpClientOptions): GuardResolver =>
615 withResolutionCache(
616 toGuardResolver(options.resolveHostname ?? resolveHostnameWithNodeDns),
617 options,
618 );
619
620const guardFetch = (209const guardFetch = (
621 underlying: typeof globalThis.fetch,210 underlying: typeof globalThis.fetch,
622 options: HostedHttpClientOptions,211 options: HostedHttpClientOptions,
623 sharedResolve?: GuardResolver,212): typeof globalThis.fetch =>
624): typeof globalThis.fetch => {213 (async (input, init) => {
625 const resolve = sharedResolve ?? makeSharedResolver(options);214 const guardOptions = {
626 const maxRedirects = Math.max(0, options.maxRedirects ?? 10);215 ...options,
627 // SAFETY: narrowed to the one thing a closure cannot express — Bun's fetch216 resolveHostname: options.resolveHostname ?? resolveHostnameWithNodeDns,
628 // type carries a static `preconnect` property, which packages built against217 };
629 // @types/bun require. The call signature is checked by the annotations, and218 const maxRedirects = options.maxRedirects ?? 10;
630 // the return type is `Promise<Response>` on every path.219 let current: Parameters<typeof globalThis.fetch>[0] | URL = input;
631 return (async (input, init) => {220 let currentInit = init;
632 const normalized = normalizeFetchInput(input, init);221 for (let redirects = 0; redirects <= maxRedirects; redirects++) {
633 let currentUrl = normalized.url;222 const url = current instanceof Request ? current.url : String(current);
634 let currentInit = normalized.init;223 await Effect.runPromise(validateHostedOutboundUrl(url, guardOptions));
635 const signal = currentInit?.signal ?? undefined;224 const response = await underlying(current, {
636 // Read once, before the loop rewrites `currentInit` on each hop.225 ...currentInit,
637 const redirectMode = currentInit?.redirect ?? "follow";226 redirect: "manual",
638 for (let redirects = 0; ; redirects++) {227 });
639 const guarded = await Effect.runPromiseExit(228 if (
640 validateOutboundUrl(currentUrl, options, resolve),229 response.status >= 300 &&
641 { signal },230 response.status < 400 &&
642 );231 response.headers.has("location") &&
643 if (Exit.isFailure(guarded)) {232 redirects < maxRedirects
644 // Both halves are required. The cause decides that this was an233 ) {
645 // interrupt rather than a guard verdict — an aborted signal alone234 const next = new URL(response.headers.get("location")!, url);
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 }
707 // Cross-origin redirects are followed (the loop re-validates every235 // Cross-origin redirects are followed (the loop re-validates every
708 // hop), but credentials minted for the original origin — in any236 // hop), but credentials minted for the original origin must not leak
709 // header or in the body — must not leak to the redirect target.237 // to the redirect target — same as fetch/curl behavior.
710 if (next.origin !== new URL(currentUrl).origin) {238 if (next.origin !== new URL(url).origin) {
711 // A 307/308 replays the method, so a request still carrying a body239 currentInit = stripCredentialHeaders(currentInit);
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);
723 }240 }
724 currentUrl = next.toString();241 current = next.toString();
725 continue;242 continue;
726 }243 }
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 }
738 return response;244 return response;
739 }245 }
246 return await underlying(current, { ...currentInit, redirect: "manual" });
740 }) as typeof globalThis.fetch;247 }) as typeof globalThis.fetch;
741};
742 248
743export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch =>249export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch =>
744 // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch250 // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch
745 guardFetch(options.fetch ?? globalThis.fetch, options);251 guardFetch(options.fetch ?? globalThis.fetch, options);
746 252
747/**253export const makeHostedHttpClientLayer = (
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 */
758export const makeHostedHttp = (
759 options: HostedHttpClientOptions = {},254 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
772const hostedHttpClientLayer = (
773 options: HostedHttpClientOptions,
774 resolve?: GuardResolver,
775): Layer.Layer<HttpClient.HttpClient> =>255): Layer.Layer<HttpClient.HttpClient> =>
776 FetchHttpClient.layer.pipe(256 FetchHttpClient.layer.pipe(
777 Layer.provide(257 Layer.provide(
778 options.fetch258 options.fetch
779 ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options, resolve))259 ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options))
780 : Layer.effect(260 : Layer.effect(
781 FetchHttpClient.Fetch,261 FetchHttpClient.Fetch,
782 Effect.map(Effect.service(FetchHttpClient.Fetch), (underlying) =>262 Effect.map(Effect.service(FetchHttpClient.Fetch), (underlying) =>
783 guardFetch(underlying, options, resolve),263 guardFetch(underlying, options),
784 ),264 ),
785 ),265 ),
786 ),266 ),
787 );267 );
788
789export const makeHostedHttpClientLayer = (
790 options: HostedHttpClientOptions = {},
791): Layer.Layer<HttpClient.HttpClient> => hostedHttpClientLayer(options);