Skip to content

fix(ssrf): pass pinned DNS lookup to ProxyAgent via requestTls#46756

Closed
yangyitao100 wants to merge 1 commit into
openclaw:mainfrom
yangyitao100:fix/issue-46685-pinned-dispatcher-proxy
Closed

fix(ssrf): pass pinned DNS lookup to ProxyAgent via requestTls#46756
yangyitao100 wants to merge 1 commit into
openclaw:mainfrom
yangyitao100:fix/issue-46685-pinned-dispatcher-proxy

Conversation

@yangyitao100
Copy link
Copy Markdown

Summary

  • Pass pinned DNS lookup to ProxyAgent via requestTls for SSRF protection

Problem

createPinnedDispatcher() correctly passes the SSRF-safe pinned hostname lookup to Agent (direct mode) and EnvHttpProxyAgent (env-proxy mode) via their connect options. However, for ProxyAgent (explicit-proxy mode), the pinned lookup was never passed. This meant DNS resolution for origin servers behind an explicit proxy bypassed the pinned hostname check, potentially allowing DNS rebinding attacks.

Root Cause

In src/infra/net/ssrf.ts, the explicit-proxy branch (lines 372-379) constructed ProxyAgent either with a plain URL string or with { uri, proxyTls } — neither of which included the requestTls option needed to carry the pinned lookup function.

Fix

  • Always pass requestTls: { lookup: pinned.lookup } when constructing ProxyAgent
  • This ensures origin server DNS resolution goes through the SSRF-safe pinned lookup for all three dispatcher modes (direct, env-proxy, explicit-proxy)
  • Updated existing test to expect requestTls in the call
  • Added new test verifying requestTls is passed even when proxyTls is absent

Test Plan

  • Updated test: explicit proxy with proxyTls now expects requestTls: { lookup }
  • Added test: explicit proxy without proxyTls also gets requestTls: { lookup }
  • Existing dispatcher tests still pass

Closes #46685

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 15, 2026

Greptile Summary

This PR attempts to close a SSRF gap in explicit-proxy mode by passing requestTls: { lookup: pinned.lookup } to ProxyAgent, mirroring the way connect.lookup is already used for Agent and EnvHttpProxyAgent.

Key concerns:

  • Likely ineffective fix for the security goal: In a standard HTTP CONNECT proxy flow, DNS resolution for origin servers is performed by the proxy, not the client. The client never calls net.connect() with a custom lookup function for the origin — it simply sends a CONNECT hostname:port string and receives an already-established tunnel socket. Because requestTls configures TLS settings applied after the tunnel is open (no new TCP connection, no DNS), requestTls.lookup is not expected to be invoked. The pinned lookup therefore does not enforce DNS resolution in this code path, leaving the rebinding window open.
  • connect vs requestTls naming asymmetry: The pattern established for Agent/EnvHttpProxyAgent uses connect.lookup. Using requestTls.lookup for the same purpose is semantically inconsistent and exploits an undocumented side-channel if it works at all.
  • Tests validate arguments, not behaviour: Both the updated and new tests mock the ProxyAgent constructor end-to-end and assert only the arguments passed to it. They cannot confirm that the lookup function is ever called at runtime, so they would pass even if the DNS pinning has no effect.

Confidence Score: 2/5

  • Not safe to merge: the security fix likely does not achieve its stated goal of DNS pinning for explicit-proxy mode due to how HTTP CONNECT proxying works.
  • The change is a security-critical fix for SSRF/DNS-rebinding. The mechanism used (requestTls.lookup) does not align with how undici or underlying Node.js networking invokes DNS resolution in a CONNECT proxy context, meaning the guard is probably never called. Tests only mock the constructor and cannot verify actual behaviour. Until an integration test or undici source analysis confirms that requestTls.lookup is reachable in this code path, the fix should be considered unproven.
  • src/infra/net/ssrf.ts lines 372–384 (the ProxyAgent construction logic) and the corresponding tests in src/infra/net/ssrf.dispatcher.test.ts.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/infra/net/ssrf.ts
Line: 376-384

Comment:
**`requestTls.lookup` may not enforce DNS pinning in proxy mode**

The fix places `lookup` inside `requestTls`, but `requestTls` configures TLS settings for connections to the origin server — it is not where DNS resolution is controlled.

In standard HTTP CONNECT proxy flow:
1. Client connects TCP/TLS to the proxy (using `proxyTls`)
2. Client sends `CONNECT api.telegram.org:443`
3. **The proxy** resolves `api.telegram.org` via its own DNS and opens a TCP connection to the origin
4. Client receives `200 Connection Established` and performs TLS over the tunnel socket (`requestTls`)

At step 4, there is no new TCP connection from the client, so `net.connect()` (which honours the `lookup` option) is never called. The pinned `lookup` function is therefore never invoked and the DNS rebinding protection does not apply.

Contrast this with the `Agent` / `EnvHttpProxyAgent` branches, which pass `lookup` inside `connect` — these do result in a fresh TCP connection from the client with custom DNS resolution.

The tests only mock the `ProxyAgent` constructor and verify which arguments are passed; they do not exercise the actual undici request dispatch path, so they cannot catch this discrepancy.

To properly guard against DNS rebinding in explicit-proxy mode the protection needs to be applied before the request is dispatched (e.g., validating that the request hostname matches the pre-resolved pinned hostname at dispatch time), rather than relying on a `lookup` override that is unlikely to be reached.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/infra/net/ssrf.dispatcher.test.ts
Line: 130-147

Comment:
**Tests only verify constructor arguments, not actual DNS pinning**

Both the updated test and the new test verify that `ProxyAgent` is instantiated with the expected options using a fully-mocked constructor. This does not exercise the actual undici `ProxyAgent` request dispatch path, so these tests would pass even if `requestTls.lookup` is never called at runtime.

Consider adding an integration-style test (or at minimum a unit test that stubs `net.connect` / the undici connector) that verifies the pinned `lookup` function is actually invoked when a request is dispatched through the `ProxyAgent`. Without this, the correctness of the DNS pinning behaviour in explicit-proxy mode is not validated.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 3730f48

Comment thread src/infra/net/ssrf.ts
Comment on lines +376 to 384
const requestTls = withPinnedLookup(pinned.lookup);
if (!policy.proxyTls) {
return new ProxyAgent(proxyUrl);
return new ProxyAgent({ uri: proxyUrl, requestTls });
}
return new ProxyAgent({
uri: proxyUrl,
requestTls,
proxyTls: { ...policy.proxyTls },
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

requestTls.lookup may not enforce DNS pinning in proxy mode

The fix places lookup inside requestTls, but requestTls configures TLS settings for connections to the origin server — it is not where DNS resolution is controlled.

In standard HTTP CONNECT proxy flow:

  1. Client connects TCP/TLS to the proxy (using proxyTls)
  2. Client sends CONNECT api.telegram.org:443
  3. The proxy resolves api.telegram.org via its own DNS and opens a TCP connection to the origin
  4. Client receives 200 Connection Established and performs TLS over the tunnel socket (requestTls)

At step 4, there is no new TCP connection from the client, so net.connect() (which honours the lookup option) is never called. The pinned lookup function is therefore never invoked and the DNS rebinding protection does not apply.

Contrast this with the Agent / EnvHttpProxyAgent branches, which pass lookup inside connect — these do result in a fresh TCP connection from the client with custom DNS resolution.

The tests only mock the ProxyAgent constructor and verify which arguments are passed; they do not exercise the actual undici request dispatch path, so they cannot catch this discrepancy.

To properly guard against DNS rebinding in explicit-proxy mode the protection needs to be applied before the request is dispatched (e.g., validating that the request hostname matches the pre-resolved pinned hostname at dispatch time), rather than relying on a lookup override that is unlikely to be reached.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/net/ssrf.ts
Line: 376-384

Comment:
**`requestTls.lookup` may not enforce DNS pinning in proxy mode**

The fix places `lookup` inside `requestTls`, but `requestTls` configures TLS settings for connections to the origin server — it is not where DNS resolution is controlled.

In standard HTTP CONNECT proxy flow:
1. Client connects TCP/TLS to the proxy (using `proxyTls`)
2. Client sends `CONNECT api.telegram.org:443`
3. **The proxy** resolves `api.telegram.org` via its own DNS and opens a TCP connection to the origin
4. Client receives `200 Connection Established` and performs TLS over the tunnel socket (`requestTls`)

At step 4, there is no new TCP connection from the client, so `net.connect()` (which honours the `lookup` option) is never called. The pinned `lookup` function is therefore never invoked and the DNS rebinding protection does not apply.

Contrast this with the `Agent` / `EnvHttpProxyAgent` branches, which pass `lookup` inside `connect` — these do result in a fresh TCP connection from the client with custom DNS resolution.

The tests only mock the `ProxyAgent` constructor and verify which arguments are passed; they do not exercise the actual undici request dispatch path, so they cannot catch this discrepancy.

To properly guard against DNS rebinding in explicit-proxy mode the protection needs to be applied before the request is dispatched (e.g., validating that the request hostname matches the pre-resolved pinned hostname at dispatch time), rather than relying on a `lookup` override that is unlikely to be reached.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +130 to +147
it("passes pinned lookup via requestTls when proxyTls is absent", () => {
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
const pinned: PinnedHostname = {
hostname: "api.telegram.org",
addresses: ["149.154.167.220"],
lookup,
};

createPinnedDispatcher(pinned, {
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7890",
});

expect(proxyAgentCtor).toHaveBeenCalledWith({
uri: "http://127.0.0.1:7890",
requestTls: { lookup },
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tests only verify constructor arguments, not actual DNS pinning

Both the updated test and the new test verify that ProxyAgent is instantiated with the expected options using a fully-mocked constructor. This does not exercise the actual undici ProxyAgent request dispatch path, so these tests would pass even if requestTls.lookup is never called at runtime.

Consider adding an integration-style test (or at minimum a unit test that stubs net.connect / the undici connector) that verifies the pinned lookup function is actually invoked when a request is dispatched through the ProxyAgent. Without this, the correctness of the DNS pinning behaviour in explicit-proxy mode is not validated.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/net/ssrf.dispatcher.test.ts
Line: 130-147

Comment:
**Tests only verify constructor arguments, not actual DNS pinning**

Both the updated test and the new test verify that `ProxyAgent` is instantiated with the expected options using a fully-mocked constructor. This does not exercise the actual undici `ProxyAgent` request dispatch path, so these tests would pass even if `requestTls.lookup` is never called at runtime.

Consider adding an integration-style test (or at minimum a unit test that stubs `net.connect` / the undici connector) that verifies the pinned `lookup` function is actually invoked when a request is dispatched through the `ProxyAgent`. Without this, the correctness of the DNS pinning behaviour in explicit-proxy mode is not validated.

How can I resolve this? If you propose a fix, please make it concise.

createPinnedDispatcher() passed the SSRF-safe pinned lookup to Agent and
EnvHttpProxyAgent but skipped it for ProxyAgent (explicit-proxy mode).
This meant DNS resolution for origin servers behind an explicit proxy
bypassed the pinned hostname check, potentially allowing DNS rebinding.

Pass the pinned lookup in requestTls for ProxyAgent so all dispatcher
modes enforce DNS pinning consistently.

Closes openclaw#46685
@yangyitao100 yangyitao100 force-pushed the fix/issue-46685-pinned-dispatcher-proxy branch from 3730f48 to aa54fa9 Compare March 21, 2026 02:35
@yangyitao100
Copy link
Copy Markdown
Author

Closing: too many versions behind, will re-evaluate on latest codebase.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory index fetch failed: createPinnedLookup incompatible with undici v7

1 participant