[py]: Apply ClientConfig user_agent and extra_headers per connection - #17944
Conversation
…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.
cgoldberg
left a comment
There was a problem hiding this comment.
erm.. yeah what he said ^^ :)
|
Thanks — this addresses all three findings from my earlier review. I re-ran the same probes
I checked the Appium row against the real Test suite: 94/94 pass on this head. Of the nine added/rewritten tests, four fail against Six small things below. None of them are blocking. 1.
|
| trunk | this head | |
|---|---|---|
type(...) |
method |
partial |
inspect.ismethod(...) |
True |
False |
__func__ / __self__ |
present | absent |
__name__ |
get_remote_connection_headers |
absent |
__doc__ |
"Get headers for remote request." |
"partial(func, *args, **keywords) - new function with partial application..." |
The __doc__ one is user-visible: help(RemoteConnection.get_remote_connection_headers) now
prints functools.partial's docstring instead of the method's. functools.update_wrapper on
the descriptor doesn't help, because the wrapper lives on the descriptor and the partial is
what callers actually receive.
One-line fix — types.MethodType is what classmethod itself hands back, so all five rows
return to the trunk column:
+import types
@@
def __get__(self, obj, cls=None):
- return functools.partial(self.func, obj if obj is not None else cls)
+ return types.MethodType(self.func, obj if obj is not None else cls)I ran this variant: every behaviour the new tests assert still holds, including the
class-bound super() path for the Appium-shaped subclass, and inspect.signature stays
(parsed_url, keep_alive=False).
2. Class-level headers set after a connection exists no longer reach it
__init__ now always writes self.extra_headers / self.user_agent, so an instance
attribute exists from construction and shadows the class attribute for that connection's
lifetime:
conn = RemoteConnection(client_config=ClientConfig(url))
RemoteConnection.extra_headers = {"Foo": "bar"}
conn._request("GET", url + "/status") # trunk: sends Foo. this head: does not.Set-before-construct still works, which is the documented pattern, so this may well be fine —
but it silently breaks refreshing a class-level token mid-session. Worth either a deliberate
note in the changelog/docstring or a test pinning whichever semantics you want. Right now
nothing covers it.
3. The ClientConfig copy has a second door
__init__ copies, but assignment through _ClientConfigDescriptor does not, so the invariant
the two new tests assert only holds via the constructor:
caller = {"X-Api-Key": "secret"}
cfg = ClientConfig(url)
cfg.extra_headers = caller
assert cfg.extra_headers is not caller # fails, on trunk and on this headNot a regression, and arguably out of scope — but if the goal is "caller-owned dicts can't be
aliased in", the setter is the other way in.
4. Stale comment from the previous approach
py/test/unit/selenium/webdriver/remote/remote_connection_tests.py:475-477 still says
extra_headers "are applied per-connection in _request". They're now resolved in __init__
and read through the descriptor; _request no longer touches them.
5. Needs a rebase — one trivial conflict, and the description now describes the old approach
The branch is CONFLICTING against trunk. Both source files auto-merge; the only conflict is
in client_config_tests.py, where this PR's new tests and trunk's
test_default_proxy_is_not_shared_between_instances (56e5c616d8) were added at the same
spot. Keeping both blocks is the whole resolution — I merged origin/trunk locally that way
and the module is 95/95 green, with trunk's proxy is None change and this PR's
init_args_for_pool_manager copy sitting happily in the same __init__.
Two things worth knowing about the rebase:
- Trunk also enabled
disallow_untyped_decoratorsinpy/pyproject.toml(34c744284a), which
landed after this branch's base. I checked whether the new_classorinstancemethoddecorator
trips it: it doesn't — mypy reports zero errors inremote_connection.pyand
client_config.pyunder the stricter config (the onlyuntyped-decoratorhits are the
pre-existingwebdriver.pyones that34c744284aalready fixed). So the green mypy check
here holds after the rebase. - The PR description still describes the previous implementation — "applied per-instance in
_request()" and "class-levelRemoteConnection.extra_headers/user_agentstill work as
process-wide defaults". Neither is true of this head any more: they're resolved once in
__init__, and class-level values are now genuine defaults rather than a floor merged under
every request. Worth rewriting before merge, since the description is what ends up in the
squashed commit message.
6. Scope note on the __init__ comment
The new comment is accurate and correctly scoped to headers now. The three lines directly
above it still mirror onto the class, though:
RemoteConnection._timeout = self._client_config.timeout
RemoteConnection._ca_certs = self._client_config.ca_certs
RemoteConnection._client_config = self._client_configSo RemoteConnection._client_config.password still returns the last-constructed connection's
credentials after quit(), and RemoteConnection.set_timeout(9) still mutates the
last-built config rather than the class. Both unchanged from trunk and reasonable to leave
here — but it does mean the description's "they outlived driver.quit()" only holds for the
header half now, which folds into the rewrite in (5). A follow-up issue for the
_client_config mirroring would be worth filing.
One separate issue, pre-existing
_request follows a 3xx Location to an arbitrary host and re-sends extra_headers plus
get_auth_header(), so Authorization can reach a redirect target. Unchanged on both trees
and out of scope here, but same "who is allowed to see this header" question.
(The other one I mentioned last time — the shared Proxy() default argument — is already
fixed on trunk by 56e5c616d8, so ignore that.)
AutomatedTester
left a comment
There was a problem hiding this comment.
Looks good now, please rebase/fix merge conflicts so we can merge this
🔗 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