@copilotkit/runtime
Version:
<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />
1 lines • 14.2 kB
Source Map (JSON)
{"version":3,"file":"header-utils.cjs","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"}