mirror of
https://github.com/caddyserver/caddy.git
synced 2026-10-31 08:21:39 -04:00
* caddy: pass ListenQUIC options as a struct
Collapse the TLS config, packet-conn wrappers, and Allow0RTT arguments
into ListenQUICOptions so the signature matches Listen's ctx/portOffset/
ListenConfig prefix. Zero-value opts keep the historical default of
allowing 0-RTT. The API remains experimental.
* caddyhttp: advertise WebTransport in HTTP/3 SETTINGS
Add github.com/quic-go/webtransport-go dep (built on quic-go, same
maintainers) and call webtransport.ConfigureHTTP3Server on the
http3.Server. This advertises WebTransport enablement in SETTINGS,
enables HTTP/3 DATAGRAMs, and stashes the *quic.Conn in each request's
context — a prerequisite for a later WebTransport-aware handler or
reverse-proxy transport to call webtransport.Server.Upgrade. Also
enable QUIC stream reset partial delivery, required by webtransport-go.
No user-visible behavior change: clients that don't speak WebTransport
ignore the extra SETTINGS, and no handler yet calls Upgrade.
Extract the http3.Server construction into buildHTTP3Server so the
SETTINGS assertions can be unit-tested without a live UDP listener.
* caddyhttp: add UnwrapResponseWriterAs helper
Go's type assertion `x.(T)` does not follow Unwrap() http.ResponseWriter
chains. Caddy wraps the writer multiple times (logging recorder,
intercept, encode, etc.), so code that needs interfaces implemented only
by the raw writer owned by the HTTP server — for example the
http3.Settingser/HTTPStreamer interfaces that webtransport.Server.Upgrade
type-asserts — cannot see through those wrappers.
UnwrapResponseWriterAs walks the Unwrap() chain and returns the first
writer that satisfies the requested interface (or the zero value if none
do). Mirrors the traversal http.ResponseController performs internally.
Used by upcoming WebTransport handler and reverse-proxy transport.
* caddyhttp: add terminating WebTransport handler
Introduces http.handlers.webtransport, an EXPERIMENTAL handler that
terminates an incoming WebTransport session on top of Caddy's HTTP/3
server and echoes bytes on each bidirectional stream. Primary use case
is as a test upstream for the forthcoming WebTransport reverse-proxy
transport; it also serves as the minimal proof that the server-side
WebTransport wiring works end-to-end.
Plumbing changes:
* caddyhttp.Server gains a *webtransport.Server field alongside
h3server. It's built in buildWebTransportServer(), wrapping the
existing http3.Server. Exposed via WebTransportServer() any on the
Server, so the caddyhttp public API does not name the upstream
webtransport-go type (per AGENTS.md).
* serveHTTP3 now runs a custom accept loop (serveH3AcceptLoop) that
dispatches each accepted QUIC connection to
webtransport.Server.ServeQUICConn instead of
http3.Server.ServeListener. The WebTransport server transparently
forwards non-WT streams to the underlying http3 request handler
(cost: one varint peek per stream), so behavior for non-WT clients
is unchanged.
* ListenQUIC enables EnableDatagrams and
EnableStreamResetPartialDelivery on the QUIC listener config.
These are capability bits negotiated during the QUIC handshake and
are prerequisites for any WebTransport session; they do not force
usage so non-WT H3 traffic is unaffected.
* Stop path closes wtServer after h3server.Shutdown to clean up any
remaining WebTransport session state.
The handler uses caddyhttp.UnwrapResponseWriterAs to reach the naked
http3.Settingser/HTTPStreamer writer through Caddy's wrapping chain
before calling webtransport.Server.Upgrade.
Includes unit tests for request-shape detection plus an integration
test (caddytest/integration/webtransport_test.go) that spins up a
Caddy HTTP/3 server with the handler, dials it with a real
webtransport.Dialer, and asserts end-to-end bidirectional-stream
echo.
* reverseproxy: add WebTransport upstream dialer helper
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: add WebTransport session pump
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.
* reverseproxy: wire WebTransport pump into ServeHTTP
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.
* reverseproxy: add webtransport Caddyfile subdirective
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.
* reverseproxy: apply standard request preparation to WebTransport CONNECT
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.
* reverseproxy: dial WebTransport upstream before upgrading client
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.
* reverseproxy: track WebTransport sessions in upstream in-flight counters
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.
* reverseproxy: extract shared upstream-selection helpers
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.
* reverseproxy: inline WebTransport protocol const and writer interface
Francis pointed out in review of #7669 that importing the whole
modules/caddyhttp/webtransport package solely to pull in one constant
and one interface wasn't worthwhile.
Move both into webtransport_transport.go as unexported identifiers
(webtransportProtocol, webtransportWriter). This removes reverseproxy's
dependency on the caddywt package and clears the way for moving the
echo handler itself out of the production module tree.
No behavior change.
* caddyhttp: move WebTransport echo handler to integration tests
Francis pointed out in review of #7669 that the echo handler — which
exists solely as a test upstream for the WebTransport reverse-proxy
tests — should not be a full-fledged module registered in every Caddy
binary. Mirroring the mockdns_test.go pattern, move it into a _test.go
file under caddytest/integration/.
The module ID http.handlers.webtransport is now registered only when
the integration test binary is built, which is when
caddytest/integration/webtransport_test.go references it by ID string
in its JSON configs. Production Caddy builds no longer include it.
Changes:
* New file: caddytest/integration/webtransport_echo_test.go —
contains the WebTransportEcho handler, its types and interface
guards, the isWebTransportEchoUpgrade helper, and the unit tests
that used to live in the deleted package's handler_test.go.
* Deleted: modules/caddyhttp/webtransport/ (handler.go + handler_test.go).
* Removed the blank import from modules/caddyhttp/standard/imports.go.
The Protocol const and Writer interface that this package used to
export were inlined into reverseproxy's own files in a preceding
commit, so nothing else depends on the deleted package.
* caddyhttp, reverseproxy: gate WebTransport behind enable_webtransport server flag
steadytao raised an architectural concern in review of #7669: the PR
put experimental WebTransport handling directly into Caddy's core
HTTP/3 accept path, so every HTTP/3 deployment paid for the feature
whether or not they used it.
Collapse the enablement surface to a single server-level opt-in that
matches Caddy's existing precedent for protocol-level features
(`protocols`, `allow_0rtt`, `enable_full_duplex`), and detect the
request shape at the handler the same way `reverse_proxy` detects a
WebSocket upgrade today — no per-handler config flag.
Core HTTP/3 path changes (modules/caddyhttp/server.go):
* New `EnableWebTransport bool` field on Server, marked EXPERIMENTAL.
* buildHTTP3Server now only calls webtransport.ConfigureHTTP3Server
and sets EnableStreamResetPartialDelivery when the flag is true.
When false, the constructed http3.Server is bit-for-bit identical
to the pre-WebTransport implementation.
* wtServer is constructed only when the flag is true.
* serveH3AcceptLoop falls back to http3.Server.ServeListener when
the flag is false — no varint peek, no per-connection dispatch.
Caddyfile wiring (caddyconfig/httpcaddyfile/serveroptions.go):
* New `enable_webtransport` global server option, modeled on
`enable_full_duplex`.
Reverse-proxy simplifications (modules/caddyhttp/reverseproxy/):
* Removed HTTPTransport.WebTransport field and its Provision-time
exclusivity check (no longer needed; H3 is validated separately).
* Removed the `webtransport` Caddyfile subdirective under
`transport http { }` — this neutralizes the prior commit that
introduced it.
* Removed Handler.webtransportEnabled cache. ServeHTTP now branches
on isWebTransportExtendedConnect(r) alone, matching how the
WebSocket upgrade branch works.
* serveWebTransport gains fail-fast guards with clear errors when
the parent server has enable_webtransport=false or when the
handler's transport does not include HTTP/3.
Tests:
* Existing TestServer_BuildHTTP3ServerEnablesWebTransport now sets
EnableWebTransport=true explicitly; new
TestServer_BuildHTTP3ServerWithoutWebTransport locks in the
regression guard that flag-off produces the pre-PR http3.Server.
* Integration tests updated: enable_webtransport: true added to
every H3 server block; "webtransport": true dropped from the
reverse_proxy transport JSON (auto-detected now).
* Caddyfile adapt test for the deleted `webtransport` subdirective
is removed; `enable_webtransport` is added to the existing
global_server_options_single adapt test alongside its peers.
No runtime behavior change when enable_webtransport is false. Diff
against master on the core HTTP/3 path is effectively zero in that
configuration.
* caddyhttp: micro-benchmark HTTP/3 server construction with/without WebTransport
Adds a hermetic pair of benchmarks on buildHTTP3Server to provide
quantitative evidence for the claim that deployments with
enable_webtransport=false pay no cost for the WebTransport feature.
Results on Apple M4, go1.25, -count=5:
BenchmarkBuildHTTP3Server_WebTransportOff ~70 ns/op 392 B/op 3 allocs/op
BenchmarkBuildHTTP3Server_WebTransportOn ~144 ns/op 600 B/op 6 allocs/op
The Off path is about half the cost on every dimension, confirming
that the work skipped when the flag is false is the
webtransport.ConfigureHTTP3Server call plus EnableStreamResetPartialDelivery.
Absolute cost is a one-time per-server setup so either branch is
negligible in practice, but the asymmetry locks in a regression guard:
a future refactor that accidentally re-enables the WT configuration
unconditionally would show up as a jump in the Off numbers.
This benchmark does not exercise the per-stream dispatch cost inside
webtransport.Server.ServeQUICConn — that would require a full QUIC
setup to measure in isolation and is follow-up work.
* reverseproxy: collapse WebTransport handler into main proxy loop
The WT path duplicated upstream resolution, LB selection, header ops,
replacer vars, and in-flight counters. Route WT through the shared
ServeHTTP -> proxyLoopIteration -> reverseProxy flow and swap RoundTrip
for a small webTransportHijack that only does WT-specific work (writer
unwrap, upstream dial, client upgrade, pump).
Rename roundtripSucceededError -> terminalError. The existing name
described when it was emitted (after a successful round-trip); the
new name describes its contract with the retry loop (stop looping,
propagate error unchanged). The WebTransport upgrade case is a second
natural caller for that same signal.
Comes with two behavior improvements that fall out of the collapse:
- WT upstream dial failures now surface as DialError, so the loop
can fail over across upstreams like normal proxies (today: 502).
- Passive health checks apply to WT dials (dial-failure countFailure
and UnhealthyLatency on dial duration) via the shared path.
Addresses reviewer feedback that the duplicated setup was a
maintenance risk.
* reverseproxy: inline upstream-resolution helpers back into the proxy loop
The earlier #7669 revision extracted resolveUpstreams and
setUpstreamReplacerVars to share between the WebTransport path and the
normal proxy path. A later commit collapsed the WebTransport path into
the main proxy loop, so the helpers became single-callers — they no
longer earn their abstraction.
Inline both back into proxyLoopIteration. The dynamic-upstream block
absorbs the CachingUpstreamSource cache-reset logic added by #7662,
which was preserved through the rebase.
No behavior change.
* caddyhttp: clarify WebTransportServer doc covers both disabled paths
WebTransportServer returns nil for two distinct configurations:
EnableWebTransport=false on the server, and HTTP/3 not in use. The
prior doc only mentioned the latter. Spell out both, per review.
* reverseproxy: drop nil-logger guard in runWebTransportPump
Production and test callers both pass a non-nil *zap.Logger. The
defensive nil check silently swapped in a no-op logger, which can mask
a real bug at the call site. Document the precondition in the doc
comment instead, per review.
* reverseproxy: use sync.WaitGroup.Go in WebTransport pump
Replace the wg.Add(6) + six "go func() { defer wg.Done(); fn() }()"
calls with sync.WaitGroup.Go (Go 1.25+, the project's minimum
version). Same number of goroutines; less boilerplate.
* caddyhttp: drop defensive checks in UnwrapResponseWriterAs
Per review, the nil-pointer guard and the next == w self-reference
guard defended against shapes that no in-tree ResponseWriter wrapper
produces. Drop both, simplifying the loop. Also remove the
self-reference test that exercised the now-removed guard.
* reverseproxy: use r.URL.RequestURI() to build the WebTransport upstream URL
Replace buildWebTransportUpstreamURL with an inline call to
r.URL.RequestURI() at the single call site. RequestURI handles the
empty-path → "/" fixup and query suffixing already, and uses
EscapedPath so encoded path segments (e.g. %2F) are preserved on the
upstream — fixing a small correctness gap where the previous helper
read r.URL.Path (decoded).
Aligns with how Caddy's normal proxy path lets stdlib render the
outbound request line via URL.RequestURI semantics.
* reverseproxy: use wg.Go in spliceBidi
* reverseproxy: expand SNI placeholders on the WebTransport upstream dial
The normal HTTP/3 path expands placeholders in tls_server_name at dial
time via a custom h3Transport.Dial hook (#7737). The WebTransport path
dials through its own webtransport.Dialer and bypasses that hook, so a
placeholder like {http.request.host} reached the QUIC ClientHello
literally. Expand it in webTransportHijack before dialing, cloning the
shared TLS config so concurrent sessions are unaffected.
Adds an integration test asserting the upstream observes the resolved
SNI rather than the literal "{...}" string.
* reverseproxy: bump webtransport-go to v0.11.0 (WebTransport draft-15)
webtransport-go v0.11.0 implements draft-15, which renames the Extended
CONNECT :protocol token from "webtransport" to "webtransport-h3" (the
server keeps accepting the legacy token for older clients). Detect both
tokens in the reverse-proxy upgrade check and in the test echo handler.
Pulls quic-go v0.59.1 -> v0.60.0, required by webtransport-go v0.11.0;
its only breaking change is requiring Go 1.25 (already met), with no API
changes affecting Caddy core. Also picks up the v0.11.0 fix for a
stream-close race on concurrent session close, relevant to the long-lived
pump.
* caddyhttp: log WebTransport QUIC connection serve errors
* caddy: gate QUIC WebTransport capabilities behind an explicit ListenQUIC parameter
* caddyhttp: fall back to plain HTTP/3 when a reused QUIC listener lacks WebTransport
* caddyhttp: enable WebTransport via a webtransport config object
Replace the enable_webtransport bool with a presence-gated
WebTransportConfig object so later options can be added without
renaming the key. JSON is "webtransport": {}, Caddyfile is
webtransport (optional empty block).
* reverseproxy: use origReq for the client WebTransport upgrade
Upgrade is the client-facing session: CheckOrigin and the CONNECT
shape must come from the downstream request. The upstream-directed
clone remains used for the dial URL, header_up, and SNI expansion.
* reverseproxy: relay WebTransport application protocol negotiation
Parse WT-Available-Protocols from the downstream request, set it on
the upstream Dialer, and copy WT-Protocol from the upstream 200 onto
the client response. The shared webtransport.Server does not select
a protocol of its own, so MOQ/MediaMTX clients receive the upstream's
choice instead of WT_ALPN_ERROR.
* caddytest: skip origin check on standalone WebTransport backends
header_up Host rewrites used by proxy tests would 400 the upstream
Upgrade via CheckOrigin. Test backends are not browsers.
* caddytest: cover MediaMTX Host-rewrite + moqt-19 through the WT proxy
Both of elee's #7669 reports fire together: header_up rewrites Host
(Upgrade must use origReq) and the client offers moqt-19 (the proxy
must relay WT-Available-Protocols / WT-Protocol). A bidi echo after
negotiation proves the session is usable, not just headers.
* caddyhttp: break self-referential Unwrap loops
A wrapper whose Unwrap returns itself would spin forever. Restore the
cycle-break mholt applied on #7669, without bringing back the nil-pointer
guard Weidi asked to drop.
* caddyhttp: close WebTransport sessions before HTTP/3 shutdown
ServeQUICConn waits for active WT sessions, and with the default
eternal grace period HTTP/3 Shutdown would hang until every client
disconnected. Close the WebTransport server first so stop/reload
can drain.
* caddyhttp: serve plain HTTP/3 when a reused listener lacks WebTransport
A pooled QUIC listener created without DATAGRAMs cannot upgrade WT
sessions. Falling back on the WT-configured http3.Server still
advertised SETTINGS the socket cannot honor. Build a separate plain
HTTP/3 server for that listener and assert it omits WT SETTINGS.
* caddyhttp: visit Unwrap writers by pointer identity
Comparing writers with == panics on uncomparable types and only
catches a one-node cycle. Track pointer identity, bound the walk,
and add tests for an uncomparable self-unwrap and an A→B→A cycle.
RecordHijackedStatus lets Upgrade write through the naked HTTP/3
writer while the access-log recorder still observes the 200.
* reverseproxy: honor prepared Host, header_down, and WT bookkeeping
Dialer.Dial always sets Host from the URL, so header_up Host never
reached the upstream. Dial the real address via DialAddr and put
the prepared Host on the CONNECT.
Parse WT-Available-Protocols from the prepared request so header_up
applies to negotiation. Copy filtered upstream response headers
before header_down, record the hijacked 200 for access logs, and
run the same latency / circuit-breaker / passive-health path as
a normal round-trip.
* caddytest: cover WT reload, access-log status, Host, and header ops
Reload with an eternal grace period must close an in-flight session.
Access logs must record 200 after Upgrade. header_up Host and
WT-Available-Protocols must reach the upstream; header_down and
non-hop upstream headers must reach the client.
* reverseproxy: build WebTransport against quic-go v0.63
webtransport-go v0.13.0 renames Dialer to Transport. v0.11.1 does not
compile against quic-go v0.63.
* reverseproxy: close the upstream body when a WebTransport dial fails
Dial can return a response with an error when the peer rejects the
CONNECT. Close that body before the error is returned.
* caddyhttp: close WebTransport sessions without closing the HTTP/3 server
webtransport.Server.Close also closes the shared HTTP/3 server and aborts
every QUIC connection before the grace period applies. Close the registered
sessions, then let HTTP/3 shutdown drain ordinary requests.
* chore: modernise
Signed-off-by: Zen Dodd <mail@steadytao.com>
* reverseproxy: preserve WT-Available-Protocols across upstream retries
The proxy loop reuses the request and only recopies headers when header
ops are configured. Deleting the offer on that map hid it from the next
upstream.
* caddyhttp: close WebTransport sessions when the grace period ends
Eternal grace leaves an active session until the peer ends it. A
configured grace period closes it at the deadline, while the socket
is still up, so the close capsule can be delivered.
* reverseproxy: use terminalError when refusing an Incremental response
The proxy-loop collapse renamed roundtripSucceededError to terminalError.
Master's Incremental refusal still returned the old type, so the rebased
package did not compile.
* caddyhttp: don't let idle HTTP/3 conns pin WebTransport shutdown
- Count in-flight requests on ServeQUICConn connections and close idle ones when shutdown starts
- Delay that close by a second so a just-finished response can be read
- Retain plain HTTP/3 fallbacks for listeners without datagrams and shut them down with the same grace period
* reverseproxy: wait for WebTransport stream workers
- Join bidi and uni splice workers after the accept loops return
- Cancel the opposite copy on a hard splice error
---------
Signed-off-by: Zen Dodd <mail@steadytao.com>
Co-authored-by: tomholford <tomholford@users.noreply.github.com>
Co-authored-by: Zen Dodd <mail@steadytao.com>
1737 lines
60 KiB
Go
1737 lines
60 KiB
Go
package caddyhttp
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/quic-go/quic-go"
|
|
"github.com/quic-go/quic-go/http3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"go.uber.org/zap/zaptest/observer"
|
|
)
|
|
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
type writeFunc func(p []byte) (int, error)
|
|
|
|
type nopSyncer writeFunc
|
|
|
|
func (s *syncBuffer) Write(p []byte) (n int, err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.buf.Write(p)
|
|
}
|
|
|
|
func (s *syncBuffer) String() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.buf.String()
|
|
}
|
|
|
|
func (n nopSyncer) Write(p []byte) (int, error) {
|
|
return n(p)
|
|
}
|
|
|
|
func (n nopSyncer) Sync() error {
|
|
return nil
|
|
}
|
|
|
|
// testLogger returns a logger and a buffer to which the logger writes. The
|
|
// buffer can be read for asserting log output.
|
|
func testLogger(wf writeFunc) *zap.Logger {
|
|
ws := nopSyncer(wf)
|
|
encoderCfg := zapcore.EncoderConfig{
|
|
MessageKey: "msg",
|
|
LevelKey: "level",
|
|
NameKey: "logger",
|
|
EncodeLevel: zapcore.LowercaseLevelEncoder,
|
|
EncodeTime: zapcore.ISO8601TimeEncoder,
|
|
EncodeDuration: zapcore.StringDurationEncoder,
|
|
}
|
|
core := zapcore.NewCore(zapcore.NewJSONEncoder(encoderCfg), ws, zap.DebugLevel)
|
|
|
|
return zap.New(core)
|
|
}
|
|
|
|
func TestServer_LogRequest(t *testing.T) {
|
|
s := &Server{}
|
|
|
|
ctx := context.Background()
|
|
ctx = context.WithValue(ctx, ExtraLogFieldsCtxKey, new(ExtraLogFields))
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
wrec := NewResponseRecorder(rec, nil, nil)
|
|
|
|
duration := 50 * time.Millisecond
|
|
repl := NewTestReplacer(req)
|
|
bodyReader := &lengthReader{Source: req.Body}
|
|
shouldLogCredentials := false
|
|
|
|
buf := bytes.Buffer{}
|
|
accLog := testLogger(buf.Write)
|
|
s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, shouldLogCredentials)
|
|
|
|
assert.JSONEq(t, `{
|
|
"msg":"handled request", "level":"info", "bytes_read":0,
|
|
"duration":"50ms", "resp_headers": {}, "size":0,
|
|
"status":0, "user_id":""
|
|
}`, buf.String())
|
|
}
|
|
|
|
func TestServer_LogRequest_WithTrace(t *testing.T) {
|
|
s := &Server{}
|
|
|
|
extra := new(ExtraLogFields)
|
|
ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
|
|
extra.Add(zap.String("traceID", "1234567890abcdef"))
|
|
extra.Add(zap.String("spanID", "12345678"))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
wrec := NewResponseRecorder(rec, nil, nil)
|
|
|
|
duration := 50 * time.Millisecond
|
|
repl := NewTestReplacer(req)
|
|
bodyReader := &lengthReader{Source: req.Body}
|
|
shouldLogCredentials := false
|
|
|
|
buf := bytes.Buffer{}
|
|
accLog := testLogger(buf.Write)
|
|
s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, shouldLogCredentials)
|
|
|
|
assert.JSONEq(t, `{
|
|
"msg":"handled request", "level":"info", "bytes_read":0,
|
|
"duration":"50ms", "resp_headers": {}, "size":0,
|
|
"status":0, "user_id":"",
|
|
"traceID":"1234567890abcdef",
|
|
"spanID":"12345678"
|
|
}`, buf.String())
|
|
}
|
|
|
|
func BenchmarkServer_LogRequest(b *testing.B) {
|
|
s := &Server{}
|
|
|
|
extra := new(ExtraLogFields)
|
|
ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
wrec := NewResponseRecorder(rec, nil, nil)
|
|
|
|
duration := 50 * time.Millisecond
|
|
repl := NewTestReplacer(req)
|
|
bodyReader := &lengthReader{Source: req.Body}
|
|
|
|
buf := io.Discard
|
|
accLog := testLogger(buf.Write)
|
|
|
|
for b.Loop() {
|
|
s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, false)
|
|
}
|
|
}
|
|
|
|
func BenchmarkServer_LogRequest_NopLogger(b *testing.B) {
|
|
s := &Server{}
|
|
|
|
extra := new(ExtraLogFields)
|
|
ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
wrec := NewResponseRecorder(rec, nil, nil)
|
|
|
|
duration := 50 * time.Millisecond
|
|
repl := NewTestReplacer(req)
|
|
bodyReader := &lengthReader{Source: req.Body}
|
|
|
|
accLog := zap.NewNop()
|
|
|
|
for b.Loop() {
|
|
s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, false)
|
|
}
|
|
}
|
|
|
|
func BenchmarkServer_LogRequest_WithTrace(b *testing.B) {
|
|
s := &Server{}
|
|
|
|
extra := new(ExtraLogFields)
|
|
ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
|
|
extra.Add(zap.String("traceID", "1234567890abcdef"))
|
|
extra.Add(zap.String("spanID", "12345678"))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
wrec := NewResponseRecorder(rec, nil, nil)
|
|
|
|
duration := 50 * time.Millisecond
|
|
repl := NewTestReplacer(req)
|
|
bodyReader := &lengthReader{Source: req.Body}
|
|
|
|
buf := io.Discard
|
|
accLog := testLogger(buf.Write)
|
|
|
|
for b.Loop() {
|
|
s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, false)
|
|
}
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_NoTrustedHeaders(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
ip := trustedRealClientIP(req, []string{}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "192.0.2.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_OneTrustedHeaderEmpty(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "192.0.2.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_OneTrustedHeaderInvalid(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "not, an, ip")
|
|
ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "192.0.2.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_OneTrustedHeaderValid(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "10.0.0.1")
|
|
ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "10.0.0.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_OneTrustedHeaderValidArray(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3")
|
|
ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "1.1.1.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_IncludesPort(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "1.1.1.1:1234")
|
|
ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "1.1.1.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_SkipsInvalidIps(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "not an ip, bad bad, 10.0.0.1")
|
|
ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip, "10.0.0.1")
|
|
}
|
|
|
|
func TestServer_TrustedRealClientIP_MultipleTrustedHeaderValidArray(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
req.Header.Set("Real-Client-IP", "1.1.1.1, 2.2.2.2, 3.3.3.3")
|
|
req.Header.Set("X-Forwarded-For", "3.3.3.3, 4.4.4.4")
|
|
ip1 := trustedRealClientIP(req, []string{"X-Forwarded-For", "Real-Client-IP"}, "192.0.2.1")
|
|
ip2 := trustedRealClientIP(req, []string{"Real-Client-IP", "X-Forwarded-For"}, "192.0.2.1")
|
|
ip3 := trustedRealClientIP(req, []string{"Missing-Header-IP", "Real-Client-IP", "X-Forwarded-For"}, "192.0.2.1")
|
|
|
|
assert.Equal(t, ip1, "3.3.3.3")
|
|
assert.Equal(t, ip2, "1.1.1.1")
|
|
assert.Equal(t, ip3, "1.1.1.1")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_NoConfig(t *testing.T) {
|
|
server := &Server{}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "192.0.2.1:12345"
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.False(t, trusted)
|
|
assert.Equal(t, clientIP, "192.0.2.1")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_NoConfigIpv6(t *testing.T) {
|
|
server := &Server{}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "[::1]:12345"
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.False(t, trusted)
|
|
assert.Equal(t, clientIP, "::1")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_NoConfigIpv6Zones(t *testing.T) {
|
|
server := &Server{}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "[::1%eth2]:12345"
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.False(t, trusted)
|
|
assert.Equal(t, clientIP, "::1")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_TrustedLoopback(t *testing.T) {
|
|
loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{loopbackPrefix},
|
|
},
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "31.40.0.10")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "31.40.0.10")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_UnixSocket(t *testing.T) {
|
|
server := &Server{
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
TrustedProxiesUnix: true,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "@"
|
|
req.Header.Set("X-Forwarded-For", "2.2.2.2, 3.3.3.3")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, "2.2.2.2", clientIP)
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_UnixSocketStrict(t *testing.T) {
|
|
server := &Server{
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
TrustedProxiesUnix: true,
|
|
TrustedProxiesStrict: 1,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "@"
|
|
req.Header.Set("X-Forwarded-For", "2.2.2.2, 3.3.3.3")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, "3.3.3.3", clientIP)
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_UntrustedPrefix(t *testing.T) {
|
|
loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{loopbackPrefix},
|
|
},
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "31.40.0.10")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.False(t, trusted)
|
|
assert.Equal(t, clientIP, "10.0.0.1")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MultipleTrustedPrefixes(t *testing.T) {
|
|
loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{loopbackPrefix, localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "31.40.0.10")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "31.40.0.10")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MultipleTrustedClientHeaders(t *testing.T) {
|
|
loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{loopbackPrefix, localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("CF-Connecting-IP", "1.1.1.1, 2.2.2.2")
|
|
req.Header.Set("X-Forwarded-For", "3.3.3.3, 4.4.4.4")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "1.1.1.1")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MatchLeftMostValidIp(t *testing.T) {
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.1")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "30.30.30.30")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MatchRightMostUntrusted(t *testing.T) {
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"X-Forwarded-For"},
|
|
TrustedProxiesStrict: 1,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.1")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "45.54.45.54")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedSkippingEmpty(t *testing.T) {
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"Missing-Header", "CF-Connecting-IP", "X-Forwarded-For"},
|
|
TrustedProxiesStrict: 1,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("CF-Connecting-IP", "not a real IP")
|
|
req.Header.Set("X-Forwarded-For", "30.30.30.30, bad, 45.54.45.54, not real")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "45.54.45.54")
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedSkippingTrusted(t *testing.T) {
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
|
|
TrustedProxiesStrict: 1,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("CF-Connecting-IP", "10.0.0.1, 10.0.0.2, 10.0.0.3")
|
|
req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.4")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "45.54.45.54")
|
|
}
|
|
|
|
// TestServer_serveHTTP_DropsUnderscoreHeader covers GHSA-f59h-q822-g45g: an
|
|
// underscore-named alias (e.g. `Remote_user`) of a hyphenated header must be
|
|
// dropped before any handler runs.
|
|
func TestServer_serveHTTP_DropsUnderscoreHeader(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["X-Real-Header"] = []string{"ok"}
|
|
req.Header["Remote_user"] = []string{"attacker"}
|
|
req.Header["Remote_groups"] = []string{"admin"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Remote_user")
|
|
assert.NotContains(t, *got, "Remote_groups")
|
|
assert.Equal(t, "ok", got.Get("X-Real-Header"))
|
|
}
|
|
|
|
// TestServer_serveHTTP_LogsDroppedUnderscoreHeader verifies each dropped
|
|
// header is emitted at debug level so operators can diagnose unexpectedly
|
|
// missing headers without spamming the log on adversarial traffic.
|
|
func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := &Server{
|
|
logger: testLogger(buf.Write),
|
|
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Remote_user"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Contains(t, buf.String(), `"level":"debug"`)
|
|
assert.Contains(t, buf.String(), `"msg":"dropping header containing underscore"`)
|
|
assert.Contains(t, buf.String(), `"header":"Remote_user"`)
|
|
}
|
|
|
|
// TestServer_serveHTTP_DropsDotHeader guards GHSA-49wc-4hcv-v58q: a
|
|
// dot-named alias (e.g. `Remote.user`) of a hyphenated header must be
|
|
// dropped too, since PHP's $_SERVER registration folds `.` to `_` just
|
|
// like CGI/FastCGI folds `-` to `_`.
|
|
func TestServer_serveHTTP_DropsDotHeader(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["X-Real-Header"] = []string{"ok"}
|
|
req.Header["Remote.user"] = []string{"attacker"}
|
|
req.Header["Remote.groups"] = []string{"admin"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Remote.user")
|
|
assert.NotContains(t, *got, "Remote.groups")
|
|
assert.Equal(t, "ok", got.Get("X-Real-Header"))
|
|
}
|
|
|
|
// TestServer_serveHTTP_LogsDroppedDotHeader verifies each dropped dotted
|
|
// header is emitted at debug level, same as the underscore case.
|
|
func TestServer_serveHTTP_LogsDroppedDotHeader(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := &Server{
|
|
logger: testLogger(buf.Write),
|
|
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Remote.user"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Contains(t, buf.String(), `"level":"debug"`)
|
|
assert.Contains(t, buf.String(), `"msg":"dropping header containing dot"`)
|
|
assert.Contains(t, buf.String(), `"header":"Remote.user"`)
|
|
}
|
|
|
|
// --- Allowlist: exact match ---
|
|
|
|
func TestServer_serveHTTP_AllowlistKeepsExactMatch(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User_id"))
|
|
}
|
|
|
|
func TestServer_serveHTTP_AllowlistDropsHyphenatedVariant(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus"}
|
|
req.Header.Set("User-Id", "attacker")
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User_id"))
|
|
assert.NotContains(t, *got, "User-Id")
|
|
}
|
|
|
|
func TestServer_serveHTTP_AllowlistDropsUnlisted(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus"}
|
|
req.Header["Remote_user"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User_id"))
|
|
assert.NotContains(t, *got, "Remote_user")
|
|
}
|
|
|
|
func TestServer_serveHTTP_AllowlistPassesThroughNormalHeaders(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header.Set("X-Real-Header", "ok")
|
|
req.Header.Set("Content-Type", "text/plain")
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "ok", got.Get("X-Real-Header"))
|
|
assert.Equal(t, "text/plain", got.Get("Content-Type"))
|
|
}
|
|
|
|
// --- Allowlist: mixed underscore/hyphen entry ---
|
|
|
|
func TestServer_serveHTTP_MixedEntryKeepsOriginal(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"__user-id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["__user-Id"] = []string{"zeus"} // canonical form of __user-id
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("__user-Id"))
|
|
}
|
|
|
|
func TestServer_serveHTTP_MixedEntryDropsFullyHyphenated(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"__user-id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["--User-Id"] = []string{"attacker"} // fully hyphenated variant
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "--User-Id")
|
|
}
|
|
|
|
func TestServer_serveHTTP_MixedEntryDropsPartialVariants(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"__user-id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
// All partial variants still contain underscores and don't match
|
|
// the allowlist, so they are dropped by the normal underscore filter.
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["-_user-Id"] = []string{"attacker1"} // canonical of -_user-id
|
|
req.Header["_-User-Id"] = []string{"attacker2"} // canonical of _-user-id
|
|
req.Header["__user_id"] = []string{"attacker3"} // all underscores variant
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "-_user-Id")
|
|
assert.NotContains(t, *got, "_-User-Id")
|
|
assert.NotContains(t, *got, "__user_id")
|
|
}
|
|
|
|
// --- Allowlist: prefix glob ---
|
|
|
|
func TestServer_serveHTTP_PrefixGlobKeepsMatch(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_event"] = []string{"push"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "push", got.Get("Webhook_event"))
|
|
}
|
|
|
|
func TestServer_serveHTTP_PrefixGlobDropsHyphenatedVariant(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header.Set("Webhook-Event", "push")
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Webhook-Event")
|
|
}
|
|
|
|
func TestServer_serveHTTP_PrefixGlobDropsNonMatching(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Other_header"] = []string{"val"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Other_header")
|
|
}
|
|
|
|
func TestServer_serveHTTP_PrefixGlobDropsMixedVariant(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
// "Webhook-Event_type" has underscores but starts with "Webhook-Event_",
|
|
// which does NOT match the allow prefix "Webhook_", so it is dropped.
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook-Event_type"] = []string{"push"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Webhook-Event_type")
|
|
}
|
|
|
|
func TestServer_serveHTTP_LiteralAsteriskInHeader(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
// A header literally named "Webhook_*" matches the prefix rule
|
|
// because "Webhook_" is a prefix of "Webhook_*".
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_*"] = []string{"val"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "val", got.Get("Webhook_*"))
|
|
}
|
|
|
|
// --- Allowlist: dot headers (ExpectedDotHeaders), independent of underscore ---
|
|
|
|
// TestServer_serveHTTP_DotAllowlistKeepsExactMatch verifies a dot-named
|
|
// header explicitly allowlisted via ExpectedDotHeaders survives, mirroring
|
|
// the underscore allowlist behavior.
|
|
func TestServer_serveHTTP_DotAllowlistKeepsExactMatch(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedDotHeaders: []string{"user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User.id"] = []string{"zeus"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User.id"))
|
|
}
|
|
|
|
// TestServer_serveHTTP_DotAllowlistDropsHyphenatedVariant verifies the
|
|
// hyphenated variant of an allowlisted dot header is dropped, same as for
|
|
// the underscore allowlist.
|
|
func TestServer_serveHTTP_DotAllowlistDropsHyphenatedVariant(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedDotHeaders: []string{"user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User.id"] = []string{"zeus"}
|
|
req.Header.Set("User-Id", "attacker")
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User.id"))
|
|
assert.NotContains(t, *got, "User-Id")
|
|
}
|
|
|
|
// TestServer_serveHTTP_DotAllowlistDropsUnderscoreVariant verifies that when
|
|
// only the dot form is allowlisted, an underscore-form header of the same
|
|
// logical name is NOT independently kept: ExpectedDotHeaders does not grant
|
|
// any allowance to underscore-named headers, so it falls through to the
|
|
// default underscore behavior (dropped, since ExpectedUnderscoreHeaders is
|
|
// unset).
|
|
func TestServer_serveHTTP_DotAllowlistDropsUnderscoreVariant(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedDotHeaders: []string{"user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User.id"] = []string{"zeus"}
|
|
req.Header["User_id"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User.id"))
|
|
assert.NotContains(t, *got, "User_id")
|
|
}
|
|
|
|
// TestServer_serveHTTP_DotAllowlistDropsUnlisted verifies an unlisted
|
|
// dot-form header is dropped just like its underscore-form counterpart.
|
|
func TestServer_serveHTTP_DotAllowlistDropsUnlisted(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedDotHeaders: []string{"user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User.id"] = []string{"zeus"}
|
|
req.Header["Remote.user"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User.id"))
|
|
assert.NotContains(t, *got, "Remote.user")
|
|
}
|
|
|
|
// TestServer_serveHTTP_DotPrefixGlobKeepsMatch verifies prefix-glob
|
|
// matching works for dot headers the same way it does for underscore
|
|
// headers.
|
|
func TestServer_serveHTTP_DotPrefixGlobKeepsMatch(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedDotHeaders: []string{"webhook.*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook.event"] = []string{"push"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "push", got.Get("Webhook.event"))
|
|
}
|
|
|
|
// --- Both allowlists configured together ---
|
|
|
|
// TestServer_serveHTTP_BothAllowlistsCoexistForDifferentNames verifies
|
|
// unrelated underscore and dot allowlist entries can coexist without
|
|
// interfering with each other.
|
|
func TestServer_serveHTTP_BothAllowlistsCoexistForDifferentNames(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
ExpectedDotHeaders: []string{"webhook.event"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus"}
|
|
req.Header["Webhook.event"] = []string{"push"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User_id"))
|
|
assert.Equal(t, "push", got.Get("Webhook.event"))
|
|
}
|
|
|
|
// TestServer_serveHTTP_BothAllowlistsPermitSameLogicalName verifies Caddy
|
|
// does not enforce mutual exclusion between the two allowlists: an
|
|
// operator whose backend doesn't fold '.' and '_' onto the same variable
|
|
// (e.g. a Node.js or Go app behind reverse_proxy, not a CGI/FastCGI/PHP
|
|
// backend) may legitimately want both spellings of the same name kept as
|
|
// distinct headers. It's their responsibility to know whether that's safe
|
|
// for their backend; see the doc comments on ExpectedUnderscoreHeaders and
|
|
// ExpectedDotHeaders.
|
|
func TestServer_serveHTTP_BothAllowlistsPermitSameLogicalName(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
ExpectedDotHeaders: []string{"user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"alice"}
|
|
req.Header["User.id"] = []string{"bob"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "alice", got.Get("User_id"))
|
|
assert.Equal(t, "bob", got.Get("User.id"))
|
|
}
|
|
|
|
// --- Headers with both underscore and dot in the same name ---
|
|
|
|
// TestServer_serveHTTP_DropsMixedUnderscoreDotHeaderByDefault verifies a
|
|
// header spelled with both an underscore and a dot (e.g. "Webhook_user.id")
|
|
// is dropped by default, same as either separator alone.
|
|
func TestServer_serveHTTP_DropsMixedUnderscoreDotHeaderByDefault(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_user.id"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Webhook_user.id")
|
|
}
|
|
|
|
// TestServer_serveHTTP_PrefixGlobDoesNotLeakMixedHeader guards against a
|
|
// prefix glob unintentionally admitting a mixed-separator header: an
|
|
// underscore prefix rule like "webhook_*" matches "Webhook_user.id" via
|
|
// plain string prefix, but the embedded dot still collides at a
|
|
// CGI/FastCGI/PHP backend the same way a bare dot-named header would, so
|
|
// it must not slip through just because it also happens to start with an
|
|
// allowlisted underscore prefix.
|
|
func TestServer_serveHTTP_PrefixGlobDoesNotLeakMixedHeader(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_user.id"] = []string{"attacker"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Webhook_user.id")
|
|
}
|
|
|
|
// TestServer_serveHTTP_ExactAllowlistPermitsMixedHeader verifies that an
|
|
// operator who explicitly vetted the exact mixed-separator spelling (not a
|
|
// glob) can still allowlist it.
|
|
func TestServer_serveHTTP_ExactAllowlistPermitsMixedHeader(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_user.id"] = []string{"ok"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "ok", got.Get("Webhook_user.id"))
|
|
}
|
|
|
|
// TestServer_serveHTTP_ExactAllowlistDropsMixedHeaderRepeatedValues verifies
|
|
// the repeated-value guard also applies to an exactly-allowlisted mixed
|
|
// header.
|
|
func TestServer_serveHTTP_ExactAllowlistDropsMixedHeaderRepeatedValues(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_user.id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_user.id"] = []string{"ok", "injected"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Webhook_user.id")
|
|
}
|
|
|
|
// --- Combined allowlist ---
|
|
|
|
func TestServer_serveHTTP_ExactAndPrefixCoexist(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id", "webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus"} // exact match → keep
|
|
req.Header["Webhook_event"] = []string{"push"} // prefix match → keep
|
|
req.Header["Other_field"] = []string{"bad"} // unlisted → drop
|
|
req.Header.Set("User-Id", "attacker") // hyphenated exact → drop
|
|
req.Header.Set("Webhook-Event", "attacker") // hyphenated prefix → drop
|
|
req.Header.Set("X-Normal-Header", "ok") // no underscore, not a variant → pass through
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Equal(t, "zeus", got.Get("User_id"))
|
|
assert.Equal(t, "push", got.Get("Webhook_event"))
|
|
assert.Equal(t, "ok", got.Get("X-Normal-Header"))
|
|
assert.NotContains(t, *got, "Other_field")
|
|
assert.NotContains(t, *got, "User-Id")
|
|
assert.NotContains(t, *got, "Webhook-Event")
|
|
}
|
|
|
|
// --- Allowlist: repeated values ---
|
|
|
|
// TestServer_serveHTTP_AllowlistDropsRepeatedExact verifies that an
|
|
// allowlisted header arriving with multiple values (repeated field)
|
|
// is dropped entirely as a safeguard against header injection.
|
|
func TestServer_serveHTTP_AllowlistDropsRepeatedExact(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus", "injected"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "User_id")
|
|
}
|
|
|
|
// TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob verifies that a
|
|
// glob-matched header arriving with multiple values is dropped entirely.
|
|
func TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob(t *testing.T) {
|
|
got := &http.Header{}
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
ExpectedUnderscoreHeaders: []string{"webhook_*"},
|
|
primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
*got = r.Header.Clone()
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["Webhook_event"] = []string{"push", "injected"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.NotContains(t, *got, "Webhook_event")
|
|
}
|
|
|
|
// TestServer_serveHTTP_LogsRepeatedValueDrop verifies that dropping an
|
|
// allowlisted header with repeated values emits a warn-level log with
|
|
// the header name and value count.
|
|
func TestServer_serveHTTP_LogsRepeatedValueDrop(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := &Server{
|
|
logger: testLogger(buf.Write),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header["User_id"] = []string{"zeus", "injected"}
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Contains(t, buf.String(), `"level":"warn"`)
|
|
assert.Contains(t, buf.String(), `"msg":"dropping allowlisted underscore header with repeated values (possible spoofing)"`)
|
|
assert.Contains(t, buf.String(), `"header":"User_id"`)
|
|
assert.Contains(t, buf.String(), `"count":2`)
|
|
}
|
|
|
|
// TestServer_serveHTTP_LogsHyphenatedVariantDrop verifies that dropping a
|
|
// hyphenated variant of an allowlisted header emits a debug-level log.
|
|
func TestServer_serveHTTP_LogsHyphenatedVariantDrop(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
s := &Server{
|
|
logger: testLogger(buf.Write),
|
|
ExpectedUnderscoreHeaders: []string{"user_id"},
|
|
primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
|
|
return nil
|
|
}),
|
|
}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.Header.Set("User-Id", "attacker")
|
|
|
|
require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
|
|
assert.Contains(t, buf.String(), `"level":"debug"`)
|
|
assert.Contains(t, buf.String(), `"msg":"dropping hyphenated variant of expected underscore/dot header"`)
|
|
assert.Contains(t, buf.String(), `"header":"User-Id"`)
|
|
}
|
|
|
|
// --- Validation ---
|
|
|
|
func TestServer_provisionUnderscoreHeaders_EmptyListIsNoOp(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{}}
|
|
// Empty slice is treated as "no allowlist" — provisionUnderscoreHeaders
|
|
// returns nil (no error) because len == 0 is a no-op.
|
|
assert.NoError(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_RejectsNoUnderscore(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"content-type"}}
|
|
assert.Error(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_RejectsBareWildcard(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"*"}}
|
|
assert.Error(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_RejectsMidGlob(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"f*oo_bar"}}
|
|
assert.Error(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_RejectsLeadingGlob(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"*_foo"}}
|
|
assert.Error(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_RejectsNonASCII(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"uşer_id"}}
|
|
assert.Error(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_ValidExact(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"user_id"}}
|
|
assert.NoError(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_ValidGlob(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"webhook_*"}}
|
|
assert.NoError(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_ValidMixed(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"__user-id"}}
|
|
assert.NoError(t, s.provisionUnderscoreHeaders())
|
|
}
|
|
|
|
func TestServer_provisionUnderscoreHeaders_DeduplicatesSilently(t *testing.T) {
|
|
s := &Server{ExpectedUnderscoreHeaders: []string{"user_id", "user_id"}}
|
|
require.NoError(t, s.provisionUnderscoreHeaders())
|
|
assert.Len(t, s.underscoreExactAllow, 1)
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_EmptyListIsNoOp(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{}}
|
|
assert.NoError(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_RejectsNoDot(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"content-type"}}
|
|
assert.Error(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_RejectsBareWildcard(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"*"}}
|
|
assert.Error(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_RejectsMidGlob(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"f*oo.bar"}}
|
|
assert.Error(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_RejectsLeadingGlob(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"*.foo"}}
|
|
assert.Error(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_RejectsNonASCII(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"uşer.id"}}
|
|
assert.Error(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_ValidExact(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"user.id"}}
|
|
assert.NoError(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_ValidGlob(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"webhook.*"}}
|
|
assert.NoError(t, s.provisionDotHeaders())
|
|
}
|
|
|
|
func TestServer_provisionDotHeaders_DeduplicatesSilently(t *testing.T) {
|
|
s := &Server{ExpectedDotHeaders: []string{"user.id", "user.id"}}
|
|
require.NoError(t, s.provisionDotHeaders())
|
|
assert.Len(t, s.dotExactAllow, 1)
|
|
}
|
|
|
|
// TestServer_SpaceInHeaderNameReturnsBadRequest documents why the underscore
|
|
// filter does not also strip space-named headers: Go's HTTP parser rejects a
|
|
// space in a field name with 400 before any handler runs, so such a request
|
|
// can never reach Caddy's pipeline.
|
|
func TestServer_SpaceInHeaderNameReturnsBadRequest(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("handler must not be reached; got headers %v", r.Header)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
addr := strings.TrimPrefix(srv.URL, "http://")
|
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
|
|
|
_, err = conn.Write([]byte("GET / HTTP/1.1\r\n" +
|
|
"Host: " + addr + "\r\n" +
|
|
"Remote User: attacker\r\n" +
|
|
"Connection: close\r\n\r\n"))
|
|
require.NoError(t, err)
|
|
|
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = resp.Body.Close() })
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
}
|
|
|
|
func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) {
|
|
localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
|
|
|
|
server := &Server{
|
|
trustedProxies: &StaticIPRange{
|
|
ranges: []netip.Prefix{localPrivatePrefix},
|
|
},
|
|
ClientIPHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
|
|
TrustedProxiesStrict: 1,
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.RemoteAddr = "10.0.0.1:12345"
|
|
req.Header.Set("CF-Connecting-IP", "10.0.0.1, 90.100.110.120, 10.0.0.2, 10.0.0.3")
|
|
req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.4")
|
|
|
|
trusted, clientIP := determineTrustedProxy(req, server)
|
|
|
|
assert.True(t, trusted)
|
|
assert.Equal(t, clientIP, "90.100.110.120")
|
|
}
|
|
|
|
func TestServer_LogRequest_WithWriteError(t *testing.T) {
|
|
s := &Server{}
|
|
ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, new(ExtraLogFields))
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
wrec := NewResponseRecorder(rec, nil, nil)
|
|
duration := 50 * time.Millisecond
|
|
repl := NewTestReplacer(req)
|
|
bodyReader := &lengthReader{Source: req.Body}
|
|
buf := bytes.Buffer{}
|
|
accLog := testLogger(buf.Write)
|
|
|
|
writeErr := errors.New("write tcp: i/o timeout")
|
|
s.logRequest(accLog, req, wrec, &duration, &writeErr, repl, bodyReader, false)
|
|
|
|
assert.JSONEq(t, `{
|
|
"msg":"handled request", "level":"info", "bytes_read":0,
|
|
"duration":"50ms", "resp_headers": {}, "size":0,
|
|
"status":0, "user_id":"",
|
|
"write_error":"write tcp: i/o timeout"
|
|
}`, buf.String())
|
|
}
|
|
|
|
// newAccessLogTestServer starts a server whose access log is written to the
|
|
// returned buffer. writeTimeout is the write timeout Caddy is configured with,
|
|
// while realWriteTimeout is the deadline net/http actually enforces on the
|
|
// connection (zero means no deadline, so writes always succeed).
|
|
func newAccessLogTestServer(t *testing.T, writeTimeout, realWriteTimeout time.Duration, h HandlerFunc) (*httptest.Server, *syncBuffer) {
|
|
t.Helper()
|
|
|
|
buf := new(syncBuffer)
|
|
s := &Server{
|
|
logger: zap.NewNop(),
|
|
errorLogger: zap.NewNop(),
|
|
accessLogger: testLogger(buf.Write),
|
|
Logs: &ServerLogConfig{},
|
|
WriteTimeout: caddy.Duration(writeTimeout),
|
|
primaryHandlerChain: h,
|
|
}
|
|
|
|
ts := httptest.NewUnstartedServer(http.HandlerFunc(s.ServeHTTP))
|
|
ts.Config.WriteTimeout = realWriteTimeout
|
|
ts.Start()
|
|
t.Cleanup(ts.Close)
|
|
|
|
return ts, buf
|
|
}
|
|
|
|
// eventuallyLogged waits for the access log entry to be written, since it is
|
|
// emitted from a defer that runs after the client has already seen the response.
|
|
func eventuallyLogged(t *testing.T, buf *syncBuffer, contains ...string) {
|
|
t.Helper()
|
|
|
|
assert.Eventually(t, func() bool {
|
|
logOutput := buf.String()
|
|
for _, want := range contains {
|
|
if !strings.Contains(logOutput, want) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}, 1*time.Second, 10*time.Millisecond, "expected log entry containing %q, got %q", contains, buf.String())
|
|
}
|
|
|
|
func TestServer_ServeHTTP_WriteTimeoutLogsWriteError(t *testing.T) {
|
|
ts, buf := newAccessLogTestServer(t, 150*time.Millisecond, 150*time.Millisecond,
|
|
func(w http.ResponseWriter, r *http.Request) error {
|
|
time.Sleep(300 * time.Millisecond)
|
|
fmt.Fprint(w, "XX")
|
|
return nil
|
|
})
|
|
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(ts.URL)
|
|
if err == nil {
|
|
resp.Body.Close() // close immediately since we don't need body contents
|
|
}
|
|
|
|
eventuallyLogged(t, buf, `"write_error"`, "i/o timeout", `"status":200`)
|
|
}
|
|
|
|
// A handler that takes longer than the configured write timeout but whose
|
|
// response is still written successfully must not be reported as a write error.
|
|
func TestServer_ServeHTTP_DelayedSuccessLogsNoWriteError(t *testing.T) {
|
|
ts, buf := newAccessLogTestServer(t, 10*time.Millisecond, 0,
|
|
func(w http.ResponseWriter, r *http.Request) error {
|
|
time.Sleep(50 * time.Millisecond)
|
|
fmt.Fprint(w, "hello")
|
|
return nil
|
|
})
|
|
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(ts.URL)
|
|
require.NoError(t, err)
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "hello", string(body))
|
|
|
|
eventuallyLogged(t, buf, `"status":200`, `"size":5`)
|
|
assert.NotContains(t, buf.String(), "write_error")
|
|
}
|
|
|
|
// The flush probe must not commit a response before the handler error has been
|
|
// turned into a response, otherwise the error status is lost (both on the wire
|
|
// and in the access log).
|
|
func TestServer_ServeHTTP_DelayedHandlerErrorWritesErrorStatus(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
handler HandlerFunc
|
|
}{
|
|
{
|
|
name: "error without writing",
|
|
handler: func(w http.ResponseWriter, r *http.Request) error {
|
|
time.Sleep(50 * time.Millisecond)
|
|
return Error(http.StatusTeapot, errors.New("delayed failure"))
|
|
},
|
|
},
|
|
{
|
|
// 1xx responses are informational and pass straight through to the
|
|
// client, which puts the recorder in streaming mode; flushing at that
|
|
// point would commit a 200 before the error status can be written.
|
|
name: "error after informational response",
|
|
handler: func(w http.ResponseWriter, r *http.Request) error {
|
|
w.WriteHeader(http.StatusEarlyHints)
|
|
time.Sleep(50 * time.Millisecond)
|
|
return Error(http.StatusTeapot, errors.New("delayed failure"))
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ts, buf := newAccessLogTestServer(t, 10*time.Millisecond, 0, tc.handler)
|
|
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(ts.URL)
|
|
require.NoError(t, err)
|
|
resp.Body.Close()
|
|
|
|
assert.Equal(t, http.StatusTeapot, resp.StatusCode)
|
|
|
|
eventuallyLogged(t, buf, `"status":418`)
|
|
assert.NotContains(t, buf.String(), "write_error")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServer_ServeHTTP_IdleTimeouts(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
timeout caddy.Duration
|
|
applied bool
|
|
}{
|
|
{name: "positive", timeout: caddy.Duration(time.Minute), applied: true},
|
|
{name: "negative disables", timeout: -1},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var reader *IdleTimeoutReader
|
|
var writer *IdleTimeoutWriter
|
|
s := &Server{
|
|
ReadIdleTimeout: tc.timeout,
|
|
WriteIdleTimeout: tc.timeout,
|
|
logger: zap.NewNop(),
|
|
errorLogger: zap.NewNop(),
|
|
primaryHandlerChain: HandlerFunc(func(_ http.ResponseWriter, r *http.Request) error {
|
|
reader, writer = IdleTimeoutsFromContext(r.Context())
|
|
return nil
|
|
}),
|
|
}
|
|
ts := httptest.NewServer(http.HandlerFunc(s.ServeHTTP))
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp, err := ts.Client().Post(ts.URL, "text/plain", strings.NewReader("x"))
|
|
require.NoError(t, err)
|
|
resp.Body.Close()
|
|
|
|
assert.Equal(t, tc.applied, reader != nil)
|
|
assert.Equal(t, tc.applied, writer != nil)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServer_BuildHTTP3ServerEnablesWebTransport asserts that with
|
|
// WebTransport set the http3.Server advertises WebTransport in
|
|
// its SETTINGS and wires the prerequisites webtransport.Server.Upgrade
|
|
// relies on: DATAGRAM support, a non-nil ConnContext hook (used to stash
|
|
// the underlying *quic.Conn for Upgrade to retrieve), and QUIC
|
|
// stream-reset partial delivery.
|
|
func TestServer_BuildHTTP3ServerEnablesWebTransport(t *testing.T) {
|
|
s := &Server{WebTransport: new(WebTransportConfig)}
|
|
h3 := s.buildHTTP3Server(&tls.Config{})
|
|
|
|
assert.NotNil(t, h3, "expected non-nil http3.Server")
|
|
assert.True(t, h3.EnableDatagrams, "EnableDatagrams must be true for WebTransport DATAGRAMs")
|
|
assert.NotEmpty(t, h3.AdditionalSettings, "AdditionalSettings must advertise WebTransport enablement")
|
|
assert.NotNil(t, h3.ConnContext, "ConnContext must be set so webtransport.Server.Upgrade can retrieve the *quic.Conn")
|
|
assert.NotNil(t, h3.QUICConfig, "QUICConfig must be set")
|
|
assert.True(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery is required by webtransport-go")
|
|
}
|
|
|
|
// TestServer_BuildHTTP3ServerWithoutWebTransport asserts that with
|
|
// WebTransport unset (the default) the http3.Server does NOT
|
|
// advertise WebTransport and does not enable the related QUIC/HTTP/3
|
|
// features. This is the load-bearing regression guard: non-WT HTTP/3
|
|
// deployments must pay zero cost for the WebTransport feature.
|
|
func TestServer_BuildHTTP3ServerWithoutWebTransport(t *testing.T) {
|
|
s := &Server{}
|
|
h3 := s.buildHTTP3Server(&tls.Config{})
|
|
|
|
assert.NotNil(t, h3)
|
|
assert.False(t, h3.EnableDatagrams, "EnableDatagrams must be false when WebTransport is disabled")
|
|
assert.Empty(t, h3.AdditionalSettings, "AdditionalSettings must be empty when WebTransport is disabled")
|
|
assert.Nil(t, h3.ConnContext, "ConnContext must be nil when WebTransport is disabled")
|
|
assert.NotNil(t, h3.QUICConfig)
|
|
assert.False(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery must be false when WebTransport is disabled")
|
|
}
|
|
|
|
// TestServer_BuildHTTP3ServerAppliesHandlerAndTLS is a smoke test for the
|
|
// non-WebTransport fields of the constructed http3.Server, guarding against a
|
|
// refactor accidentally dropping them.
|
|
func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) {
|
|
s := &Server{MaxHeaderBytes: 4096}
|
|
tlsCfg := &tls.Config{}
|
|
h3 := s.buildHTTP3Server(tlsCfg)
|
|
|
|
assert.Same(t, s, h3.Handler, "http3.Server.Handler should be the caddyhttp.Server itself")
|
|
assert.Same(t, tlsCfg, h3.TLSConfig, "http3.Server.TLSConfig should be the config passed in")
|
|
assert.Equal(t, 4096, h3.MaxHeaderBytes)
|
|
}
|
|
|
|
// TestServer_WebTransportServerNilUntilH3 asserts the accessor returns nil
|
|
// when HTTP/3 has not been configured.
|
|
func TestServer_WebTransportServerNilUntilH3(t *testing.T) {
|
|
s := &Server{}
|
|
assert.Nil(t, s.WebTransportServer(), "expected nil before HTTP/3 setup")
|
|
}
|
|
|
|
// stubQUICListenerWithoutWT implements http3.QUICListener and reports that
|
|
// WebTransport is not supported. Accept returns immediately with an error so
|
|
// that h3server.ServeListener exits promptly when the fallback path is taken.
|
|
type stubQUICListenerWithoutWT struct{}
|
|
|
|
func (stubQUICListenerWithoutWT) Accept(context.Context) (*quic.Conn, error) {
|
|
return nil, errors.New("stub: no connections")
|
|
}
|
|
|
|
func (stubQUICListenerWithoutWT) Addr() net.Addr {
|
|
return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9443}
|
|
}
|
|
|
|
func (stubQUICListenerWithoutWT) Close() error { return nil }
|
|
|
|
func (stubQUICListenerWithoutWT) SupportsWebTransport() bool { return false }
|
|
|
|
// TestServeH3AcceptLoopFallsBackWhenListenerLacksWebTransport verifies that
|
|
// when WebTransport is enabled but the QUIC listener was created without
|
|
// DATAGRAM support, serveH3AcceptLoop falls back to plain HTTP/3 with a
|
|
// warning instead of calling webtransport.Server.ServeQUICConn. wtServer is
|
|
// intentionally nil so the WebTransport branch cannot be silently taken; the
|
|
// logged warning proves the fallback path was used.
|
|
func TestServeH3AcceptLoopFallsBackWhenListenerLacksWebTransport(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
s := &Server{
|
|
WebTransport: new(WebTransportConfig),
|
|
logger: zap.New(core),
|
|
h3server: &http3.Server{},
|
|
// wtServer left nil: WebTransport path would nil-panic
|
|
}
|
|
// Must not panic: fallback uses a plain ServeListener, not wtServer.
|
|
s.serveH3AcceptLoop(stubQUICListenerWithoutWT{})
|
|
|
|
if logs.FilterMessageSnippet("WebTransport unavailable").Len() != 1 {
|
|
t.Errorf("expected fallback warning to be logged, got logs: %v", logs.All())
|
|
}
|
|
}
|
|
|
|
// TestHTTP3ServerForListenerOmitsWebTransportSettingsWhenListenerLacksSupport
|
|
// is the SETTINGS half of the fallback: the server used for that listener
|
|
// must not advertise WebTransport, even though the parent Server opted in.
|
|
func TestHTTP3ServerForListenerOmitsWebTransportSettingsWhenListenerLacksSupport(t *testing.T) {
|
|
core, logs := observer.New(zapcore.WarnLevel)
|
|
tlsCfg := &tls.Config{}
|
|
s := &Server{
|
|
WebTransport: new(WebTransportConfig),
|
|
logger: zap.New(core),
|
|
}
|
|
s.h3server = s.buildHTTP3Server(tlsCfg)
|
|
assert.True(t, s.h3server.EnableDatagrams)
|
|
assert.NotEmpty(t, s.h3server.AdditionalSettings)
|
|
|
|
plain := s.http3ServerForListener(stubQUICListenerWithoutWT{})
|
|
if plain == s.h3server {
|
|
t.Fatal("fallback must not reuse the WebTransport-configured HTTP/3 server")
|
|
}
|
|
assert.False(t, plain.EnableDatagrams, "fallback must not enable DATAGRAMs")
|
|
assert.Empty(t, plain.AdditionalSettings, "fallback must not advertise WebTransport SETTINGS")
|
|
assert.Nil(t, plain.ConnContext)
|
|
assert.False(t, plain.QUICConfig.EnableStreamResetPartialDelivery)
|
|
if logs.FilterMessageSnippet("WebTransport unavailable").Len() != 1 {
|
|
t.Errorf("expected fallback warning to be logged, got logs: %v", logs.All())
|
|
}
|
|
}
|
|
|
|
func TestServerLogLevel(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
level any
|
|
status int
|
|
minimum zapcore.Level
|
|
want zapcore.Level
|
|
skip bool
|
|
count int
|
|
}{
|
|
{"default info", nil, 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
|
|
{"default error", nil, 500, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
|
|
{"debug", "debug", 200, zapcore.DebugLevel, zapcore.DebugLevel, false, 1},
|
|
{"warn", "WARN", 200, zapcore.DebugLevel, zapcore.WarnLevel, false, 1},
|
|
{"error", "ERROR", 200, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
|
|
{"override error", "INFO", 500, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
|
|
{"invalid", "invalid", 500, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
|
|
{"unsafe level", "FATAL", 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
|
|
{"wrong type", 2, 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
|
|
{"filtered", "INFO", 200, zapcore.WarnLevel, zapcore.InfoLevel, false, 0},
|
|
{"promoted", "WARN", 200, zapcore.WarnLevel, zapcore.WarnLevel, false, 1},
|
|
{"skipped", "ERROR", 200, zapcore.DebugLevel, zapcore.ErrorLevel, true, 0},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
core, logs := observer.New(tc.minimum)
|
|
ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, new(ExtraLogFields))
|
|
ctx = context.WithValue(ctx, VarsCtxKey, map[string]any{LogLevelVar: tc.level, LogSkipVar: tc.skip})
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
rec := NewResponseRecorder(httptest.NewRecorder(), nil, nil)
|
|
rec.WriteHeader(tc.status)
|
|
duration := time.Millisecond
|
|
(&Server{}).logRequest(zap.New(core), req, rec, &duration, nil, NewTestReplacer(req), nil, false)
|
|
require.Equal(t, tc.count, logs.Len())
|
|
if tc.count > 0 {
|
|
assert.Equal(t, tc.want, logs.All()[0].Level)
|
|
}
|
|
})
|
|
}
|
|
}
|