Skip to content

HTTP/Mixed forward proxy uses tunnel-style session semantics and breaks persistent HTTP request handling #17

Description

@kovawx

Summary

The http / mixed inbound currently treats a plain HTTP forward-proxy request too much like a tunnel protocol:

accept TCP connection
-> parse one HTTP proxy request
-> create one Session
-> evaluate route once
-> connect one upstream
-> replay the first normalized request head
-> turn the rest of the TCP connection into a raw byte relay

That lifecycle is correct for CONNECT, SOCKS5 CONNECT, VLESS/Trojan-style streams, etc., where one accepted protocol request establishes one fixed tunnel.

It is not correct for plain HTTP forward-proxy traffic. HTTP/1.1 is a message protocol: a single client-to-proxy TCP connection can carry multiple requests, and each request has its own request target and may require its own routing/policy decision.

The currently reproduced failure is HTTP keep-alive: only the first absolute-form request is parsed and rewritten. A second request on the same proxy connection bypasses HTTP parsing entirely and is forwarded through the already-established upstream as raw bytes.

This breaks normal browser/system-proxy traffic and is reproducible with Vite/VitePress, but the root cause is broader than Vite and broader than request-line rewriting.


Reproduction

The target is reachable through WireGuard and is matched by an IPv4 CIDR route:

16.0.0.0/8 -> DIRECT

Example target:

16.10.68.3:5173

Using the znet-sink mixed proxy at 127.0.0.1:7890:

curl.exe -v -x http://127.0.0.1:7890 \
  http://16.10.68.3:5173/ \
  http://16.10.68.3:5173/@vite/client

First request:

> GET http://16.10.68.3:5173/ HTTP/1.1
> Host: 16.10.68.3:5173
> Proxy-Connection: Keep-Alive

< HTTP/1.1 200 OK
< Content-Type: text/html
< Connection: keep-alive

curl then explicitly reuses the same proxy TCP connection:

* Reusing existing http: connection with proxy 127.0.0.1
> GET http://16.10.68.3:5173/@vite/client HTTP/1.1
> Host: 16.10.68.3:5173
> Proxy-Connection: Keep-Alive

The second response is incorrectly the Vite index.html document rather than the module resource:

< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 521

<!DOCTYPE html>
<html>
  ...

Browser result:

Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html".

The expected request reaching the origin is:

GET /@vite/client HTTP/1.1

but the reused connection can deliver the original proxy-form request directly to the origin:

GET http://16.10.68.3:5173/@vite/client HTTP/1.1

Vite then treats the target differently and falls back to index.html.

If 16.10.68.3 is added to the Windows system-proxy bypass list, the site works because the traffic no longer enters the HTTP proxy path. This is therefore not evidence of a DIRECT/WireGuard routing failure. The request reaches the destination correctly when proxy protocol handling is bypassed.


Current implementation and root cause

The relevant flow is currently split across:

protocols/http/src/inbound.rs
crates/proxy/src/adapters/http/inbound.rs
crates/proxy/src/adapters/mixed/inbound.rs
crates/proxy/src/runtime/tcp_ingress/lifecycle/serve.rs

For a plain absolute-form HTTP request:

  1. HttpConnectInbound::accept_request() reads exactly one HTTP request head.
  2. It parses the request target and constructs a Session.
  3. It rewrites the first request line from absolute-form to origin-form and returns those bytes as replay data.
  4. The HTTP or Mixed adapter wraps the connection in HttpRequestReplayStream.
  5. context.serve(...) passes the single Session into the generic TCP ingress lifecycle.
  6. serve_inbound() performs fake-IP resolution, URL rewrite, kernel rate-limit selection, Session preparation, route evaluation, outbound selection, tracking, and upstream establishment once.
  7. protocol.relay(...) then owns the connection as a raw bidirectional byte stream.

After step 7, later bytes are no longer interpreted as HTTP proxy messages.

This is the architectural mismatch:

Generic tunnel model
====================
TCP connection
  -> one Session
  -> one target
  -> one route decision
  -> one upstream
  -> raw relay

HTTP forward-proxy model
========================
TCP proxy connection
  -> request #1 -> target/session/route #1
  -> request #2 -> target/session/route #2
  -> request #3 -> target/session/route #3
  -> ...

The existing generic lifecycle therefore cannot provide correct persistent forward-proxy behavior simply by replaying more request bytes.


Confirmed consequences of the current lifecycle

1. Only the first request is normalized

The first absolute-form request is converted correctly:

GET http://example.test/a HTTP/1.1

becomes:

GET /a HTTP/1.1

but a later request on the same proxy TCP connection never enters accept_request() and therefore receives no normalization.

This is the directly reproduced #17 failure.

2. Later requests are not routed again

The problem is not limited to request-line rewriting.

Because serve_inbound() evaluates a route only once for the original Session, a later HTTP request on the same client proxy connection does not receive a new:

  • target extraction;
  • fake-IP resolution;
  • URL rewrite;
  • route-rule evaluation;
  • reject/direct/proxy decision;
  • outbound selection;
  • route trace;
  • logical Session attribution.

For example, a persistent proxy connection must not behave like this:

request #1 -> allowed.example -> DIRECT
request #2 -> blocked.example -> still uses request #1's established DIRECT stream

Whether a particular client reuses one proxy connection across different authorities depends on the client, but the core lifecycle must not rely on same-destination reuse as a correctness or security boundary.

3. Forward HTTP and CONNECT currently share too much lifecycle semantics

These modes need to diverge after parsing.

CONNECT is correctly tunnel-oriented:

CONNECT host:443
-> parse target
-> route once
-> establish upstream
-> send 200 Connection Established
-> raw bidirectional relay

Plain forward HTTP is message-oriented:

GET http://host/a
-> parse request
-> route request
-> normalize request
-> forward HTTP message
-> process response boundary
-> remain in HTTP proxy mode

GET http://host/b
-> parse again
-> route again
-> normalize again
-> ...

A successful plain HTTP request must not silently convert the entire client-proxy connection into an origin tunnel.


Additional confirmed HTTP intermediary correctness gaps

These are related to the same implementation strategy and should be considered while designing the fix. They are not all required to reproduce the Vite failure.

A. Host is not regenerated from the absolute request target

Current normalization changes only the request line. The remaining header bytes are replayed unchanged.

For example:

GET http://allowed.example/path HTTP/1.1
Host: different.example

creates/routs the Session from the absolute request target (allowed.example), while the original Host header can still be sent upstream unchanged.

For an HTTP/1.1 forward proxy, RFC 9112 §3.2 requires the proxy receiving an absolute-form request target to ignore a received Host field and generate Host from the request target.

Reference:

This mismatch can cause the authority used for core routing/policy to differ from the authority interpreted by a virtual-host/CDN/reverse-proxy origin. The exact policy-bypass impact should be covered by a dedicated regression/security test rather than assumed from theory alone.

B. Proxy-specific / connection-specific headers are replayed unchanged

The current replay path does not appear to normalize HTTP intermediary headers. The reproduced request already contains:

Proxy-Connection: Keep-Alive

and that header is currently part of the replayed header block.

Forward-proxy handling should explicitly define behavior for at least:

Connection
Proxy-Connection
Keep-Alive
TE
Trailer
Transfer-Encoding
Upgrade
Proxy-Authorization
Proxy-Authenticate

Connection-specific fields named by Connection also require handling rather than blind forwarding.

References:

C. HTTP message framing is not currently owned by the forward-proxy layer

A complete persistent forward proxy needs message boundaries, not raw TCP boundaries.

At minimum it must correctly reason about:

  • request Content-Length;
  • chunked request bodies;
  • response Content-Length;
  • chunked responses;
  • methods/statuses with no response body (HEAD, 1xx, 204, 304, etc.);
  • connection-close delimited messages;
  • malformed/conflicting framing, including Transfer-Encoding + Content-Length;
  • pipelined/sequential requests if supported;
  • switching to raw relay only at a valid Upgrade/CONNECT boundary.

Without owning these boundaries, the proxy cannot safely decide where request #1 ends and request #2 begins.

D. Upgrade/WebSocket requires an explicit state transition

For plain forward HTTP, receiving:

Upgrade: websocket

does not itself mean the proxy should immediately become a raw tunnel.

The proxy must forward the HTTP handshake and switch to raw relay only after the origin successfully returns the protocol-switch response (normally 101 Switching Protocols).

CONNECT followed by a WebSocket handshake is different: after the CONNECT tunnel is established, the proxy should remain protocol-agnostic and relay bytes normally.

The current Vite failure occurs before HMR can initialize, so WebSocket is not the root cause of the reproduced bug. It is a compatibility boundary that must be tested when the HTTP state machine is changed.


Other implementation defects discovered in the same HTTP/Mixed path

These are concrete code issues but are separable from the core keep-alive state-machine defect.

1. HTTP redirect rewrite behavior differs between http and mixed

The standalone HTTP inbound checks context.select_http_redirect(&session) before entering serve(...).

The Mixed HTTP branch currently parses the HTTP request and goes directly to context.serve(...) without the equivalent redirect check.

Therefore an HTTP request can behave differently depending on whether it enters through http or mixed, even though Mixed is expected to expose the same HTTP semantics after protocol detection.

2. Redirect-rule iteration can stop on the first non-redirect rewrite rule

select_redirect_target() currently contains logic equivalent to:

for rule in rules {
    let status = rule.status_code?;
    ...
}

Because the function returns Option, a rewrite rule with status_code: None causes the entire function to return None immediately instead of continuing to later rules.

Example:

{
  "url_rewrite": [
    { "from": "a.example", "to": "b.example" },
    { "from": "c.example", "to": "d.example", "status_code": 302 }
  ]
}

The second redirect rule cannot be selected because the first normal rewrite terminates redirect lookup.

This should be fixed independently or as part of the same HTTP/Mixed cleanup, with ordering regression tests.


Inbound resource-management concern discovered during analysis

The generic TCP listener spawns a task per accepted connection. The configured relay idle_timeout() is applied after a Session has been parsed/routed and an upstream has been established.

HTTP request-head parsing itself has no outer handshake/first-request deadline in the adapter/runtime path. A peer can therefore connect and provide an incomplete request head very slowly before the normal relay idle timeout applies.

SOCKS5 and other TCP inbound handshakes should also be audited for the same lifecycle gap.

This is broader than #17 and can be split into a separate issue for a generic inbound_handshake_timeout / admission-control primitive. It should not block defining the correct HTTP forward-proxy architecture here.


Parser limitations worth addressing during the HTTP rewrite

These are compatibility/performance issues rather than the primary #17 failure:

  • request-head parsing currently reads one byte at a time until \r\n\r\n;
  • the complete request head is capped at 8192 bytes;
  • request-line extraction currently performs UTF-8 validation over the complete captured request head rather than treating header field bytes independently;
  • absolute-form parsing currently accepts http:// but not ws://; supported behavior should be intentional and tested;
  • unsupported target/scheme cases are currently easy to collapse into generic 405 Method Not Allowed behavior even when the method itself is valid.

These should be revisited if the implementation moves to a real byte-oriented HTTP/1 parser/state machine rather than extending the current replay mechanism.


Required semantic model

The implementation should explicitly separate two modes.

Mode 1: CONNECT tunnel

client TCP connection
  -> parse CONNECT authority
  -> create Session
  -> evaluate route once
  -> establish upstream once
  -> reply 200
  -> raw relay until close/timeout

One client connection == one tunnel Session is appropriate here.

Mode 2: HTTP forward proxy

client proxy TCP connection
  -> parse request #1
      -> create logical request Session
      -> normalize authority/headers
      -> evaluate route
      -> establish/reuse suitable upstream
      -> relay exactly one HTTP request/response transaction
  -> parse request #2
      -> create logical request Session
      -> normalize authority/headers
      -> evaluate route again
      -> ...

The client-proxy TCP connection is a transport container, not itself the routing identity.

Whether upstream origin connections are reused is an implementation optimization and must not change request-level routing/policy semantics.


Fix strategy

Phase 1 — correctness/safety fix

If implementing a complete persistent HTTP state machine immediately is too large, prefer an explicit one-request-per-client-connection behavior for plain forward HTTP rather than the current implicit tunnel behavior.

A safe temporary strategy can be:

  1. parse one complete HTTP request;
  2. normalize request target and required proxy headers;
  3. evaluate routing for that request;
  4. forward exactly that request/response transaction;
  5. ensure the client-side proxy connection closes after the transaction, forcing the next request to establish a new proxy connection;
  6. keep CONNECT behavior unchanged.

Simply injecting Connection: close into the request without understanding the response boundary is not sufficient by itself; the implementation still needs to know when that transaction is complete before safely accepting/closing the client side.

Phase 2 — persistent HTTP/1.1 forward-proxy state machine

Implement explicit request/response framing and allow persistent client connections while creating request-level routing/session semantics.

This phase should define:

  • same-authority keep-alive;
  • different-authority requests on one client proxy connection;
  • upstream connection reuse policy;
  • request/response body framing;
  • header normalization;
  • redirects/rewrite interaction;
  • Upgrade/WebSocket transition;
  • cancellation and idle timeout semantics;
  • traffic accounting and logical Session records.

Route/session/telemetry expectations

A fix must not only make the browser render correctly.

For every plain HTTP forward request, core should be able to attribute at least:

request target
inbound tag
route decision / route trace
selected outbound
upstream endpoint
principal / rate-limit context where applicable
traffic belonging to the request or clearly documented connection-level accounting
result / failure reason

A later request must not inherit a previous request's target/route merely because the client reused the same proxy TCP connection.

CONNECT should continue to be represented as one tunnel Session.


Regression coverage

Tests should cover both http and mixed inbounds.

Confirmed failure coverage

  • Two sequential absolute-form GET requests on one client proxy TCP connection.
  • Origin observes /first then /second, never an absolute URI for the second request.
  • Vite-like / followed by /@vite/client or /asset.js.
  • Same test through mixed inbound.

Routing/policy coverage

Header/authority coverage

  • Absolute-form request target is the authority used for routing.
  • Upstream Host is regenerated/validated consistently from the effective request target.
  • Proxy-Connection is not blindly forwarded to the origin.
  • Connection-nominated hop-by-hop fields are handled correctly.
  • Proxy-Authorization does not leak to a normal origin.
  • conflicting framing (Transfer-Encoding + Content-Length) is rejected or normalized according to the selected HTTP parser semantics.

Body/framing coverage

  • GET with no body.
  • POST with Content-Length body.
  • chunked request body.
  • fixed-length response.
  • chunked response.
  • HEAD response.
  • 204 / 304 no-body response.
  • connection-close-delimited response if supported.

CONNECT / Upgrade coverage

  • CONNECT remains route-once + raw relay.
  • HTTP parser does not attempt to parse bytes inside an established CONNECT tunnel.
  • plain HTTP Upgrade: websocket stays in HTTP mode until a successful 101 boundary.
  • after successful Upgrade, bidirectional raw relay works.
  • failed/non-101 Upgrade response does not incorrectly switch to raw mode.
  • Vite/VitePress HMR works after normal module requests succeed.

HTTP/Mixed parity

  • http and Mixed's HTTP branch have equivalent redirect/rewrite behavior.
  • redirect selection continues past normal rewrite rules with no status_code.

Non-goals / do not workaround in znet-sink

Do not solve this by automatically adding DIRECT destinations to the Windows system-proxy bypass list.

These are different semantics:

DIRECT
= traffic enters core, is evaluated by core, and core opens the destination directly

system proxy bypass
= traffic never enters the HTTP/Mixed inbound at all

Changing DIRECT into OS-level bypass would hide the HTTP proxy defect while changing routing, observability, policy, DNS/process behavior, and user expectations.


Acceptance criteria

This issue is complete when the implementation has an explicit, documented distinction between CONNECT tunnel semantics and plain HTTP forward-proxy semantics, and the following are true:

  1. Plain HTTP forward requests never become an unqualified raw origin tunnel after the first request.
  2. Every request accepted on a persistent proxy connection receives correct request-target parsing and normalization.
  3. Every request receives an appropriate independent routing/policy decision.
  4. Header/authority handling is suitable for an HTTP intermediary rather than replaying the captured header block blindly.
  5. HTTP message boundaries are handled safely for supported request/response body forms.
  6. Upgrade switches to raw relay only at a valid protocol-switch boundary.
  7. http and Mixed's HTTP branch expose the same HTTP behavior.
  8. CONNECT behavior remains unchanged and continues to work as a raw tunnel after establishment.
  9. Regression tests cover persistent requests, routing separation, normal bodies/framing, and Vite/HMR-style traffic.

The immediate Vite reproduction can be used as the integration proof, but the fix should target the underlying HTTP forward-proxy semantics rather than Vite-specific behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions