Skip to content

fix(mcp-store): connect upstream MCP fetches to the validated IP - #97614

Open
posthog[bot] wants to merge 1 commit into
masterfrom
posthog-self-driving/securityhog-pos-353-high-mcp-store-ssrf-a119a1
Open

posthog[bot] wants to merge 1 commit into
masterfrom
posthog-self-driving/securityhog-pos-353-high-mcp-store-ssrf-a119a1

Conversation

@posthog

@posthog posthog Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

  • An MCP Store server URL is supplied by a team member, and every fetch of it validated one address and then connected to another.
  • check_mcp_url_policy resolves the hostname to decide the URL is safe. The httpx.Client that opens the connection resolves the same hostname again. A record that answers the two lookups differently passes the check and still reaches a loopback, cluster-internal, or metadata address.
  • The proxy returns the upstream response body to the caller, so the second answer decides what an internal service hands back to a person outside it. The tool discovery and tool call paths carry the same shape.
  • The repository already fixed this for requests callers: pinned_requests states that validation alone leaves the window open and that a caller must connect to the returned addresses. No equivalent existed for httpx, and the MCP Store is an httpx caller.
Path Before After
proxy_mcp_request validate, then resolve again validate, then connect to the validated address
fetch_upstream_tools validate, then resolve again validate, then connect to the validated address
call_upstream_tool validate, then resolve again validate, then connect to the validated address

Found by an internal security review. No exploitation was observed, and nothing in this PR comes from customer data.

Changes

  • An MCP server whose DNS answer changes between the check and the connect is now refused: the request goes to the address the check accepted, and a host with no validated address cannot be connected to at all.
  • posthog/security/pinned_httpx.py is the httpx counterpart of pinned_requests. It wraps the transports httpx builds for itself instead of replacing them, so a request that must leave through an egress proxy still does. Replacing the transport would have dropped that routing silently, because httpx ignores HTTP_PROXY as soon as a client is given a transport of its own.
  • The Host header and TLS keep naming the original host, so certificate verification and the upstream server see no change for an allowed URL.
  • products/mcp_store/backend/upstream_http.py is the one way a fetch reaches an upstream server. The client cannot be built without a verdict, and a blocked verdict raises, so a fetch path cannot skip the policy or drop the pin.
  • An operator-allowlisted internal URL is allowed with no pinned address and connects as before. It names a service we run, not a name a team controls.
  • Mechanical: _canonical_host in pinned_requests is now canonical_pin_host, shared by both helpers. Test patch points move from httpx.Client in each product module to the shared client builder.

How did you test this code?

  • products/mcp_store/backend/test/test_proxy.py gains a proxy request that resolves to a public address and asserts the outbound request carries that address, with the hostname still in the Host header and in SNI. Without pinning the request would carry the hostname and resolve a second time, so this case fails on master.
  • test_upstream_http.py covers the verdict: an allowed URL carries the addresses it was validated on, a blocked one carries none, an operator-allowlisted URL is allowed unpinned, and a blocked verdict refuses to build a client.
  • test_pinned_httpx.py covers the transport: the rewrite for IPv4 and IPv6, an internationalized host matching its pin, refusal of an unpinned host, and every proxy route carrying the pin when the environment configures one. That last case is the one that catches a future change to how the client is built.
  • Ran locally: products/mcp_store/backend/test and posthog/security/test, mypy --cache-fine-grained over both packages, and hogli ci:preflight --fix.
  • Not done: no manual run against a live MCP server, and no rebinding attack was staged. The pinning claim rests on the assertions above.

Automatic notifications

  • Publish to changelog?

Docs update

None. Behavior for an allowed URL is unchanged.

🤖 Agent context

Autonomy: Fully autonomous

  • Duplicate check: #95848 is an open community PR fixing the same defect, filed before this report reached the queue. It is unmerged, so the defect is still live on master. Reviewers should land one and close the other. The difference worth weighing: that PR passes its own transport to httpx.Client, which turns off environment proxy routing for MCP traffic, and it re-validates on every connect rather than pinning the verdict the call site already has.
  • Considered and rejected: moving the MCP Store onto the existing requests helper. It would have meant rewriting the streaming, redirect, and error-mapping code around a second HTTP library, for a product already built on httpx.
  • Skills: /simplify was invoked over the branch and found nothing to cut, though its parallel review agents were not spawned, so the four cleanup angles were applied by hand. No other repo skill file was loaded; the conventions came from CLAUDE.md.
  • Tools: Claude Code (Opus 5) in a PostHog cloud task sandbox, with the dev stack started locally to run the suites.

Created with PostHog Desktop from this inbox report.

The MCP Store validated a team-supplied server URL, resolving the hostname
once, and then let httpx resolve the same hostname again when it opened the
connection. A record that answers the two lookups differently passed the SSRF
check and still reached an internal address, and the proxy returns the
upstream body to the caller.

Add pinned_httpx_client, the httpx counterpart of the existing pinned
requests helper. It wraps the transports httpx builds for itself, so an
egress proxy route stays in place and only the address behind the hostname
is fixed. TLS still verifies the original hostname through the sni_hostname
extension.

The proxy and the tool fetch and call paths now validate through
upstream_http, which hands the validated addresses to the client, so a fetch
cannot skip the policy or reach a different address than the one it passed.

Generated-By: PostHog Desktop
Task-Id: d5881707-ad37-42cd-8c5d-d6b6b8f0bd89
@trunk-io

trunk-io Bot commented Sep 9, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@posthog

posthog Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

🦔 PostHog Review reviewed this pull request

Found 2 must fix, 2 should fix, 0 consider.

Published 4 findings (view the review).

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

🚨 Comment density — 8% of added code lines are comments (30 of 376)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
products/mcp_store/backend/test/test_proxy.py 11 85
posthog/security/pinned_httpx.py 10 74
posthog/security/test/test_pinned_httpx.py 4 56
products/mcp_store/backend/test/test_api.py 2 11
products/mcp_store/backend/test/test_tools.py 2 36
posthog/security/pinned_requests.py 1 6

This check does not block merging. It updates on every push and clears when the share drops.

Playwright — all passed

All tests passed.

View test results →

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not approved — escalated to a human reviewer.

Re-add the stamphog label to request another review once you have addressed this.

This is a security-sensitive SSRF/DNS-rebinding fix in the MCP upstream connection path, and it has zero reviews or approvals from any agent reviewer or human — the only review activity shown is an automated review that hasn't finished yet. The author is a machine user not on the owning team, so there's no independent assurance covering this risky-territory change.

  • No completed independent review (human or agent reviewer) on the current head to cover this security-sensitive change
  • Author (posthog[bot]) is not on the owning team and, being a machine author, carries no familiarity signal
  • A duplicate open community PR (#95848) addresses the same vulnerability — reviewers should confirm which lands to avoid conflicting fixes
Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size 206L, 6F substantive, 558L/11F incl. docs/generated/snapshots — within ceiling
tier T1-agent / T1d-complex (558L, 11F, two-areas, fix)
stamphog 2.0.0b4 .stamphog/policy.yml @ 63af84c · reviewed head 63af84c

@stamphog stamphog Bot added the reviewhog ($$$) Reviews pull requests before humans do label Sep 9, 2026
@posthog

posthog Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PostHog Review

Found 2 must fix, 2 should fix.

Comment on lines +9 to +11
Call ``validate_upstream_url`` first and hand its verdict to
``upstream_mcp_client``: the client cannot be built without one, so a fetch
cannot skip the policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Active async MCP calls still permit DNS rebinding

should_fix security

Why we think it's a valid issue
  • Checked: Every caller of check_mcp_url_policy outside tests, the MCPClient transport code, the tool's wiring in the chat agent, and the new helper's type surface.
  • Found: ee/hogai/tools/call_mcp_server/tool.py:307 validates the team-supplied URL, then tool.py:220 builds MCPClient(server_url, ...). mcp_client.py:48 opens a plain httpx.AsyncClient for the streamable HTTP transport, and mcp_client.py:65 calls sse_client(...) for the fallback. Neither client carries a pin, so each connect resolves the hostname a second time.
  • Found: sse_client keeps its default httpx_client_factory=create_mcp_http_client (mcp/client/sse.py:36), so the fallback builds a third unpinned client that the product cannot configure.
  • Found: Both transports send the stored credential. _build_server_headers puts Authorization: Bearer <api_key or access_token> on every installation URL (ee/hogai/tools/call_mcp_server/installations.py:126-131), and tool.py:220 passes those headers into the client.
  • Found: The path is live, not dead code. ee/hogai/chat_agent/toolkit.py:140-149 adds the tool whenever the mcp-gateway flag resolves true and the team has one installation.
  • Found: The new helper is synchronous only. pinned_httpx_client returns httpx.Client (posthog/security/pinned_httpx.py:68), and PinnedIPTransport extends httpx.BaseTransport, so no async caller can reuse it today.
  • Found: This PR edits the guard the async path calls. products/mcp_store/backend/url_policy.py:28-30 now tells a fetch path to use upstream_http.validate_upstream_url for the pinned addresses. tool.py:307 is the one remaining caller that fetches after the check, and it does not follow that guidance.
  • Impact: A team member who installs a server on a hostname with a short-lived record keeps the same rebinding window the PR closes elsewhere. The check accepts a public address, the connect reaches a loopback, cluster, or metadata address, the stored bearer token goes with it, and the tool output returns to the chat. The fix therefore closes the class for the MCP Store product module but leaves the agent path open.
  • Priority: Lowered to should_fix. The defect exists on master and sits outside the changed files, so it does not block this PR. A repair also needs new infrastructure: an httpx.AsyncBaseTransport wrapper plus an httpx_client_factory for the SSE fallback. That belongs in a linked follow-up rather than in this diff.
Issue description

The new helper protects only the synchronous proxy and backend tool callers. CallMCPServerTool remains active behind the MCP gateway flag. It validates with check_mcp_url_policy, then MCPClient opens a new httpx.AsyncClient. Its SSE fallback opens another client. Both clients resolve the team-controlled hostname again and send stored bearer credentials. A rebinding record can still connect this path to a private or metadata address.

Suggested fix

Route CallMCPServerTool through the hardened product facade, or add an async pinned client based on validate_upstream_url. Apply the pin to both streamable HTTP and SSE transports. Add a rebinding test for each connection attempt.

Prompt to fix with AI (copy-paste)
## Context
@products/mcp_store/backend/upstream_http.py#L9-11

<issue_description>
The new helper protects only the synchronous proxy and backend tool callers. `CallMCPServerTool` remains active behind the MCP gateway flag. It validates with `check_mcp_url_policy`, then `MCPClient` opens a new `httpx.AsyncClient`. Its SSE fallback opens another client. Both clients resolve the team-controlled hostname again and send stored bearer credentials. A rebinding record can still connect this path to a private or metadata address.
</issue_description>

<issue_validation>
- **Checked:** Every caller of `check_mcp_url_policy` outside tests, the `MCPClient` transport code, the tool's wiring in the chat agent, and the new helper's type surface.
- **Found:** `ee/hogai/tools/call_mcp_server/tool.py:307` validates the team-supplied URL, then `tool.py:220` builds `MCPClient(server_url, ...)`. `mcp_client.py:48` opens a plain `httpx.AsyncClient` for the streamable HTTP transport, and `mcp_client.py:65` calls `sse_client(...)` for the fallback. Neither client carries a pin, so each connect resolves the hostname a second time.
- **Found:** `sse_client` keeps its default `httpx_client_factory=create_mcp_http_client` (`mcp/client/sse.py:36`), so the fallback builds a third unpinned client that the product cannot configure.
- **Found:** Both transports send the stored credential. `_build_server_headers` puts `Authorization: Bearer <api_key or access_token>` on every installation URL (`ee/hogai/tools/call_mcp_server/installations.py:126-131`), and `tool.py:220` passes those headers into the client.
- **Found:** The path is live, not dead code. `ee/hogai/chat_agent/toolkit.py:140-149` adds the tool whenever the `mcp-gateway` flag resolves true and the team has one installation.
- **Found:** The new helper is synchronous only. `pinned_httpx_client` returns `httpx.Client` (`posthog/security/pinned_httpx.py:68`), and `PinnedIPTransport` extends `httpx.BaseTransport`, so no async caller can reuse it today.
- **Found:** This PR edits the guard the async path calls. `products/mcp_store/backend/url_policy.py:28-30` now tells a fetch path to use `upstream_http.validate_upstream_url` for the pinned addresses. `tool.py:307` is the one remaining caller that fetches after the check, and it does not follow that guidance.
- **Impact:** A team member who installs a server on a hostname with a short-lived record keeps the same rebinding window the PR closes elsewhere. The check accepts a public address, the connect reaches a loopback, cluster, or metadata address, the stored bearer token goes with it, and the tool output returns to the chat. The fix therefore closes the class for the MCP Store product module but leaves the agent path open.
- **Priority:** Lowered to `should_fix`. The defect exists on master and sits outside the changed files, so it does not block this PR. A repair also needs new infrastructure: an `httpx.AsyncBaseTransport` wrapper plus an `httpx_client_factory` for the SSE fallback. That belongs in a linked follow-up rather than in this diff.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Route `CallMCPServerTool` through the hardened product facade, or add an async pinned client based on `validate_upstream_url`. Apply the pin to both streamable HTTP and SSE transports. Add a rebinding test for each connection attempt.
</potential_solution>

Comment on lines +46 to +50
# extension carries the original hostname into TLS, for SNI and for certificate
# verification, which would otherwise run against the IP.
request.extensions = {**request.extensions, "sni_hostname": host}
request.url = request.url.copy_with(host=ip)
return self._inner.handle_request(request)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pinned HTTPS requests fail through the egress proxy

must_fix

Why we think it's a valid issue
  • Checked: httpcore's two connection classes, the locked versions, and the end-to-end behavior of the new client through a real CONNECT proxy. I also ran the same scenario against the pre-existing requests helper for comparison, and read the one proxy test the PR adds.
  • Found: httpcore/_sync/http_proxy.py:312 builds the tunneled TLS with "server_hostname": self._remote_origin.host.decode("ascii") and never reads request.extensions["sni_hostname"]. The direct path does read it (httpcore/_sync/connection.py:107 and :151). uv.lock:3217-3218 pins httpcore 1.0.9, and the venv carries httpx 0.28.1, so the installed code is the code above. Because PinnedIPTransport rewrites request.url at posthog/security/pinned_httpx.py:49, _remote_origin.host is the pinned IP.
  • Found: Reproduced end to end. With HTTPS_PROXY set to a local CONNECT proxy that serves a certificate for mcp.example.test, a client from pinned_httpx_client("https://mcp.example.test/mcp", {127.0.0.1}) fails: ConnectError [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: IP address mismatch, certificate is not valid for '127.0.0.1'. The proxy logged CONNECT 127.0.0.1:443, so the pin does hold, and only the certificate check breaks.
  • Found: The break is specific to the tunnel. The same pinned client on the direct route returns 200 against the same hostname certificate. The pre-existing PinnedIPAdapter also returns 200 through the same fake proxy, because cert_verify injects server_hostname and assert_hostname into the urllib3 pool (posthog/security/pinned_requests.py:141-155). The new httpx helper therefore regresses against its own sibling on this route.
  • Found: The route is on by default for the new callers. trust_environment_proxy returns True for every URL that is not operator-allowlisted (products/mcp_store/backend/url_policy.py:41-44), and upstream_mcp_client passes that as trust_env (products/mcp_store/backend/upstream_http.py:50), so httpx mounts the proxy transport whenever the process has HTTPS_PROXY.
  • Found: An egress proxy is the expected production shape for external calls in this repo, not a rare setup. posthog/security/outbound_proxy.py:1-18 states external calls honor HTTP_PROXY/HTTPS_PROXY and internal calls must opt out, .semgrep/rules/security/aiohttp-missing-trust-env.yaml enforces trust_env=True on external aiohttp calls, and posthog/metrics.py:8 plus bin/granian_metrics.py:24 bypass the egress proxy for loopback fetches.
  • Found: The new test cannot catch this. test_an_env_proxy_route_is_pinned_too (posthog/security/test/test_pinned_httpx.py:63-76) asserts only that each mount is a PinnedIPTransport; no test opens a TLS connection through a proxy.
  • Impact: In any environment that sets HTTPS_PROXY, all three paths this PR moves onto upstream_mcp_client — the proxy view, tool discovery, and tool calls — fail for every public HTTPS MCP server, and they worked before the change. The failure surfaces as httpx.ConnectError, which products/mcp_store/backend/tools.py maps to "Upstream MCP server unreachable", so the cause is hidden behind a generic transport error. That is a functional break of the product the PR hardens, which matches the must_fix priority the reviewer set.
Issue description

PinnedIPTransport adds sni_hostname, but httpcore 1.0.9 ignores this extension in TunnelHTTPConnection. The tunnel uses the rewritten IP for server_hostname. A normal certificate for the MCP hostname then fails verification whenever HTTPS_PROXY routes the request. upstream_mcp_client enables environment proxies for every external MCP URL, so this can disable all public MCP traffic in proxied environments.

Suggested fix

Add an HTTPS CONNECT-proxy integration test that checks the CONNECT target, SNI, and certificate host. Use a tunnel transport that connects to the pinned IP but passes the original hostname to start_tls. Keep certificate verification and the egress proxy enabled.

Prompt to fix with AI (copy-paste)
## Context
@posthog/security/pinned_httpx.py#L46-50

<issue_description>
`PinnedIPTransport` adds `sni_hostname`, but httpcore 1.0.9 ignores this extension in `TunnelHTTPConnection`. The tunnel uses the rewritten IP for `server_hostname`. A normal certificate for the MCP hostname then fails verification whenever `HTTPS_PROXY` routes the request. `upstream_mcp_client` enables environment proxies for every external MCP URL, so this can disable all public MCP traffic in proxied environments.
</issue_description>

<issue_validation>
- **Checked:** httpcore's two connection classes, the locked versions, and the end-to-end behavior of the new client through a real CONNECT proxy. I also ran the same scenario against the pre-existing `requests` helper for comparison, and read the one proxy test the PR adds.
- **Found:** `httpcore/_sync/http_proxy.py:312` builds the tunneled TLS with `"server_hostname": self._remote_origin.host.decode("ascii")` and never reads `request.extensions["sni_hostname"]`. The direct path does read it (`httpcore/_sync/connection.py:107` and `:151`). `uv.lock:3217-3218` pins httpcore 1.0.9, and the venv carries httpx 0.28.1, so the installed code is the code above. Because `PinnedIPTransport` rewrites `request.url` at `posthog/security/pinned_httpx.py:49`, `_remote_origin.host` is the pinned IP.
- **Found:** Reproduced end to end. With `HTTPS_PROXY` set to a local CONNECT proxy that serves a certificate for `mcp.example.test`, a client from `pinned_httpx_client("https://mcp.example.test/mcp", {127.0.0.1})` fails: `ConnectError [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: IP address mismatch, certificate is not valid for '127.0.0.1'`. The proxy logged `CONNECT 127.0.0.1:443`, so the pin does hold, and only the certificate check breaks.
- **Found:** The break is specific to the tunnel. The same pinned client on the direct route returns 200 against the same hostname certificate. The pre-existing `PinnedIPAdapter` also returns 200 through the same fake proxy, because `cert_verify` injects `server_hostname` and `assert_hostname` into the urllib3 pool (`posthog/security/pinned_requests.py:141-155`). The new httpx helper therefore regresses against its own sibling on this route.
- **Found:** The route is on by default for the new callers. `trust_environment_proxy` returns `True` for every URL that is not operator-allowlisted (`products/mcp_store/backend/url_policy.py:41-44`), and `upstream_mcp_client` passes that as `trust_env` (`products/mcp_store/backend/upstream_http.py:50`), so httpx mounts the proxy transport whenever the process has `HTTPS_PROXY`.
- **Found:** An egress proxy is the expected production shape for external calls in this repo, not a rare setup. `posthog/security/outbound_proxy.py:1-18` states external calls honor `HTTP_PROXY`/`HTTPS_PROXY` and internal calls must opt out, `.semgrep/rules/security/aiohttp-missing-trust-env.yaml` enforces `trust_env=True` on external aiohttp calls, and `posthog/metrics.py:8` plus `bin/granian_metrics.py:24` bypass the egress proxy for loopback fetches.
- **Found:** The new test cannot catch this. `test_an_env_proxy_route_is_pinned_too` (`posthog/security/test/test_pinned_httpx.py:63-76`) asserts only that each mount is a `PinnedIPTransport`; no test opens a TLS connection through a proxy.
- **Impact:** In any environment that sets `HTTPS_PROXY`, all three paths this PR moves onto `upstream_mcp_client` — the proxy view, tool discovery, and tool calls — fail for every public HTTPS MCP server, and they worked before the change. The failure surfaces as `httpx.ConnectError`, which `products/mcp_store/backend/tools.py` maps to "Upstream MCP server unreachable", so the cause is hidden behind a generic transport error. That is a functional break of the product the PR hardens, which matches the `must_fix` priority the reviewer set.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Add an HTTPS CONNECT-proxy integration test that checks the CONNECT target, SNI, and certificate host. Use a tunnel transport that connects to the pinned IP but passes the original hostname to `start_tls`. Keep certificate verification and the egress proxy enabled.
</potential_solution>

Comment on lines +48 to +50
request.extensions = {**request.extensions, "sni_hostname": host}
request.url = request.url.copy_with(host=ip)
return self._inner.handle_request(request)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Restore the request URL after transport handling

should_fix

Why we think it's a valid issue
  • Checked: httpx's cookie handling in _client.py, the jar contents and outbound headers through a real pinned client, the same flow without pinning as a control, both MCP Store call sites that reuse one client, and whether the stale URL reaches the redirect SSRF check.
  • Found: The mechanism is confirmed in httpx 0.28.1: _client.py:1018-1022 assigns response.request = request and then calls self.cookies.extract_cookies(response), so extraction reads the URL the transport rewrote at posthog/security/pinned_httpx.py:49.
  • Found: Reproduced against the shipped helper. With a pin of 93.184.216.34 for mcp.example.test, a Set-Cookie: AWSALB=sticky1; Path=/ lands in the jar under domain 93.184.216.34, and the next request the same client builds for the hostname carries no Cookie header. A Set-Cookie with Domain=mcp.example.test is dropped outright, leaving an empty jar. The unpinned control stores the cookie under mcp.example.test and sends AWSALB=sticky1 on the second request, so the three converted paths did keep cookies before this change. r1.request.url also stays https://93.184.216.34/mcp after the call, which is what httpx logs for the request.
  • Found: One client spans several requests on the discovery path. products/mcp_store/backend/tools.py sends initialize, notifications/initialized, tools/list, and DELETE on the client from upstream_mcp_client, and products/mcp_store/backend/proxy.py:105-114 sends a request plus one redirect retry. So a cookie an upstream sets on the first request is gone for the rest of the sequence.
  • Found: No security consequence. validated_same_origin_redirect_url compares against the caller's own original_url string (products/mcp_store/backend/proxy.py:54,68-71), not response.url, so the stale URL never reaches the origin check or the redirect SSRF validation.
  • Found: The sibling helper behaves the same way, so this is the house pinning convention rather than a new deviation in kind: requests/sessions.py:721 extracts from the rewritten PreparedRequest, and requests/cookies.py:43-44 derives the host from that URL. Existing pinned_requests callers such as the webhook delivery and probe paths have shipped with that behavior.
  • Impact: Confirmed loss of cookie continuity within one pinned client, which is a behavior regression for the converted paths. The protocol's own session mechanism survives, because tools.py propagates Mcp-Session-Id as a header on every step, so the break needs an upstream that keeps session state or load-balancer affinity in a cookie. If one does, the follow-up handshake steps can reach a different backend and the failure surfaces as a generic "Upstream MCP server unreachable" or a session error, which is expensive to diagnose.
  • Priority: Lowered to consider. The mechanism is verified and the repair is a small save-and-restore, but nothing in this repository shows an MCP upstream that depends on cookies, and the identical behavior has run in the requests helper without a known failure. That keeps it a real, cheap cleanup rather than an evidenced defect.
Issue description

handle_request leaves the caller's request URL rewritten after the inner transport returns. HTTPX attaches that request to the response and uses its URL to extract cookies. It stores host-only cookies for the IP and rejects Domain cookies for the original hostname. Later redirect and handshake requests start with the hostname, so HTTPX sends no cookie. This breaks upstream sessions and load-balancer affinity that depend on cookies.

Suggested fix

Save the original URL and extensions. Restore both in a finally block after the inner transport returns. Add a test that sets a cookie, sends a second request, and checks the cookie header.

Prompt to fix with AI (copy-paste)
## Context
@posthog/security/pinned_httpx.py#L48-50

<issue_description>
`handle_request` leaves the caller's request URL rewritten after the inner transport returns. HTTPX attaches that request to the response and uses its URL to extract cookies. It stores host-only cookies for the IP and rejects `Domain` cookies for the original hostname. Later redirect and handshake requests start with the hostname, so HTTPX sends no cookie. This breaks upstream sessions and load-balancer affinity that depend on cookies.
</issue_description>

<issue_validation>
- **Checked:** httpx's cookie handling in `_client.py`, the jar contents and outbound headers through a real pinned client, the same flow without pinning as a control, both MCP Store call sites that reuse one client, and whether the stale URL reaches the redirect SSRF check.
- **Found:** The mechanism is confirmed in httpx 0.28.1: `_client.py:1018-1022` assigns `response.request = request` and then calls `self.cookies.extract_cookies(response)`, so extraction reads the URL the transport rewrote at `posthog/security/pinned_httpx.py:49`.
- **Found:** Reproduced against the shipped helper. With a pin of `93.184.216.34` for `mcp.example.test`, a `Set-Cookie: AWSALB=sticky1; Path=/` lands in the jar under domain `93.184.216.34`, and the next request the same client builds for the hostname carries no `Cookie` header. A `Set-Cookie` with `Domain=mcp.example.test` is dropped outright, leaving an empty jar. The unpinned control stores the cookie under `mcp.example.test` and sends `AWSALB=sticky1` on the second request, so the three converted paths did keep cookies before this change. `r1.request.url` also stays `https://93.184.216.34/mcp` after the call, which is what httpx logs for the request.
- **Found:** One client spans several requests on the discovery path. `products/mcp_store/backend/tools.py` sends `initialize`, `notifications/initialized`, `tools/list`, and `DELETE` on the client from `upstream_mcp_client`, and `products/mcp_store/backend/proxy.py:105-114` sends a request plus one redirect retry. So a cookie an upstream sets on the first request is gone for the rest of the sequence.
- **Found:** No security consequence. `validated_same_origin_redirect_url` compares against the caller's own `original_url` string (`products/mcp_store/backend/proxy.py:54,68-71`), not `response.url`, so the stale URL never reaches the origin check or the redirect SSRF validation.
- **Found:** The sibling helper behaves the same way, so this is the house pinning convention rather than a new deviation in kind: `requests/sessions.py:721` extracts from the rewritten `PreparedRequest`, and `requests/cookies.py:43-44` derives the host from that URL. Existing `pinned_requests` callers such as the webhook delivery and probe paths have shipped with that behavior.
- **Impact:** Confirmed loss of cookie continuity within one pinned client, which is a behavior regression for the converted paths. The protocol's own session mechanism survives, because `tools.py` propagates `Mcp-Session-Id` as a header on every step, so the break needs an upstream that keeps session state or load-balancer affinity in a cookie. If one does, the follow-up handshake steps can reach a different backend and the failure surfaces as a generic "Upstream MCP server unreachable" or a session error, which is expensive to diagnose.
- **Priority:** Lowered to `consider`. The mechanism is verified and the repair is a small save-and-restore, but nothing in this repository shows an MCP upstream that depends on cookies, and the identical behavior has run in the `requests` helper without a known failure. That keeps it a real, cheap cleanup rather than an evidenced defect.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Save the original URL and extensions. Restore both in a `finally` block after the inner transport returns. Add a test that sets a cookie, sends a second request, and checks the cookie header.
</potential_solution>

Comment on lines +48 to +49
request.extensions = {**request.extensions, "sni_hostname": host}
request.url = request.url.copy_with(host=ip)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

TLS proxy handshake uses the upstream hostname

must_fix

Why we think it's a valid issue
  • Checked: How httpcore builds the CONNECT request, where the proxy-leg TLS reads its server_hostname, and the real behavior of the new client against an https://-scheme CONNECT proxy with a proxy-only certificate. I also compared an unpinned client and searched the repo for the proxy scheme actually configured.
  • Found: The extension does reach the proxy handshake. httpcore/_sync/http_proxy.py:282-287 builds the CONNECT request with extensions=request.extensions, and it goes to the tunnel's inner HTTPConnection, whose origin is the proxy. That connection applies TLS when its own origin scheme is https and takes "server_hostname": sni_hostname or self._origin.host.decode("ascii") (httpcore/_sync/connection.py:107 and :139-152). The sni_hostname set at posthog/security/pinned_httpx.py:48 therefore names the MCP host on the leg to the proxy.
  • Found: Reproduced end to end. With HTTPS_PROXY=https://127.0.0.1:<port> and a proxy certificate that carries only the proxy's own IP SAN, a pinned client for https://mcp.example.test/mcp fails with ConnectError [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'mcp.example.test'. The proxy recorded zero completed handshakes and no CONNECT line, and its SNI callback saw mcp.example.test, so the failure happens on the proxy handshake and before CONNECT, exactly as described.
  • Found: An unpinned client on the same setup completes the proxy handshake and sends CONNECT mcp.example.test:443, because server_hostname falls back to the proxy origin. The break comes from the pin, not from the test rig.
  • Found: This is the second symptom of one root gap, not a duplicate report. A single global sni_hostname extension is applied to both TLS legs, so the proxy leg gets the upstream name here, while the tunnel leg gets the pinned IP through _remote_origin.host (httpcore/_sync/http_proxy.py:312). A repair that patches only the tunnel leaves this failure standing, and a repair that just drops the extension breaks the direct route, which relies on it.
  • Impact: In an environment whose proxy endpoint speaks TLS, every pinned HTTPS MCP fetch fails before any traffic leaves, with no workaround. That covers the proxy view, tool discovery, and tool calls, and the error surfaces as a generic ConnectError that the product reports as "Upstream MCP server unreachable".
  • Priority: Lowered to should_fix. The external egress proxy here is Smokescreen (posthog/otel_logs.py:68, posthog/settings/web.py:1250, products/mcp_store/README.md:121), and every proxy URL in the repository uses the http:// scheme, including posthog/security/test/test_pinned_httpx.py:64 and the test_outbound_proxy.py fixtures. I found no evidence of a TLS-listening proxy endpoint, so the trigger is a plausible but unevidenced deployment choice, unlike the tunnel-leg defect that fires on any proxy. It still belongs next to that fix, because both live in the same lines.
Issue description

When the proxy URL uses https://, httpcore copies this sni_hostname extension into the CONNECT request. The TLS connection to the proxy then verifies its certificate against the MCP hostname. A normal proxy certificate fails before httpcore sends CONNECT.

Suggested fix

Keep separate TLS names for the proxy and the upstream server. Apply the upstream SNI only after CONNECT. Add an https:// proxy test that checks both TLS handshakes.

Prompt to fix with AI (copy-paste)
## Context
@posthog/security/pinned_httpx.py#L48-49

<issue_description>
When the proxy URL uses `https://`, httpcore copies this `sni_hostname` extension into the CONNECT request. The TLS connection to the proxy then verifies its certificate against the MCP hostname. A normal proxy certificate fails before httpcore sends CONNECT.
</issue_description>

<issue_validation>
- **Checked:** How httpcore builds the CONNECT request, where the proxy-leg TLS reads its `server_hostname`, and the real behavior of the new client against an `https://`-scheme CONNECT proxy with a proxy-only certificate. I also compared an unpinned client and searched the repo for the proxy scheme actually configured.
- **Found:** The extension does reach the proxy handshake. `httpcore/_sync/http_proxy.py:282-287` builds the CONNECT request with `extensions=request.extensions`, and it goes to the tunnel's inner `HTTPConnection`, whose origin is the proxy. That connection applies TLS when its own origin scheme is `https` and takes `"server_hostname": sni_hostname or self._origin.host.decode("ascii")` (`httpcore/_sync/connection.py:107` and `:139-152`). The `sni_hostname` set at `posthog/security/pinned_httpx.py:48` therefore names the MCP host on the leg to the proxy.
- **Found:** Reproduced end to end. With `HTTPS_PROXY=https://127.0.0.1:<port>` and a proxy certificate that carries only the proxy's own IP SAN, a pinned client for `https://mcp.example.test/mcp` fails with `ConnectError [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'mcp.example.test'`. The proxy recorded zero completed handshakes and no CONNECT line, and its SNI callback saw `mcp.example.test`, so the failure happens on the proxy handshake and before CONNECT, exactly as described.
- **Found:** An unpinned client on the same setup completes the proxy handshake and sends `CONNECT mcp.example.test:443`, because `server_hostname` falls back to the proxy origin. The break comes from the pin, not from the test rig.
- **Found:** This is the second symptom of one root gap, not a duplicate report. A single global `sni_hostname` extension is applied to both TLS legs, so the proxy leg gets the upstream name here, while the tunnel leg gets the pinned IP through `_remote_origin.host` (`httpcore/_sync/http_proxy.py:312`). A repair that patches only the tunnel leaves this failure standing, and a repair that just drops the extension breaks the direct route, which relies on it.
- **Impact:** In an environment whose proxy endpoint speaks TLS, every pinned HTTPS MCP fetch fails before any traffic leaves, with no workaround. That covers the proxy view, tool discovery, and tool calls, and the error surfaces as a generic `ConnectError` that the product reports as "Upstream MCP server unreachable".
- **Priority:** Lowered to `should_fix`. The external egress proxy here is Smokescreen (`posthog/otel_logs.py:68`, `posthog/settings/web.py:1250`, `products/mcp_store/README.md:121`), and every proxy URL in the repository uses the `http://` scheme, including `posthog/security/test/test_pinned_httpx.py:64` and the `test_outbound_proxy.py` fixtures. I found no evidence of a TLS-listening proxy endpoint, so the trigger is a plausible but unevidenced deployment choice, unlike the tunnel-leg defect that fires on any proxy. It still belongs next to that fix, because both live in the same lines.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Keep separate TLS names for the proxy and the upstream server. Apply the upstream SNI only after CONNECT. Add an `https://` proxy test that checks both TLS handshakes.
</potential_solution>

@andrewm4894
andrewm4894 marked this pull request as ready for review September 9, 2026 18:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T18:40:56.556997Z 63af84c Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@parameterai

parameterai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Warning

Finding on ee/hogai/tools/call_mcp_server/mcp_client.py:46 — could not attach an inline comment (line is not part of the diff), so reporting it here.

🟠 Chat agent's MCP tool-call path still lets DNS rebinding reach internal addresses

This PR pins the MCP Store's own HTTP fetches to the validated IP address, but the parallel MCP call path used by the Max chat agent (ee/hogai/tools/call_mcp_server/tool.py) was not updated and still has the exact rebinding gap this PR set out to close. tool.py:307 calls check_mcp_url_policy(server_url, self._team.id) — which only validates the hostname via DNS resolution — and then tool.py:220 builds MCPClient(server_url, ...). Inside ee/hogai/tools/call_mcp_server/mcp_client.py:46-58, _connect_streamable_http opens a plain httpx.AsyncClient (and _connect_sse opens another unpinned client via sse_client's default httpx_client_factory) that resolves the same hostname a second time when it actually connects. A team member who controls an MCP server URL with a short-TTL DNS record can serve a public IP to the validation lookup and a loopback/cluster-internal/cloud-metadata address to the connection lookup, reaching internal services with the request carrying the stored OAuth bearer token (_build_server_headers, installations.py:126-131). This path is live whenever the mcp-gateway flag is enabled (ee/hogai/chat_agent/toolkit.py:140-149), so it is not dead code.

Fix by routing this caller through the same pinning primitives this PR introduces: have tool.py call products/mcp_store/backend/upstream_http.validate_upstream_url to get a PinnedUrlVerdict, and change MCPClient/mcp_client.py to accept the pinned IP set and build its httpx.AsyncClient through an async equivalent of pinned_httpx_client (or a pinning transport for httpx.AsyncClient/sse_client's httpx_client_factory) instead of connecting straight from the raw server_url.


Severity: high | Confidence: 80% | React with 👍 if useful or 👎 if not

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team September 9, 2026 18:29

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63af84ceed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# extension carries the original hostname into TLS, for SNI and for certificate
# verification, which would otherwise run against the IP.
request.extensions = {**request.extensions, "sni_hostname": host}
request.url = request.url.copy_with(host=ip)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the hostname through HTTPS proxy tunnels

When HTTPS_PROXY is configured, rewriting the URL before invoking the proxy transport makes httpcore build the CONNECT tunnel's remote origin from the pinned IP. In the locked httpcore 1.0.9, TunnelHTTPConnection ignores the sni_hostname extension and uses that remote-origin IP for TLS SNI and certificate verification, so ordinary hostname certificates fail and public HTTPS MCP requests return 502. The proxy test only verifies that transports are wrapped, rather than exercising the tunnel handshake.

AGENTS.md reference: AGENTS.md:L206-L206

Useful? React with 👍 / 👎.

# extension carries the original hostname into TLS, for SNI and for certificate
# verification, which would otherwise run against the IP.
request.extensions = {**request.extensions, "sni_hostname": host}
request.url = request.url.copy_with(host=ip)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore the request URL before HTTPX processes cookies

When an upstream sets a session, affinity, or CSRF cookie during initialize, this mutation remains on the request after the transport returns. HTTPX then assigns that request to response.request and extracts cookies against the pinned IP, so host-only cookies are stored for the IP and domain cookies for the original hostname are rejected; subsequent handshake and tool requests built for the hostname omit them. Preserve the logical request URL for HTTPX's response processing while changing only the connection target.

Useful? React with 👍 / 👎.

if chosen_ip is None:
return client

pins = {canonical_pin_host(urlparse.urlparse(url).hostname or ""): str(chosen_ip)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Align pin hostname canonicalization with URL validation

For internationalized domains containing IDNA deviation characters, URL validation and this pin map use different hostnames. The validator's built-in IDNA codec turns faß.de into fass.de, whereas canonical_pin_host and HTTPX use xn--fa-hia.de; the client therefore connects to the IP validated for fass.de while sending Host/SNI for the other domain. This generally causes HTTPS certificate failures and, over HTTP, can send credentials to the wrong domain's address.

Useful? React with 👍 / 👎.

# extension carries the original hostname into TLS, for SNI and for certificate
# verification, which would otherwise run against the IP.
request.extensions = {**request.extensions, "sni_hostname": host}
request.url = request.url.copy_with(host=ip)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bracket IPv6 pins in proxy request targets

When a validated hostname has only AAAA records and an environment proxy is active, this rewrite gives httpcore the raw host 2001:db8::1. Httpcore 1.0.9 then constructs an HTTPS CONNECT authority such as 2001:db8::1:443, rather than the required [2001:db8::1]:443; its forward-proxy absolute URL serialization has the same ambiguity for HTTP. Proxies can reject or misparse these targets, so IPv6-only MCP upstreams cannot be reached through the egress proxy even after preserving the TLS hostname.

Useful? React with 👍 / 👎.

@trunk-io

trunk-io Bot commented Sep 9, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

Failed Test Failure Summary Logs
test_a_card_cut_as_a_duplicate_does_not_reserve_a_recording_for_itself Pydantic failed to generate a schema for the datetime type because it is an unsupported or unknown type in the current configuration. Logs ↗︎
test_unresolvable_property_filter_fails_soft Pydantic failed to generate a schema for the datetime type because it is an unsupported or unknown type in the current configuration. Logs ↗︎
test_a_noisy_session_does_not_outrank_the_behavior_the_card_claims Pydantic failed to generate a schema for the datetime type because it is an unsupported or unknown type in the current configuration. Logs ↗︎
test_a_cards_highlights_name_which_of_its_recordings_to_open_first Pydantic failed to generate a schema for the datetime type because it is an unsupported or unknown type in the current configuration. Logs ↗︎

... and 52 more

View Full Report ↗︎Docs

@scheduled-actions-posthog

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, please remove the stale label – otherwise this will be closed in another week. If you want to permanently keep it open, use the waiting label.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reviewhog ($$$) Reviews pull requests before humans do stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant