From a6acb3902cb6453153db0738bd8210e093449ce1 Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Tue, 3 Mar 2026 17:08:09 -0500 Subject: [PATCH] proxyproto: Generated test coverage (#7540) --- caddytest/integration/proxyprotocol_test.go | 595 ++++++++++++++++++++ 1 file changed, 595 insertions(+) create mode 100644 caddytest/integration/proxyprotocol_test.go diff --git a/caddytest/integration/proxyprotocol_test.go b/caddytest/integration/proxyprotocol_test.go new file mode 100644 index 000000000..e57c323bc --- /dev/null +++ b/caddytest/integration/proxyprotocol_test.go @@ -0,0 +1,595 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Integration tests for Caddy's PROXY protocol support, covering two distinct +// roles that Caddy can play: +// +// 1. As a PROXY protocol *sender* (reverse proxy outbound transport): +// Caddy receives an inbound request from a test client and the +// reverse_proxy handler forwards it to an upstream with a PROXY protocol +// header (v1 or v2) prepended to the connection. A lightweight backend +// built with go-proxyproto validates that the header was received and +// carries the correct client address. +// +// Transport versions tested: +// - "1.1" -> plain HTTP/1.1 to the upstream +// - "h2c" -> HTTP/2 cleartext (h2c) to the upstream (regression for #7529) +// - "2" -> HTTP/2 over TLS (h2) to the upstream +// +// For each transport version both PROXY protocol v1 and v2 are exercised. +// +// HTTP/3 (h3) is not included because it uses QUIC/UDP and therefore +// bypasses the TCP-level dialContext that injects PROXY protocol headers; +// there is no meaningful h3 + proxy protocol sender combination to test. +// +// 2. As a PROXY protocol *receiver* (server-side listener wrapper): +// A raw TCP client dials Caddy directly, injects a PROXY v2 header +// spoofing a source address, and sends a normal HTTP/1.1 request. The +// Caddy server is configured with the proxy_protocol listener wrapper and +// is expected to surface the spoofed address via the +// {http.request.remote.host} placeholder. + +package integration + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + goproxy "github.com/pires/go-proxyproto" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +// proxyProtoBackend is a minimal HTTP server that sits behind a +// go-proxyproto listener and records the source address that was +// delivered in the PROXY header for each request. +type proxyProtoBackend struct { + mu sync.Mutex + headerAddrs []string // host:port strings extracted from each PROXY header + + ln net.Listener + srv *http.Server +} + +// newProxyProtoBackend starts a TCP listener wrapped with go-proxyproto on a +// random local port and serves requests with a simple "OK" body. The PROXY +// header source addresses are accumulated in headerAddrs so tests can +// inspect them. +func newProxyProtoBackend(t *testing.T) *proxyProtoBackend { + t.Helper() + + b := &proxyProtoBackend{} + + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("backend: listen: %v", err) + } + + // Wrap with go-proxyproto so the PROXY header is stripped and parsed + // before the HTTP server sees the connection. We use REQUIRE so that a + // missing header returns an error instead of silently passing through. + pLn := &goproxy.Listener{ + Listener: rawLn, + Policy: func(_ net.Addr) (goproxy.Policy, error) { + return goproxy.REQUIRE, nil + }, + } + b.ln = pLn + + // Wrap the handler with h2c support so the backend can speak HTTP/2 + // cleartext (h2c) as well as plain HTTP/1.1. Without this, Caddy's + // reverse proxy would receive a 'frame too large' error when the + // upstream transport is configured to use h2c. + h2Server := &http2.Server{} + handlerFn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // go-proxyproto has already updated the net.Conn's remote + // address to the value from the PROXY header; the HTTP server + // surfaces it in r.RemoteAddr. + b.mu.Lock() + b.headerAddrs = append(b.headerAddrs, r.RemoteAddr) + b.mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "OK") + }) + + b.srv = &http.Server{ + Handler: h2c.NewHandler(handlerFn, h2Server), + } + + go b.srv.Serve(pLn) //nolint:errcheck + t.Cleanup(func() { + _ = b.srv.Close() + _ = rawLn.Close() + }) + + return b +} + +// addr returns the listening address (host:port) of the backend. +func (b *proxyProtoBackend) addr() string { + return b.ln.Addr().String() +} + +// recordedAddrs returns a snapshot of all PROXY-header source addresses seen +// so far. +func (b *proxyProtoBackend) recordedAddrs() []string { + b.mu.Lock() + defer b.mu.Unlock() + cp := make([]string, len(b.headerAddrs)) + copy(cp, b.headerAddrs) + return cp +} + +// tlsProxyProtoBackend is a TLS-enabled backend that sits behind a +// go-proxyproto listener. The PROXY header is stripped before the TLS +// handshake so the layer order on a connection is: +// +// raw TCP → go-proxyproto (strips PROXY header) → TLS handshake → HTTP/2 +type tlsProxyProtoBackend struct { + mu sync.Mutex + headerAddrs []string + + srv *httptest.Server +} + +// newTLSProxyProtoBackend starts a TLS listener that first reads and strips +// PROXY protocol headers (go-proxyproto, REQUIRE policy) and then performs a +// TLS handshake. The backend speaks HTTP/2 over TLS (h2). +// +// The certificate is the standard self-signed certificate generated by +// httptest.Server; the Caddy transport must be configured with +// insecure_skip_verify: true to trust it. +func newTLSProxyProtoBackend(t *testing.T) *tlsProxyProtoBackend { + t.Helper() + + b := &tlsProxyProtoBackend{} + + handlerFn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b.mu.Lock() + b.headerAddrs = append(b.headerAddrs, r.RemoteAddr) + b.mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "OK") + }) + + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("tlsBackend: listen: %v", err) + } + + // Wrap with go-proxyproto so the PROXY header is consumed before TLS. + pLn := &goproxy.Listener{ + Listener: rawLn, + Policy: func(_ net.Addr) (goproxy.Policy, error) { + return goproxy.REQUIRE, nil + }, + } + + // httptest.NewUnstartedServer lets us replace the listener before + // calling StartTLS(), which wraps our proxyproto listener with + // tls.NewListener. This gives us the right layer order. + b.srv = httptest.NewUnstartedServer(handlerFn) + b.srv.Listener = pLn + + // StartTLS enables HTTP/2 on the server automatically. + b.srv.StartTLS() + + t.Cleanup(func() { + b.srv.Close() + }) + + return b +} + +// addr returns the listening address (host:port) of the TLS backend. +func (b *tlsProxyProtoBackend) addr() string { + return b.srv.Listener.Addr().String() +} + +// tlsConfig returns the *tls.Config used by the backend server. +// Tests can use it to verify cert details if needed. +func (b *tlsProxyProtoBackend) tlsConfig() *tls.Config { + return b.srv.TLS +} + +// recordedAddrs returns a snapshot of all PROXY-header source addresses. +func (b *tlsProxyProtoBackend) recordedAddrs() []string { + b.mu.Lock() + defer b.mu.Unlock() + cp := make([]string, len(b.headerAddrs)) + copy(cp, b.headerAddrs) + return cp +} + +// proxyProtoTLSConfig builds a Caddy JSON configuration that proxies to a TLS +// upstream with PROXY protocol. The transport uses insecure_skip_verify so +// the self-signed certificate generated by httptest.Server is accepted. +func proxyProtoTLSConfig(listenPort int, backendAddr, ppVersion string, transportVersions []string) string { + versionsJSON, _ := json.Marshal(transportVersions) + return fmt.Sprintf(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "pki": { + "certificate_authorities": { + "local": { + "install_trust": false + } + } + }, + "http": { + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":%d"], + "automatic_https": { + "disable": true + }, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "%s"}], + "transport": { + "protocol": "http", + "proxy_protocol": "%s", + "versions": %s, + "tls": { + "insecure_skip_verify": true + } + } + } + ] + } + ] + } + } + } + } + }`, listenPort, backendAddr, ppVersion, string(versionsJSON)) +} + +// testTLSProxyProtocolMatrix is the shared implementation for TLS-based proxy +// protocol tests. It mirrors testProxyProtocolMatrix but uses a TLS backend. +func testTLSProxyProtocolMatrix(t *testing.T, ppVersion string, transportVersions []string, numRequests int) { + t.Helper() + + backend := newTLSProxyProtoBackend(t) + listenPort := freePort(t) + + tester := caddytest.NewTester(t) + tester.WithDefaultOverrides(caddytest.Config{ + AdminPort: 2999, + }) + cfg := proxyProtoTLSConfig(listenPort, backend.addr(), ppVersion, transportVersions) + tester.InitServer(cfg, "json") + + proxyURL := fmt.Sprintf("http://127.0.0.1:%d/", listenPort) + + for i := 0; i < numRequests; i++ { + resp, err := tester.Client.Get(proxyURL) + if err != nil { + t.Fatalf("request %d/%d: GET %s: %v", i+1, numRequests, proxyURL, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("request %d/%d: expected status 200, got %d", i+1, numRequests, resp.StatusCode) + } + } + + addrs := backend.recordedAddrs() + if len(addrs) == 0 { + t.Fatalf("backend recorded no PROXY protocol addresses (expected at least 1)") + } + + for i, addr := range addrs { + host, _, err := net.SplitHostPort(addr) + if err != nil { + t.Errorf("addr[%d] %q: SplitHostPort: %v", i, addr, err) + continue + } + if host != "127.0.0.1" { + t.Errorf("addr[%d]: expected source 127.0.0.1, got %q", i, host) + } + } +} + +// proxyProtoConfig builds a Caddy JSON configuration that: +// - listens on listenPort for inbound HTTP requests +// - proxies them to backendAddr with PROXY protocol ppVersion ("v1"/"v2") +// - uses the given transport versions (e.g. ["1.1"] or ["h2c"]) +func proxyProtoConfig(listenPort int, backendAddr, ppVersion string, transportVersions []string) string { + versionsJSON, _ := json.Marshal(transportVersions) + return fmt.Sprintf(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "pki": { + "certificate_authorities": { + "local": { + "install_trust": false + } + } + }, + "http": { + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":%d"], + "automatic_https": { + "disable": true + }, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "%s"}], + "transport": { + "protocol": "http", + "proxy_protocol": "%s", + "versions": %s + } + } + ] + } + ] + } + } + } + } + }`, listenPort, backendAddr, ppVersion, string(versionsJSON)) +} + +// freePort returns a free local TCP port by binding briefly and releasing it. +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freePort: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return port +} + +// TestProxyProtocolV1WithH1 verifies that PROXY protocol v1 headers are sent +// correctly when the transport uses HTTP/1.1 to the upstream. +func TestProxyProtocolV1WithH1(t *testing.T) { + testProxyProtocolMatrix(t, "v1", []string{"1.1"}, 1) +} + +// TestProxyProtocolV2WithH1 verifies that PROXY protocol v2 headers are sent +// correctly when the transport uses HTTP/1.1 to the upstream. +func TestProxyProtocolV2WithH1(t *testing.T) { + testProxyProtocolMatrix(t, "v2", []string{"1.1"}, 1) +} + +// TestProxyProtocolV1WithH2C verifies that PROXY protocol v1 headers are sent +// correctly when the transport uses h2c (HTTP/2 cleartext) to the upstream. +func TestProxyProtocolV1WithH2C(t *testing.T) { + testProxyProtocolMatrix(t, "v1", []string{"h2c"}, 1) +} + +// TestProxyProtocolV2WithH2C verifies that PROXY protocol v2 headers are sent +// correctly when the transport uses h2c (HTTP/2 cleartext) to the upstream. +// This is the primary regression test for github.com/caddyserver/caddy/issues/7529: +// before the fix, the h2 transport opened a new TCP connection per request +// (because req.URL.Host was mangled differently for each request due to the +// varying client port), which caused file-descriptor exhaustion under load. +func TestProxyProtocolV2WithH2C(t *testing.T) { + testProxyProtocolMatrix(t, "v2", []string{"h2c"}, 1) +} + +// TestProxyProtocolV2WithH2CMultipleRequests sends several sequential requests +// through the h2c + PROXY-protocol path and confirms that: +// 1. Every request receives a 200 response (no connection exhaustion). +// 2. The backend received at least one PROXY header (connection was reused). +// +// This is the core regression guard for issue #7529: without the fix, a new +// TCP connection was opened per request, quickly exhausting file descriptors. +func TestProxyProtocolV2WithH2CMultipleRequests(t *testing.T) { + testProxyProtocolMatrix(t, "v2", []string{"h2c"}, 5) +} + +// TestProxyProtocolV1WithH2 verifies that PROXY protocol v1 headers are sent +// correctly when the transport uses HTTP/2 over TLS (h2) to the upstream. +func TestProxyProtocolV1WithH2(t *testing.T) { + testTLSProxyProtocolMatrix(t, "v1", []string{"2"}, 1) +} + +// TestProxyProtocolV2WithH2 verifies that PROXY protocol v2 headers are sent +// correctly when the transport uses HTTP/2 over TLS (h2) to the upstream. +func TestProxyProtocolV2WithH2(t *testing.T) { + testTLSProxyProtocolMatrix(t, "v2", []string{"2"}, 1) +} + +// TestProxyProtocolServerAndProxy is an end-to-end matrix test that exercises +// all combinations of PROXY protocol version x transport version. +func TestProxyProtocolServerAndProxy(t *testing.T) { + plainTests := []struct { + name string + ppVersion string + transportVersions []string + numRequests int + }{ + {"h1-v1", "v1", []string{"1.1"}, 3}, + {"h1-v2", "v2", []string{"1.1"}, 3}, + {"h2c-v1", "v1", []string{"h2c"}, 3}, + {"h2c-v2", "v2", []string{"h2c"}, 3}, + } + for _, tc := range plainTests { + t.Run(tc.name, func(t *testing.T) { + testProxyProtocolMatrix(t, tc.ppVersion, tc.transportVersions, tc.numRequests) + }) + } + + tlsTests := []struct { + name string + ppVersion string + transportVersions []string + numRequests int + }{ + {"h2-v1", "v1", []string{"2"}, 3}, + {"h2-v2", "v2", []string{"2"}, 3}, + } + for _, tc := range tlsTests { + t.Run(tc.name, func(t *testing.T) { + testTLSProxyProtocolMatrix(t, tc.ppVersion, tc.transportVersions, tc.numRequests) + }) + } +} + +// testProxyProtocolMatrix is the shared implementation for the proxy protocol +// tests. It: +// 1. Starts a go-proxyproto-wrapped backend. +// 2. Configures Caddy as a reverse proxy with the given PROXY protocol +// version and transport versions. +// 3. Sends numRequests GET requests through Caddy and asserts 200 OK each time. +// 4. Asserts the backend recorded at least one PROXY header whose source host +// is 127.0.0.1 (the loopback address used by the test client). +func testProxyProtocolMatrix(t *testing.T, ppVersion string, transportVersions []string, numRequests int) { + t.Helper() + + backend := newProxyProtoBackend(t) + listenPort := freePort(t) + + tester := caddytest.NewTester(t) + tester.WithDefaultOverrides(caddytest.Config{ + AdminPort: 2999, + }) + cfg := proxyProtoConfig(listenPort, backend.addr(), ppVersion, transportVersions) + tester.InitServer(cfg, "json") + + // If the test is h2c-only (no "1.1" in versions), reconfigure the test + // client transport to use unencrypted HTTP/2 so we actually exercise the + // h2c code path through Caddy. + if slices.Contains(transportVersions, "h2c") && !slices.Contains(transportVersions, "1.1") { + tr, ok := tester.Client.Transport.(*http.Transport) + if ok { + tr.Protocols = new(http.Protocols) + tr.Protocols.SetHTTP1(false) + tr.Protocols.SetUnencryptedHTTP2(true) + } + } + + proxyURL := fmt.Sprintf("http://127.0.0.1:%d/", listenPort) + + for i := 0; i < numRequests; i++ { + resp, err := tester.Client.Get(proxyURL) + if err != nil { + t.Fatalf("request %d/%d: GET %s: %v", i+1, numRequests, proxyURL, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("request %d/%d: expected status 200, got %d", i+1, numRequests, resp.StatusCode) + } + } + + // The backend must have seen at least one PROXY header. For h1, there is + // one per request; for h2c, requests share the same connection so only one + // header is written at connection establishment. + addrs := backend.recordedAddrs() + if len(addrs) == 0 { + t.Fatalf("backend recorded no PROXY protocol addresses (expected at least 1)") + } + + // Every PROXY-decoded source address must be the loopback address since + // the test client always connects from 127.0.0.1. + for i, addr := range addrs { + host, _, err := net.SplitHostPort(addr) + if err != nil { + t.Errorf("addr[%d] %q: SplitHostPort: %v", i, addr, err) + continue + } + if host != "127.0.0.1" { + t.Errorf("addr[%d]: expected source 127.0.0.1, got %q", i, host) + } + } +} + +// TestProxyProtocolListenerWrapper verifies that Caddy's +// caddy.listeners.proxy_protocol listener wrapper can successfully parse +// incoming PROXY protocol headers. +// +// The test dials Caddy's listening port directly, injects a raw PROXY v2 +// header spoofing source address 10.0.0.1:1234, then sends a normal +// HTTP/1.1 GET request. The Caddy server is configured to echo back the +// remote address ({http.request.remote.host}). The test asserts that the +// echoed address is the spoofed 10.0.0.1. +func TestProxyProtocolListenerWrapper(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(`{ + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + servers :9080 { + listener_wrappers { + proxy_protocol { + timeout 5s + allow 127.0.0.0/8 + } + } + } + } + http://localhost:9080 { + respond "{http.request.remote.host}" + }`, "caddyfile") + + // Dial the Caddy listener directly and inject a PROXY v2 header that + // claims the connection originates from 10.0.0.1:1234. + conn, err := net.Dial("tcp", "127.0.0.1:9080") + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + spoofedSrc := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234} + spoofedDst := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9080} + hdr := goproxy.HeaderProxyFromAddrs(2, spoofedSrc, spoofedDst) + if _, err := hdr.WriteTo(conn); err != nil { + t.Fatalf("write proxy header: %v", err) + } + + // Write a minimal HTTP/1.1 GET request. + _, err = fmt.Fprintf(conn, + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + if err != nil { + t.Fatalf("write HTTP request: %v", err) + } + + // Read the raw response and look for the spoofed address in the body. + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + raw := string(buf[:n]) + + if !strings.Contains(raw, "10.0.0.1") { + t.Errorf("expected spoofed address 10.0.0.1 in response body; full response:\n%s", raw) + } +}