Repository navigation
feat: support plain HTTP forward proxy for private IP endpoints #155
Description
Problem Statement
The sandbox proxy only handles CONNECT tunneling. When a client sets HTTP_PROXY and makes a plain http:// request, standard HTTP libraries (Python's requests, httpx, aiohttp, Go's net/http, etc.) send a forward proxy request — not a CONNECT tunnel. The proxy rejects this with 403 Forbidden, forcing users to write custom CONNECT tunnel code (raw sockets, manual HTTP framing) for plain HTTP calls to internal services. This is a poor developer experience and a recurring source of confusion.
Technical Context
The sandbox proxy (crates/navigator-sandbox/src/proxy.rs) is CONNECT-tunnel-only. At line 199, any non-CONNECT method is immediately rejected with 403. For plain HTTP targets (e.g., internal tool servers on private IPs), this means the standard proxy behavior that every HTTP library implements — forward proxy requests like GET http://host/path HTTP/1.1 — doesn't work. Users must either write custom CONNECT tunnel code or use our sandbox_http.py wrapper, both of which are fragile and non-obvious.
The proxy already has the infrastructure to handle this: OPA policy evaluation, SSRF checks with allowed_ips override, and L7 relay/inspection. The missing piece is dispatching non-CONNECT requests through that same pipeline.
Affected Components
| Component | Key Files | Role |
|---|---|---|
| Sandbox proxy | crates/navigator-sandbox/src/proxy.rs |
Main proxy server — CONNECT-only dispatch, SSRF checks, OPA eval |
| L7 relay | crates/navigator-sandbox/src/l7/relay.rs, l7/rest.rs |
HTTP request parsing, forwarding, and inspection |
| OPA engine | crates/navigator-sandbox/src/opa.rs |
Policy evaluation for network decisions |
| Policy schema | architecture/security-policy.md |
Documents endpoint config including allowed_ips |
Proposed Approach
Add forward proxy support restricted to private IP endpoints that are explicitly allowed by policy. When the proxy receives a non-CONNECT request (e.g., GET http://10.86.8.223:8000/path), parse the absolute-form URI, run the same OPA + SSRF pipeline as CONNECT, and if the destination is a private IP with allowed_ips configured, connect upstream, rewrite the request to origin-form, and relay the response. Reject if the destination is public (plain HTTP should never traverse the public internet), if the scheme is https:// (use CONNECT), or if no policy matches.
No policy schema changes are needed — allowed_ips already serves as both the SSRF override and the opt-in signal for private endpoint access.
Scope Assessment
- Complexity: Medium
- Confidence: High — clear path, reuses existing infrastructure
- Estimated files to change: 2-3 (proxy.rs primarily, minor touches to l7/rest.rs)
- Issue type:
feat
Risks & Open Questions
- Connection reuse / keep-alive: Should the forward proxy support HTTP keep-alive (multiple request-response cycles on one connection), or close after each response? Keep-alive is more performant but adds complexity. L7 relay already handles multi-request loops, so reuse may come naturally.
- Request body forwarding: Need to handle
Content-LengthandTransfer-Encoding: chunkedon the forwarded request. The L7 rest provider already hasrelay_fixedandrelay_chunked— can these be reused? - Hop-by-hop headers: Per RFC 7230, the proxy should strip hop-by-hop headers (
Connection,Proxy-Authorization, etc.) when forwarding. Need to implement this correctly. Viaheader: RFC 7230 §5.7 recommends adding aViaheader. Minor but good practice.
Test Considerations
- Unit tests for URI parsing (
parse_proxy_uri) and request rewriting (rewrite_forward_request) - Integration tests: forward proxy to allowed private endpoint (200), to public IP (403), to private IP without
allowed_ips(403), to unmatched policy (403), HTTPS forward proxy (403) - Integration test with L7 inspection:
access: read-onlyallows GET, denies POST - Regression: all existing CONNECT tunnel behavior unchanged
Created by spike investigation. Use build-from-issue to plan and implement.
Activity
johntmyers commentedon Mar 6, 2026
Technical Investigation
Architecture Overview
The sandbox proxy (
crates/navigator-sandbox/src/proxy.rs, 1715 lines) is a CONNECT-tunnel-only HTTP proxy bound to10.200.0.1:3128inside the sandbox. Every TCP connection is dispatched tohandle_tcp_connection(line 160), which reads the HTTP request line, extracts the method and target, and branches:The OPA engine (
opa.rs) returns a tri-stateNetworkAction:Allow,InspectForInference, orDeny. ForAllow, the proxy resolves DNS, validates against SSRF checks (is_internal_iporallowed_ips), connects upstream, and relays traffic — either raw bidirectional copy (L4) or protocol-aware inspection (L7).The L7 relay subsystem (
l7/relay.rs,l7/rest.rs) already handles full HTTP request/response parsing, forwarding, and per-request policy evaluation. It supportsContent-Length, chunked transfer encoding, and EOF-terminated bodies.Code References
proxy.rs:160-524handle_tcp_connection— main per-connection handlerproxy.rs:199-208proxy.rs:210-224proxy.rs:284-287proxy.rs:292-331InspectForInferencehandlingproxy.rs:336-386allowed_ipscheck + SSRF validation + upstream connectproxy.rs:388-521proxy.rs:979-997is_internal_ip()— blocks all RFC 1918 + loopback + link-localproxy.rs:1035-1053is_always_blocked_ip()— blocks only loopback + link-local (used withallowed_ips)proxy.rs:1062-1098resolve_and_check_allowed_ips()— DNS resolve + CIDR allowlist validationproxy.rs:1134-1166query_allowed_ips()— queries OPA for endpoint'sallowed_ipsconfigproxy.rs:1173-1193extract_host_from_uri()— parses hostname from absolute-form URI (currently logging-only)proxy.rs:1195-1203parse_target()— parseshost:portfrom CONNECT targetl7/relay.rs:41-67relay_with_inspection()— L7 relay dispatcherl7/relay.rs:70-154relay_rest()— REST relay loop: parse request → evaluate policy → relay/denyl7/rest.rs:119-161relay_http_request()— forwards request + relays responsel7/rest.rs:218-239relay_fixed()— relay fixed-length bodyl7/rest.rs:250-352relay_chunked()— relay chunked transfer encodingl7/rest.rs:365-488relay_response()— parse + relay HTTP response with framing detectionl7/rest.rs:551-564looks_like_http()— peek-based HTTP protocol detectionCurrent Behavior
When a standard HTTP client (e.g.,
httpxwithproxy=) makes a request tohttp://10.86.8.223:8000/screenshot/, it sends:The proxy reads this at line 196, sees
method = "GET"(not"CONNECT"), and immediately returns 403 at line 206. The OPA engine is never consulted. The existingextract_host_from_urifunction (line 1173) already parses the hostname from these absolute-form URIs but is only used for logging the rejected request.What Would Need to Change
1.
proxy.rs— Replace the deny block (lines 199-208)Instead of blanket 403, dispatch to a new
handle_forward_proxyfunction. This function:parse_proxy_urito extract scheme, host, port, and path from the absolute-form URIhttps://scheme (must use CONNECT)evaluate_opa_tcp— same OPA call as CONNECT, identical identity bindingDenyandInspectForInference— forward proxy only works for explicitlyAllowed endpointsallowed_ips— if empty, reject (no implicit private IP access)resolve_and_check_allowed_ips— same SSRF validation as CONNECTall_addrs_are_private()— ensures resolved IPs are RFC 1918 (the forward proxy security boundary)TcpStream::connectGET http://host:port/path HTTP/1.1→GET /path HTTP/1.12.
proxy.rs— New helper functionsparse_proxy_uri(uri: &str) -> Result<(&str, String, u16, String)>— parse absolute-form URI into (scheme, host, port, path)rewrite_forward_request(raw_request: &[u8], path: &str) -> Vec<u8>— rewrite request-line, strip hop-by-hop headers, addViaall_addrs_are_private(addrs: &[SocketAddr]) -> bool— ensure all resolved IPs are RFC 19183.
proxy.rs— LoggingAdd a distinct log prefix for forward proxy requests (e.g.,
"FORWARD"instead of"CONNECT") so they're distinguishable in sandbox logs.4. Optional: L7 integration
After connecting upstream, check
query_l7_configfor the matched endpoint. If L7 is configured, the existingrelay_with_inspectioncan handle per-request policy evaluation on the forward-proxied traffic. This is possible because the traffic is already plaintext HTTP — no TLS termination needed. The L7 REST provider'srelay_restloop already handles multi-request connections.Alternative Approaches Considered
A. Synthetic CONNECT: Convert the forward proxy request into an internal CONNECT flow — parse the URI, create a synthetic tunnel, inject the original request bytes. This reuses the entire CONNECT code path but is hacky and harder to reason about. The separate
handle_forward_proxyfunction is cleaner.B. Client-side fix only: Keep the proxy CONNECT-only and have client libraries (like
sandbox_http.py) always use CONNECT for HTTP. This is what exists today but pushes complexity to every user and every language. Fragile and poor DX.C. Accept all forward proxy requests: Handle forward proxy for any allowed endpoint, not just private IPs. This would allow plain HTTP to the public internet, which is a security concern. The private-IP restriction is the right boundary.
Patterns to Follow
info!(src_addr, dst_host, dst_port, binary, action, policy, ...).respond(&mut client, b"HTTP/1.1 4xx ...\r\n\r\n")pattern.query_l7_config, buildL7EvalContext, dispatch).Test Coverage Notes
Proxy tests are primarily in
crates/navigator-sandbox/testdata/(policy fixtures) and integration/e2e tests ine2e/. The OPA engine has unit tests inopa.rsusing test fixtures fromtestdata/sandbox-policy.yaml. For this feature:parse_proxy_uriandrewrite_forward_requestcan live inproxy.rsas#[cfg(test)]modulesallowed_ips(403), no policy match (403), HTTPS scheme (403)This investigation provides context for implementation. Next step: review the issue, refine if needed, then use
build-from-issueto create a plan and implement.johntmyers commentedon Mar 6, 2026
Implementation Plan
Issue type:
featComplexity: Medium
Confidence: High — reuses existing OPA, SSRF, and relay infrastructure
Summary
Add HTTP forward proxy mode to the sandbox proxy, restricted to private IP endpoints with explicit
allowed_ipspolicy. When the proxy receives a non-CONNECT request (e.g.GET http://10.86.8.223:8000/path HTTP/1.1), it parses the absolute-form URI, evaluates OPA policy, validates the destination is a private IP withallowed_ips, connects upstream, rewrites the request to origin-form, and relays the response usingcopy_bidirectionalfor full streaming support. This eliminates the need for users to write custom CONNECT tunnel code for plain HTTP calls to internal services.Decisions on Risks & Open Questions
Connection reuse / keep-alive: v1 will inject
Connection: closeinto forwarded requests. Each forward proxy request opens a fresh upstream connection and closes after the response. This is simpler and sufficient for the primary use cases (tool server calls, LLM inference). Keep-alive can be added later if profiling shows it matters.Streaming (LLM SSE / chunked responses): After forwarding the rewritten request, the proxy uses
tokio::io::copy_bidirectionalbetween client and upstream. This has no idle timeout and copies bytes as they arrive — suitable for long-lived chunked/SSE streams from LLMs that can run for minutes. This avoids the 5-secondRELAY_EOF_IDLE_TIMEOUTin the L7relay_until_eofpath, which only applies to the L7-inspected codepath (not used here).Request body forwarding: The initial request buffer may contain overflow body bytes beyond the header
\r\n\r\nboundary. The rewrite function includes these in the forwarded payload. Any remaining body bytes (beyond what was buffered) flow throughcopy_bidirectionalfrom client→upstream. This handlesContent-Length,Transfer-Encoding: chunked, and no-body requests.Hop-by-hop headers: Strip
Proxy-Connection,Proxy-Authorization, andProxy-Authenticate. ReplaceConnectionheader withConnection: close. PreserveTransfer-Encodingand all other end-to-end headers.Via header: Add
Via: 1.1 navigator-sandboxper RFC 7230 §5.7.L7 inspection: Not included in v1. The forward proxy is already gated by OPA endpoint+binary match,
allowed_ips, and the private-IP requirement. Per-request L7 rules (e.g.access: read-only) can be added as a follow-up by integratingrelay_with_inspectionafter the initial request rewrite.Scope
crates/navigator-sandbox/src/proxy.rs: Primary change — newhandle_forward_proxyfunction,parse_proxy_uriandrewrite_forward_requesthelpers, update dispatch inhandle_tcp_connectione2e/python/test_sandbox_policy.py: E2E tests for forward proxy allow/deny behaviorarchitecture/security-policy.md: Document thatallowed_ipsenables forward proxy mode for plain HTTP to private endpointsarchitecture/plans/plain-http-forward-proxy.md: Already exists, update with final decisionsImplementation Steps
Add
parse_proxy_urihelper (proxy.rs)http://host:port/path?queryinto(scheme, host, port, path_with_query)/), query stringsAdd
rewrite_forward_requesthelper (proxy.rs)buf[..used], target path\r\n\r\n)METHOD http://host:port/path HTTP/1.1→METHOD /path HTTP/1.1Proxy-Connection,Proxy-Authorization,Proxy-AuthenticateConnection: closeVia: 1.1 navigator-sandboxAdd
handle_forward_proxyfunction (proxy.rs)handle_tcp_connectionwhen method != CONNECTa.
parse_proxy_uri(target)— reject if scheme ishttps(respond 400: "Use CONNECT for HTTPS")b.
evaluate_opa_tcp(peer_addr, ...)— same identity binding as CONNECTc. Match on
NetworkAction: only proceed onAllow; rejectDenyandInspectForInferencewith 403d. Structured log with
"FORWARD"tag (mirrors CONNECT log format)e.
query_allowed_ips(...)— if empty, reject 403 (forward proxy requires explicitallowed_ips)f.
parse_allowed_ips+resolve_and_check_allowed_ips— same SSRF validation as CONNECTg. Private-IP gate: verify all resolved addresses pass
is_internal_ip()— reject 403 if any is publich.
TcpStream::connect(addrs)to upstream — on failure, respond502 Bad Gatewayand returni.
rewrite_forward_request(buf, path)— transform headersj.
upstream.write_all(rewritten_bytes)— forward requestk.
tokio::io::copy_bidirectional(&mut client, &mut upstream)— relay response (supports streaming)Update dispatch in
handle_tcp_connection(proxy.rs)handle_forward_proxyAdd unit tests (
proxy.rs#[cfg(test)])parse_proxy_uri: standard URI, default port, IPv6, missing path, query string, HTTPS scheme, malformedrewrite_forward_request: GET rewrite, POST with body overflow, hop-by-hop stripping, Via addition, Connection: close injectionAdd E2E tests (
e2e/python/test_sandbox_policy.py)Update documentation
architecture/security-policy.md: Add section explaining forward proxy behavior forallowed_ipsendpointsarchitecture/plans/plain-http-forward-proxy.md: Mark decisions resolvedE2E Test Plan
New tests in
e2e/python/test_sandbox_policy.py, following the existing patterns (_proxy_connectclosure factory,sb.exec_python(), assert on stdout).New closure factory:
_forward_proxy_with_server()A closure factory (module-level, like
_proxy_connect) that:http.server.HTTPServerin a background daemon thread on0.0.0.0:19876inside the sandboxGET http://10.200.0.2:19876/test HTTP/1.1) to the sandbox proxy at10.200.0.1:3128New closure factory:
_forward_proxy_raw()A simpler closure (no server) for testing deny cases — just sends the forward proxy request and returns the proxy's response:
Test cases
test_forward_proxy_allows_private_ip_with_allowed_ipsallowed_ips=["10.200.0.0/24"], host=10.200.0.2, port=19876, binary=/**http://10.200.0.2:19876/testvia proxy"200"+"forward-proxy-ok"in response_forward_proxy_with_server()test_forward_proxy_denied_without_allowed_ips10.200.0.2, port=19876, binary=/**, NOallowed_ipshttp://10.200.0.2:19876/testvia proxy"403"in responseallowed_ips. Uses_forward_proxy_raw().test_forward_proxy_rejects_https_schemeallowed_ips=["10.200.0.0/24"], host=10.200.0.2, port=19876, binary=/**https://10.200.0.2:19876/testvia proxy"400"in response_forward_proxy_raw().test_forward_proxy_denied_no_policy_matchhttp://10.200.0.2:19876/testvia proxy"403"in response_forward_proxy_raw().test_forward_proxy_public_ip_deniedallowed_ips=["93.184.0.0/16"], host=example.com, port=80, binary=/**http://example.com/via proxy"403"in response_forward_proxy_raw().test_l4_non_connect_method_rejected(existing)allowed_ipsforexample.com)http://example.com/via proxy"403"in responseLog verification test
test_forward_proxy_log_fields/var/log/navigator.logvia_read_navigator_log(). Assert: log containsFORWARDtag,action=allowfor permitted request,dst_host=10.200.0.2,dst_port=19876, matched policy name. Also verify denied case logged withaction=deny.Test Plan (Summary)
parse_proxy_uri(8+ cases) andrewrite_forward_request(5+ cases) inproxy.rs#[cfg(test)]modulee2e/python/test_sandbox_policy.pycovering: happy path with real HTTP relay, deny withoutallowed_ips, HTTPS scheme rejection, no policy match, public IP rejection, log field verificationRisks & Open Questions
method != "CONNECT") that already exists — we're just changing the else branch from 403 tohandle_forward_proxy. No performance impact.exec_pythonclosure before the forward proxy request arrives. The 300ms sleep should be sufficient but could be flaky on very slow CI. Mitigation: increase sleep or add a retry wrapper if CI shows flakiness.Documentation Impact
architecture/security-policy.md: Document forward proxy mode and its security constraintsarchitecture/plans/plain-http-forward-proxy.md: Update with resolved decisionsRevision 2 — added E2E test plan with 7 test cases covering happy path (with in-sandbox HTTP server), deny paths, HTTPS rejection, public IP rejection, and log verification
Revision 1 — initial plan
johntmyers commentedon Mar 6, 2026
Implementation Complete
PR #158 implements the plain HTTP forward proxy for private IP endpoints.
What was built
handle_forward_proxy) inproxy.rs— handles non-CONNECT HTTP methods with absolute-form URIsparse_proxy_uri) — extracts scheme, host, port, path from proxy requestsrewrite_forward_request) — converts absolute-form to origin-form, strips hop-by-hop headers, addsViaandConnection: closetokio::io::copy_bidirectionalwith no idle timeout for chunked/SSE/LLM streaming responsesSecurity constraints enforced
All three must be true for a forward proxy request to succeed:
allowed_ipsconfiguredTests
parse_proxy_uri, 5 forrewrite_forward_request)test_l4_non_connect_method_rejectedstill passesV1 simplifications
Connection: closeinjected (no keep-alive)https://forward proxy rejected (must use CONNECT)feat(proxy): support plain HTTP forward proxy for private IP endpoints
feat(proxy): support plain HTTP forward proxy for private IP endpoints (
feat(proxy): support plain HTTP forward proxy for private IP endpoints (