import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate, Result } from "effect";import type * as Tracer from "effect/Tracer";import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { type HostedHostnameResolver, makeHostedFetch, makeHostedHttpClientLayer, validateHostedOutboundUrl,} from "./hosted-http-client"; const publicResolver: HostedHostnameResolver = async () => [ { address: "93.184.216.34", family: 4 },]; describe("hosted outbound HTTP client", () => { it.effect("allows public HTTP and HTTPS URLs", () => Effect.gen(function* () { yield* validateHostedOutboundUrl("https://example.com/openapi.json"); yield* validateHostedOutboundUrl("http://example.com/graphql"); }), ); it.effect("rejects local and private network URLs", () => Effect.gen(function* () { for (const url of [ "http://localhost:3000", "http://127.0.0.1:3000", "http://10.0.0.1/openapi.json", "http://172.16.0.1/graphql", "http://192.168.1.10/mcp", "http://169.254.169.254/latest/meta-data/", ]) { const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip); expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); } }), ); it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () => Effect.gen(function* () { for (const url of [ "http://[::ffff:127.0.0.1]:3000", "http://[::ffff:10.0.0.1]/openapi.json", "http://[::ffff:172.16.0.1]/graphql", "http://[::ffff:192.168.1.10]/mcp", "http://[::ffff:169.254.169.254]/latest/meta-data/", ]) { const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip); expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); } }), ); it.effect("can allow local network URLs explicitly", () => Effect.gen(function* () { yield* validateHostedOutboundUrl("http://127.0.0.1:3000", { allowLocalNetwork: true, }); }), ); it.effect("rejects hostnames that resolve to local or private addresses", () => Effect.gen(function* () { const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", { resolveHostname: async () => [{ address: "10.0.0.10", family: 4 }], }).pipe(Effect.flip); expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true); }), ); it.effect("checks DNS before the first fetch call", () => Effect.gen(function* () { let calls = 0; const fakeFetch: typeof globalThis.fetch = (async () => { calls++; return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch; const result = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.execute(HttpClientRequest.get("https://api.example/start")); }).pipe( Effect.provide( makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: async () => [{ address: "169.254.169.254", family: 4 }], }), ), Effect.result, ); expect(Result.isFailure(result)).toBe(true); expect(calls).toBe(0); }), ); it("applies the DNS guard to fetch callers", async () => { let calls = 0; const hostedFetch = makeHostedFetch({ fetch: (async () => { calls++; return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch, resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }], }); await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({ _tag: "HostedOutboundRequestBlocked", }); expect(calls).toBe(0); }); it.effect("checks redirected URLs before following them", () => Effect.gen(function* () { let calls = 0; const fakeFetch: typeof globalThis.fetch = (async (input) => { calls++; const url = input instanceof Request ? input.url : String(input); if (url === "https://public.example/start") { return new Response(null, { status: 302, headers: { location: "http://127.0.0.1:3000/internal" }, }); } return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch; const result = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.execute(HttpClientRequest.get("https://public.example/start")); }).pipe( Effect.provide( makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }), ), Effect.result, ); expect(Result.isFailure(result)).toBe(true); expect(calls).toBe(1); }), ); it.effect("follows cross-origin redirects but strips credential headers", () => Effect.gen(function* () { const seen: Array<{ url: string; authorization: string | null; cookie: string | null; }> = []; const fakeFetch: typeof globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); const headers = new Headers(init?.headers); seen.push({ url, authorization: headers.get("authorization"), cookie: headers.get("cookie"), }); if (url === "https://api.example/start") { return new Response(null, { status: 302, headers: { location: "https://cdn.example/blob" }, }); } return new Response("bytes", { status: 200 }); }) as typeof globalThis.fetch; const response = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.execute( HttpClientRequest.get("https://api.example/start").pipe( HttpClientRequest.setHeaders({ authorization: "Bearer secret", cookie: "session=abc", accept: "application/octet-stream", }), ), ); }).pipe( Effect.provide( makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }), ), ); expect(response.status).toBe(200); expect(seen).toHaveLength(2); expect(seen[0]).toMatchObject({ url: "https://api.example/start", authorization: "Bearer secret", cookie: "session=abc", }); expect(seen[1]).toMatchObject({ url: "https://cdn.example/blob", authorization: null, cookie: null, }); }), ); it.effect("keeps credential headers on same-origin redirects", () => Effect.gen(function* () { const seen: Array<{ url: string; authorization: string | null }> = []; const fakeFetch: typeof globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); seen.push({ url, authorization: new Headers(init?.headers).get("authorization"), }); if (url === "https://api.example/start") { return new Response(null, { status: 302, headers: { location: "/moved" }, }); } return new Response("ok", { status: 200 }); }) as typeof globalThis.fetch; const response = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.execute( HttpClientRequest.get("https://api.example/start").pipe( HttpClientRequest.setHeaders({ authorization: "Bearer secret" }), ), ); }).pipe( Effect.provide( makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }), ), ); expect(response.status).toBe(200); expect(seen).toHaveLength(2); expect(seen[1]).toMatchObject({ url: "https://api.example/moved", authorization: "Bearer secret", }); }), ); it.effect("rejects cross-origin redirects to private addresses", () => Effect.gen(function* () { let calls = 0; const fakeFetch: typeof globalThis.fetch = (async (input) => { calls++; const url = input instanceof Request ? input.url : String(input); if (url === "https://api.example/start") { return new Response(null, { status: 302, headers: { location: "http://169.254.169.254/latest/meta-data/" }, }); } return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch; const result = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.execute(HttpClientRequest.get("https://api.example/start")); }).pipe( Effect.provide( makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }), ), Effect.result, ); expect(Result.isFailure(result)).toBe(true); expect(calls).toBe(1); }), ); it.effect("redacts every span header attribute outside the safe allowlist", () => Effect.gen(function* () { const spanAttributes = new Map<string, unknown>(); const recordingTracer: Tracer.Tracer = { span: (options) => { let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; return { _tag: "Span", name: options.name, spanId: "0000000000000001", traceId: "00000000000000000000000000000001", parent: options.parent, annotations: options.annotations, get status() { return status; }, attributes: spanAttributes, links: options.links, sampled: options.sampled, kind: options.kind, end: (endTime, exit) => { status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; }, attribute: (key, value) => { spanAttributes.set(key, value); }, event: () => undefined, addLinks: () => undefined, }; }, }; const fakeFetch: typeof globalThis.fetch = (async () => new Response("{}", { status: 200, headers: { "content-type": "application/json", "cf-ray": "a2ca5b47bb7f3550", }, })) as typeof globalThis.fetch; yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; return yield* client.execute( HttpClientRequest.get("https://api.example/data").pipe( HttpClientRequest.setHeaders({ accept: "application/json", "x-goog-api-key": "live-credential", }), ), ); }).pipe( Effect.provide( makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }), ), Effect.withTracer(recordingTracer), ); expect(String(spanAttributes.get("http.request.header.accept"))).toBe("application/json"); expect(String(spanAttributes.get("http.request.header.x-goog-api-key"))).toBe("<redacted>"); expect(String(spanAttributes.get("http.response.header.content-type"))).toBe( "application/json", ); expect(String(spanAttributes.get("http.response.header.cf-ray"))).toBe("<redacted>"); }), );});
import { describe, expect, it } from "@effect/vitest";
import { Effect, Predicate, Result } from "effect";
import type * as Tracer from "effect/Tracer";
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
import {
type HostedHostnameResolver,
makeHostedFetch,
makeHostedHttpClientLayer,
validateHostedOutboundUrl,
} from "./hosted-http-client";
const publicResolver: HostedHostnameResolver = async () => [
{ address: "93.184.216.34", family: 4 },
];
describe("hosted outbound HTTP client", () => {
it.effect("allows public HTTP and HTTPS URLs", () =>
Effect.gen(function* () {
yield* validateHostedOutboundUrl("https://example.com/openapi.json");
yield* validateHostedOutboundUrl("http://example.com/graphql");
}),
);
it.effect("rejects local and private network URLs", () =>
Effect.gen(function* () {
for (const url of [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://10.0.0.1/openapi.json",
"http://172.16.0.1/graphql",
"http://192.168.1.10/mcp",
"http://169.254.169.254/latest/meta-data/",
]) {
const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
}
}),
);
it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () =>
Effect.gen(function* () {
for (const url of [
"http://[::ffff:127.0.0.1]:3000",
"http://[::ffff:10.0.0.1]/openapi.json",
"http://[::ffff:172.16.0.1]/graphql",
"http://[::ffff:192.168.1.10]/mcp",
"http://[::ffff:169.254.169.254]/latest/meta-data/",
]) {
const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
}
}),
);
it.effect("can allow local network URLs explicitly", () =>
Effect.gen(function* () {
yield* validateHostedOutboundUrl("http://127.0.0.1:3000", {
allowLocalNetwork: true,
});
}),
);
it.effect("rejects hostnames that resolve to local or private addresses", () =>
Effect.gen(function* () {
const error = yield* validateHostedOutboundUrl("https://api.example/openapi.json", {
resolveHostname: async () => [{ address: "10.0.0.10", family: 4 }],
}).pipe(Effect.flip);
expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
}),
);
it.effect("checks DNS before the first fetch call", () =>
Effect.gen(function* () {
let calls = 0;
const fakeFetch: typeof globalThis.fetch = (async () => {
calls++;
return new Response("unexpected", { status: 200 });
}) as typeof globalThis.fetch;
const result = yield* Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.execute(HttpClientRequest.get("https://api.example/start"));
}).pipe(
Effect.provide(
makeHostedHttpClientLayer({
fetch: fakeFetch,
resolveHostname: async () => [{ address: "169.254.169.254", family: 4 }],
}),
),
Effect.result,
);
expect(Result.isFailure(result)).toBe(true);
expect(calls).toBe(0);
}),
);
it("applies the DNS guard to fetch callers", async () => {
let calls = 0;
const hostedFetch = makeHostedFetch({
fetch: (async () => {
calls++;
return new Response("unexpected", { status: 200 });
}) as typeof globalThis.fetch,
resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }],
});
await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({
_tag: "HostedOutboundRequestBlocked",
});
expect(calls).toBe(0);
});
it.effect("checks redirected URLs before following them", () =>
Effect.gen(function* () {
let calls = 0;
const fakeFetch: typeof globalThis.fetch = (async (input) => {
calls++;
const url = input instanceof Request ? input.url : String(input);
if (url === "https://public.example/start") {
return new Response(null, {
status: 302,
headers: { location: "http://127.0.0.1:3000/internal" },
});
}
return new Response("unexpected", { status: 200 });
}) as typeof globalThis.fetch;
const result = yield* Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.execute(HttpClientRequest.get("https://public.example/start"));
}).pipe(
Effect.provide(
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
),
Effect.result,
);
expect(Result.isFailure(result)).toBe(true);
expect(calls).toBe(1);
}),
);
it.effect("follows cross-origin redirects but strips credential headers", () =>
Effect.gen(function* () {
const seen: Array<{
url: string;
authorization: string | null;
cookie: string | null;
}> = [];
const fakeFetch: typeof globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
const headers = new Headers(init?.headers);
seen.push({
url,
authorization: headers.get("authorization"),
cookie: headers.get("cookie"),
});
if (url === "https://api.example/start") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.example/blob" },
});
}
return new Response("bytes", { status: 200 });
}) as typeof globalThis.fetch;
const response = yield* Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.execute(
HttpClientRequest.get("https://api.example/start").pipe(
HttpClientRequest.setHeaders({
authorization: "Bearer secret",
cookie: "session=abc",
accept: "application/octet-stream",
}),
),
);
}).pipe(
Effect.provide(
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
),
);
expect(response.status).toBe(200);
expect(seen).toHaveLength(2);
expect(seen[0]).toMatchObject({
url: "https://api.example/start",
authorization: "Bearer secret",
cookie: "session=abc",
});
expect(seen[1]).toMatchObject({
url: "https://cdn.example/blob",
authorization: null,
cookie: null,
});
}),
);
it.effect("keeps credential headers on same-origin redirects", () =>
Effect.gen(function* () {
const seen: Array<{ url: string; authorization: string | null }> = [];
const fakeFetch: typeof globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
seen.push({
url,
authorization: new Headers(init?.headers).get("authorization"),
});
if (url === "https://api.example/start") {
return new Response(null, {
status: 302,
headers: { location: "/moved" },
});
}
return new Response("ok", { status: 200 });
}) as typeof globalThis.fetch;
const response = yield* Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.execute(
HttpClientRequest.get("https://api.example/start").pipe(
HttpClientRequest.setHeaders({ authorization: "Bearer secret" }),
),
);
}).pipe(
Effect.provide(
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
),
);
expect(response.status).toBe(200);
expect(seen).toHaveLength(2);
expect(seen[1]).toMatchObject({
url: "https://api.example/moved",
authorization: "Bearer secret",
});
}),
);
it.effect("rejects cross-origin redirects to private addresses", () =>
Effect.gen(function* () {
let calls = 0;
const fakeFetch: typeof globalThis.fetch = (async (input) => {
calls++;
const url = input instanceof Request ? input.url : String(input);
if (url === "https://api.example/start") {
return new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data/" },
});
}
return new Response("unexpected", { status: 200 });
}) as typeof globalThis.fetch;
const result = yield* Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.execute(HttpClientRequest.get("https://api.example/start"));
}).pipe(
Effect.provide(
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
),
Effect.result,
);
expect(Result.isFailure(result)).toBe(true);
expect(calls).toBe(1);
}),
);
it.effect("redacts every span header attribute outside the safe allowlist", () =>
Effect.gen(function* () {
const spanAttributes = new Map<string, unknown>();
const recordingTracer: Tracer.Tracer = {
span: (options) => {
let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
return {
_tag: "Span",
name: options.name,
spanId: "0000000000000001",
traceId: "00000000000000000000000000000001",
parent: options.parent,
annotations: options.annotations,
get status() {
return status;
},
attributes: spanAttributes,
links: options.links,
sampled: options.sampled,
kind: options.kind,
end: (endTime, exit) => {
status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
},
attribute: (key, value) => {
spanAttributes.set(key, value);
},
event: () => undefined,
addLinks: () => undefined,
};
},
};
const fakeFetch: typeof globalThis.fetch = (async () =>
new Response("{}", {
status: 200,
headers: {
"content-type": "application/json",
"cf-ray": "a2ca5b47bb7f3550",
},
})) as typeof globalThis.fetch;
yield* Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.execute(
HttpClientRequest.get("https://api.example/data").pipe(
HttpClientRequest.setHeaders({
accept: "application/json",
"x-goog-api-key": "live-credential",
}),
),
);
}).pipe(
Effect.provide(
makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }),
),
Effect.withTracer(recordingTracer),
);
expect(String(spanAttributes.get("http.request.header.accept"))).toBe("application/json");
expect(String(spanAttributes.get("http.request.header.x-goog-api-key"))).toBe("<redacted>");
expect(String(spanAttributes.get("http.response.header.content-type"))).toBe(
"application/json",
);
expect(String(spanAttributes.get("http.response.header.cf-ray"))).toBe("<redacted>");
}),
);
});