fix: add Desktop PAC-aware egress with SSRF protections - #3999
Conversation
Review: Desktop PAC-aware egress with SSRF protectionsRight direction and a high-quality implementation overall — but I'd request changes. Three items should be fixed before merge (one security-relevant), and the guarded-proxy design deserves an explicit security sign-off since it changes the SSRF trust model. The issue itself flagged this approach as the "alternative worth security review," and that review should be visible on the PR. What the PR doesRoot-cause fix matches the issue precisely: Strengths worth calling out
Must-address1. The guarded proxy path weakens the anti-rebinding guarantee — and fails open on resolver errors. 2. Guarded standalone requests pay a wasted DNS lookup on every call. 3. Should-fix / design notes
TestsCoverage is genuinely good (hermetic Desktop detection override, cooldown preservation, detection-flip caching, typed-error HTTP mapping per route). Gaps:
Nits / follow-ups (fine to defer)
|
Sayt-0
left a comment
There was a problem hiding this comment.
Thanks for tracking this down — #3998 is a real, well-diagnosed bug, and the OCI-vs-URL asymmetry (pkg/remote/pull.go already uses the Desktop transport, urlSource.Read does not) is convincing evidence. The commit-per-phase split, the hermetic detection hook, keeping loopback direct, shipping a kill switch and documenting the behaviour across every affected tool page are all solid.
Requesting changes on one substantive point plus four cheap ones. Details are inline.
Blocking — the security property, not the feature. For guarded clients this PR replaces an enforced SSRF control with an advisory, fail-open one whenever Docker Desktop runs. NewDesktopTransport overwrites the guarded DialContext, so SSRFDialControl never executes on the proxy branch, and proxySafe then returns true on any resolver error. Since fetch takes model-chosen URLs, a prompt-injected http://intranet.corp/ that fails local resolution is handed to the corporate PAC proxy, which resolves it — with no allow_private_ips: true involved.
#3998 anticipated this trade-off and asked for it to be arbitrated: remediation (1) scoped the Desktop transport to docker.com source URLs and flagged the broader option as "worth security review: teach NewSSRFSafeTransport the Desktop proxy socket while keeping its dial-time allowlist". This PR takes the broad route — every guarded consumer (fetch, api, openapi, a2a, webhook, skills, toolinstall, MCP OAuth, tui/image, URL sources) — without preserving the enforcement point. That decision deserves to be explicit, ideally with a security reviewer.
Any of these unblocks: scope the PAC branch to trusted Docker hosts; or fail closed on local DNS errors with proxy-side resolution behind an explicit allowlist; or move IsPublicIP enforcement into whatever performs the final dial.
Blocking-adjacent: an explicitly configured HTTPS_PROXY/NO_PROXY is now silently ignored for guarded clients — reproduced locally, the configured proxy is never contacted. New versus baseline, undocumented and untested.
Cheap fixes before merge, all covered inline: the kill switch accepts only the exact string "1" while every other boolean env var in the repo is permissive; the proxySafe DNS lookup runs before the DesktopRunning() check, costing an extra lookup per request and per redirect hop even where Docker Desktop is absent; every teamloader.Load/config.Load error becomes a 502, so malformed local YAML now reports as a gateway failure; and agentSourceHTTPError returning nil in its default branch lets getAgentConfig answer 200 with an empty body.
On validation. The description notes that PAC-only/Desktop-host smoke validation was not run. Given that PAC behaviour is the entire point of the change and that an SSRF control is being relaxed, task build/test/lint alone looks insufficient. Minimum ask: one PAC-only manual run, a test pinning the NO_PROXY interaction, and a test for the true → false → true detection flap — the current cooldown test only performs a single transition.
Suggestion. The HTTP-status refactor and the startup retry are independent and uncontroversial, and between them they cover remediations (2) and (3) of #3998. Splitting them into their own PR would ship most of the user-visible fix now and let the transport work take the security review and PAC validation it needs; as it stands, 881 additions across 12 commits mix four concerns and none can be reverted independently.
One caveat on the evidence: findings 1 and 2 are code-proven and the proxy-precedence one was reproduced locally, but no Docker Desktop + PAC environment was available, so whether Desktop's own proxy refuses private destinations and compensates for the missing client-side guard remains unverified. If it does, the first finding downgrades considerably — which is precisely the fact worth establishing before merge.
|
Thanks for the detailed review. The reviewed feedback has been addressed across the follow-up commits below:
The final security/behavior decision is explicit: Desktop-selected egress—including PAC Outstanding NON-ACTION (not claimed resolved): a live Desktop PAC-only smoke test and independent security acceptance of the guarded-proxy trust model. Neither has been performed or accepted by these changes. These commits address the implementation, error mapping, proxy opt-out/precedence, resolver, concurrency, pooling, test, and documentation feedback described above. This is a feedback-resolution summary, not an approval or a claim that review-level change requests are approved. Please re-review the current head |
3deb9fd to
b3620f8
Compare
Sayt-0
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Thanks for the follow-up work — a lot of the previous round landed cleanly. Confirmed fixed at b3620f83: NXDOMAIN-only resolver delegation, DesktopRunning checked before the DNS preflight (verified: 0 resolver calls for 10 requests when Desktop is absent), default -> 500 in agentSourceHTTPError, malformed local YAML back to 500, truthy kill-switch parsing, Desktop transport + cooldown surviving a true->false->true flap, and the testing import removed from production code. The OCI-vs-URL asymmetry (pkg/remote/pull.go:69 vs the old pkg/config/sources.go:343) is a convincing diagnosis, and the PR also quietly fixes a latent misuse — after this change no production caller passes unsafe=true to NewSafeClient.
Five things still block. Four of them are cheap. Details inline.
- A data race, reproduced under
-race— lazy Desktop-transport creation writesDisableCompressionon the shared*http.Transportthat concurrent requests are reading. The PR's own "concurrent" test cannot catch it. *.localhostinvalidateAgentURLnow accepts plaintexthttp://evil.localhost/agent.yaml(base rejected it) and serves it overhttp.DefaultTransportwith no SSRF guard at all. Unrelated to PAC egress, and untested.- Six security contracts still promise the guarantee this PR scopes down — including
agent-schema.json, which feeds IDE autocomplete. The PR body's "Retains SSRF protections ... for every supported consumer" is contradicted by your own resolution comment and should be corrected before merge. sanitizeURLForLogkeeps the URL path, andNewSafeClient(_, false)now reaches that log site for the first time — webhook secrets (Slack/Discord/Telegram) live in the path.- The design decision itself is still unvalidated. Your own note says as much. The single fact that decides whether this is a blocker or a footnote: does Docker Desktop's proxy refuse RFC1918 / link-local / metadata destinations, including on PAC
DIRECT? I had no Desktop+PAC environment, so I could not establish it. If it does, the residual risk drops sharply. If it does not,fetchtakes model-chosen URLs and this is prompt-injectable SSRF.
Plus, not anchorable inline because the files are untouched — five stale security comments, all reachable through a now-PAC-routed client and all inaccurate whenever Desktop is running:
agent-schema.json:2354— "after DNS resolution, so DNS rebinding is also blocked". No generator:AGENTS.mdand.agents/skills/bump-config-version/SKILL.mdrequire a manual edit coordinated withtypes.go;pkg/config/schema_test.goonly cross-validates.pkg/config/latest/types.go:1603-1611— same claim.pkg/toolinstall/registry.go:167-173— "NewSafeClient enforces dial-time SSRF protection".pkg/skills/cache.go:26-33— "refuses such targets at dial time, after DNS resolution, defeating DNS rebinding".pkg/httpclient/ssrf.go:246—LocalhostOnlyRedirectsstill hard-codes exactlocalhost, now inconsistent with the two widened predicates (see thesources.gothread).
I have a handful of minor/nit items (an ALL_PROXY claim repeated in 8 doc paragraphs that the code does not implement, raw err.Error() incl. the source URL in the new 502 bodies, no verdict cache on the per-request DNS preflight, and a latent probe-amplification path in desktopDetectionCache) — happy to post those separately if useful, but they should not gate this.
Suggestion on scoping: the HTTP-status refactor and the startup retry are independent and uncontroversial, and between them cover remediations (2) and (3) of #3998. Splitting them out would ship most of the user-visible fix now and let the transport work take the PAC validation and security sign-off it needs. As it stands, 17 commits across 31 files mix four concerns and none can be reverted independently.
For the record on what I checked: CI is fully green (20 checks), golangci-lint run ./... -> 0 issues, the custom cop suite -> no offenses, go mod tidy -diff clean, wasm build OK, and go test -race -count=10 -shuffle=on ./pkg/desktop/transport/... ./pkg/httpclient/... clean. None of the findings below are lint-visible. The one pkg/tools/builtin/openapi failure I hit was environmental (ambient HTTP_PROXY in my sandbox) and reproduces identically at base — not yours.
| func (t *desktopAwareTransport) proxySafe(ctx context.Context, host string) bool { | ||
| if ip := net.ParseIP(host); ip != nil { | ||
| return IsPublicIP(ip) | ||
| } | ||
| ips, err := t.resolver(ctx, host) | ||
| if err != nil { | ||
| var dnsErr *net.DNSError | ||
| return errors.As(err, &dnsErr) && dnsErr.IsNotFound | ||
| } | ||
| if len(ips) == 0 { | ||
| return false | ||
| } | ||
| for _, ip := range ips { | ||
| if !IsPublicIP(ip) { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } |
There was a problem hiding this comment.
blocker (design decision, not a coding defect) — NXDOMAIN delegation + TOCTOU, and this is the item that needs an explicit sign-off
The narrowing from "any resolver error" to dnsErr.IsNotFound is a real improvement over the previous round. But NXDOMAIN is arguably the most load-bearing case: in split-horizon corporate DNS, internal names are precisely the ones that NXDOMAIN locally and resolve through the proxy. Since fetch takes model-chosen URLs, a prompt-injected http://jenkins.internal/ or http://intranet.corp/ is handed to the PAC proxy with no allow_private_ips: true anywhere. The reachable set goes from zero proxy-only internal names to all proxy-reachable internal names.
Separately, proxySafe resolves and Desktop then resolves again independently — a check-time/use-time gap. A domain that answers public here and 169.254.169.254 at connect time is exactly the rebinding that SSRFDialControl's own comment (ssrf.go:64-69) was written to defeat.
To be fair on severity, and I want this on the record: base already trusted an explicitly configured HTTP_PROXY/HTTPS_PROXY to enforce destination policy — that is the documented proxyDialAllowlist exception at ssrf.go:99-104. So this is not different in kind. It is different in default: (a) active merely because Desktop is running, with no operator action; (b) extended to every guarded consumer rather than the #3998 source-fetch case; (c) PAC DIRECT also escapes the guard; (d) NO_PROXY no longer provides an exit.
#3998 anticipated exactly this and asked for it to be arbitrated — remediation (1) scoped the Desktop transport to docker.com source URLs and flagged the broad option as "worth security review". Your resolution comment is refreshingly honest that neither the live PAC smoke test nor the security acceptance has happened. I could not close that gap either: no Desktop+PAC environment here.
The one fact that settles this: does Docker Desktop's host proxy refuse RFC1918 / link-local / metadata destinations, including when PAC returns DIRECT? If yes, residual risk drops sharply and this becomes a documentation matter. If no, it is prompt-injectable SSRF. Please get that answered by the Desktop networking owners and record it on the PR.
Any of these also unblocks without needing the answer: scope the PAC branch to trusted Docker hosts; fail closed on NXDOMAIN for guarded transports and require an explicit host allowlist or allow_private_ips for proxy-only names; or make Desktop routing opt-in for guarded consumers.
Test gap worth closing regardless: there is no end-to-end test of the guarded happy path. newDesktopAwareTransport(true) appears only in TestDesktopAwareTransportProxySafe (unit-tests proxySafe, never round-trips) and in TestDesktopAwareTransportDesktopWinsOverNoProxyUntilKillSwitch (fakes newDesktopTransport). NewDesktopAwareSSRFSafeTransport() — the actual production constructor — is never called from any test.
e4237b8 to
9df0801
Compare
9df0801 to
86a1de6
Compare
Summary
Closes #3998.
Adds Desktop-optional, PAC-aware egress for configured HTTP clients while preserving standalone proxy behavior and SSRF protections. The implementation covers all supported consumers, including agent/source fetches, sessions, tools, MCP HTTP transports, and MCP OAuth flows. Remote MCP Streamable HTTP/SSE remains intentionally excluded; MCP OAuth flows are supported.
Behavior
404) from upstream/source failure (502) semantics.DOCKER_AGENT_DISABLE_DESKTOP_PROXY=1disables Desktop proxy use (kill switch).HTTP_PROXY,HTTPS_PROXY, andNO_PROXYas appropriate.Commit / phase map
Validation
task buildpassed.task testpassed.task lintpassed.Manual PAC-only / Desktop-host smoke validation was not run and remains environment-limited coverage.