@copilotkit/runtime 1.62.1 → 1.62.2-canary.1783457132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/package.cjs +1 -1
  2. package/dist/package.mjs +1 -1
  3. package/dist/v2/index.cjs +5 -1
  4. package/dist/v2/index.d.cts +2 -2
  5. package/dist/v2/index.d.mts +2 -2
  6. package/dist/v2/index.mjs +2 -2
  7. package/dist/v2/runtime/core/fetch-handler.cjs +27 -0
  8. package/dist/v2/runtime/core/fetch-handler.cjs.map +1 -1
  9. package/dist/v2/runtime/core/fetch-handler.d.cts.map +1 -1
  10. package/dist/v2/runtime/core/fetch-handler.d.mts.map +1 -1
  11. package/dist/v2/runtime/core/fetch-handler.mjs +27 -0
  12. package/dist/v2/runtime/core/fetch-handler.mjs.map +1 -1
  13. package/dist/v2/runtime/core/fetch-router.cjs +10 -0
  14. package/dist/v2/runtime/core/fetch-router.cjs.map +1 -1
  15. package/dist/v2/runtime/core/fetch-router.mjs +10 -0
  16. package/dist/v2/runtime/core/fetch-router.mjs.map +1 -1
  17. package/dist/v2/runtime/core/hooks.cjs.map +1 -1
  18. package/dist/v2/runtime/core/hooks.d.cts +7 -0
  19. package/dist/v2/runtime/core/hooks.d.cts.map +1 -1
  20. package/dist/v2/runtime/core/hooks.d.mts +7 -0
  21. package/dist/v2/runtime/core/hooks.d.mts.map +1 -1
  22. package/dist/v2/runtime/core/hooks.mjs.map +1 -1
  23. package/dist/v2/runtime/core/runtime.cjs +5 -0
  24. package/dist/v2/runtime/core/runtime.cjs.map +1 -1
  25. package/dist/v2/runtime/core/runtime.d.cts +21 -0
  26. package/dist/v2/runtime/core/runtime.d.cts.map +1 -1
  27. package/dist/v2/runtime/core/runtime.d.mts +21 -0
  28. package/dist/v2/runtime/core/runtime.d.mts.map +1 -1
  29. package/dist/v2/runtime/core/runtime.mjs +5 -0
  30. package/dist/v2/runtime/core/runtime.mjs.map +1 -1
  31. package/dist/v2/runtime/handlers/handle-connect.cjs +2 -1
  32. package/dist/v2/runtime/handlers/handle-connect.cjs.map +1 -1
  33. package/dist/v2/runtime/handlers/handle-connect.mjs +2 -1
  34. package/dist/v2/runtime/handlers/handle-connect.mjs.map +1 -1
  35. package/dist/v2/runtime/handlers/header-utils.cjs +169 -9
  36. package/dist/v2/runtime/handlers/header-utils.cjs.map +1 -1
  37. package/dist/v2/runtime/handlers/header-utils.d.cts +54 -0
  38. package/dist/v2/runtime/handlers/header-utils.d.cts.map +1 -0
  39. package/dist/v2/runtime/handlers/header-utils.d.mts +54 -0
  40. package/dist/v2/runtime/handlers/header-utils.d.mts.map +1 -0
  41. package/dist/v2/runtime/handlers/header-utils.mjs +168 -9
  42. package/dist/v2/runtime/handlers/header-utils.mjs.map +1 -1
  43. package/dist/v2/runtime/handlers/intelligence/memories.cjs +209 -0
  44. package/dist/v2/runtime/handlers/intelligence/memories.cjs.map +1 -0
  45. package/dist/v2/runtime/handlers/intelligence/memories.mjs +204 -0
  46. package/dist/v2/runtime/handlers/intelligence/memories.mjs.map +1 -0
  47. package/dist/v2/runtime/handlers/shared/agent-utils.cjs +18 -5
  48. package/dist/v2/runtime/handlers/shared/agent-utils.cjs.map +1 -1
  49. package/dist/v2/runtime/handlers/shared/agent-utils.mjs +18 -5
  50. package/dist/v2/runtime/handlers/shared/agent-utils.mjs.map +1 -1
  51. package/dist/v2/runtime/handlers/sse/connect.cjs +2 -2
  52. package/dist/v2/runtime/handlers/sse/connect.cjs.map +1 -1
  53. package/dist/v2/runtime/handlers/sse/connect.mjs +3 -3
  54. package/dist/v2/runtime/handlers/sse/connect.mjs.map +1 -1
  55. package/dist/v2/runtime/index.d.cts +1 -1
  56. package/dist/v2/runtime/index.d.mts +1 -1
  57. package/dist/v2/runtime/intelligence-platform/client.cjs +71 -2
  58. package/dist/v2/runtime/intelligence-platform/client.cjs.map +1 -1
  59. package/dist/v2/runtime/intelligence-platform/client.d.cts +112 -0
  60. package/dist/v2/runtime/intelligence-platform/client.d.cts.map +1 -1
  61. package/dist/v2/runtime/intelligence-platform/client.d.mts +112 -0
  62. package/dist/v2/runtime/intelligence-platform/client.d.mts.map +1 -1
  63. package/dist/v2/runtime/intelligence-platform/client.mjs +71 -2
  64. package/dist/v2/runtime/intelligence-platform/client.mjs.map +1 -1
  65. package/dist/v2/runtime/runner/in-memory.cjs +221 -37
  66. package/dist/v2/runtime/runner/in-memory.cjs.map +1 -1
  67. package/dist/v2/runtime/runner/in-memory.d.cts +164 -3
  68. package/dist/v2/runtime/runner/in-memory.d.cts.map +1 -1
  69. package/dist/v2/runtime/runner/in-memory.d.mts +164 -3
  70. package/dist/v2/runtime/runner/in-memory.d.mts.map +1 -1
  71. package/dist/v2/runtime/runner/in-memory.mjs +218 -38
  72. package/dist/v2/runtime/runner/in-memory.mjs.map +1 -1
  73. package/dist/v2/runtime/runner/index.d.cts +1 -1
  74. package/dist/v2/runtime/runner/index.d.mts +1 -1
  75. package/dist/v2/runtime/runner/index.mjs +1 -1
  76. package/package.json +2 -2
@@ -2,25 +2,185 @@ require("reflect-metadata");
2
2
 
3
3
  //#region src/v2/runtime/handlers/header-utils.ts
4
4
  /**
5
- * Determines if a header should be forwarded based on the allowlist.
6
- * Forwards: authorization header and all x-* custom headers.
5
+ * Exact header names (lowercased) stripped from forwarding by default.
6
+ *
7
+ * These are infrastructure/proxy/platform artifacts that no legitimate agent
8
+ * integration depends on receiving *forwarded from the inbound edge* — the
9
+ * inbound request has already traversed a browser, CDN/edge, load balancer, and
10
+ * hosting platform, each of which stamps its own `x-*` headers. Forwarding them
11
+ * verbatim to an arbitrary configured agent URL leaks client topology and, in
12
+ * the Copilot Cloud case, a platform credential (#5712).
13
+ *
14
+ * `x-amz-cf-id` and `x-copilotcloud-public-api-key` are also covered by the
15
+ * `x-amz-` / `x-copilotcloud-` prefixes below; the exact entries are kept
16
+ * intentionally as documentation anchors for the highest-severity headers
17
+ * (notably the platform API key), not as drift/oversight.
7
18
  */
8
- function shouldForwardHeader(headerName) {
19
+ const DEFAULT_DENY_HEADER_NAMES = new Set([
20
+ "x-forwarded-for",
21
+ "x-forwarded-proto",
22
+ "x-forwarded-host",
23
+ "x-forwarded-port",
24
+ "x-forwarded-server",
25
+ "x-real-ip",
26
+ "x-amzn-trace-id",
27
+ "x-amz-cf-id",
28
+ "x-cloud-trace-context",
29
+ "x-cache",
30
+ "x-served-by",
31
+ "x-request-id",
32
+ "x-copilotcloud-public-api-key"
33
+ ]);
34
+ /**
35
+ * Header-name prefixes (lowercased) stripped from forwarding by default.
36
+ *
37
+ * Prefix matching covers the well-known platform/CDN families so a new member
38
+ * of a family (e.g. a future `x-vercel-foo`) is denied without a constant edit.
39
+ */
40
+ const DEFAULT_DENY_HEADER_PREFIXES = [
41
+ "x-amz-",
42
+ "x-azure-",
43
+ "x-fastly-",
44
+ "x-vercel-",
45
+ "x-middleware-",
46
+ "x-copilotcloud-"
47
+ ];
48
+ /**
49
+ * Normalizes a public {@link ForwardHeadersConfig} (or `undefined`) into a
50
+ * fully-resolved {@link ResolvedForwardHeadersPolicy}.
51
+ *
52
+ * - `useDefaultDenylist` defaults to `true` (the built-in denylist is active
53
+ * on upgrade); pass `false` to restore the previous wide-open behavior.
54
+ * - `deny` / `denyPrefixes` extend (do not replace) the defaults.
55
+ * - `allow` activates allowlist mode only when it has at least one non-empty
56
+ * entry after normalization.
57
+ *
58
+ * All names/prefixes are trimmed, lowercased, and stripped of empty/
59
+ * whitespace-only entries before use. Trimming/lowercasing keeps matching a
60
+ * plain set/prefix check against the lowercased inbound keys; dropping empties
61
+ * is a safety guard: a stray `denyPrefixes: [""]` would make `startsWith("")`
62
+ * true for every header (silently denying ALL forwarding). Because empties are
63
+ * dropped BEFORE the allowlist-mode decision, an `allow: [""]` / `allow: [" "]`
64
+ * normalizes to an empty set and does NOT switch on allowlist mode — the runtime
65
+ * stays in denylist mode. Allowlist mode activates only when `allow` has at
66
+ * least one non-empty entry; these empty/whitespace-only entries are integrator
67
+ * typos, not intent, so we filter them.
68
+ */
69
+ function normalizeHeaderEntries(entries) {
70
+ return (entries ?? []).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
71
+ }
72
+ function resolveForwardHeadersPolicy(config) {
73
+ const denyNames = new Set(normalizeHeaderEntries(config?.deny));
74
+ const denyPrefixes = normalizeHeaderEntries(config?.denyPrefixes);
75
+ const allowEntries = normalizeHeaderEntries(config?.allow);
76
+ const allow = allowEntries.length > 0 ? new Set(allowEntries) : void 0;
77
+ return {
78
+ useDefaultDenylist: config?.useDefaultDenylist ?? true,
79
+ denyNames,
80
+ denyPrefixes,
81
+ allow
82
+ };
83
+ }
84
+ /**
85
+ * True iff the (already-lowercased) header name matches the integrator's OWN
86
+ * `deny` / `denyPrefixes`. This is the authoritative subtractive check: it is
87
+ * consulted in BOTH allowlist and denylist mode. It deliberately does NOT
88
+ * include the built-in {@link DEFAULT_DENY_HEADER_NAMES} /
89
+ * {@link DEFAULT_DENY_HEADER_PREFIXES} — an explicit `allow` opts the integrator
90
+ * back into a default-denied header on purpose, so only their own `deny`
91
+ * subtracts from an allowlist.
92
+ */
93
+ function matchesIntegratorDeny(lower, policy) {
94
+ if (policy.denyNames.has(lower)) return true;
95
+ return policy.denyPrefixes.some((prefix) => lower.startsWith(prefix));
96
+ }
97
+ /**
98
+ * Determines if a header should be forwarded under the given resolved policy.
99
+ *
100
+ * The integrator's `deny` / `denyPrefixes` ALWAYS strip, including in allowlist
101
+ * mode: `allow` selects the candidate set, `deny` removes from it. A header the
102
+ * integrator lists in BOTH `allow` and `deny` is NOT forwarded — deny is
103
+ * authoritative so a security-motivated `deny` can never be silently defeated
104
+ * by an overlapping `allow` (the footgun this hardens against).
105
+ *
106
+ * Modes:
107
+ * - Allowlist (`policy.allow` set): forward iff the name is in `allow` AND is
108
+ * NOT matched by the integrator's `deny` / `denyPrefixes`. Nothing else
109
+ * forwards — not even the usual `authorization` / `x-*` eligibility.
110
+ * - Denylist (default): base eligibility is `authorization` or any `x-*`, then
111
+ * the built-in denylist (when enabled) and the integrator's own
112
+ * names/prefixes strip from that set.
113
+ *
114
+ * Note: the built-in default denylist applies ONLY in denylist mode; an
115
+ * explicit `allow` is treated as the integrator deliberately opting back into
116
+ * those headers, so only their OWN `deny` subtracts in allowlist mode.
117
+ */
118
+ function shouldForwardHeader(headerName, policy) {
9
119
  const lower = headerName.toLowerCase();
10
- return lower === "authorization" || lower.startsWith("x-");
120
+ if (policy.allow) return policy.allow.has(lower) && !matchesIntegratorDeny(lower, policy);
121
+ if (!(lower === "authorization" || lower.startsWith("x-"))) return false;
122
+ if (policy.useDefaultDenylist) {
123
+ if (DEFAULT_DENY_HEADER_NAMES.has(lower)) return false;
124
+ if (DEFAULT_DENY_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))) return false;
125
+ }
126
+ if (matchesIntegratorDeny(lower, policy)) return false;
127
+ return true;
11
128
  }
12
129
  /**
13
- * Extracts headers that should be forwarded from a Request object.
14
- * Forwards only authorization and x-* headers.
130
+ * Extracts headers that should be forwarded from a Request object, applying the
131
+ * resolved forwarding policy. Keys are normalized to the lowercased form the
132
+ * `Headers` iterator yields.
15
133
  */
16
- function extractForwardableHeaders(request) {
134
+ function extractForwardableHeaders(request, policy) {
17
135
  const forwardableHeaders = {};
18
136
  request.headers.forEach((value, key) => {
19
- if (shouldForwardHeader(key)) forwardableHeaders[key] = value;
137
+ if (shouldForwardHeader(key, policy)) forwardableHeaders[key] = value;
20
138
  });
21
139
  return forwardableHeaders;
22
140
  }
141
+ /**
142
+ * Merges forwardable inbound request headers onto the headers a server
143
+ * explicitly configured on an agent, letting the SERVER-CONFIGURED headers WIN
144
+ * on collision — a server-set service-to-service token (e.g. an IAM bearer)
145
+ * must never be silently overridden by a browser/edge/platform-injected inbound
146
+ * header (#5712).
147
+ *
148
+ * The collision check is case-insensitive: `extractForwardableHeaders`
149
+ * normalizes inbound keys to lowercase (`authorization`) while the server
150
+ * typically configures canonical casing (`Authorization`). A plain object
151
+ * spread would treat those as distinct keys and emit BOTH — which downstream
152
+ * (undici) comma-joins into a single invalid "multiple JWTs" value. So we drop
153
+ * any forwarded header the agent already sets, matched case-insensitively, and
154
+ * let non-colliding inbound headers pass through unchanged.
155
+ *
156
+ * The same comma-join hazard exists if the SERVER CONFIG ITSELF contains two
157
+ * case-variants of one header (e.g. both `Authorization` and `authorization`
158
+ * in `agent.headers`). A plain `{ ...serverHeaders }` spread would keep both,
159
+ * so we additionally collapse server-self case-collisions to a SINGLE entry,
160
+ * FIRST-OCCURRENCE WINS: the first key seen (in `Object.keys` order) keeps its
161
+ * exact casing and value, and any later case-variant of that name is dropped.
162
+ * Server-wins-over-inbound and case-insensitive inbound suppression are
163
+ * otherwise unchanged.
164
+ *
165
+ * Breadth (which inbound headers are eligible to forward at all) is decided by
166
+ * `policy` upstream in `extractForwardableHeaders` → `shouldForwardHeader`; the
167
+ * merge never re-widens or re-narrows the set.
168
+ */
169
+ function mergeForwardableHeaders(serverHeaders, request, policy) {
170
+ const base = serverHeaders ?? {};
171
+ const merged = {};
172
+ const serverHeaderNames = /* @__PURE__ */ new Set();
173
+ for (const [name, value] of Object.entries(base)) {
174
+ const lower = name.toLowerCase();
175
+ if (serverHeaderNames.has(lower)) continue;
176
+ serverHeaderNames.add(lower);
177
+ merged[name] = value;
178
+ }
179
+ for (const [name, value] of Object.entries(extractForwardableHeaders(request, policy))) if (!serverHeaderNames.has(name.toLowerCase())) merged[name] = value;
180
+ return merged;
181
+ }
23
182
 
24
183
  //#endregion
25
- exports.extractForwardableHeaders = extractForwardableHeaders;
184
+ exports.mergeForwardableHeaders = mergeForwardableHeaders;
185
+ exports.resolveForwardHeadersPolicy = resolveForwardHeadersPolicy;
26
186
  //# sourceMappingURL=header-utils.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"header-utils.cjs","names":[],"sources":["../../../../src/v2/runtime/handlers/header-utils.ts"],"sourcesContent":["/**\n * Determines if a header should be forwarded based on the allowlist.\n * Forwards: authorization header and all x-* custom headers.\n */\nexport function shouldForwardHeader(headerName: string): boolean {\n const lower = headerName.toLowerCase();\n return lower === \"authorization\" || lower.startsWith(\"x-\");\n}\n\n/**\n * Extracts headers that should be forwarded from a Request object.\n * Forwards only authorization and x-* headers.\n */\nexport function extractForwardableHeaders(\n request: Request,\n): Record<string, string> {\n const forwardableHeaders: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n if (shouldForwardHeader(key)) {\n forwardableHeaders[key] = value;\n }\n });\n return forwardableHeaders;\n}\n"],"mappings":";;;;;;;AAIA,SAAgB,oBAAoB,YAA6B;CAC/D,MAAM,QAAQ,WAAW,aAAa;AACtC,QAAO,UAAU,mBAAmB,MAAM,WAAW,KAAK;;;;;;AAO5D,SAAgB,0BACd,SACwB;CACxB,MAAM,qBAA6C,EAAE;AACrD,SAAQ,QAAQ,SAAS,OAAO,QAAQ;AACtC,MAAI,oBAAoB,IAAI,CAC1B,oBAAmB,OAAO;GAE5B;AACF,QAAO"}
1
+ {"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"}
@@ -0,0 +1,54 @@
1
+
2
+ //#region src/v2/runtime/handlers/header-utils.d.ts
3
+ /**
4
+ * Fully-resolved inbound-header forwarding policy read by the call sites.
5
+ *
6
+ * Distinct from the public `ForwardHeadersConfig` option an integrator passes:
7
+ * the runtime resolves that option ONCE in its constructor into this shape
8
+ * (lowercasing names/prefixes, defaulting `useDefaultDenylist`, building the
9
+ * `allow` set) so the predicate stays branch-simple and the policy can never be
10
+ * re-resolved divergently at a call site. See `resolveForwardHeadersPolicy`.
11
+ */
12
+ interface ResolvedForwardHeadersPolicy {
13
+ /** When true, the built-in infra/platform denylist is active. */
14
+ useDefaultDenylist: boolean;
15
+ /** Extra exact names to strip (lowercased). */
16
+ denyNames: ReadonlySet<string>;
17
+ /** Extra prefixes to strip (lowercased). */
18
+ denyPrefixes: readonly string[];
19
+ /**
20
+ * If set, allowlist mode: ONLY these (lowercased) names are candidates to
21
+ * forward — and `denyNames` / `denyPrefixes` still subtract from them.
22
+ */
23
+ allow?: ReadonlySet<string>;
24
+ }
25
+ /**
26
+ * Public, integrator-facing config for inbound-header forwarding. Resolved into
27
+ * a {@link ResolvedForwardHeadersPolicy} by {@link resolveForwardHeadersPolicy}.
28
+ */
29
+ interface ForwardHeadersConfig {
30
+ /** Strip the built-in infra/platform denylist. @default true */
31
+ useDefaultDenylist?: boolean;
32
+ /** Additional exact header names to strip (case-insensitive). */
33
+ deny?: string[];
34
+ /** Additional header-name prefixes to strip (case-insensitive). */
35
+ denyPrefixes?: string[];
36
+ /**
37
+ * If set (with at least one non-empty entry), switch to allowlist mode: ONLY
38
+ * these headers are candidates to forward, overriding the default `x-*` /
39
+ * `authorization` eligibility (case-insensitive). `deny` / `denyPrefixes`
40
+ * still apply and subtract from this set — a header listed in both `allow` and
41
+ * `deny` is NOT forwarded.
42
+ *
43
+ * Footgun: in allowlist mode the built-in DEFAULT denylist (and
44
+ * `useDefaultDenylist`) is BYPASSED — only your `allow` set, minus your own
45
+ * `deny` / `denyPrefixes`, is forwarded. Do NOT allow-list protected/platform
46
+ * headers (e.g. `x-copilotcloud-public-api-key`, `x-forwarded-*`) unless you
47
+ * truly intend to forward them, since the default protection does not apply
48
+ * here.
49
+ */
50
+ allow?: string[];
51
+ }
52
+ //#endregion
53
+ export { ForwardHeadersConfig, ResolvedForwardHeadersPolicy };
54
+ //# sourceMappingURL=header-utils.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"header-utils.d.cts","names":[],"sources":["../../../../src/v2/runtime/handlers/header-utils.ts"],"mappings":";;;;;;;;;;;UA0DiB,4BAAA;;EAEf,kBAAA;;EAEA,SAAA,EAAW,WAAA;;EAEX,YAAA;;;;;EAKA,KAAA,GAAQ,WAAA;AAAA;;;;;UAOO,oBAAA;;EAEf,kBAAA;;EAEA,IAAA;;EAEA,YAAA;;;;;;;;;;;;;;;EAeA,KAAA;AAAA"}
@@ -0,0 +1,54 @@
1
+ import "reflect-metadata";
2
+ //#region src/v2/runtime/handlers/header-utils.d.ts
3
+ /**
4
+ * Fully-resolved inbound-header forwarding policy read by the call sites.
5
+ *
6
+ * Distinct from the public `ForwardHeadersConfig` option an integrator passes:
7
+ * the runtime resolves that option ONCE in its constructor into this shape
8
+ * (lowercasing names/prefixes, defaulting `useDefaultDenylist`, building the
9
+ * `allow` set) so the predicate stays branch-simple and the policy can never be
10
+ * re-resolved divergently at a call site. See `resolveForwardHeadersPolicy`.
11
+ */
12
+ interface ResolvedForwardHeadersPolicy {
13
+ /** When true, the built-in infra/platform denylist is active. */
14
+ useDefaultDenylist: boolean;
15
+ /** Extra exact names to strip (lowercased). */
16
+ denyNames: ReadonlySet<string>;
17
+ /** Extra prefixes to strip (lowercased). */
18
+ denyPrefixes: readonly string[];
19
+ /**
20
+ * If set, allowlist mode: ONLY these (lowercased) names are candidates to
21
+ * forward — and `denyNames` / `denyPrefixes` still subtract from them.
22
+ */
23
+ allow?: ReadonlySet<string>;
24
+ }
25
+ /**
26
+ * Public, integrator-facing config for inbound-header forwarding. Resolved into
27
+ * a {@link ResolvedForwardHeadersPolicy} by {@link resolveForwardHeadersPolicy}.
28
+ */
29
+ interface ForwardHeadersConfig {
30
+ /** Strip the built-in infra/platform denylist. @default true */
31
+ useDefaultDenylist?: boolean;
32
+ /** Additional exact header names to strip (case-insensitive). */
33
+ deny?: string[];
34
+ /** Additional header-name prefixes to strip (case-insensitive). */
35
+ denyPrefixes?: string[];
36
+ /**
37
+ * If set (with at least one non-empty entry), switch to allowlist mode: ONLY
38
+ * these headers are candidates to forward, overriding the default `x-*` /
39
+ * `authorization` eligibility (case-insensitive). `deny` / `denyPrefixes`
40
+ * still apply and subtract from this set — a header listed in both `allow` and
41
+ * `deny` is NOT forwarded.
42
+ *
43
+ * Footgun: in allowlist mode the built-in DEFAULT denylist (and
44
+ * `useDefaultDenylist`) is BYPASSED — only your `allow` set, minus your own
45
+ * `deny` / `denyPrefixes`, is forwarded. Do NOT allow-list protected/platform
46
+ * headers (e.g. `x-copilotcloud-public-api-key`, `x-forwarded-*`) unless you
47
+ * truly intend to forward them, since the default protection does not apply
48
+ * here.
49
+ */
50
+ allow?: string[];
51
+ }
52
+ //#endregion
53
+ export { ForwardHeadersConfig, ResolvedForwardHeadersPolicy };
54
+ //# sourceMappingURL=header-utils.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"header-utils.d.mts","names":[],"sources":["../../../../src/v2/runtime/handlers/header-utils.ts"],"mappings":";;;;;;;;;;;UA0DiB,4BAAA;;EAEf,kBAAA;;EAEA,SAAA,EAAW,WAAA;;EAEX,YAAA;;;;;EAKA,KAAA,GAAQ,WAAA;AAAA;;;;;UAOO,oBAAA;;EAEf,kBAAA;;EAEA,IAAA;;EAEA,YAAA;;;;;;;;;;;;;;;EAeA,KAAA;AAAA"}
@@ -1,25 +1,184 @@
1
1
  import "reflect-metadata";
2
2
  //#region src/v2/runtime/handlers/header-utils.ts
3
3
  /**
4
- * Determines if a header should be forwarded based on the allowlist.
5
- * Forwards: authorization header and all x-* custom headers.
4
+ * Exact header names (lowercased) stripped from forwarding by default.
5
+ *
6
+ * These are infrastructure/proxy/platform artifacts that no legitimate agent
7
+ * integration depends on receiving *forwarded from the inbound edge* — the
8
+ * inbound request has already traversed a browser, CDN/edge, load balancer, and
9
+ * hosting platform, each of which stamps its own `x-*` headers. Forwarding them
10
+ * verbatim to an arbitrary configured agent URL leaks client topology and, in
11
+ * the Copilot Cloud case, a platform credential (#5712).
12
+ *
13
+ * `x-amz-cf-id` and `x-copilotcloud-public-api-key` are also covered by the
14
+ * `x-amz-` / `x-copilotcloud-` prefixes below; the exact entries are kept
15
+ * intentionally as documentation anchors for the highest-severity headers
16
+ * (notably the platform API key), not as drift/oversight.
6
17
  */
7
- function shouldForwardHeader(headerName) {
18
+ const DEFAULT_DENY_HEADER_NAMES = new Set([
19
+ "x-forwarded-for",
20
+ "x-forwarded-proto",
21
+ "x-forwarded-host",
22
+ "x-forwarded-port",
23
+ "x-forwarded-server",
24
+ "x-real-ip",
25
+ "x-amzn-trace-id",
26
+ "x-amz-cf-id",
27
+ "x-cloud-trace-context",
28
+ "x-cache",
29
+ "x-served-by",
30
+ "x-request-id",
31
+ "x-copilotcloud-public-api-key"
32
+ ]);
33
+ /**
34
+ * Header-name prefixes (lowercased) stripped from forwarding by default.
35
+ *
36
+ * Prefix matching covers the well-known platform/CDN families so a new member
37
+ * of a family (e.g. a future `x-vercel-foo`) is denied without a constant edit.
38
+ */
39
+ const DEFAULT_DENY_HEADER_PREFIXES = [
40
+ "x-amz-",
41
+ "x-azure-",
42
+ "x-fastly-",
43
+ "x-vercel-",
44
+ "x-middleware-",
45
+ "x-copilotcloud-"
46
+ ];
47
+ /**
48
+ * Normalizes a public {@link ForwardHeadersConfig} (or `undefined`) into a
49
+ * fully-resolved {@link ResolvedForwardHeadersPolicy}.
50
+ *
51
+ * - `useDefaultDenylist` defaults to `true` (the built-in denylist is active
52
+ * on upgrade); pass `false` to restore the previous wide-open behavior.
53
+ * - `deny` / `denyPrefixes` extend (do not replace) the defaults.
54
+ * - `allow` activates allowlist mode only when it has at least one non-empty
55
+ * entry after normalization.
56
+ *
57
+ * All names/prefixes are trimmed, lowercased, and stripped of empty/
58
+ * whitespace-only entries before use. Trimming/lowercasing keeps matching a
59
+ * plain set/prefix check against the lowercased inbound keys; dropping empties
60
+ * is a safety guard: a stray `denyPrefixes: [""]` would make `startsWith("")`
61
+ * true for every header (silently denying ALL forwarding). Because empties are
62
+ * dropped BEFORE the allowlist-mode decision, an `allow: [""]` / `allow: [" "]`
63
+ * normalizes to an empty set and does NOT switch on allowlist mode — the runtime
64
+ * stays in denylist mode. Allowlist mode activates only when `allow` has at
65
+ * least one non-empty entry; these empty/whitespace-only entries are integrator
66
+ * typos, not intent, so we filter them.
67
+ */
68
+ function normalizeHeaderEntries(entries) {
69
+ return (entries ?? []).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
70
+ }
71
+ function resolveForwardHeadersPolicy(config) {
72
+ const denyNames = new Set(normalizeHeaderEntries(config?.deny));
73
+ const denyPrefixes = normalizeHeaderEntries(config?.denyPrefixes);
74
+ const allowEntries = normalizeHeaderEntries(config?.allow);
75
+ const allow = allowEntries.length > 0 ? new Set(allowEntries) : void 0;
76
+ return {
77
+ useDefaultDenylist: config?.useDefaultDenylist ?? true,
78
+ denyNames,
79
+ denyPrefixes,
80
+ allow
81
+ };
82
+ }
83
+ /**
84
+ * True iff the (already-lowercased) header name matches the integrator's OWN
85
+ * `deny` / `denyPrefixes`. This is the authoritative subtractive check: it is
86
+ * consulted in BOTH allowlist and denylist mode. It deliberately does NOT
87
+ * include the built-in {@link DEFAULT_DENY_HEADER_NAMES} /
88
+ * {@link DEFAULT_DENY_HEADER_PREFIXES} — an explicit `allow` opts the integrator
89
+ * back into a default-denied header on purpose, so only their own `deny`
90
+ * subtracts from an allowlist.
91
+ */
92
+ function matchesIntegratorDeny(lower, policy) {
93
+ if (policy.denyNames.has(lower)) return true;
94
+ return policy.denyPrefixes.some((prefix) => lower.startsWith(prefix));
95
+ }
96
+ /**
97
+ * Determines if a header should be forwarded under the given resolved policy.
98
+ *
99
+ * The integrator's `deny` / `denyPrefixes` ALWAYS strip, including in allowlist
100
+ * mode: `allow` selects the candidate set, `deny` removes from it. A header the
101
+ * integrator lists in BOTH `allow` and `deny` is NOT forwarded — deny is
102
+ * authoritative so a security-motivated `deny` can never be silently defeated
103
+ * by an overlapping `allow` (the footgun this hardens against).
104
+ *
105
+ * Modes:
106
+ * - Allowlist (`policy.allow` set): forward iff the name is in `allow` AND is
107
+ * NOT matched by the integrator's `deny` / `denyPrefixes`. Nothing else
108
+ * forwards — not even the usual `authorization` / `x-*` eligibility.
109
+ * - Denylist (default): base eligibility is `authorization` or any `x-*`, then
110
+ * the built-in denylist (when enabled) and the integrator's own
111
+ * names/prefixes strip from that set.
112
+ *
113
+ * Note: the built-in default denylist applies ONLY in denylist mode; an
114
+ * explicit `allow` is treated as the integrator deliberately opting back into
115
+ * those headers, so only their OWN `deny` subtracts in allowlist mode.
116
+ */
117
+ function shouldForwardHeader(headerName, policy) {
8
118
  const lower = headerName.toLowerCase();
9
- return lower === "authorization" || lower.startsWith("x-");
119
+ if (policy.allow) return policy.allow.has(lower) && !matchesIntegratorDeny(lower, policy);
120
+ if (!(lower === "authorization" || lower.startsWith("x-"))) return false;
121
+ if (policy.useDefaultDenylist) {
122
+ if (DEFAULT_DENY_HEADER_NAMES.has(lower)) return false;
123
+ if (DEFAULT_DENY_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))) return false;
124
+ }
125
+ if (matchesIntegratorDeny(lower, policy)) return false;
126
+ return true;
10
127
  }
11
128
  /**
12
- * Extracts headers that should be forwarded from a Request object.
13
- * Forwards only authorization and x-* headers.
129
+ * Extracts headers that should be forwarded from a Request object, applying the
130
+ * resolved forwarding policy. Keys are normalized to the lowercased form the
131
+ * `Headers` iterator yields.
14
132
  */
15
- function extractForwardableHeaders(request) {
133
+ function extractForwardableHeaders(request, policy) {
16
134
  const forwardableHeaders = {};
17
135
  request.headers.forEach((value, key) => {
18
- if (shouldForwardHeader(key)) forwardableHeaders[key] = value;
136
+ if (shouldForwardHeader(key, policy)) forwardableHeaders[key] = value;
19
137
  });
20
138
  return forwardableHeaders;
21
139
  }
140
+ /**
141
+ * Merges forwardable inbound request headers onto the headers a server
142
+ * explicitly configured on an agent, letting the SERVER-CONFIGURED headers WIN
143
+ * on collision — a server-set service-to-service token (e.g. an IAM bearer)
144
+ * must never be silently overridden by a browser/edge/platform-injected inbound
145
+ * header (#5712).
146
+ *
147
+ * The collision check is case-insensitive: `extractForwardableHeaders`
148
+ * normalizes inbound keys to lowercase (`authorization`) while the server
149
+ * typically configures canonical casing (`Authorization`). A plain object
150
+ * spread would treat those as distinct keys and emit BOTH — which downstream
151
+ * (undici) comma-joins into a single invalid "multiple JWTs" value. So we drop
152
+ * any forwarded header the agent already sets, matched case-insensitively, and
153
+ * let non-colliding inbound headers pass through unchanged.
154
+ *
155
+ * The same comma-join hazard exists if the SERVER CONFIG ITSELF contains two
156
+ * case-variants of one header (e.g. both `Authorization` and `authorization`
157
+ * in `agent.headers`). A plain `{ ...serverHeaders }` spread would keep both,
158
+ * so we additionally collapse server-self case-collisions to a SINGLE entry,
159
+ * FIRST-OCCURRENCE WINS: the first key seen (in `Object.keys` order) keeps its
160
+ * exact casing and value, and any later case-variant of that name is dropped.
161
+ * Server-wins-over-inbound and case-insensitive inbound suppression are
162
+ * otherwise unchanged.
163
+ *
164
+ * Breadth (which inbound headers are eligible to forward at all) is decided by
165
+ * `policy` upstream in `extractForwardableHeaders` → `shouldForwardHeader`; the
166
+ * merge never re-widens or re-narrows the set.
167
+ */
168
+ function mergeForwardableHeaders(serverHeaders, request, policy) {
169
+ const base = serverHeaders ?? {};
170
+ const merged = {};
171
+ const serverHeaderNames = /* @__PURE__ */ new Set();
172
+ for (const [name, value] of Object.entries(base)) {
173
+ const lower = name.toLowerCase();
174
+ if (serverHeaderNames.has(lower)) continue;
175
+ serverHeaderNames.add(lower);
176
+ merged[name] = value;
177
+ }
178
+ for (const [name, value] of Object.entries(extractForwardableHeaders(request, policy))) if (!serverHeaderNames.has(name.toLowerCase())) merged[name] = value;
179
+ return merged;
180
+ }
22
181
 
23
182
  //#endregion
24
- export { extractForwardableHeaders };
183
+ export { mergeForwardableHeaders, resolveForwardHeadersPolicy };
25
184
  //# sourceMappingURL=header-utils.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"header-utils.mjs","names":[],"sources":["../../../../src/v2/runtime/handlers/header-utils.ts"],"sourcesContent":["/**\n * Determines if a header should be forwarded based on the allowlist.\n * Forwards: authorization header and all x-* custom headers.\n */\nexport function shouldForwardHeader(headerName: string): boolean {\n const lower = headerName.toLowerCase();\n return lower === \"authorization\" || lower.startsWith(\"x-\");\n}\n\n/**\n * Extracts headers that should be forwarded from a Request object.\n * Forwards only authorization and x-* headers.\n */\nexport function extractForwardableHeaders(\n request: Request,\n): Record<string, string> {\n const forwardableHeaders: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n if (shouldForwardHeader(key)) {\n forwardableHeaders[key] = value;\n }\n });\n return forwardableHeaders;\n}\n"],"mappings":";;;;;;AAIA,SAAgB,oBAAoB,YAA6B;CAC/D,MAAM,QAAQ,WAAW,aAAa;AACtC,QAAO,UAAU,mBAAmB,MAAM,WAAW,KAAK;;;;;;AAO5D,SAAgB,0BACd,SACwB;CACxB,MAAM,qBAA6C,EAAE;AACrD,SAAQ,QAAQ,SAAS,OAAO,QAAQ;AACtC,MAAI,oBAAoB,IAAI,CAC1B,oBAAmB,OAAO;GAE5B;AACF,QAAO"}
1
+ {"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"}