UNPKG

@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;" />

186 lines (184 loc) 8.41 kB
require("reflect-metadata"); //#region src/v2/runtime/handlers/header-utils.ts /** * Exact header names (lowercased) stripped from forwarding by default. * * These are infrastructure/proxy/platform artifacts that no legitimate agent * integration depends on receiving *forwarded from the inbound edge* — the * inbound request has already traversed a browser, CDN/edge, load balancer, and * hosting platform, each of which stamps its own `x-*` headers. Forwarding them * verbatim to an arbitrary configured agent URL leaks client topology and, in * the Copilot Cloud case, a platform credential (#5712). * * `x-amz-cf-id` and `x-copilotcloud-public-api-key` are also covered by the * `x-amz-` / `x-copilotcloud-` prefixes below; the exact entries are kept * intentionally as documentation anchors for the highest-severity headers * (notably the platform API key), not as drift/oversight. */ const DEFAULT_DENY_HEADER_NAMES = new Set([ "x-forwarded-for", "x-forwarded-proto", "x-forwarded-host", "x-forwarded-port", "x-forwarded-server", "x-real-ip", "x-amzn-trace-id", "x-amz-cf-id", "x-cloud-trace-context", "x-cache", "x-served-by", "x-request-id", "x-copilotcloud-public-api-key" ]); /** * Header-name prefixes (lowercased) stripped from forwarding by default. * * Prefix matching covers the well-known platform/CDN families so a new member * of a family (e.g. a future `x-vercel-foo`) is denied without a constant edit. */ const DEFAULT_DENY_HEADER_PREFIXES = [ "x-amz-", "x-azure-", "x-fastly-", "x-vercel-", "x-middleware-", "x-copilotcloud-" ]; /** * Normalizes a public {@link ForwardHeadersConfig} (or `undefined`) into a * fully-resolved {@link ResolvedForwardHeadersPolicy}. * * - `useDefaultDenylist` defaults to `true` (the built-in denylist is active * on upgrade); pass `false` to restore the previous wide-open behavior. * - `deny` / `denyPrefixes` extend (do not replace) the defaults. * - `allow` activates allowlist mode only when it has at least one non-empty * entry after normalization. * * All names/prefixes are trimmed, lowercased, and stripped of empty/ * whitespace-only entries before use. Trimming/lowercasing keeps matching a * plain set/prefix check against the lowercased inbound keys; dropping empties * is a safety guard: a stray `denyPrefixes: [""]` would make `startsWith("")` * true for every header (silently denying ALL forwarding). Because empties are * dropped BEFORE the allowlist-mode decision, an `allow: [""]` / `allow: [" "]` * normalizes to an empty set and does NOT switch on allowlist mode — the runtime * stays in denylist mode. Allowlist mode activates only when `allow` has at * least one non-empty entry; these empty/whitespace-only entries are integrator * typos, not intent, so we filter them. */ function normalizeHeaderEntries(entries) { return (entries ?? []).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0); } function resolveForwardHeadersPolicy(config) { const denyNames = new Set(normalizeHeaderEntries(config?.deny)); const denyPrefixes = normalizeHeaderEntries(config?.denyPrefixes); const allowEntries = normalizeHeaderEntries(config?.allow); const allow = allowEntries.length > 0 ? new Set(allowEntries) : void 0; return { useDefaultDenylist: config?.useDefaultDenylist ?? true, denyNames, denyPrefixes, allow }; } /** * True iff the (already-lowercased) header name matches the integrator's OWN * `deny` / `denyPrefixes`. This is the authoritative subtractive check: it is * consulted in BOTH allowlist and denylist mode. It deliberately does NOT * include the built-in {@link DEFAULT_DENY_HEADER_NAMES} / * {@link DEFAULT_DENY_HEADER_PREFIXES} — an explicit `allow` opts the integrator * back into a default-denied header on purpose, so only their own `deny` * subtracts from an allowlist. */ function matchesIntegratorDeny(lower, policy) { if (policy.denyNames.has(lower)) return true; return policy.denyPrefixes.some((prefix) => lower.startsWith(prefix)); } /** * Determines if a header should be forwarded under the given resolved policy. * * The integrator's `deny` / `denyPrefixes` ALWAYS strip, including in allowlist * mode: `allow` selects the candidate set, `deny` removes from it. A header the * integrator lists in BOTH `allow` and `deny` is NOT forwarded — deny is * authoritative so a security-motivated `deny` can never be silently defeated * by an overlapping `allow` (the footgun this hardens against). * * Modes: * - Allowlist (`policy.allow` set): forward iff the name is in `allow` AND is * NOT matched by the integrator's `deny` / `denyPrefixes`. Nothing else * forwards — not even the usual `authorization` / `x-*` eligibility. * - Denylist (default): base eligibility is `authorization` or any `x-*`, then * the built-in denylist (when enabled) and the integrator's own * names/prefixes strip from that set. * * Note: the built-in default denylist applies ONLY in denylist mode; an * explicit `allow` is treated as the integrator deliberately opting back into * those headers, so only their OWN `deny` subtracts in allowlist mode. */ function shouldForwardHeader(headerName, policy) { const lower = headerName.toLowerCase(); if (policy.allow) return policy.allow.has(lower) && !matchesIntegratorDeny(lower, policy); if (!(lower === "authorization" || lower.startsWith("x-"))) return false; if (policy.useDefaultDenylist) { if (DEFAULT_DENY_HEADER_NAMES.has(lower)) return false; if (DEFAULT_DENY_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))) return false; } if (matchesIntegratorDeny(lower, policy)) return false; return true; } /** * Extracts headers that should be forwarded from a Request object, applying the * resolved forwarding policy. Keys are normalized to the lowercased form the * `Headers` iterator yields. */ function extractForwardableHeaders(request, policy) { const forwardableHeaders = {}; request.headers.forEach((value, key) => { if (shouldForwardHeader(key, policy)) forwardableHeaders[key] = value; }); return forwardableHeaders; } /** * Merges forwardable inbound request headers onto the headers a server * explicitly configured on an agent, letting the SERVER-CONFIGURED headers WIN * on collision — a server-set service-to-service token (e.g. an IAM bearer) * must never be silently overridden by a browser/edge/platform-injected inbound * header (#5712). * * The collision check is case-insensitive: `extractForwardableHeaders` * normalizes inbound keys to lowercase (`authorization`) while the server * typically configures canonical casing (`Authorization`). A plain object * spread would treat those as distinct keys and emit BOTH — which downstream * (undici) comma-joins into a single invalid "multiple JWTs" value. So we drop * any forwarded header the agent already sets, matched case-insensitively, and * let non-colliding inbound headers pass through unchanged. * * The same comma-join hazard exists if the SERVER CONFIG ITSELF contains two * case-variants of one header (e.g. both `Authorization` and `authorization` * in `agent.headers`). A plain `{ ...serverHeaders }` spread would keep both, * so we additionally collapse server-self case-collisions to a SINGLE entry, * FIRST-OCCURRENCE WINS: the first key seen (in `Object.keys` order) keeps its * exact casing and value, and any later case-variant of that name is dropped. * Server-wins-over-inbound and case-insensitive inbound suppression are * otherwise unchanged. * * Breadth (which inbound headers are eligible to forward at all) is decided by * `policy` upstream in `extractForwardableHeaders` → `shouldForwardHeader`; the * merge never re-widens or re-narrows the set. */ function mergeForwardableHeaders(serverHeaders, request, policy) { const base = serverHeaders ?? {}; const merged = {}; const serverHeaderNames = /* @__PURE__ */ new Set(); for (const [name, value] of Object.entries(base)) { const lower = name.toLowerCase(); if (serverHeaderNames.has(lower)) continue; serverHeaderNames.add(lower); merged[name] = value; } for (const [name, value] of Object.entries(extractForwardableHeaders(request, policy))) if (!serverHeaderNames.has(name.toLowerCase())) merged[name] = value; return merged; } //#endregion exports.mergeForwardableHeaders = mergeForwardableHeaders; exports.resolveForwardHeadersPolicy = resolveForwardHeadersPolicy; //# sourceMappingURL=header-utils.cjs.map