{"version":3,"file":"header-utils.mjs","names":[],"sources":["../../../../src/v2/runtime/handlers/header-utils.ts"],"sourcesContent":["/**\n * Exact header names (lowercased) stripped from forwarding by default.\n *\n * These are infrastructure/proxy/platform artifacts that no legitimate agent\n * integration depends on receiving *forwarded from the inbound edge* — the\n * inbound request has already traversed a browser, CDN/edge, load balancer, and\n * hosting platform, each of which stamps its own `x-*` headers. Forwarding them\n * verbatim to an arbitrary configured agent URL leaks client topology and, in\n * the Copilot Cloud case, a platform credential (#5712).\n *\n * `x-amz-cf-id` and `x-copilotcloud-public-api-key` are also covered by the\n * `x-amz-` / `x-copilotcloud-` prefixes below; the exact entries are kept\n * intentionally as documentation anchors for the highest-severity headers\n * (notably the platform API key), not as drift/oversight.\n */\nexport const DEFAULT_DENY_HEADER_NAMES: ReadonlySet<string> = new Set([\n  // Hop-by-hop / proxy topology\n  \"x-forwarded-for\",\n  \"x-forwarded-proto\",\n  \"x-forwarded-host\",\n  \"x-forwarded-port\",\n  \"x-forwarded-server\",\n  \"x-real-ip\",\n  // Cloud / CDN tracing + infra\n  \"x-amzn-trace-id\",\n  \"x-amz-cf-id\",\n  \"x-cloud-trace-context\",\n  \"x-cache\",\n  \"x-served-by\",\n  \"x-request-id\",\n  // CopilotKit platform credentials/identifiers\n  \"x-copilotcloud-public-api-key\",\n]);\n\n/**\n * Header-name prefixes (lowercased) stripped from forwarding by default.\n *\n * Prefix matching covers the well-known platform/CDN families so a new member\n * of a family (e.g. a future `x-vercel-foo`) is denied without a constant edit.\n */\nexport const DEFAULT_DENY_HEADER_PREFIXES: readonly string[] = [\n  \"x-amz-\", // AWS\n  \"x-azure-\", // Azure Front Door\n  \"x-fastly-\", // Fastly\n  \"x-vercel-\", // Vercel\n  \"x-middleware-\", // Next.js\n  \"x-copilotcloud-\", // CopilotKit platform-internal\n];\n\n/**\n * Fully-resolved inbound-header forwarding policy read by the call sites.\n *\n * Distinct from the public `ForwardHeadersConfig` option an integrator passes:\n * the runtime resolves that option ONCE in its constructor into this shape\n * (lowercasing names/prefixes, defaulting `useDefaultDenylist`, building the\n * `allow` set) so the predicate stays branch-simple and the policy can never be\n * re-resolved divergently at a call site. See `resolveForwardHeadersPolicy`.\n */\nexport interface ResolvedForwardHeadersPolicy {\n  /** When true, the built-in infra/platform denylist is active. */\n  useDefaultDenylist: boolean;\n  /** Extra exact names to strip (lowercased). */\n  denyNames: ReadonlySet<string>;\n  /** Extra prefixes to strip (lowercased). */\n  denyPrefixes: readonly string[];\n  /**\n   * If set, allowlist mode: ONLY these (lowercased) names are candidates to\n   * forward — and `denyNames` / `denyPrefixes` still subtract from them.\n   */\n  allow?: ReadonlySet<string>;\n}\n\n/**\n * Public, integrator-facing config for inbound-header forwarding. Resolved into\n * a {@link ResolvedForwardHeadersPolicy} by {@link resolveForwardHeadersPolicy}.\n */\nexport interface ForwardHeadersConfig {\n  /** Strip the built-in infra/platform denylist. @default true */\n  useDefaultDenylist?: boolean;\n  /** Additional exact header names to strip (case-insensitive). */\n  deny?: string[];\n  /** Additional header-name prefixes to strip (case-insensitive). */\n  denyPrefixes?: string[];\n  /**\n   * If set (with at least one non-empty entry), switch to allowlist mode: ONLY\n   * these headers are candidates to forward, overriding the default `x-*` /\n   * `authorization` eligibility (case-insensitive). `deny` / `denyPrefixes`\n   * still apply and subtract from this set — a header listed in both `allow` and\n   * `deny` is NOT forwarded.\n   *\n   * Footgun: in allowlist mode the built-in DEFAULT denylist (and\n   * `useDefaultDenylist`) is BYPASSED — only your `allow` set, minus your own\n   * `deny` / `denyPrefixes`, is forwarded. Do NOT allow-list protected/platform\n   * headers (e.g. `x-copilotcloud-public-api-key`, `x-forwarded-*`) unless you\n   * truly intend to forward them, since the default protection does not apply\n   * here.\n   */\n  allow?: string[];\n}\n\n/**\n * Normalizes a public {@link ForwardHeadersConfig} (or `undefined`) into a\n * fully-resolved {@link ResolvedForwardHeadersPolicy}.\n *\n * - `useDefaultDenylist` defaults to `true` (the built-in denylist is active\n *   on upgrade); pass `false` to restore the previous wide-open behavior.\n * - `deny` / `denyPrefixes` extend (do not replace) the defaults.\n * - `allow` activates allowlist mode only when it has at least one non-empty\n *   entry after normalization.\n *\n * All names/prefixes are trimmed, lowercased, and stripped of empty/\n * whitespace-only entries before use. Trimming/lowercasing keeps matching a\n * plain set/prefix check against the lowercased inbound keys; dropping empties\n * is a safety guard: a stray `denyPrefixes: [\"\"]` would make `startsWith(\"\")`\n * true for every header (silently denying ALL forwarding). Because empties are\n * dropped BEFORE the allowlist-mode decision, an `allow: [\"\"]` / `allow: [\" \"]`\n * normalizes to an empty set and does NOT switch on allowlist mode — the runtime\n * stays in denylist mode. Allowlist mode activates only when `allow` has at\n * least one non-empty entry; these empty/whitespace-only entries are integrator\n * typos, not intent, so we filter them.\n */\nfunction normalizeHeaderEntries(entries: string[] | undefined): string[] {\n  return (entries ?? [])\n    .map((entry) => entry.trim().toLowerCase())\n    .filter((entry) => entry.length > 0);\n}\n\nexport function resolveForwardHeadersPolicy(\n  config: ForwardHeadersConfig | undefined,\n): ResolvedForwardHeadersPolicy {\n  const denyNames = new Set<string>(normalizeHeaderEntries(config?.deny));\n  const denyPrefixes = normalizeHeaderEntries(config?.denyPrefixes);\n  const allowEntries = normalizeHeaderEntries(config?.allow);\n  const allow =\n    allowEntries.length > 0 ? new Set<string>(allowEntries) : undefined;\n\n  return {\n    useDefaultDenylist: config?.useDefaultDenylist ?? true,\n    denyNames,\n    denyPrefixes,\n    allow,\n  };\n}\n\n/**\n * True iff the (already-lowercased) header name matches the integrator's OWN\n * `deny` / `denyPrefixes`. This is the authoritative subtractive check: it is\n * consulted in BOTH allowlist and denylist mode. It deliberately does NOT\n * include the built-in {@link DEFAULT_DENY_HEADER_NAMES} /\n * {@link DEFAULT_DENY_HEADER_PREFIXES} — an explicit `allow` opts the integrator\n * back into a default-denied header on purpose, so only their own `deny`\n * subtracts from an allowlist.\n */\nfunction matchesIntegratorDeny(\n  lower: string,\n  policy: ResolvedForwardHeadersPolicy,\n): boolean {\n  if (policy.denyNames.has(lower)) return true;\n  return policy.denyPrefixes.some((prefix) => lower.startsWith(prefix));\n}\n\n/**\n * Determines if a header should be forwarded under the given resolved policy.\n *\n * The integrator's `deny` / `denyPrefixes` ALWAYS strip, including in allowlist\n * mode: `allow` selects the candidate set, `deny` removes from it. A header the\n * integrator lists in BOTH `allow` and `deny` is NOT forwarded — deny is\n * authoritative so a security-motivated `deny` can never be silently defeated\n * by an overlapping `allow` (the footgun this hardens against).\n *\n * Modes:\n * - Allowlist (`policy.allow` set): forward iff the name is in `allow` AND is\n *   NOT matched by the integrator's `deny` / `denyPrefixes`. Nothing else\n *   forwards — not even the usual `authorization` / `x-*` eligibility.\n * - Denylist (default): base eligibility is `authorization` or any `x-*`, then\n *   the built-in denylist (when enabled) and the integrator's own\n *   names/prefixes strip from that set.\n *\n * Note: the built-in default denylist applies ONLY in denylist mode; an\n * explicit `allow` is treated as the integrator deliberately opting back into\n * those headers, so only their OWN `deny` subtracts in allowlist mode.\n */\nexport function shouldForwardHeader(\n  headerName: string,\n  policy: ResolvedForwardHeadersPolicy,\n): boolean {\n  const lower = headerName.toLowerCase();\n\n  // Allowlist mode: forward iff explicitly allowed AND not subtracted by the\n  // integrator's own deny/denyPrefixes. Nothing else forwards — not even the\n  // usual `authorization` / `x-*` eligibility.\n  if (policy.allow) {\n    return policy.allow.has(lower) && !matchesIntegratorDeny(lower, policy);\n  }\n\n  // Base eligibility (unchanged): authorization + any x-*.\n  const eligible = lower === \"authorization\" || lower.startsWith(\"x-\");\n  if (!eligible) return false;\n\n  // Built-in denylist (default-on): strip known infra/platform headers.\n  if (policy.useDefaultDenylist) {\n    if (DEFAULT_DENY_HEADER_NAMES.has(lower)) return false;\n    if (\n      DEFAULT_DENY_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))\n    ) {\n      return false;\n    }\n  }\n\n  // Integrator-supplied additions extend the denylist regardless of the default.\n  if (matchesIntegratorDeny(lower, policy)) return false;\n\n  return true;\n}\n\n/**\n * Extracts headers that should be forwarded from a Request object, applying the\n * resolved forwarding policy. Keys are normalized to the lowercased form the\n * `Headers` iterator yields.\n */\nexport function extractForwardableHeaders(\n  request: Request,\n  policy: ResolvedForwardHeadersPolicy,\n): Record<string, string> {\n  const forwardableHeaders: Record<string, string> = {};\n  request.headers.forEach((value, key) => {\n    if (shouldForwardHeader(key, policy)) {\n      forwardableHeaders[key] = value;\n    }\n  });\n  return forwardableHeaders;\n}\n\n/**\n * Merges forwardable inbound request headers onto the headers a server\n * explicitly configured on an agent, letting the SERVER-CONFIGURED headers WIN\n * on collision — a server-set service-to-service token (e.g. an IAM bearer)\n * must never be silently overridden by a browser/edge/platform-injected inbound\n * header (#5712).\n *\n * The collision check is case-insensitive: `extractForwardableHeaders`\n * normalizes inbound keys to lowercase (`authorization`) while the server\n * typically configures canonical casing (`Authorization`). A plain object\n * spread would treat those as distinct keys and emit BOTH — which downstream\n * (undici) comma-joins into a single invalid \"multiple JWTs\" value. So we drop\n * any forwarded header the agent already sets, matched case-insensitively, and\n * let non-colliding inbound headers pass through unchanged.\n *\n * The same comma-join hazard exists if the SERVER CONFIG ITSELF contains two\n * case-variants of one header (e.g. both `Authorization` and `authorization`\n * in `agent.headers`). A plain `{ ...serverHeaders }` spread would keep both,\n * so we additionally collapse server-self case-collisions to a SINGLE entry,\n * FIRST-OCCURRENCE WINS: the first key seen (in `Object.keys` order) keeps its\n * exact casing and value, and any later case-variant of that name is dropped.\n * Server-wins-over-inbound and case-insensitive inbound suppression are\n * otherwise unchanged.\n *\n * Breadth (which inbound headers are eligible to forward at all) is decided by\n * `policy` upstream in `extractForwardableHeaders` → `shouldForwardHeader`; the\n * merge never re-widens or re-narrows the set.\n */\nexport function mergeForwardableHeaders(\n  serverHeaders: Record<string, string> | undefined,\n  request: Request,\n  policy: ResolvedForwardHeadersPolicy,\n): Record<string, string> {\n  const base = serverHeaders ?? {};\n  const merged: Record<string, string> = {};\n  const serverHeaderNames = new Set<string>();\n  // Collapse server-self case-collisions: first occurrence wins, later\n  // case-variants of the same name are dropped.\n  for (const [name, value] of Object.entries(base)) {\n    const lower = name.toLowerCase();\n    if (serverHeaderNames.has(lower)) {\n      continue;\n    }\n    serverHeaderNames.add(lower);\n    merged[name] = value;\n  }\n  for (const [name, value] of Object.entries(\n    extractForwardableHeaders(request, policy),\n  )) {\n    if (!serverHeaderNames.has(name.toLowerCase())) {\n      merged[name] = value;\n    }\n  }\n  return merged;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAeA,MAAa,4BAAiD,IAAI,IAAI;CAEpE;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CAEA;CACD,CAAC;;;;;;;AAQF,MAAa,+BAAkD;CAC7D;CACA;CACA;CACA;CACA;CACA;CACD;;;;;;;;;;;;;;;;;;;;;;AA0ED,SAAS,uBAAuB,SAAyC;AACvE,SAAQ,WAAW,EAAE,EAClB,KAAK,UAAU,MAAM,MAAM,CAAC,aAAa,CAAC,CAC1C,QAAQ,UAAU,MAAM,SAAS,EAAE;;AAGxC,SAAgB,4BACd,QAC8B;CAC9B,MAAM,YAAY,IAAI,IAAY,uBAAuB,QAAQ,KAAK,CAAC;CACvE,MAAM,eAAe,uBAAuB,QAAQ,aAAa;CACjE,MAAM,eAAe,uBAAuB,QAAQ,MAAM;CAC1D,MAAM,QACJ,aAAa,SAAS,IAAI,IAAI,IAAY,aAAa,GAAG;AAE5D,QAAO;EACL,oBAAoB,QAAQ,sBAAsB;EAClD;EACA;EACA;EACD;;;;;;;;;;;AAYH,SAAS,sBACP,OACA,QACS;AACT,KAAI,OAAO,UAAU,IAAI,MAAM,CAAE,QAAO;AACxC,QAAO,OAAO,aAAa,MAAM,WAAW,MAAM,WAAW,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;AAwBvE,SAAgB,oBACd,YACA,QACS;CACT,MAAM,QAAQ,WAAW,aAAa;AAKtC,KAAI,OAAO,MACT,QAAO,OAAO,MAAM,IAAI,MAAM,IAAI,CAAC,sBAAsB,OAAO,OAAO;AAKzE,KAAI,EADa,UAAU,mBAAmB,MAAM,WAAW,KAAK,EACrD,QAAO;AAGtB,KAAI,OAAO,oBAAoB;AAC7B,MAAI,0BAA0B,IAAI,MAAM,CAAE,QAAO;AACjD,MACE,6BAA6B,MAAM,WAAW,MAAM,WAAW,OAAO,CAAC,CAEvE,QAAO;;AAKX,KAAI,sBAAsB,OAAO,OAAO,CAAE,QAAO;AAEjD,QAAO;;;;;;;AAQT,SAAgB,0BACd,SACA,QACwB;CACxB,MAAM,qBAA6C,EAAE;AACrD,SAAQ,QAAQ,SAAS,OAAO,QAAQ;AACtC,MAAI,oBAAoB,KAAK,OAAO,CAClC,oBAAmB,OAAO;GAE5B;AACF,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BT,SAAgB,wBACd,eACA,SACA,QACwB;CACxB,MAAM,OAAO,iBAAiB,EAAE;CAChC,MAAM,SAAiC,EAAE;CACzC,MAAM,oCAAoB,IAAI,KAAa;AAG3C,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,KAAK,EAAE;EAChD,MAAM,QAAQ,KAAK,aAAa;AAChC,MAAI,kBAAkB,IAAI,MAAM,CAC9B;AAEF,oBAAkB,IAAI,MAAM;AAC5B,SAAO,QAAQ;;AAEjB,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QACjC,0BAA0B,SAAS,OAAO,CAC3C,CACC,KAAI,CAAC,kBAAkB,IAAI,KAAK,aAAa,CAAC,CAC5C,QAAO,QAAQ;AAGnB,QAAO"}