mirror of
https://github.com/caddyserver/caddy.git
synced 2026-09-15 15:17:25 -04:00
Caddy's underscore header filter (GHSA-f59h-q822-g45g) only checked for `_`. PHP folds `.` to `_` when registering $_SERVER keys the same way CGI/FastCGI folds `-` to `_`, so a dotted alias (e.g. Remote.User) survived the filter and collided with the legitimate hyphenated header once it reached a PHP/FastCGI backend, bypassing forward_auth copy_headers the same way the underscore alias did. Extends the filter to drop `.` symmetrically, adds an `expected_dot_headers` allowlist mirroring `expected_underscore_headers`, and handles header names containing both separators (only an exact allowlist entry is honored there, since a prefix glob's free-form suffix can't be vetted for an embedded second separator). Root cause identified by @iliaal in the FrankenPHP advisory GHSA-49wc-4hcv-v58q.
1368 lines
46 KiB
Go
1368 lines
46 KiB
Go
package caddyhttp
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
)
|
|
|
|
type writeFunc func(p []byte) (int, error)
|
|
|
|
type nopSyncer writeFunc
|
|
|
|
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, 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, 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, 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, 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, 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")
|
|
}
|