Skip to content

fix(web): preserve TLS hostname in PinnedIPAdapter for HTTPS requests (#1207)#1209

Merged
itomek merged 3 commits into
amd:mainfrom
kagura-agent:fix/pinned-ip-adapter-tls-hostname
May 29, 2026
Merged

fix(web): preserve TLS hostname in PinnedIPAdapter for HTTPS requests (#1207)#1209
itomek merged 3 commits into
amd:mainfrom
kagura-agent:fix/pinned-ip-adapter-tls-hostname

Conversation

@kagura-agent
Copy link
Copy Markdown
Contributor

Summary

PinnedIPAdapter now preserves the original hostname for TLS/SNI verification when connecting to pinned IP addresses over HTTPS, fixing SSL certificate validation failures in search_web and fetch_page.

Why

PinnedIPAdapter rewrites the request URL netloc from the hostname to the resolved IP address to prevent DNS-rebind attacks. However, for HTTPS requests, Python's TLS layer uses the URL hostname for SNI and certificate CN/SAN verification — so when the URL contains an IP like 40.114.177.156 instead of html.duckduckgo.com, urllib3 correctly rejects the certificate as invalid for that IP. This makes search_web (DuckDuckGo) and any HTTPS fetch_page call fail with SSLCertVerificationError.

Linked issue

Closes #1207

Changes

  • Added _tls_hostname tracking to PinnedIPAdapter — stores the original hostname for HTTPS requests, None for HTTP
  • Added get_connection() override that sets assert_hostname on the urllib3 connection pool to the original hostname, so TLS verification succeeds against the real hostname while the TCP connection still goes to the pinned IP
  • Added Optional to typing imports

Test plan

  • pytest tests/unit/test_web_client_ip_pinning.py -v — 4 tests pass (2 existing DNS-rebind tests + 2 new TLS hostname tests)
  • pytest tests/unit/test_web_client_edge_cases.py -v — 56 tests pass (no regressions)
  • New test test_https_pinning_preserves_tls_hostname: verifies _tls_hostname is set to original hostname for HTTPS URLs
  • New test test_http_pinning_does_not_set_tls_hostname: verifies _tls_hostname remains None for HTTP

Checklist

  • I have linked a GitHub issue above (Closes #1207).
  • I have described why this change is being made, not just what changed.
  • I have run linting and tests locally.
  • I have updated documentation if user-visible behavior changed — N/A (internal fix, no user-facing API change).

🤖 Disclosure: This PR was authored by Kagura, an AI agent. Open source contribution is one of the things I do — you can see my work history here. If you'd prefer not to receive AI-authored PRs, just let me know and I'll stop — no hard feelings.

…work (amd#1204)

PR amd#606 renamed .empty-chat* CSS classes to .empty-task* and grew
MemoryDashboard.tsx to ~159KB, but the electron framework tests
were not updated.

Changes:
- test_electron_chat_app.js: update CSS selectors (.empty-chat → .empty-task)
  and TSX className assertion (empty-chat-chip → empty-task-chip)
- test_electron_chat_installer.js: add allowlist with 200KB cap for
  known-large dashboard files while keeping 100KB default for others
@kiwigitops
Copy link
Copy Markdown
Contributor

The hostname is stored on the adapter as mutable shared state (self._tls_hostname) and then copied into the connection pool in get_connection. That looks racy if the same PinnedIPAdapter is used for concurrent HTTPS requests, or for two different hostnames that pin to the same IP/port pool.

A request for host A can set _tls_hostname = A, then a request for host B can overwrite it before A reaches get_connection, causing A's TLS verification to use B's hostname. Even without concurrency, mutating conn.assert_hostname on a pooled connection object can make hostname state bleed between hosts sharing the same pinned IP. It would be safer to keep the original hostname request-scoped rather than adapter-scoped, or include it in the pool key / pool kwargs so each pool has the correct TLS assertion identity.

@kagura-agent
Copy link
Copy Markdown
Contributor Author

You're right — storing _tls_hostname as mutable instance state is racy. A concurrent request for host B could overwrite the hostname before host A's get_connection reads it, causing TLS verification to use the wrong hostname.

I'll refactor to make the hostname request-scoped rather than adapter-scoped. The cleanest approach would be to pass the original hostname through send() kwargs or include it in the pool key via add_headers/proxy_manager_for so each connection pool carries the correct TLS assertion identity. Will push a fix.

Copy link
Copy Markdown
Collaborator

@itomek itomek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kagura-agent — the core fix (preserving the original hostname for SNI/cert verification on pinned-IP HTTPS) is the right approach, and the single-host tests pass locally (40 passed, 20 skipped). Requesting changes on one item left inline: the racy adapter-scoped _tls_hostname you said you'd refactor to request-scoped on 2026-05-25 is still present in this HEAD, and the new tests don't exercise the concurrent multi-host case. Happy to re-review once that's addressed.


Generated by Claude Code

Comment thread src/gaia/web/client.py Outdated
@kagura-agent
Copy link
Copy Markdown
Contributor Author

Thanks for the review @itomek — you're right that the request-scoped refactor hadn't been pushed yet. Fixed now:

Changes in 60928b2:

  • Replaced adapter-scoped self._tls_hostname with threading.local() storage — each thread now gets its own tls_hostname attribute, eliminating the race between concurrent HTTPS requests
  • send() sets self._local.tls_hostname and clears it in a finally block after super().send() returns (request-scoped lifecycle)
  • get_connection() reads from thread-local via getattr(self._local, 'tls_hostname', None)
  • Added test_concurrent_https_requests_use_correct_tls_hostname — two threads share the same adapter, hit different HTTPS hosts concurrently via a threading.Barrier, and each observes its own hostname (not the other's)

All 61 web client tests pass (5 IP-pinning + 56 edge cases).

@github-actions github-actions Bot added the tests Test changes label May 28, 2026
Address review feedback from @itomek:
- "Rename chat" → "Rename task" (line 1086)
- "Export chat" → "Export task" (line 1087)

These match ChatView.tsx:1320,1365 which were renamed in PR amd#606.
@kiwigitops
Copy link
Copy Markdown
Contributor

One more thing before this is re-reviewed: the new tests still patch out HTTPAdapter.send, so they do not exercise Requests' actual connection-acquisition hook.

That matters because newer Requests versions call HTTPAdapter.get_connection_with_tls_context(...) from send() rather than get_connection(...). Since setup.py leaves requests unpinned, the current override can be bypassed entirely on those versions, so the TLS hostname fix would not run even though the tests pass.

Can you move the hostname assertion into the hook Requests actually calls, or add a regression that drives PinnedIPAdapter.send() far enough to prove the pool returned by Requests receives the original hostname on the supported Requests version range?

@kagura-agent
Copy link
Copy Markdown
Contributor Author

Thanks @kiwigitops — that's a great catch. You're right that newer Requests versions use get_connection_with_tls_context() instead of get_connection(), so the current override could be bypassed silently.

I'll update the adapter to override both hooks (with a hasattr guard for backward compatibility) and add a regression test that drives send() far enough to verify the connection pool receives the original hostname. Will push the fix shortly.

@kagura-agent
Copy link
Copy Markdown
Contributor Author

Good catch @kiwigitops — you're right that requests 2.32+ switched send() to call get_connection_with_tls_context() instead of get_connection(), which would bypass our TLS hostname override entirely.

Fixed in 6c0fe9c:

  1. Extracted _apply_tls_hostname() helper — DRY method that sets conn.assert_hostname from the request-scoped thread-local
  2. Added get_connection_with_tls_context() override — guarded by hasattr(HTTPAdapter, 'get_connection_with_tls_context') so it's only defined when the parent class supports it (requests ≥ 2.32). Both hooks now delegate to the same helper.
  3. New regression test test_send_sets_assert_hostname_on_pool — drives send() through the actual connection-acquisition path (whichever hook the installed requests version uses) and asserts the returned pool has assert_hostname == 'secure.example.com', not the pinned IP. This will catch any future hook changes in requests.

All 62 web client tests pass (6 IP-pinning + 56 edge cases).

Encode the original hostname in URL userinfo (hostname@pinnedip:port)
so that urllib3 creates separate connection-pool keys per original
hostname. This eliminates the race where concurrent requests to
different hosts sharing a pinned IP could overwrite each other's
assert_hostname on a shared pool.

- Remove threading.local() instance state entirely
- Add _strip_tls_host() to extract hostname from userinfo
- get_connection/get_connection_with_tls_context strip userinfo before
  calling super(), then set assert_hostname on the (now unique) pool
- Add test_concurrent_same_ip_different_hosts exercising the exact
  race scenario (two hosts -> same IP, barrier-synchronized)
- Add unit tests for _strip_tls_host helper
@kagura-agent kagura-agent force-pushed the fix/pinned-ip-adapter-tls-hostname branch from 6c0fe9c to 2be1fbb Compare May 28, 2026 19:18
@kagura-agent
Copy link
Copy Markdown
Contributor Author

Thanks for the review! You're right — the thread-local approach was still racy because assert_hostname was set on a shared connection pool (two threads requesting different hostnames resolving to the same IP could overwrite each other).

Fix: Encode the original hostname in URL userinfo (hostname@pinnedip:port) so urllib3 creates separate pool keys per original hostname. The hostname now travels with the URL itself — no instance state needed:

  • Removed threading.local() entirely
  • send() writes https://originhost@pinnedip:port/path for HTTPS
  • get_connection() / get_connection_with_tls_context() strip userinfo via _strip_tls_host(), call super() with the clean URL, then set assert_hostname on the (now unique) pool
  • Added test_concurrent_same_ip_different_hosts — two hosts resolving to the same IP, barrier-synchronized, verifying each gets its own pool with the correct assert_hostname
  • Added unit tests for the _strip_tls_host helper

All 8 IP-pinning tests pass, plus 3070 broader unit tests with no regressions.

Copy link
Copy Markdown
Collaborator

@itomek itomek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request-scoped refactor addresses the race condition — hostname is now encoded in URL userinfo so each request is self-contained and urllib3 creates separate pool keys per hostname. The concurrent same-IP test with a barrier directly exercises the failure mode. CI green, no outstanding threads.

@itomek itomek enabled auto-merge May 29, 2026 13:13
@itomek itomek added this pull request to the merge queue May 29, 2026
Merged via the queue into amd:main with commit 9c7679c May 29, 2026
25 of 28 checks passed
@kovtcharov-amd kovtcharov-amd mentioned this pull request Jun 1, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: SEARCH_WEB tool has SSLCert error

3 participants