Skip to content

fix: honor HTTP_PROXY/HTTPS_PROXY when SSRF protection is enabled - #849

Merged
chenyme merged 1 commit into
devfrom
proxy
Oct 9, 2026
Merged

chenyme merged 1 commit into
devfrom
proxy

Conversation

@chenyme

@chenyme chenyme commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Summary

When SSRF protection was enabled (production + SSRF_PROTECTION_ENABLED=true), NewOutboundHTTPTransport set transport.Proxy = nil, silently ignoring HTTP_PROXY / HTTPS_PROXY / NO_PROXY. Deployments that must egress through a proxy (corporate egress proxy, local Clash/Surge, restricted networks) had to choose between SSRF protection and working outbound requests for models, embeddings, MCP, OCR, identity providers, etc.

Root cause: Go's Transport dials the proxy through the same DialContext hook that hosts the SSRF guard, so a loopback/private proxy address (e.g. 127.0.0.1:7890) was rejected as an unsafe target, while the real target was never visible to the dialer.

This PR keeps the standard proxy variables working under SSRF protection without adding any config option:

  • Target validation moves to Transport.Proxy (validatingProxy): every request, including each redirect hop, is checked against the active OutboundPolicy before a proxy is selected. Metadata, link-local, private literals and user-endpoint (public-only) restrictions still apply.
  • Proxy address is dialed directly (allowProxyDial): only the literal host:port from the proxy URL bypasses the guarded dialer; other hosts resolving to the same IP are still checked.
  • Extra checks for proxied requests (validateProxiedTargetHost), since the proxy resolves the target itself:
    • reject numeric hostnames that inet_aton-style resolvers treat as IPv4 (127.1, 2130706433, 0x7f000001, 0177.0.0.1);
    • resolve the hostname locally once and reject it if any address is blocked by the policy (e.g. 127.0.0.1.nip.io); if local resolution fails, the request is left to the proxy.
  • Direct requests are unchanged: NO_PROXY hits and loopback targets keep the existing resolve → validate → dial-validated-IP path.
  • Single proxy snapshot: the environment is read once per process (sync.OnceValue), shared by proxy selection, the dial allowance and startup logging.
  • Startup visibility: the composition root logs outbound_proxy_enabled (proxy URLs reduced to scheme://host:port, no credentials), outbound_proxy_disabled, or warns outbound_proxy_ignored when only the unsupported ALL_PROXY is set.
  • Credentials in the target URL (user:pass@) are stripped before validation only, so fixed URLs such as GEOIP_DATABASE_URL with embedded credentials are not rejected; the outgoing request is unchanged.

golang.org/x/net moves from indirect to direct (http/httpproxy). The signature of NewOutboundHTTPTransport is unchanged; no call sites change. Adds a read-only OutboundPolicy.Enforced() accessor for the startup log.

Change type

  • Bug fix
  • Feature
  • Documentation
  • Refactor
  • Configuration / deployment
  • Security hardening
  • Other

Affected areas

  • Frontend / UI
  • Backend / API
  • Authentication / authorization
  • Conversations / streaming
  • Files / RAG / extraction
  • Model routing / providers
  • MCP / tools
  • Billing / payments
  • Admin console
  • Deployment / Docker / configuration
  • Documentation

Verification

cd backend
gofmt -l ./internal/shared/security ./internal/app   # no output
go build ./...
go test ./internal/shared/security/... ./internal/app/... ./internal/infra/... ./internal/transport/http/...
make lint   # gofmt, go vet, staticcheck, deadcode

New tests in backend/internal/shared/security/outbound_proxy_test.go:

  • unsafe targets rejected before proxy selection (metadata IP/host, private literal, user endpoint on a local proxy port);
  • admin-trusted private endpoints still allowed;
  • proxy address dials directly, everything else uses the guarded dialer;
  • numeric hostnames and hosts resolving to restricted addresses are rejected when proxied; lookup failure is left to the proxy; direct requests skip the extra lookup;
  • end-to-end: a loopback proxy works with protection enabled, while metadata, loopback-resolving and numeric hosts never reach it;
  • userinfo does not cause false rejections or mask unsafe targets;
  • proxy status redacts credentials and detects an ignored ALL_PROXY.

Not verified manually against a real Clash / corporate proxy and model upstream.

Screenshots, API examples, or logs

INFO  outbound_proxy_enabled  {"http_proxy": "http://127.0.0.1:7890", "https_proxy": "http://127.0.0.1:7890", "no_proxy": "localhost,.internal", "ssrf_protection": true}
WARN  outbound_proxy_ignored  {"detail": "ALL_PROXY is set but not supported; set HTTP_PROXY and HTTPS_PROXY instead. Outbound requests connect directly."}

Configuration, migration, and compatibility notes

  • No new config keys. Proxying is controlled only by the standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment variables; changes require a restart.
  • ALL_PROXY is not supported (Go's standard library does not read it). For SOCKS, set HTTP_PROXY / HTTPS_PROXY to a socks5:// URL.
  • Without proxy variables, behavior is the same as before.
  • With proxy variables and SSRF protection enabled, requests now go through the proxy instead of failing. For proxied requests, the proxy resolves the target hostname: DNS rebinding at the proxy cannot be prevented by the application, so DNS-level egress control is the proxy's responsibility.
  • Minor: numeric-form IPv4 hostnames (e.g. http://1.1/) are rejected when proxied.

Documentation

  • Documentation is not needed for this change.
  • Documentation was updated.
  • Documentation still needs to be updated.

README.md and docs/README.zh-CN.md: new HTTP_PROXY / HTTPS_PROXY / NO_PROXY row in the environment variable table and a paragraph in the SSRF section.

Security and privacy

  • No secrets, tokens, credentials, local config, or personal data are included.
  • User data access remains scoped by authenticated user context unless an admin-only path explicitly requires broader access.
  • Security-sensitive behavior was reviewed, including authentication, authorization, provider routing, file processing, billing, and admin APIs where relevant.

Checklist

  • I searched existing issues and pull requests.
  • Changes are focused and do not include unrelated refactors.
  • Tests or static verification were run where practical.
  • User-facing behavior, deployment steps, API contracts, or configuration changes are documented.
  • Generated artifacts are included only when this project explicitly requires them.
  • Caches, build output, .pyc files, .env files, and local storage data are not committed.

@chenyme chenyme self-assigned this Oct 9, 2026
@chenyme
chenyme merged commit ffce1a1 into dev Oct 9, 2026
13 checks passed
@chenyme
chenyme deleted the proxy branch October 9, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant