The WebTransport proxy path in serveWebTransport duplicated the
dynamic-upstream-fallback block and the {http.reverse_proxy.upstream.*}
replacer-variable block from proxyLoopIteration. Francis flagged this
as a maintenance burden in review of #7669.
Extract two helpers:
* resolveUpstreams(r) returns the candidate upstream set — dynamic
when configured (with provisioning + fallback-on-error), static
otherwise. Caller runs the LB selection policy, since the two call
sites diverge on how selection failure is reported (retry loop vs.
fast 502 for long-lived WT sessions).
* setUpstreamReplacerVars(repl, up, di) publishes the seven
placeholders describing the selected upstream.
Both are used by proxyLoopIteration and serveWebTransport with
identical semantics to the inlined code they replace. No behavior
change for either path.
Bracket the pump's lifetime with Host.countRequest(±1) and
incInFlightRequest/decInFlightRequest so WT sessions participate in the
same accounting as the normal proxy path:
- MaxRequests gating (Upstream.Full) now blocks WT sessions past the
cap, instead of silently failing open.
- LeastConn / FirstAvailable selection sees WT load, instead of seeing
busy upstreams as idle.
- Admin /reverse_proxy/upstreams reports WT sessions under num_requests.
Integration test holds an upstream session open via a standalone WT
server, polls the admin API to assert num_requests increments during
the session and drops back to 0 after close.
Reorder serveWebTransport so the upstream is dialed first. If the
upstream is unreachable or refuses the CONNECT, a proper 5xx is returned
from the handler — the client's Dial() surfaces the real status instead
of a successful upgrade followed by an opaque session close.
Also apply `h.Headers.Response` (gated by `Require`, if configured)
against the upstream response status/headers; the ops run on the
client-visible response headers, which webtransport.Server.Upgrade
flushes with the 200 OK. If the client-side upgrade fails after the
upstream dial succeeded, close the upstream session cleanly.
Integration test drives a dial to an unbound loopback port and asserts
the client sees a 5xx status instead of a bare session close.
The WebTransport proxy path previously bypassed the request-preparation
pipeline that normal reverse-proxy traffic runs through. Reuse it so
`header_up`, `X-Forwarded-For`/`Host`/`Proto`, `Via`, `Rewrite`, the
`{http.reverse_proxy.upstream.*}` placeholders, dynamic upstreams,
`countFailure`, and the `{http.reverse_proxy.duration{_ms}}` timing
placeholder all behave the same as on the regular path.
Retries, `handle_response`, and response-header ops are intentionally
not run here — a WebTransport session has no HTTP response body to
post-process and is not idempotent. Integration test exercises the
header-forwarding contract end-to-end through a standalone (non-Caddy)
WebTransport upstream so the forwarded Extended CONNECT can be
inspected.
Adds a `webtransport` subdirective to the `transport http {}` block of
reverse_proxy that sets the new WebTransport bool on the transport.
Takes no arguments; exclusivity with versions 3 is enforced at
Provision time so parse order doesn't matter.
Example:
reverse_proxy https://backend:9443 {
transport http {
versions 3
webtransport
tls_insecure_skip_verify
}
}
Includes a Caddyfile-to-JSON adapt test round-tripping the new
subdirective.
Extends the http reverse-proxy transport with a webtransport boolean
that opts the upstream into WebTransport passthrough. Must be combined
with versions: ["3"]; WebTransport rides on HTTP/3 exclusively.
When enabled, Handler.ServeHTTP detects Extended CONNECT with
:protocol=webtransport early — before any of the normal round-trip
machinery — and branches to serveWebTransport, which:
1. Pulls the *webtransport.Server off caddyhttp.Server (via
WebTransportServer()) and errors out cleanly if HTTP/3 isn't
enabled on the frontend.
2. Picks a single upstream through the configured load-balancer.
No retries: a failed dial closes the client session and returns.
3. Walks the response-writer Unwrap() chain to reach the raw http3
writer and calls webtransport.Server.Upgrade to terminate the
incoming session.
4. Uses dialUpstreamWebTransport to open a session to the selected
upstream, forwarding request headers on the Extended CONNECT.
5. Runs runWebTransportPump between the two sessions and blocks
until both close.
The transport's wtTLSConfig is built at Provision time from the
existing TLS config (same path h3Transport already uses) and reused
for every session.
Tests: adds TestWebTransport_ReverseProxyEndToEnd which spins up a
single Caddy instance with two HTTP/3 servers — one proxy on :9443,
one terminating echo upstream on :9444 — and drives a real
webtransport.Dialer through the proxy to assert end-to-end
bidirectional-stream echo.
runWebTransportPump bridges two WebTransport sessions so every
bidirectional stream, unidirectional stream, and datagram opened on one
side is mirrored on the other. Uses six goroutines (bidi both ways, uni
both ways, datagrams both ways) and blocks until both sessions end.
Close propagation: when either session ends, the peer is closed via
CloseWithError. The code/message are read from the closing session's
stored close state (by probing AcceptStream with a short timeout),
since Receive{Datagram,UniStream} return the underlying stream error
rather than the SessionError and can win the propagation race. Close
propagation is best-effort for client-initiated close through a
Dialer-dedicated QUIC conn: webtransport-go tears down the QUIC
connection immediately after CloseWithError, so the upstream may
observe a QUIC ApplicationError before the WT_CLOSE_SESSION capsule is
parsed. The pump still closes the peer session; only the specific
error code may not survive.
Not yet wired into ServeHTTP.
Tests: topology of client -> frontend -> upstream where frontend runs
the pump. Exercises bidi both ways, uni client-to-upstream, datagram
round-trip, CloseWithError propagation both ways, and a basic
goroutine-leak check.
dialUpstreamWebTransport is a thin wrapper around webtransport.Dialer.Dial
that sets the QUIC config flags WebTransport requires (EnableDatagrams,
EnableStreamResetPartialDelivery) and forwards request headers on the
Extended CONNECT. Intended as an internal building block for the
upcoming WebTransport reverse-proxy transport; not yet wired into
ServeHTTP.
Unit-tested against an in-process webtransport.Server with a freshly
minted self-signed certificate. Covers: successful dial, header
forwarding, and connection-refused against an unbound port.
* reverseproxy: validate on weighted_round_robin policy
Validate that weighted_round_robin has a non-zero total weight.
This prevents configurations such as:
weighted_round_robin 0 0
from being accepted and causing a divide-by-zero panic during request handling.
* test: validation test on zero weight upstreams.
* test: provision called instead of totalweight setting
* reverseproxy: validate on negative upstream weights
* test: regression test on weighted_round_robin selection policy
* reverseproxy: replace placeholders specified for sni while using http3
* add test for placeholder
* reverseproxy: replace placeholders specified for sni while using http3
* add test for placeholder
* reverseproxy: test HTTP/3 SNI host placeholder
---------
Co-authored-by: Zen Dodd <mail@steadytao.com>
* feat: drop headers with underscore in their names
* feat: Caddyfile binding and tests for underscore-in-header drop
Add the `allow_underscore_in_headers` global server option, refine the
doc comment, and cover the filter end-to-end: server-level unit tests
(drop, opt-out, debug log, RFC-7230 space rejection), a fastcgi unit
test for the trimmed header name replacer, and forward_auth integration
tests for both the default-drop and opt-out paths.
* remove allow_underscore_in_headers option for now
Both fallbacks in splitPos relied on golang.org/x/text/search with
search.IgnoreCase, which performs Unicode equivalence matching far beyond
ASCII case folding. Combined with the validated-ASCII guarantee on every
SplitPath entry, that fallback turned non-PHP filenames into PHP scripts:
- when the inner loop hit a non-ASCII byte and the IndexString fallback
returned -1, the loop broke without resetting match=false, so a stale
match=true caused a non-existent .php to be reported (PoC:
"/name.<U+00A1>.txt").
- search.IgnoreCase folded fullwidth, mathematical and circled letters
onto ASCII, so "/shell.<math sans-serif php>",
"/shell.<fullwidth p>hp", "/shell.<circled php>" were all detected as
".php" files.
Replace the fallback with strict byte-level ASCII case-insensitive
matching: any byte >= utf8.RuneSelf in the path can never be part of a
match, since SplitPath entries are validated ASCII-only and lower-cased
in Provision(). This keeps the hot path branch-light and removes the
x/text/search dependency from the main module.
Reported against FrankenPHP as GHSA-3g8v-8r37-cgjm and
GHSA-v4h7-cj44-8fc8. The vulnerable function in this module was adapted
from the same FrankenPHP code.
* reverseproxy: Add ability to clear dynamic upstreams cache during retries
This is an optional interface for dynamic upstream modules to implement if they cache results.
TODO: More documentation; this is an experiment.
* Add some godoc
* Export interface; update godoc
* admin: Redact sensitive request headers in API logs
* Fix govulncheck and typed atomic lint failures
* Sync Go module metadata after dependency downgrade
* add 'root' key to Helper.State for access in frankenphp's `php_server` directive
* clone state before passing it to child directives, but keep sharing it among sibling directives
* propagate named route state from children to parent
* use BlockState to set "root" instead
* gofmt -w .
* go fmt ./...
* here we go
When using copy_headers in a forward_auth block, client-supplied headers with
the same names were not being removed before being forwarded to the backend.
This happens because PR #6608 added a MatchNot guard that skips the Set
operation when the auth service does not return a given header. That guard
prevents setting headers to empty strings, which is the correct behavior,
but it also means a client can send X-User-Id: admin in their request and
if the auth service validates the token without returning X-User-Id, Caddy
skips the Set and the client value passes through unchanged to the backend.
The fix adds an unconditional delete route for each copy_headers entry,
placed just before the existing conditional set route. The delete always runs
regardless of what the auth service returns. The conditional set still only
runs when the auth service provides that header.
The end result is:
- Client-supplied headers are always removed
- When the auth service returns the header, the backend gets that value
- When the auth service does not return the header, the backend sees nothing
Existing behavior is unchanged for any deployment where the auth service
returns all of the configured copy_headers entries.
Fixes GHSA-7r4p-vjf4-gxv4
This refactors the initial approach in PR #7281, replacing the UsagePool
with a dedicated package-level sync.Map and atomic.Int64 to track
in-flight requests without global lock contention.
It also introduces a lookup map in the admin API to fix a potential
O(n^2) iteration over upstreams, ensuring that draining upstreams
are correctly exposed across config reloads without leaking memory.
Co-authored-by: Y.Horie <u5.horie@gmail.com>
reverseproxy: optimize in-flight tracking and admin API
- Replaced sync.RWMutex with sync.Map and atomic.Int64 to avoid lock contention under high RPS.
- Introduced a lookup map in the admin API to fix a potential O(n^2) iteration over upstreams.
When a request arrives via a Unix domain socket (RemoteAddr == "@"),
net.SplitHostPort fails, causing addForwardedHeaders to strip all
X-Forwarded-* headers even when the connection is trusted via
trusted_proxies_unix.
Handle Unix socket connections before parsing RemoteAddr: if untrusted,
strip headers for security; if trusted, let clientIP remain empty (no
peer IP for a Unix socket hop) and fall through to the shared header
logic, preserving the existing XFF chain without appending a spurious
entry.
Amp-Thread-ID: https://ampcode.com/threads/T-019c4225-a0ad-7283-ac56-e2c01eae1103
Co-authored-by: Amp <amp@ampcode.com>
* capture the buffered body once, then reset clonedReq.Body before each retry
* no copy
* keep receiver name
* set the buf to nil after extraction and only return it to pool if not nil
---------
Co-authored-by: WeidiDeng <weidi_deng@icloud.com>