Skip to content

feat(protocol): forward absolute-form requests to their origin - #1480

Draft
esnible wants to merge 3 commits into
praxis-proxy:mainfrom
esnible:feat/forward-proxy-absolute-form
Draft

esnible wants to merge 3 commits into
praxis-proxy:mainfrom
esnible:feat/forward-proxy-absolute-form

Conversation

@esnible

@esnible esnible commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

What

On a forward-proxy listener, an HTTP/1 absolute-form plain request
(GET http://host/path — what HTTP_PROXY clients send for http://
URLs) now runs the same pipeline and the same allow_destinations rules
as CONNECT, and is then forwarded to its origin: TLS-originated with SNI
for https destinations, plain TCP for http.

Details:

  • The destination is parsed from the verbatim wire target (raw_path()),
    because the HTTP/1 reader keeps scheme and authority off the Uri.
  • The upstream leg is rewritten to origin-form before forwarding.
  • A destination not on the allowlist is answered with 403, matching the
    CONNECT gate.
  • Origin-form requests keep the reverse-proxy pipeline when it selects an upstream (a router/load_balancer on the listener), so one port can reverse- and forward-proxy. When it selects none, the request is answered with 400 instead of failing later with a 500 for want of an upstream — that applies on HTTP/2 too. Pipeline rejections and terminal responses (e.g. an ip_acl 403) still answer first.
  • A private or reserved IP-literal destination (GET http://169.254.169.254/, loopback, RFC 1918) is refused with 502 unless insecure_options.allow_private_upstreams is set. The HTTP upstream resolver checks only names it resolves and trusts literals — fine for reverse proxying, where literals come from validated config, but here the client picks them. The CONNECT dialer already checked literals.
  • The destination is dialed as an absolute name (a trailing dot), as CONNECT is, so the resolver cannot append search domains and reach an unapproved host. The Host header and TLS SNI keep the name the client sent.
  • An IPv6 literal (http://[::1]/) is stored without its brackets. http::Uri keeps them, which made the dial address [[::1]]:8080 and kept the private-address check from parsing the host as an IP, so private IPv6 literals were not refused.

Why

HTTP_PROXY clients send absolute-form targets for plain-http URLs and
CONNECT tunnels for https. Without this, a forward-proxy listener
answers every plain request with 500 (no upstream selected), so the
forward proxy from #781 only half-works.

Stacking and dependencies

Fixes part of #781.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@esnible
esnible force-pushed the feat/forward-proxy-absolute-form branch 2 times, most recently from ec1aa0b to 889e71d Compare November 2, 2026 15:22
@esnible
esnible force-pushed the feat/forward-proxy-absolute-form branch from 889e71d to b028e77 Compare November 2, 2026 19:24
@araujof
araujof self-requested a review November 2, 2026 19:44
@esnible
esnible force-pushed the feat/forward-proxy-absolute-form branch 7 times, most recently from 58da69d to c6ba7f8 Compare November 6, 2026 21:49
Fork 0.11.3 carries the CONNECT tunnel support the forward-proxy runtime
needs: 2xx responses to CONNECT switch the session to tunnel mode
(praxis-proxy/pingora#7), and a refused CONNECT closes the connection so
early tunnel bytes are never parsed as a pipelined request
(praxis-proxy/pingora#8).

The fork has capped h2 at >=0.4.16, <0.4.20 since 0.11.1, so the
workspace h2 requirement moves from 0.4.20 to 0.4.19; cargo cannot
resolve the two otherwise. h2 0.4.20 began rejecting conflicting
duplicate Content-Length itself, and the fork's tests expect Pingora's
own check to do it. That defense is unchanged: the fork and Praxis
inbound admission both reject conflicting values. The other 0.4.20 fixes
are forgone until the fork lifts the cap, as the Cargo.toml comment
records. No RustSec advisory affects h2 0.4.19.

Signed-off-by: Ed Snible <snible@us.ibm.com>
Listeners with an enabled forward_proxy block now forward-proxy:
CONNECT requests pass the same inbound admission as every other request,
run the filter pipeline, then the destination allowlist and the
private-IP guard decide the dial, and the tunnel relays raw bytes under
the configured idle timeout and optional max duration, with structured
tunnel_established / tunnel_close events. HTTP/2 CONNECT is answered
with 501; every non-CONNECT request keeps the reverse-proxy pipeline.

Admission runs before the pipeline so a client cannot hand trusted
context to policy filters: reserved x-praxis-* headers are refused with
400, as on any other request. A target without a port is refused with
400, since authority-form requires one (RFC 9112 Section 3.2.3). A
filter that answers the CONNECT with a 2xx gets a 502 instead, without
its body: to CONNECT a 2xx means the tunnel is open, and none was.

The pump splits the destination stream so a pending destination write is
its own select! arm: a destination that stops reading stalls only the
client-to-destination direction, while destination bytes keep reaching
the client and both deadlines keep firing. Client writes share the
session with client reads, so each runs under the idle timeout and the
whole relay under the hard deadline; a peer that stops reading cannot
hold a tunnel open past either limit. Byte counts include only what the
receiving side accepted.

The tunnel 200 goes through the response filters like any response,
before the pump starts, and the tunnel ends with a bodyless
end-of-stream body pass, so access_log emits its own record at close
with the final byte counts. The logging hook then neither re-runs the
response filters without a response nor falls back to a fixed-shape
record.

The destination host is resolved as an absolute name (with a trailing
dot), so the system resolver cannot append its search domains and dial
a host the destination rules never approved, such as api.internal
becoming api.internal.corp.example. IP literals are dialed as given.

The runtime is behind the forward-proxy cargo feature (protocol and
server), which also turns on the config key in core; without it the
connect module is not compiled and CONNECT stays answered with 405,
even when the config key is on through the core feature alone.
The feature selects the Pingora backend, so the hyper suite lanes leave
it out, as they do tls-health-check.

Relies on the pingora fork 0.11.3 2xx-to-CONNECT tunnel mode
(praxis-proxy/pingora#7).

Signed-off-by: Ed Snible <snible@us.ibm.com>
An HTTP/1 absolute-form plain request (GET http://host/path, what
HTTP_PROXY clients send for http:// URLs) on a forward_proxy listener
runs the same pipeline and destination rules as CONNECT and is then
forwarded to its origin, TLS-originated with SNI for https. The
destination is parsed from the verbatim wire target (the fork's H1
reader keeps scheme and authority off the Uri), and the upstream leg
is rewritten to origin-form. Origin-form requests keep the
reverse-proxy pipeline when it selects an upstream, so one port can
reverse- and forward-proxy; when it selects none they are answered
with 400 rather than failing with a 500 for want of an upstream.

A private or reserved IP-literal destination is refused with 502
unless allow_private_upstreams is set. The HTTP upstream resolver
checks only names it resolves and trusts literals, which come from
validated config in reverse-proxy mode but from the client here; the
CONNECT dialer already checks literals.

The destination is dialed as an absolute name (with a trailing dot), as
CONNECT is, so the system resolver cannot append its search domains to
a relative name and reach a host the destination rules never approved.
The Host header and TLS SNI keep the name the client sent.

An IPv6 literal destination (http://[::1]/) is stored without its
brackets. http::Uri keeps them, which made the dial address
[[::1]]:8080 and kept the private-address check from parsing the host
as an IP, so a private IPv6 literal was not refused.

Signed-off-by: Ed Snible <snible@us.ibm.com>
@esnible
esnible force-pushed the feat/forward-proxy-absolute-form branch from c6ba7f8 to 6edd06d Compare November 7, 2026 03:24

This branch has not been deployed

No deployments
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