{"version":3,"file":"outbound-policy.mjs","names":[],"sources":["../../../../../../../ai/src/security/outbound-policy.ts"],"sourcesContent":["import { lookup } from \"node:dns/promises\";\nimport { isIP } from \"node:net\";\nimport { OutboundPolicyError } from \"../errors\";\nimport { isPrivateOrReservedIp } from \"./private-ip\";\nimport type {\n  OutboundPolicy,\n  ResolvedOutboundPolicy,\n} from \"./outbound-policy.type\";\n\n/** 5 MiB — default cap on an outbound response body. */\nconst DEFAULT_MAX_BYTES = 5 * 1024 * 1024;\n/** 10s — default per-request timeout. */\nconst DEFAULT_TIMEOUT_MS = 10_000;\n/** Default cap on the number of policy-validated redirect hops. */\nconst DEFAULT_MAX_REDIRECTS = 5;\n\n/** 3xx statuses whose `Location` a follow re-issues. */\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);\n\n/** Credential headers that must not survive a cross-origin redirect. */\nconst CROSS_ORIGIN_STRIP_HEADERS = [\n  \"authorization\",\n  \"cookie\",\n  \"proxy-authorization\",\n];\n\n/**\n * Fill an {@link OutboundPolicy} with strict defaults: https-only,\n * private-IP deny on, 10s timeout, 5 MiB cap, global `fetch`. Idempotent\n * — resolving an already-resolved policy yields the same shape.\n */\nexport function resolveOutboundPolicy(\n  policy: OutboundPolicy = {},\n): ResolvedOutboundPolicy {\n  return {\n    allowedSchemes: policy.allowedSchemes ?? [\"https\"],\n    hostAllowlist: policy.hostAllowlist,\n    denyPrivateIPsAfterDNS: policy.denyPrivateIPsAfterDNS ?? true,\n    maxBytes: policy.maxBytes ?? DEFAULT_MAX_BYTES,\n    timeoutMs: policy.timeoutMs ?? DEFAULT_TIMEOUT_MS,\n    maxRedirects: policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS,\n    signal: policy.signal,\n    fetch: policy.fetch ?? globalThis.fetch,\n  };\n}\n\n/** Strip the `[ ]` IPv6 brackets `URL.hostname` keeps. */\nfunction stripBrackets(host: string): string {\n  return host.startsWith(\"[\") && host.endsWith(\"]\") ? host.slice(1, -1) : host;\n}\n\n/** Whether `host` equals or is a subdomain of any allowlist entry. */\nfunction hostAllowed(host: string, allowlist: string[]): boolean {\n  const lower = host.toLowerCase();\n  return allowlist.some(entry => {\n    const e = entry.toLowerCase();\n    return lower === e || lower.endsWith(`.${e}`);\n  });\n}\n\n/**\n * Validate a URL against the policy BEFORE any network call: scheme\n * allowlist, host allowlist, and (when enabled) a DNS resolution that\n * rejects private / loopback / link-local / metadata addresses — the SSRF\n * guard. Returns the parsed `URL` on success; throws\n * {@link OutboundPolicyError} otherwise.\n */\nexport async function assertUrlAllowed(\n  rawUrl: string,\n  policy: ResolvedOutboundPolicy,\n): Promise<URL> {\n  let url: URL;\n  try {\n    url = new URL(rawUrl);\n  } catch {\n    throw new OutboundPolicyError(`outbound request blocked — invalid URL: ${rawUrl}`, {\n      context: { url: rawUrl },\n    });\n  }\n\n  const scheme = url.protocol.replace(/:$/, \"\").toLowerCase();\n  if (!policy.allowedSchemes.some(s => s.toLowerCase() === scheme)) {\n    throw new OutboundPolicyError(\n      `outbound request blocked — scheme \"${scheme}\" is not allowed (allowed: ${policy.allowedSchemes.join(\", \")})`,\n      { context: { url: rawUrl, scheme } },\n    );\n  }\n\n  const host = stripBrackets(url.hostname);\n\n  if (policy.hostAllowlist && !hostAllowed(host, policy.hostAllowlist)) {\n    throw new OutboundPolicyError(\n      `outbound request blocked — host \"${host}\" is not in the allowlist`,\n      { context: { url: rawUrl, host } },\n    );\n  }\n\n  if (policy.denyPrivateIPsAfterDNS) {\n    await assertHostNotPrivate(host, rawUrl);\n  }\n\n  return url;\n}\n\n/**\n * Reject when `host` is — or resolves to — a private / reserved address.\n * IP literals are checked directly; hostnames are resolved via DNS and\n * every returned address is checked (a public name pointing inward is\n * caught). A resolution failure fails closed.\n */\nasync function assertHostNotPrivate(host: string, rawUrl: string): Promise<void> {\n  if (isIP(host) !== 0) {\n    if (isPrivateOrReservedIp(host)) {\n      throw new OutboundPolicyError(\n        `outbound request blocked — \"${host}\" is a private/reserved address`,\n        { context: { url: rawUrl, address: host } },\n      );\n    }\n    return;\n  }\n\n  let addresses: Array<{ address: string }>;\n  try {\n    addresses = await lookup(host, { all: true });\n  } catch (cause) {\n    throw new OutboundPolicyError(\n      `outbound request blocked — could not resolve host \"${host}\" to verify it is public`,\n      { cause, context: { url: rawUrl, host } },\n    );\n  }\n\n  for (const { address } of addresses) {\n    if (isPrivateOrReservedIp(address)) {\n      throw new OutboundPolicyError(\n        `outbound request blocked — host \"${host}\" resolves to a private/reserved address (${address})`,\n        { context: { url: rawUrl, host, address } },\n      );\n    }\n  }\n}\n\n/** Merge the internal timeout signal with an optional caller signal. */\nfunction mergeSignals(\n  timeout: AbortSignal,\n  external?: AbortSignal,\n): AbortSignal {\n  if (!external) return timeout;\n\n  const controller = new AbortController();\n  const abort = (from: AbortSignal) => controller.abort(from.reason);\n\n  if (timeout.aborted) abort(timeout);\n  else timeout.addEventListener(\"abort\", () => abort(timeout), { once: true });\n\n  if (external.aborted) abort(external);\n  else external.addEventListener(\"abort\", () => abort(external), { once: true });\n\n  return controller.signal;\n}\n\n/** Flatten a headers init into a mutable lower-cased-key record. */\nfunction headersToRecord(\n  headersInit?: RequestInit[\"headers\"],\n): Record<string, string> {\n  const record: Record<string, string> = {};\n  new Headers(headersInit).forEach((value, key) => {\n    record[key] = value;\n  });\n  return record;\n}\n\n/**\n * Policy-guarded `fetch`: validates the URL ({@link assertUrlAllowed}),\n * then performs the request with the policy's timeout and (optional)\n * caller signal merged. Returns the raw `Response` — read its body via\n * {@link readTextCapped} to enforce `maxBytes`. Throws\n * {@link OutboundPolicyError} on a policy violation or timeout.\n *\n * Redirects are NEVER delegated to the platform: every hop is issued\n * with `redirect: \"manual\"` and its `Location` is re-run through\n * {@link assertUrlAllowed} before being followed (capped at\n * `maxRedirects`), so a 3xx from an allowed host cannot smuggle the\n * request to a private / metadata / off-allowlist target. Credential\n * headers are stripped when a hop crosses an origin boundary. Pass\n * `init.redirect: \"manual\"` to receive the raw 3xx, or `\"error\"` to\n * reject on any redirect.\n */\nexport async function guardedFetch(\n  rawUrl: string,\n  policyInput: OutboundPolicy,\n  init?: RequestInit,\n): Promise<Response> {\n  const policy = resolveOutboundPolicy(policyInput);\n  let url = await assertUrlAllowed(rawUrl, policy);\n\n  const timeoutController = new AbortController();\n  const timer = setTimeout(() => {\n    timeoutController.abort(\n      new OutboundPolicyError(\n        `outbound request timed out after ${policy.timeoutMs}ms`,\n        { context: { url: rawUrl, timeoutMs: policy.timeoutMs } },\n      ),\n    );\n  }, policy.timeoutMs);\n\n  const signal = mergeSignals(timeoutController.signal, policy.signal);\n  const redirectMode = init?.redirect ?? \"follow\";\n  const headers = headersToRecord(init?.headers);\n  let method = init?.method ?? \"GET\";\n  let body = init?.body ?? undefined;\n\n  try {\n    for (let hop = 0; ; hop++) {\n      const response = await policy.fetch(url, {\n        ...init,\n        method,\n        headers: { ...headers },\n        body,\n        redirect: \"manual\",\n        signal,\n      });\n\n      const location = response.headers.get(\"location\");\n      if (!REDIRECT_STATUSES.has(response.status) || location === null) {\n        return response;\n      }\n\n      if (redirectMode === \"manual\") {\n        return response;\n      }\n\n      if (redirectMode === \"error\") {\n        throw new OutboundPolicyError(\n          `outbound request blocked — redirect received with redirect: \"error\" (${response.status} → ${location})`,\n          { context: { url: url.toString(), location, status: response.status } },\n        );\n      }\n\n      if (hop >= policy.maxRedirects) {\n        throw new OutboundPolicyError(\n          `outbound request blocked — more than ${policy.maxRedirects} redirects`,\n          { context: { url: rawUrl, maxRedirects: policy.maxRedirects } },\n        );\n      }\n\n      let target: URL;\n      try {\n        target = new URL(location, url);\n      } catch {\n        throw new OutboundPolicyError(\n          `outbound request blocked — invalid redirect Location: ${location}`,\n          { context: { url: url.toString(), location } },\n        );\n      }\n\n      // The redirect target gets the SAME scheme / allowlist / private-IP\n      // validation as the original URL.\n      const next = await assertUrlAllowed(target.toString(), policy);\n\n      // Discard the interim body so the connection can be reused.\n      if (response.body) {\n        await response.body.cancel().catch(() => undefined);\n      }\n\n      if (next.origin !== url.origin) {\n        for (const name of CROSS_ORIGIN_STRIP_HEADERS) {\n          delete headers[name];\n        }\n      }\n\n      // 303 — and the legacy 301/302-on-a-non-GET convention — re-issue\n      // as a bodyless GET, matching platform follow semantics.\n      if (\n        response.status === 303 ||\n        ((response.status === 301 || response.status === 302) &&\n          method !== \"GET\" &&\n          method !== \"HEAD\")\n      ) {\n        method = \"GET\";\n        body = undefined;\n      }\n\n      url = next;\n    }\n  } finally {\n    clearTimeout(timer);\n  }\n}\n\n/**\n * Read a response body as UTF-8 text with a hard byte cap. A declared\n * `content-length` over the cap fails fast; otherwise the stream is read\n * chunk-by-chunk and aborted the moment the running total exceeds\n * `maxBytes`. Throws {@link OutboundPolicyError} on overflow.\n */\nexport async function readTextCapped(\n  response: Response,\n  maxBytes: number,\n): Promise<string> {\n  const declared = Number(response.headers.get(\"content-length\"));\n  if (Number.isFinite(declared) && declared > maxBytes) {\n    throw new OutboundPolicyError(\n      `outbound response body too large — declared ${declared} bytes exceeds the ${maxBytes}-byte cap`,\n      { context: { declared, maxBytes } },\n    );\n  }\n\n  if (!response.body) {\n    const text = await response.text();\n    if (Buffer.byteLength(text) > maxBytes) {\n      throw new OutboundPolicyError(\n        `outbound response body exceeded the ${maxBytes}-byte cap`,\n        { context: { maxBytes } },\n      );\n    }\n    return text;\n  }\n\n  const reader = response.body.getReader();\n  const chunks: Uint8Array[] = [];\n  let total = 0;\n\n  for (;;) {\n    const { done, value } = await reader.read();\n    if (done) break;\n    if (!value) continue;\n\n    total += value.byteLength;\n    if (total > maxBytes) {\n      await reader.cancel();\n      throw new OutboundPolicyError(\n        `outbound response body exceeded the ${maxBytes}-byte cap`,\n        { context: { maxBytes } },\n      );\n    }\n    chunks.push(value);\n  }\n\n  return Buffer.concat(chunks).toString(\"utf8\");\n}\n\n/**\n * Convenience: {@link guardedFetch} + {@link readTextCapped}. Returns the\n * response status alongside the (capped) body text so callers can shape\n * their own not-OK error. The body is only read when the response is OK.\n */\nexport async function fetchTextWithPolicy(\n  rawUrl: string,\n  policyInput: OutboundPolicy,\n  init?: RequestInit,\n): Promise<{ ok: boolean; status: number; statusText: string; text: string }> {\n  const policy = resolveOutboundPolicy(policyInput);\n  const response = await guardedFetch(rawUrl, policy, init);\n\n  return {\n    ok: response.ok,\n    status: response.status,\n    statusText: response.statusText,\n    text: response.ok ? await readTextCapped(response, policy.maxBytes) : \"\",\n  };\n}\n"],"mappings":"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"}