336 lines · 11.2 KB · TypeScript
RawDownload
import { Effect, Layer, Schema } from "effect";import { FetchHttpClient, Headers as HttpHeaders, HttpClient } from "effect/unstable/http"; export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<HostedOutboundRequestBlocked>()(  "HostedOutboundRequestBlocked",  {    url: Schema.String,    reason: Schema.String,  },) {} export interface HostedResolvedAddress {  readonly address: string;  readonly family?: 4 | 6;} export type HostedHostnameResolver = (  hostname: string,) => Promise<ReadonlyArray<HostedResolvedAddress>>; export interface HostedHttpClientOptions {  readonly allowLocalNetwork?: boolean;  readonly maxRedirects?: number;  readonly fetch?: typeof globalThis.fetch;  readonly resolveHostname?: HostedHostnameResolver;} const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {  const parts = hostname.split(".");  if (parts.length !== 4) return null;  const parsed: number[] = [];  for (const part of parts) {    if (!/^\d+$/.test(part)) return null;    const value = Number(part);    if (!Number.isInteger(value) || value < 0 || value > 255) return null;    parsed.push(value);  }  return parsed as [number, number, number, number];}; const parseIpv4MappedIpv6 = (  hostname: string,): readonly [number, number, number, number] | null => {  const prefix = "::ffff:";  if (!hostname.startsWith(prefix)) return null;  const embedded = hostname.slice(prefix.length);  const dotted = parseIpv4(embedded);  if (dotted) return dotted;   const parts = embedded.split(":");  if (parts.length !== 2) return null;   const words = parts.map((part) => Number.parseInt(part, 16));  if (    words.some(      (word, index) =>        parts[index] === "" ||        !/^[0-9a-f]+$/i.test(parts[index]) ||        !Number.isInteger(word) ||        word < 0 ||        word > 0xffff,    )  ) {    return null;  }   const [high, low] = words;  return [high >> 8, high & 0xff, low >> 8, low & 0xff];}; const isBlockedIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>  a === 0 ||  a === 10 ||  a === 127 ||  (a === 100 && b >= 64 && b <= 127) ||  (a === 169 && b === 254) ||  (a === 172 && b >= 16 && b <= 31) ||  (a === 192 && b === 0) ||  (a === 192 && b === 168) ||  (a === 198 && (b === 18 || b === 19)) ||  a >= 224; const isBlockedIpv6 = (hostname: string): boolean => {  const normalized = hostname.toLowerCase();  if (    normalized === "::" ||    normalized === "::1" ||    normalized === "0:0:0:0:0:0:0:0" ||    normalized === "0:0:0:0:0:0:0:1"  ) {    return true;  }  const firstWordText = normalized.split(":").find((part) => part.length > 0);  if (!firstWordText || !/^[0-9a-f]{1,4}$/.test(firstWordText)) return false;  const firstWord = Number.parseInt(firstWordText, 16);  return (    (firstWord & 0xffc0) === 0xfe80 ||    (firstWord & 0xfe00) === 0xfc00 ||    (firstWord & 0xff00) === 0xff00  );}; const isBlockedMetadataHostname = (hostname: string): boolean => {  const normalized = hostname.toLowerCase();  return (    normalized === "metadata.google.internal" ||    normalized === "metadata" ||    normalized === "instance-data" ||    normalized === "169.254.169.254"  );}; const isLocalOrPrivateHostname = (hostname: string): boolean => {  const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");  if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;  const ipv4 = parseIpv4(normalized);  if (ipv4) return isBlockedIpv4(ipv4);  const mappedIpv4 = parseIpv4MappedIpv6(normalized);  if (mappedIpv4) return isBlockedIpv4(mappedIpv4);  return isBlockedIpv6(normalized);}; const isAddressLiteral = (hostname: string): boolean => {  const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");  return parseIpv4(normalized) !== null || /^[0-9a-f:.]+$/i.test(normalized);}; const resolveHostnameWithNodeDns: HostedHostnameResolver = async (hostname) => {  const { lookup } = await import("node:dns/promises");  const addresses = await lookup(hostname, { all: true, verbatim: true });  return addresses.map(({ address, family }) => ({    address,    family: family === 6 ? 6 : 4,  }));}; export const validateHostedOutboundUrl = (  value: string,  options: HostedHttpClientOptions = {},): Effect.Effect<void, HostedOutboundRequestBlocked> =>  Effect.gen(function* () {    const url = yield* Effect.try({      try: () => new URL(value),      catch: () =>        new HostedOutboundRequestBlocked({          url: value,          reason: "URL is invalid",        }),    });     if (url.protocol !== "http:" && url.protocol !== "https:") {      return yield* new HostedOutboundRequestBlocked({        url: value,        reason: "Only HTTP and HTTPS outbound requests are allowed",      });    }     if (isBlockedMetadataHostname(url.hostname)) {      return yield* new HostedOutboundRequestBlocked({        url: value,        reason: "Metadata service addresses are not allowed",      });    }     if (!options.allowLocalNetwork && isLocalOrPrivateHostname(url.hostname)) {      return yield* new HostedOutboundRequestBlocked({        url: value,        reason: "Local and private network addresses are not allowed",      });    }     const normalizedHostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");    if (!options.allowLocalNetwork && options.resolveHostname && !isAddressLiteral(url.hostname)) {      const addresses = yield* Effect.tryPromise({        try: () => options.resolveHostname!(normalizedHostname),        catch: () =>          new HostedOutboundRequestBlocked({            url: value,            reason: "Hostname could not be resolved",          }),      });       if (addresses.length === 0) {        return yield* new HostedOutboundRequestBlocked({          url: value,          reason: "Hostname did not resolve to an address",        });      }       for (const { address } of addresses) {        if (isLocalOrPrivateHostname(address)) {          return yield* new HostedOutboundRequestBlocked({            url: value,            reason: "Resolved address is local or private",          });        }      }    }  }); const CREDENTIAL_HEADERS = ["authorization", "proxy-authorization", "cookie"] as const; const stripCredentialHeaders = (init: RequestInit | undefined): RequestInit => {  const headers = new Headers(init?.headers);  for (const name of CREDENTIAL_HEADERS) headers.delete(name);  return { ...init, headers };}; const guardFetch = (  underlying: typeof globalThis.fetch,  options: HostedHttpClientOptions,): typeof globalThis.fetch =>  (async (input, init) => {    const guardOptions = {      ...options,      resolveHostname: options.resolveHostname ?? resolveHostnameWithNodeDns,    };    const maxRedirects = options.maxRedirects ?? 10;    let current: Parameters<typeof globalThis.fetch>[0] | URL = input;    let currentInit = init;    for (let redirects = 0; redirects <= maxRedirects; redirects++) {      const url = current instanceof Request ? current.url : String(current);      await Effect.runPromise(validateHostedOutboundUrl(url, guardOptions));      const response = await underlying(current, {        ...currentInit,        redirect: "manual",      });      if (        response.status >= 300 &&        response.status < 400 &&        response.headers.has("location") &&        redirects < maxRedirects      ) {        const next = new URL(response.headers.get("location")!, url);        // Cross-origin redirects are followed (the loop re-validates every        // hop), but credentials minted for the original origin must not leak        // to the redirect target — same as fetch/curl behavior.        if (next.origin !== new URL(url).origin) {          currentInit = stripCredentialHeaders(currentInit);        }        current = next.toString();        continue;      }      return response;    }    return await underlying(current, { ...currentInit, redirect: "manual" });  }) as typeof globalThis.fetch; export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch =>  // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch  guardFetch(options.fetch ?? globalThis.fetch, options); // ---------------------------------------------------------------------------// Span header redaction.//// The HttpClient tracer records every request and response header as a span// attribute, masking only the names in `Headers.CurrentRedactedNames`// (default: authorization, cookie, set-cookie, x-api-key). That blocklist can// never be right for a hosted client whose whole job is calling arbitrary// upstream APIs: credentials ride in provider-specific headers// (x-goog-api-key, api-key, x-auth-token, ...), and any name missing from the// list ships a live secret to the trace backend verbatim. So the model is// inverted: every header is redacted unless its name is on the allowlist of// structurally safe, diagnostically useful headers. Enumerating known// secret-bearing names was rejected: new integrations add credential headers// faster than a blocklist can learn them, and one miss is a leaked customer// credential.//// Bound to the client itself (not a host telemetry layer): consumers capture// the client value at construction and execute requests on fibers whose// context a host layer never sees, so the reference must travel with every// request effect.// --------------------------------------------------------------------------- const SPAN_SAFE_HEADER_NAMES = [  "accept",  "accept-encoding",  "accept-language",  "age",  "cache-control",  "connection",  "content-encoding",  "content-length",  "content-type",  "date",  "etag",  "expires",  "host",  "if-modified-since",  "if-none-match",  "last-modified",  "location",  "mcp-protocol-version",  "mcp-session-id",  "origin",  "referer",  "retry-after",  "traceparent",  "tracestate",  "transfer-encoding",  "user-agent",  "vary",  "via",  "x-request-id",] as const; // Header names are lowercased at `Headers` construction, and `Headers.redact`// masks every name a RegExp entry matches. One negative lookahead turns the// allowlist into "redact everything else".export const spanRedactedHeaderNames: readonly (string | RegExp)[] = [  new RegExp(`^(?!(?:${SPAN_SAFE_HEADER_NAMES.join("|")})$)`),]; const withSpanHeaderRedaction = (client: HttpClient.HttpClient): HttpClient.HttpClient =>  HttpClient.transformResponse(client, (effect) =>    Effect.provideService(effect, HttpHeaders.CurrentRedactedNames, spanRedactedHeaderNames),  ); export const makeHostedHttpClientLayer = (  options: HostedHttpClientOptions = {},): Layer.Layer<HttpClient.HttpClient> =>  Layer.effect(HttpClient.HttpClient)(    Effect.map(Effect.service(HttpClient.HttpClient), withSpanHeaderRedaction),  ).pipe(    Layer.provide(FetchHttpClient.layer),    Layer.provide(      options.fetch        ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options))        : Layer.effect(            FetchHttpClient.Fetch,            Effect.map(Effect.service(FetchHttpClient.Fetch), (underlying) =>              guardFetch(underlying, options),            ),          ),    ),  );