[py]: Apply ClientConfig user_agent and extra_headers per connection - #17944
[py]: Apply ClientConfig user_agent and extra_headers per connection#17944navin772 wants to merge 6 commits into
user_agent and extra_headers per connection#17944Conversation
…Connection instances
PR Summary by Qodo[py] Prevent RemoteConnection user-agent/header leakage across instances
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTip of the day💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt |
|
@cgoldberg could you review this PR? |
AutomatedTester
left a comment
There was a problem hiding this comment.
Thanks for digging into this — the leak is real and the two-driver repro in the description is exactly right. But I don't think this closes it, I think it reverses its direction.
I checked the claims below by running trunk, this branch (83bd329) and a prototype side by side rather than by reading the diff, so the tables are measured rather than inferred.
The framing I'd suggest: RemoteConnection.extra_headers / user_agent want to be class-level templates, handled the way capabilities already are in this codebase — copy.deepcopy(caps) in _create_caps, DesiredCapabilities.CHROME.copy() in ChromiumOptions.default_capabilities. A template gets copied by every consumer at construction and is never written back to. Trunk writes back to the template — that's the leak you found. This PR stops writing back, but leaves the template as a live layer underneath every request. Copying at construction takes it out of the request path altogether.
The copy is missing in a second place too, and it isn't in this diff — ClientConfig.__init__ stores the caller's dicts by reference:
self.init_args_for_pool_manager = init_args_for_pool_manager or {}
self.extra_headers = extra_headersSo two connections handed the same ClientConfig share one extra_headers dict, and anything that mutates it in place reaches back into the user's own dict. Appium does precisely that: cls.extra_headers[HEADER_IDEMOTENCY_KEY] = str(uuid.uuid4()). dict(extra_headers) if extra_headers is not None else None plus dict(init_args_for_pool_manager or {}) covers it.
Two things I'd file separately rather than grow this PR:
ClientConfig.__init__hasproxy: Proxy | None = Proxy(raw={"proxyType": ProxyType.SYSTEM})— one instance, evaluated at import, shared by everyClientConfigever constructed._requestfollows a 3xxLocationto an arbitrary host and re-sendsextra_headersplusget_auth_header(). I confirmedAuthorization: Bearer SECRETis sent on to the redirect target, on both trunk and this branch.
| if self._client_config.user_agent: | ||
| headers["User-Agent"] = self._client_config.user_agent | ||
| if self._client_config.extra_headers: | ||
| headers.update(self._client_config.extra_headers) |
There was a problem hiding this comment.
Blocking — this is where the leak reverses direction rather than closing.
The write moved per-instance but the read didn't. get_remote_connection_headers is a @classmethod still reading cls.extra_headers / cls.user_agent, and these lines then layer the instance config on top of its output. Trunk had one slot with replace semantics (cfg.extra_headers or RemoteConnection.extra_headers); this is two layers with union semantics. A process-wide class-level header now reaches every connection that has its own config — which trunk specifically prevented:
RemoteConnection.extra_headers = {"Authorization": "Bearer GLOBAL-SECRET"}
b = RemoteConnection(client_config=ClientConfig(url_b, extra_headers={"X-Tenant": "A"}))
b._request("GET", url_b + "/status")| headers on the wire | |
|---|---|
| trunk | X-Tenant: A |
| this branch | X-Tenant: A, Authorization: Bearer GLOBAL-SECRET |
The description says class-level attributes "still work as process-wide defaults", but a default is what applies when nothing else is set — this makes them an unconditional floor.
Two further consequences of the same root cause, resolving after the hook instead of inside it:
1. get_remote_connection_headers() is public and no longer reports what goes on the wire. With ClientConfig(user_agent="my-ua/1", extra_headers={"X-Tenant": "A"}) the hook returns User-Agent: selenium/4.49.0… and no X-Tenant, while the request sends my-ua/1 and X-Tenant: A. On trunk the two agreed.
2. It breaks the downstream that the comment a few lines above names (#14694). appium-python-client's AppiumConnection sets user_agent = f'appium/{library_version()} ({RemoteConnection.user_agent})' and merges cls.extra_headers inside its override. Running that class verbatim against ClientConfig(user_agent="my-app/1.0", extra_headers={"X-Idempotency-Key": "user"}):
| User-Agent sent | X-Idempotency-Key | |
|---|---|---|
| trunk | appium/5.2.6 (selenium/4.49.0 (python mac)) |
Appium's uuid |
| this branch | my-app/1.0 |
user |
That's a public behaviour change in a PR labelled "Bug fix (backwards compatible)". Whether subclass class attributes should outrank ClientConfig is a fair question, but it should be a deliberate decision rather than a consequence of where the headers.update() landed.
Smallest fix — resolve once, with or (replace, matching trunk), into instance-owned state, and read it during header construction instead of patching over the result:
# in __init__, in place of the two removed lines
self.user_agent = self._client_config.user_agent or type(self).user_agent
self.extra_headers = dict(self._client_config.extra_headers or type(self).extra_headers or {}) or NoneThe obstacle is that get_remote_connection_headers is a @classmethod, so cls.* cannot see instance state — which I assume is why the merge ended up in _request. A small class-or-instance descriptor solves it, and usefully keeps Appium working unchanged: their override is a @classmethod, so its zero-arg super() is class-bound and still reads its own subclass attributes exactly as on trunk.
class _classorinstancemethod:
def __init__(self, func):
self.func = func
functools.update_wrapper(self, func)
def __get__(self, obj, cls=None):
return functools.partial(self.func, obj if obj is not None else cls)I prototyped that and re-ran every probe: the original two-driver leak stays fixed, the global-header leak above is gone, the hook matches the wire again, Appium's prefix survives, and RemoteConnection.get_remote_connection_headers(url) is still callable on the class. Adding keyword arguments to the classmethod instead is worse — it breaks Appium's override signature outright.
| RemoteConnection._client_config = self._client_config | ||
| RemoteConnection.extra_headers = self._client_config.extra_headers or RemoteConnection.extra_headers | ||
| RemoteConnection.user_agent = self._client_config.user_agent or RemoteConnection.user_agent | ||
| # user_agent and extra_headers are not mirrored onto the class: that leaked one |
There was a problem hiding this comment.
The comment overclaims — the three lines above it still mirror onto the class.
_timeout, _ca_certs and especially _client_config are still copied to the class, and _client_config carries username / password / token / auth_type as well as extra_headers / user_agent. So the description's "they outlived driver.quit()" is narrowed rather than fixed — the class still holds a live reference to the last-constructed connection's credentials after quit.
The deprecated class-level setters also still mutate whichever connection happened to be built last:
a = RemoteConnection(client_config=cfg_a) # timeout 111
b = RemoteConnection(client_config=cfg_b) # timeout 222
RemoteConnection.set_timeout(9) # -> cfg_b.timeout == 9, cfg_a untouchedConfirmed on both trunk and this branch. Same bug class, same root cause. Either drop those three as well (with a deprecation pointer, since #14694 is why they exist) or keep them and reword this comment — as written it reads as though the whole block had been cleaned up.
| def test_client_config_headers_do_not_leak_across_connections(monkeypatch): | ||
| """Per-connection user_agent/extra_headers must not be shared across RemoteConnection instances.""" | ||
| # Reset any class-level pollution left by other tests so the default is deterministic. | ||
| monkeypatch.setattr(RemoteConnection, "extra_headers", None) |
There was a problem hiding this comment.
This line nulls out the one interaction that is now broken.
Setting extra_headers to None and user_agent to a fixed default is precisely the configuration in which the layering problem cannot show up, so the regression is invisible to the suite and CI stays green.
The case worth adding: a class-level extra_headers and a per-connection ClientConfig(extra_headers=...) both set, asserting the class-level entry is not on the wire.
monkeypatch.setattr(RemoteConnection, "extra_headers", {"Authorization": "Bearer GLOBAL"})
conn = RemoteConnection(client_config=ClientConfig("http://localhost:4444", extra_headers={"X-Tenant": "A"}))
sent = _capture_sent_headers(conn)
conn._request("GET", "http://localhost:4444/status")
assert sent[0]["X-Tenant"] == "A"
assert "Authorization" not in sent[0]One other thing worth guarding: _capture_sent_headers only replaces self._conn, which exists only when keep_alive=True. A config with keep_alive=False falls through to _get_connection_manager() and would attempt a real request instead of failing loudly.
cgoldberg
left a comment
There was a problem hiding this comment.
erm.. yeah what he said ^^ :)
🔗 Related Issues
💥 What does this PR do?
RemoteConnection.__init__copied the connection'suser_agentandextra_headersonto the class, not the instance:Every
RemoteConnectionin the process then shared them, and they outliveddriver.quit(). A driver pointed at an authenticated Grid followed by a local driver meant the local driver sent the Grid'sAuthorizationheader:Now they're applied per-instance in
_request()fromself._client_config, so one connection's headers can't reach another. Class-levelRemoteConnection.extra_headers/user_agentstill work as process-wide defaults.🔧 Implementation Notes
🤖 AI assistance
💡 Additional Considerations
🔄 Types of changes