@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 1,998 lines 68.5 KB
Raw
1 package caddyhttp
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto/tls"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 "net/http"
13 "net/http/httptest"
14 "net/netip"
15 "os"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "github.com/caddyserver/caddy/v2"
22 "github.com/quic-go/quic-go"
23 "github.com/quic-go/quic-go/http3"
24 "github.com/stretchr/testify/assert"
25 "github.com/stretchr/testify/require"
26 "go.uber.org/zap"
27 "go.uber.org/zap/zapcore"
28 "go.uber.org/zap/zaptest/observer"
29 )
30
31 type syncBuffer struct {
32 mu sync.Mutex
33 buf bytes.Buffer
34 }
35
36 type writeFunc func(p []byte) (int, error)
37
38 type nopSyncer writeFunc
39
40 func (s *syncBuffer) Write(p []byte) (n int, err error) {
41 s.mu.Lock()
42 defer s.mu.Unlock()
43 return s.buf.Write(p)
44 }
45
46 func (s *syncBuffer) String() string {
47 s.mu.Lock()
48 defer s.mu.Unlock()
49 return s.buf.String()
50 }
51
52 func (n nopSyncer) Write(p []byte) (int, error) {
53 return n(p)
54 }
55
56 func (n nopSyncer) Sync() error {
57 return nil
58 }
59
60 // testLogger returns a logger and a buffer to which the logger writes. The
61 // buffer can be read for asserting log output.
62 func testLogger(wf writeFunc) *zap.Logger {
63 ws := nopSyncer(wf)
64 encoderCfg := zapcore.EncoderConfig{
65 MessageKey: "msg",
66 LevelKey: "level",
67 NameKey: "logger",
68 EncodeLevel: zapcore.LowercaseLevelEncoder,
69 EncodeTime: zapcore.ISO8601TimeEncoder,
70 EncodeDuration: zapcore.StringDurationEncoder,
71 }
72 core := zapcore.NewCore(zapcore.NewJSONEncoder(encoderCfg), ws, zap.DebugLevel)
73
74 return zap.New(core)
75 }
76
77 func TestServer_LogRequest(t *testing.T) {
78 s := &Server{}
79
80 ctx := context.Background()
81 ctx = context.WithValue(ctx, ExtraLogFieldsCtxKey, new(ExtraLogFields))
82 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
83 rec := httptest.NewRecorder()
84 wrec := NewResponseRecorder(rec, nil, nil)
85
86 duration := 50 * time.Millisecond
87 repl := NewTestReplacer(req)
88 bodyReader := &lengthReader{Source: req.Body}
89 shouldLogCredentials := false
90
91 buf := bytes.Buffer{}
92 accLog := testLogger(buf.Write)
93 s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, shouldLogCredentials)
94
95 assert.JSONEq(t, `{
96 "msg":"handled request", "level":"info", "bytes_read":0,
97 "duration":"50ms", "resp_headers": {}, "size":0,
98 "status":0, "user_id":""
99 }`, buf.String())
100 }
101
102 func TestServer_LogRequest_WithTrace(t *testing.T) {
103 s := &Server{}
104
105 extra := new(ExtraLogFields)
106 ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
107 extra.Add(zap.String("traceID", "1234567890abcdef"))
108 extra.Add(zap.String("spanID", "12345678"))
109
110 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
111 rec := httptest.NewRecorder()
112 wrec := NewResponseRecorder(rec, nil, nil)
113
114 duration := 50 * time.Millisecond
115 repl := NewTestReplacer(req)
116 bodyReader := &lengthReader{Source: req.Body}
117 shouldLogCredentials := false
118
119 buf := bytes.Buffer{}
120 accLog := testLogger(buf.Write)
121 s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, shouldLogCredentials)
122
123 assert.JSONEq(t, `{
124 "msg":"handled request", "level":"info", "bytes_read":0,
125 "duration":"50ms", "resp_headers": {}, "size":0,
126 "status":0, "user_id":"",
127 "traceID":"1234567890abcdef",
128 "spanID":"12345678"
129 }`, buf.String())
130 }
131
132 func BenchmarkServer_LogRequest(b *testing.B) {
133 s := &Server{}
134
135 extra := new(ExtraLogFields)
136 ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
137
138 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
139 rec := httptest.NewRecorder()
140 wrec := NewResponseRecorder(rec, nil, nil)
141
142 duration := 50 * time.Millisecond
143 repl := NewTestReplacer(req)
144 bodyReader := &lengthReader{Source: req.Body}
145
146 buf := io.Discard
147 accLog := testLogger(buf.Write)
148
149 for b.Loop() {
150 s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, false)
151 }
152 }
153
154 func BenchmarkServer_LogRequest_NopLogger(b *testing.B) {
155 s := &Server{}
156
157 extra := new(ExtraLogFields)
158 ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
159
160 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
161 rec := httptest.NewRecorder()
162 wrec := NewResponseRecorder(rec, nil, nil)
163
164 duration := 50 * time.Millisecond
165 repl := NewTestReplacer(req)
166 bodyReader := &lengthReader{Source: req.Body}
167
168 accLog := zap.NewNop()
169
170 for b.Loop() {
171 s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, false)
172 }
173 }
174
175 func BenchmarkServer_LogRequest_WithTrace(b *testing.B) {
176 s := &Server{}
177
178 extra := new(ExtraLogFields)
179 ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, extra)
180 extra.Add(zap.String("traceID", "1234567890abcdef"))
181 extra.Add(zap.String("spanID", "12345678"))
182
183 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
184 rec := httptest.NewRecorder()
185 wrec := NewResponseRecorder(rec, nil, nil)
186
187 duration := 50 * time.Millisecond
188 repl := NewTestReplacer(req)
189 bodyReader := &lengthReader{Source: req.Body}
190
191 buf := io.Discard
192 accLog := testLogger(buf.Write)
193
194 for b.Loop() {
195 s.logRequest(accLog, req, wrec, &duration, nil, repl, bodyReader, false)
196 }
197 }
198
199 func TestServer_TrustedRealClientIP_NoTrustedHeaders(t *testing.T) {
200 req := httptest.NewRequest("GET", "/", nil)
201 req.RemoteAddr = "192.0.2.1:12345"
202 ip := trustedRealClientIP(req, []string{}, "192.0.2.1")
203
204 assert.Equal(t, ip, "192.0.2.1")
205 }
206
207 func TestServer_TrustedRealClientIP_OneTrustedHeaderEmpty(t *testing.T) {
208 req := httptest.NewRequest("GET", "/", nil)
209 req.RemoteAddr = "192.0.2.1:12345"
210 ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
211
212 assert.Equal(t, ip, "192.0.2.1")
213 }
214
215 func TestServer_TrustedRealClientIP_OneTrustedHeaderInvalid(t *testing.T) {
216 req := httptest.NewRequest("GET", "/", nil)
217 req.RemoteAddr = "192.0.2.1:12345"
218 req.Header.Set("X-Forwarded-For", "not, an, ip")
219 ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
220
221 assert.Equal(t, ip, "192.0.2.1")
222 }
223
224 func TestServer_TrustedRealClientIP_OneTrustedHeaderValid(t *testing.T) {
225 req := httptest.NewRequest("GET", "/", nil)
226 req.RemoteAddr = "192.0.2.1:12345"
227 req.Header.Set("X-Forwarded-For", "10.0.0.1")
228 ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
229
230 assert.Equal(t, ip, "10.0.0.1")
231 }
232
233 func TestServer_TrustedRealClientIP_OneTrustedHeaderValidArray(t *testing.T) {
234 req := httptest.NewRequest("GET", "/", nil)
235 req.RemoteAddr = "192.0.2.1:12345"
236 req.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3")
237 ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
238
239 assert.Equal(t, ip, "1.1.1.1")
240 }
241
242 func TestServer_TrustedRealClientIP_IncludesPort(t *testing.T) {
243 req := httptest.NewRequest("GET", "/", nil)
244 req.RemoteAddr = "192.0.2.1:12345"
245 req.Header.Set("X-Forwarded-For", "1.1.1.1:1234")
246 ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
247
248 assert.Equal(t, ip, "1.1.1.1")
249 }
250
251 func TestServer_TrustedRealClientIP_SkipsInvalidIps(t *testing.T) {
252 req := httptest.NewRequest("GET", "/", nil)
253 req.RemoteAddr = "192.0.2.1:12345"
254 req.Header.Set("X-Forwarded-For", "not an ip, bad bad, 10.0.0.1")
255 ip := trustedRealClientIP(req, []string{"X-Forwarded-For"}, "192.0.2.1")
256
257 assert.Equal(t, ip, "10.0.0.1")
258 }
259
260 func TestServer_TrustedRealClientIP_MultipleTrustedHeaderValidArray(t *testing.T) {
261 req := httptest.NewRequest("GET", "/", nil)
262 req.RemoteAddr = "192.0.2.1:12345"
263 req.Header.Set("Real-Client-IP", "1.1.1.1, 2.2.2.2, 3.3.3.3")
264 req.Header.Set("X-Forwarded-For", "3.3.3.3, 4.4.4.4")
265 ip1 := trustedRealClientIP(req, []string{"X-Forwarded-For", "Real-Client-IP"}, "192.0.2.1")
266 ip2 := trustedRealClientIP(req, []string{"Real-Client-IP", "X-Forwarded-For"}, "192.0.2.1")
267 ip3 := trustedRealClientIP(req, []string{"Missing-Header-IP", "Real-Client-IP", "X-Forwarded-For"}, "192.0.2.1")
268
269 assert.Equal(t, ip1, "3.3.3.3")
270 assert.Equal(t, ip2, "1.1.1.1")
271 assert.Equal(t, ip3, "1.1.1.1")
272 }
273
274 func TestServer_DetermineTrustedProxy_NoConfig(t *testing.T) {
275 server := &Server{}
276
277 req := httptest.NewRequest("GET", "/", nil)
278 req.RemoteAddr = "192.0.2.1:12345"
279
280 trusted, clientIP := determineTrustedProxy(req, server)
281
282 assert.False(t, trusted)
283 assert.Equal(t, clientIP, "192.0.2.1")
284 }
285
286 func TestServer_DetermineTrustedProxy_NoConfigIpv6(t *testing.T) {
287 server := &Server{}
288
289 req := httptest.NewRequest("GET", "/", nil)
290 req.RemoteAddr = "[::1]:12345"
291
292 trusted, clientIP := determineTrustedProxy(req, server)
293
294 assert.False(t, trusted)
295 assert.Equal(t, clientIP, "::1")
296 }
297
298 func TestServer_DetermineTrustedProxy_NoConfigIpv6Zones(t *testing.T) {
299 server := &Server{}
300
301 req := httptest.NewRequest("GET", "/", nil)
302 req.RemoteAddr = "[::1%eth2]:12345"
303
304 trusted, clientIP := determineTrustedProxy(req, server)
305
306 assert.False(t, trusted)
307 assert.Equal(t, clientIP, "::1")
308 }
309
310 func TestServer_DetermineTrustedProxy_TrustedLoopback(t *testing.T) {
311 loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
312
313 server := &Server{
314 trustedProxies: &StaticIPRange{
315 ranges: []netip.Prefix{loopbackPrefix},
316 },
317 ClientIPHeaders: []string{"X-Forwarded-For"},
318 }
319
320 req := httptest.NewRequest("GET", "/", nil)
321 req.RemoteAddr = "127.0.0.1:12345"
322 req.Header.Set("X-Forwarded-For", "31.40.0.10")
323
324 trusted, clientIP := determineTrustedProxy(req, server)
325
326 assert.True(t, trusted)
327 assert.Equal(t, clientIP, "31.40.0.10")
328 }
329
330 func TestServer_DetermineTrustedProxy_UnixSocket(t *testing.T) {
331 server := &Server{
332 ClientIPHeaders: []string{"X-Forwarded-For"},
333 TrustedProxiesUnix: true,
334 }
335
336 req := httptest.NewRequest("GET", "/", nil)
337 req.RemoteAddr = "@"
338 req.Header.Set("X-Forwarded-For", "2.2.2.2, 3.3.3.3")
339
340 trusted, clientIP := determineTrustedProxy(req, server)
341
342 assert.True(t, trusted)
343 assert.Equal(t, "2.2.2.2", clientIP)
344 }
345
346 func TestServer_DetermineTrustedProxy_UnixSocketStrict(t *testing.T) {
347 server := &Server{
348 ClientIPHeaders: []string{"X-Forwarded-For"},
349 TrustedProxiesUnix: true,
350 TrustedProxiesStrict: 1,
351 }
352
353 req := httptest.NewRequest("GET", "/", nil)
354 req.RemoteAddr = "@"
355 req.Header.Set("X-Forwarded-For", "2.2.2.2, 3.3.3.3")
356
357 trusted, clientIP := determineTrustedProxy(req, server)
358
359 assert.True(t, trusted)
360 assert.Equal(t, "3.3.3.3", clientIP)
361 }
362
363 func TestServer_DetermineTrustedProxy_UntrustedPrefix(t *testing.T) {
364 loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
365
366 server := &Server{
367 trustedProxies: &StaticIPRange{
368 ranges: []netip.Prefix{loopbackPrefix},
369 },
370 ClientIPHeaders: []string{"X-Forwarded-For"},
371 }
372
373 req := httptest.NewRequest("GET", "/", nil)
374 req.RemoteAddr = "10.0.0.1:12345"
375 req.Header.Set("X-Forwarded-For", "31.40.0.10")
376
377 trusted, clientIP := determineTrustedProxy(req, server)
378
379 assert.False(t, trusted)
380 assert.Equal(t, clientIP, "10.0.0.1")
381 }
382
383 func TestServer_DetermineTrustedProxy_MultipleTrustedPrefixes(t *testing.T) {
384 loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
385 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
386
387 server := &Server{
388 trustedProxies: &StaticIPRange{
389 ranges: []netip.Prefix{loopbackPrefix, localPrivatePrefix},
390 },
391 ClientIPHeaders: []string{"X-Forwarded-For"},
392 }
393
394 req := httptest.NewRequest("GET", "/", nil)
395 req.RemoteAddr = "10.0.0.1:12345"
396 req.Header.Set("X-Forwarded-For", "31.40.0.10")
397
398 trusted, clientIP := determineTrustedProxy(req, server)
399
400 assert.True(t, trusted)
401 assert.Equal(t, clientIP, "31.40.0.10")
402 }
403
404 func TestServer_DetermineTrustedProxy_MultipleTrustedClientHeaders(t *testing.T) {
405 loopbackPrefix, _ := netip.ParsePrefix("127.0.0.1/8")
406 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
407
408 server := &Server{
409 trustedProxies: &StaticIPRange{
410 ranges: []netip.Prefix{loopbackPrefix, localPrivatePrefix},
411 },
412 ClientIPHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
413 }
414
415 req := httptest.NewRequest("GET", "/", nil)
416 req.RemoteAddr = "10.0.0.1:12345"
417 req.Header.Set("CF-Connecting-IP", "1.1.1.1, 2.2.2.2")
418 req.Header.Set("X-Forwarded-For", "3.3.3.3, 4.4.4.4")
419
420 trusted, clientIP := determineTrustedProxy(req, server)
421
422 assert.True(t, trusted)
423 assert.Equal(t, clientIP, "1.1.1.1")
424 }
425
426 func TestServer_DetermineTrustedProxy_MatchLeftMostValidIp(t *testing.T) {
427 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
428
429 server := &Server{
430 trustedProxies: &StaticIPRange{
431 ranges: []netip.Prefix{localPrivatePrefix},
432 },
433 ClientIPHeaders: []string{"X-Forwarded-For"},
434 }
435
436 req := httptest.NewRequest("GET", "/", nil)
437 req.RemoteAddr = "10.0.0.1:12345"
438 req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.1")
439
440 trusted, clientIP := determineTrustedProxy(req, server)
441
442 assert.True(t, trusted)
443 assert.Equal(t, clientIP, "30.30.30.30")
444 }
445
446 func TestServer_DetermineTrustedProxy_MatchRightMostUntrusted(t *testing.T) {
447 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
448
449 server := &Server{
450 trustedProxies: &StaticIPRange{
451 ranges: []netip.Prefix{localPrivatePrefix},
452 },
453 ClientIPHeaders: []string{"X-Forwarded-For"},
454 TrustedProxiesStrict: 1,
455 }
456
457 req := httptest.NewRequest("GET", "/", nil)
458 req.RemoteAddr = "10.0.0.1:12345"
459 req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.1")
460
461 trusted, clientIP := determineTrustedProxy(req, server)
462
463 assert.True(t, trusted)
464 assert.Equal(t, clientIP, "45.54.45.54")
465 }
466
467 func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedSkippingEmpty(t *testing.T) {
468 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
469
470 server := &Server{
471 trustedProxies: &StaticIPRange{
472 ranges: []netip.Prefix{localPrivatePrefix},
473 },
474 ClientIPHeaders: []string{"Missing-Header", "CF-Connecting-IP", "X-Forwarded-For"},
475 TrustedProxiesStrict: 1,
476 }
477
478 req := httptest.NewRequest("GET", "/", nil)
479 req.RemoteAddr = "10.0.0.1:12345"
480 req.Header.Set("CF-Connecting-IP", "not a real IP")
481 req.Header.Set("X-Forwarded-For", "30.30.30.30, bad, 45.54.45.54, not real")
482
483 trusted, clientIP := determineTrustedProxy(req, server)
484
485 assert.True(t, trusted)
486 assert.Equal(t, clientIP, "45.54.45.54")
487 }
488
489 func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedSkippingTrusted(t *testing.T) {
490 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
491
492 server := &Server{
493 trustedProxies: &StaticIPRange{
494 ranges: []netip.Prefix{localPrivatePrefix},
495 },
496 ClientIPHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
497 TrustedProxiesStrict: 1,
498 }
499
500 req := httptest.NewRequest("GET", "/", nil)
501 req.RemoteAddr = "10.0.0.1:12345"
502 req.Header.Set("CF-Connecting-IP", "10.0.0.1, 10.0.0.2, 10.0.0.3")
503 req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.4")
504
505 trusted, clientIP := determineTrustedProxy(req, server)
506
507 assert.True(t, trusted)
508 assert.Equal(t, clientIP, "45.54.45.54")
509 }
510
511 func TestServer_serveHTTP_RejectsPathLikeHost(t *testing.T) {
512 for _, tc := range []struct {
513 host string
514 reject bool
515 }{
516 {host: ".", reject: true},
517 {host: "..", reject: true},
518 {host: "..:443", reject: true},
519 {host: "[..]:443", reject: true},
520 {host: "../../..", reject: true},
521 {host: "a/b", reject: true},
522 {host: `a\b`, reject: true},
523 {host: `..\..`, reject: true},
524 {host: "a\x00b", reject: true},
525 {host: "...", reject: true},
526 {host: "....:443", reject: true},
527 {host: ".. ", reject: true},
528 {host: "a b", reject: true},
529 {host: "example.com", reject: false},
530 {host: "example.com:8443", reject: false},
531 {host: "example.com.", reject: false},
532 {host: "..example.com", reject: false},
533 {host: "127.0.0.1:80", reject: false},
534 {host: "[::1]:443", reject: false},
535 {host: "[::1]", reject: false},
536 } {
537 t.Run(tc.host, func(t *testing.T) {
538 called := false
539 s := &Server{
540 logger: zap.NewNop(),
541 primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
542 called = true
543 return nil
544 }),
545 }
546
547 req := httptest.NewRequest(http.MethodGet, "/", nil)
548 req.Host = tc.host
549
550 err := s.serveHTTP(httptest.NewRecorder(), req)
551 if !tc.reject {
552 require.NoError(t, err)
553 assert.True(t, called)
554 return
555 }
556 var handlerErr HandlerError
557 require.ErrorAs(t, err, &handlerErr)
558 assert.Equal(t, http.StatusBadRequest, handlerErr.StatusCode)
559 assert.False(t, called)
560 })
561 }
562 }
563
564 // TestServer_serveHTTP_DropsUnderscoreHeader covers GHSA-f59h-q822-g45g: an
565 // underscore-named alias (e.g. `Remote_user`) of a hyphenated header must be
566 // dropped before any handler runs.
567 func TestServer_serveHTTP_DropsUnderscoreHeader(t *testing.T) {
568 got := &http.Header{}
569 s := &Server{
570 logger: zap.NewNop(),
571 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
572 *got = r.Header.Clone()
573 return nil
574 }),
575 }
576
577 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
578 req.Header["X-Real-Header"] = []string{"ok"}
579 req.Header["Remote_user"] = []string{"attacker"}
580 req.Header["Remote_groups"] = []string{"admin"}
581
582 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
583 assert.NotContains(t, *got, "Remote_user")
584 assert.NotContains(t, *got, "Remote_groups")
585 assert.Equal(t, "ok", got.Get("X-Real-Header"))
586 }
587
588 // TestServer_serveHTTP_LogsDroppedUnderscoreHeader verifies each dropped
589 // header is emitted at debug level so operators can diagnose unexpectedly
590 // missing headers without spamming the log on adversarial traffic.
591 func TestServer_serveHTTP_LogsDroppedUnderscoreHeader(t *testing.T) {
592 var buf bytes.Buffer
593 s := &Server{
594 logger: testLogger(buf.Write),
595 primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
596 return nil
597 }),
598 }
599
600 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
601 req.Header["Remote_user"] = []string{"attacker"}
602
603 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
604 assert.Contains(t, buf.String(), `"level":"debug"`)
605 assert.Contains(t, buf.String(), `"msg":"dropping header containing underscore"`)
606 assert.Contains(t, buf.String(), `"header":"Remote_user"`)
607 }
608
609 // TestServer_serveHTTP_DropsDotHeader guards GHSA-49wc-4hcv-v58q: a
610 // dot-named alias (e.g. `Remote.user`) of a hyphenated header must be
611 // dropped too, since PHP's $_SERVER registration folds `.` to `_` just
612 // like CGI/FastCGI folds `-` to `_`.
613 func TestServer_serveHTTP_DropsDotHeader(t *testing.T) {
614 got := &http.Header{}
615 s := &Server{
616 logger: zap.NewNop(),
617 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
618 *got = r.Header.Clone()
619 return nil
620 }),
621 }
622
623 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
624 req.Header["X-Real-Header"] = []string{"ok"}
625 req.Header["Remote.user"] = []string{"attacker"}
626 req.Header["Remote.groups"] = []string{"admin"}
627
628 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
629 assert.NotContains(t, *got, "Remote.user")
630 assert.NotContains(t, *got, "Remote.groups")
631 assert.Equal(t, "ok", got.Get("X-Real-Header"))
632 }
633
634 // TestServer_serveHTTP_LogsDroppedDotHeader verifies each dropped dotted
635 // header is emitted at debug level, same as the underscore case.
636 func TestServer_serveHTTP_LogsDroppedDotHeader(t *testing.T) {
637 var buf bytes.Buffer
638 s := &Server{
639 logger: testLogger(buf.Write),
640 primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
641 return nil
642 }),
643 }
644
645 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
646 req.Header["Remote.user"] = []string{"attacker"}
647
648 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
649 assert.Contains(t, buf.String(), `"level":"debug"`)
650 assert.Contains(t, buf.String(), `"msg":"dropping header containing dot"`)
651 assert.Contains(t, buf.String(), `"header":"Remote.user"`)
652 }
653
654 // --- Allowlist: exact match ---
655
656 func TestServer_serveHTTP_AllowlistKeepsExactMatch(t *testing.T) {
657 got := &http.Header{}
658 s := &Server{
659 logger: zap.NewNop(),
660 ExpectedUnderscoreHeaders: []string{"user_id"},
661 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
662 *got = r.Header.Clone()
663 return nil
664 }),
665 }
666 require.NoError(t, s.provisionUnderscoreHeaders())
667
668 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
669 req.Header["User_id"] = []string{"zeus"}
670
671 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
672 assert.Equal(t, "zeus", got.Get("User_id"))
673 }
674
675 func TestServer_serveHTTP_AllowlistDropsHyphenatedVariant(t *testing.T) {
676 got := &http.Header{}
677 s := &Server{
678 logger: zap.NewNop(),
679 ExpectedUnderscoreHeaders: []string{"user_id"},
680 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
681 *got = r.Header.Clone()
682 return nil
683 }),
684 }
685 require.NoError(t, s.provisionUnderscoreHeaders())
686
687 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
688 req.Header["User_id"] = []string{"zeus"}
689 req.Header.Set("User-Id", "attacker")
690
691 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
692 assert.Equal(t, "zeus", got.Get("User_id"))
693 assert.NotContains(t, *got, "User-Id")
694 }
695
696 func TestServer_serveHTTP_AllowlistDropsUnlisted(t *testing.T) {
697 got := &http.Header{}
698 s := &Server{
699 logger: zap.NewNop(),
700 ExpectedUnderscoreHeaders: []string{"user_id"},
701 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
702 *got = r.Header.Clone()
703 return nil
704 }),
705 }
706 require.NoError(t, s.provisionUnderscoreHeaders())
707
708 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
709 req.Header["User_id"] = []string{"zeus"}
710 req.Header["Remote_user"] = []string{"attacker"}
711
712 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
713 assert.Equal(t, "zeus", got.Get("User_id"))
714 assert.NotContains(t, *got, "Remote_user")
715 }
716
717 func TestServer_serveHTTP_AllowlistPassesThroughNormalHeaders(t *testing.T) {
718 got := &http.Header{}
719 s := &Server{
720 logger: zap.NewNop(),
721 ExpectedUnderscoreHeaders: []string{"user_id"},
722 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
723 *got = r.Header.Clone()
724 return nil
725 }),
726 }
727 require.NoError(t, s.provisionUnderscoreHeaders())
728
729 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
730 req.Header.Set("X-Real-Header", "ok")
731 req.Header.Set("Content-Type", "text/plain")
732
733 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
734 assert.Equal(t, "ok", got.Get("X-Real-Header"))
735 assert.Equal(t, "text/plain", got.Get("Content-Type"))
736 }
737
738 // --- Allowlist: mixed underscore/hyphen entry ---
739
740 func TestServer_serveHTTP_MixedEntryKeepsOriginal(t *testing.T) {
741 got := &http.Header{}
742 s := &Server{
743 logger: zap.NewNop(),
744 ExpectedUnderscoreHeaders: []string{"__user-id"},
745 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
746 *got = r.Header.Clone()
747 return nil
748 }),
749 }
750 require.NoError(t, s.provisionUnderscoreHeaders())
751
752 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
753 req.Header["__user-Id"] = []string{"zeus"} // canonical form of __user-id
754
755 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
756 assert.Equal(t, "zeus", got.Get("__user-Id"))
757 }
758
759 func TestServer_serveHTTP_MixedEntryDropsFullyHyphenated(t *testing.T) {
760 got := &http.Header{}
761 s := &Server{
762 logger: zap.NewNop(),
763 ExpectedUnderscoreHeaders: []string{"__user-id"},
764 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
765 *got = r.Header.Clone()
766 return nil
767 }),
768 }
769 require.NoError(t, s.provisionUnderscoreHeaders())
770
771 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
772 req.Header["--User-Id"] = []string{"attacker"} // fully hyphenated variant
773
774 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
775 assert.NotContains(t, *got, "--User-Id")
776 }
777
778 func TestServer_serveHTTP_MixedEntryDropsPartialVariants(t *testing.T) {
779 got := &http.Header{}
780 s := &Server{
781 logger: zap.NewNop(),
782 ExpectedUnderscoreHeaders: []string{"__user-id"},
783 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
784 *got = r.Header.Clone()
785 return nil
786 }),
787 }
788 require.NoError(t, s.provisionUnderscoreHeaders())
789
790 // All partial variants still contain underscores and don't match
791 // the allowlist, so they are dropped by the normal underscore filter.
792 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
793 req.Header["-_user-Id"] = []string{"attacker1"} // canonical of -_user-id
794 req.Header["_-User-Id"] = []string{"attacker2"} // canonical of _-user-id
795 req.Header["__user_id"] = []string{"attacker3"} // all underscores variant
796
797 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
798 assert.NotContains(t, *got, "-_user-Id")
799 assert.NotContains(t, *got, "_-User-Id")
800 assert.NotContains(t, *got, "__user_id")
801 }
802
803 // --- Allowlist: prefix glob ---
804
805 func TestServer_serveHTTP_PrefixGlobKeepsMatch(t *testing.T) {
806 got := &http.Header{}
807 s := &Server{
808 logger: zap.NewNop(),
809 ExpectedUnderscoreHeaders: []string{"webhook_*"},
810 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
811 *got = r.Header.Clone()
812 return nil
813 }),
814 }
815 require.NoError(t, s.provisionUnderscoreHeaders())
816
817 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
818 req.Header["Webhook_event"] = []string{"push"}
819
820 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
821 assert.Equal(t, "push", got.Get("Webhook_event"))
822 }
823
824 func TestServer_serveHTTP_PrefixGlobDropsHyphenatedVariant(t *testing.T) {
825 got := &http.Header{}
826 s := &Server{
827 logger: zap.NewNop(),
828 ExpectedUnderscoreHeaders: []string{"webhook_*"},
829 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
830 *got = r.Header.Clone()
831 return nil
832 }),
833 }
834 require.NoError(t, s.provisionUnderscoreHeaders())
835
836 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
837 req.Header.Set("Webhook-Event", "push")
838
839 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
840 assert.NotContains(t, *got, "Webhook-Event")
841 }
842
843 func TestServer_serveHTTP_PrefixGlobDropsNonMatching(t *testing.T) {
844 got := &http.Header{}
845 s := &Server{
846 logger: zap.NewNop(),
847 ExpectedUnderscoreHeaders: []string{"webhook_*"},
848 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
849 *got = r.Header.Clone()
850 return nil
851 }),
852 }
853 require.NoError(t, s.provisionUnderscoreHeaders())
854
855 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
856 req.Header["Other_header"] = []string{"val"}
857
858 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
859 assert.NotContains(t, *got, "Other_header")
860 }
861
862 func TestServer_serveHTTP_PrefixGlobDropsMixedVariant(t *testing.T) {
863 got := &http.Header{}
864 s := &Server{
865 logger: zap.NewNop(),
866 ExpectedUnderscoreHeaders: []string{"webhook_*"},
867 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
868 *got = r.Header.Clone()
869 return nil
870 }),
871 }
872 require.NoError(t, s.provisionUnderscoreHeaders())
873
874 // "Webhook-Event_type" has underscores but starts with "Webhook-Event_",
875 // which does NOT match the allow prefix "Webhook_", so it is dropped.
876 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
877 req.Header["Webhook-Event_type"] = []string{"push"}
878
879 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
880 assert.NotContains(t, *got, "Webhook-Event_type")
881 }
882
883 func TestServer_serveHTTP_LiteralAsteriskInHeader(t *testing.T) {
884 got := &http.Header{}
885 s := &Server{
886 logger: zap.NewNop(),
887 ExpectedUnderscoreHeaders: []string{"webhook_*"},
888 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
889 *got = r.Header.Clone()
890 return nil
891 }),
892 }
893 require.NoError(t, s.provisionUnderscoreHeaders())
894
895 // A header literally named "Webhook_*" matches the prefix rule
896 // because "Webhook_" is a prefix of "Webhook_*".
897 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
898 req.Header["Webhook_*"] = []string{"val"}
899
900 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
901 assert.Equal(t, "val", got.Get("Webhook_*"))
902 }
903
904 // --- Allowlist: dot headers (ExpectedDotHeaders), independent of underscore ---
905
906 // TestServer_serveHTTP_DotAllowlistKeepsExactMatch verifies a dot-named
907 // header explicitly allowlisted via ExpectedDotHeaders survives, mirroring
908 // the underscore allowlist behavior.
909 func TestServer_serveHTTP_DotAllowlistKeepsExactMatch(t *testing.T) {
910 got := &http.Header{}
911 s := &Server{
912 logger: zap.NewNop(),
913 ExpectedDotHeaders: []string{"user.id"},
914 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
915 *got = r.Header.Clone()
916 return nil
917 }),
918 }
919 require.NoError(t, s.provisionDotHeaders())
920
921 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
922 req.Header["User.id"] = []string{"zeus"}
923
924 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
925 assert.Equal(t, "zeus", got.Get("User.id"))
926 }
927
928 // TestServer_serveHTTP_DotAllowlistDropsHyphenatedVariant verifies the
929 // hyphenated variant of an allowlisted dot header is dropped, same as for
930 // the underscore allowlist.
931 func TestServer_serveHTTP_DotAllowlistDropsHyphenatedVariant(t *testing.T) {
932 got := &http.Header{}
933 s := &Server{
934 logger: zap.NewNop(),
935 ExpectedDotHeaders: []string{"user.id"},
936 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
937 *got = r.Header.Clone()
938 return nil
939 }),
940 }
941 require.NoError(t, s.provisionDotHeaders())
942
943 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
944 req.Header["User.id"] = []string{"zeus"}
945 req.Header.Set("User-Id", "attacker")
946
947 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
948 assert.Equal(t, "zeus", got.Get("User.id"))
949 assert.NotContains(t, *got, "User-Id")
950 }
951
952 // TestServer_serveHTTP_DotAllowlistDropsUnderscoreVariant verifies that when
953 // only the dot form is allowlisted, an underscore-form header of the same
954 // logical name is NOT independently kept: ExpectedDotHeaders does not grant
955 // any allowance to underscore-named headers, so it falls through to the
956 // default underscore behavior (dropped, since ExpectedUnderscoreHeaders is
957 // unset).
958 func TestServer_serveHTTP_DotAllowlistDropsUnderscoreVariant(t *testing.T) {
959 got := &http.Header{}
960 s := &Server{
961 logger: zap.NewNop(),
962 ExpectedDotHeaders: []string{"user.id"},
963 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
964 *got = r.Header.Clone()
965 return nil
966 }),
967 }
968 require.NoError(t, s.provisionDotHeaders())
969
970 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
971 req.Header["User.id"] = []string{"zeus"}
972 req.Header["User_id"] = []string{"attacker"}
973
974 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
975 assert.Equal(t, "zeus", got.Get("User.id"))
976 assert.NotContains(t, *got, "User_id")
977 }
978
979 // TestServer_serveHTTP_DotAllowlistDropsUnlisted verifies an unlisted
980 // dot-form header is dropped just like its underscore-form counterpart.
981 func TestServer_serveHTTP_DotAllowlistDropsUnlisted(t *testing.T) {
982 got := &http.Header{}
983 s := &Server{
984 logger: zap.NewNop(),
985 ExpectedDotHeaders: []string{"user.id"},
986 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
987 *got = r.Header.Clone()
988 return nil
989 }),
990 }
991 require.NoError(t, s.provisionDotHeaders())
992
993 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
994 req.Header["User.id"] = []string{"zeus"}
995 req.Header["Remote.user"] = []string{"attacker"}
996
997 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
998 assert.Equal(t, "zeus", got.Get("User.id"))
999 assert.NotContains(t, *got, "Remote.user")
1000 }
1001
1002 // TestServer_serveHTTP_DotPrefixGlobKeepsMatch verifies prefix-glob
1003 // matching works for dot headers the same way it does for underscore
1004 // headers.
1005 func TestServer_serveHTTP_DotPrefixGlobKeepsMatch(t *testing.T) {
1006 got := &http.Header{}
1007 s := &Server{
1008 logger: zap.NewNop(),
1009 ExpectedDotHeaders: []string{"webhook.*"},
1010 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1011 *got = r.Header.Clone()
1012 return nil
1013 }),
1014 }
1015 require.NoError(t, s.provisionDotHeaders())
1016
1017 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1018 req.Header["Webhook.event"] = []string{"push"}
1019
1020 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1021 assert.Equal(t, "push", got.Get("Webhook.event"))
1022 }
1023
1024 // --- Both allowlists configured together ---
1025
1026 // TestServer_serveHTTP_BothAllowlistsCoexistForDifferentNames verifies
1027 // unrelated underscore and dot allowlist entries can coexist without
1028 // interfering with each other.
1029 func TestServer_serveHTTP_BothAllowlistsCoexistForDifferentNames(t *testing.T) {
1030 got := &http.Header{}
1031 s := &Server{
1032 logger: zap.NewNop(),
1033 ExpectedUnderscoreHeaders: []string{"user_id"},
1034 ExpectedDotHeaders: []string{"webhook.event"},
1035 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1036 *got = r.Header.Clone()
1037 return nil
1038 }),
1039 }
1040 require.NoError(t, s.provisionUnderscoreHeaders())
1041 require.NoError(t, s.provisionDotHeaders())
1042
1043 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1044 req.Header["User_id"] = []string{"zeus"}
1045 req.Header["Webhook.event"] = []string{"push"}
1046
1047 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1048 assert.Equal(t, "zeus", got.Get("User_id"))
1049 assert.Equal(t, "push", got.Get("Webhook.event"))
1050 }
1051
1052 // TestServer_serveHTTP_BothAllowlistsPermitSameLogicalName verifies Caddy
1053 // does not enforce mutual exclusion between the two allowlists: an
1054 // operator whose backend doesn't fold '.' and '_' onto the same variable
1055 // (e.g. a Node.js or Go app behind reverse_proxy, not a CGI/FastCGI/PHP
1056 // backend) may legitimately want both spellings of the same name kept as
1057 // distinct headers. It's their responsibility to know whether that's safe
1058 // for their backend; see the doc comments on ExpectedUnderscoreHeaders and
1059 // ExpectedDotHeaders.
1060 func TestServer_serveHTTP_BothAllowlistsPermitSameLogicalName(t *testing.T) {
1061 got := &http.Header{}
1062 s := &Server{
1063 logger: zap.NewNop(),
1064 ExpectedUnderscoreHeaders: []string{"user_id"},
1065 ExpectedDotHeaders: []string{"user.id"},
1066 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1067 *got = r.Header.Clone()
1068 return nil
1069 }),
1070 }
1071 require.NoError(t, s.provisionUnderscoreHeaders())
1072 require.NoError(t, s.provisionDotHeaders())
1073
1074 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1075 req.Header["User_id"] = []string{"alice"}
1076 req.Header["User.id"] = []string{"bob"}
1077
1078 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1079 assert.Equal(t, "alice", got.Get("User_id"))
1080 assert.Equal(t, "bob", got.Get("User.id"))
1081 }
1082
1083 // --- Headers with both underscore and dot in the same name ---
1084
1085 // TestServer_serveHTTP_DropsMixedUnderscoreDotHeaderByDefault verifies a
1086 // header spelled with both an underscore and a dot (e.g. "Webhook_user.id")
1087 // is dropped by default, same as either separator alone.
1088 func TestServer_serveHTTP_DropsMixedUnderscoreDotHeaderByDefault(t *testing.T) {
1089 got := &http.Header{}
1090 s := &Server{
1091 logger: zap.NewNop(),
1092 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1093 *got = r.Header.Clone()
1094 return nil
1095 }),
1096 }
1097
1098 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1099 req.Header["Webhook_user.id"] = []string{"attacker"}
1100
1101 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1102 assert.NotContains(t, *got, "Webhook_user.id")
1103 }
1104
1105 // TestServer_serveHTTP_PrefixGlobDoesNotLeakMixedHeader guards against a
1106 // prefix glob unintentionally admitting a mixed-separator header: an
1107 // underscore prefix rule like "webhook_*" matches "Webhook_user.id" via
1108 // plain string prefix, but the embedded dot still collides at a
1109 // CGI/FastCGI/PHP backend the same way a bare dot-named header would, so
1110 // it must not slip through just because it also happens to start with an
1111 // allowlisted underscore prefix.
1112 func TestServer_serveHTTP_PrefixGlobDoesNotLeakMixedHeader(t *testing.T) {
1113 got := &http.Header{}
1114 s := &Server{
1115 logger: zap.NewNop(),
1116 ExpectedUnderscoreHeaders: []string{"webhook_*"},
1117 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1118 *got = r.Header.Clone()
1119 return nil
1120 }),
1121 }
1122 require.NoError(t, s.provisionUnderscoreHeaders())
1123
1124 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1125 req.Header["Webhook_user.id"] = []string{"attacker"}
1126
1127 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1128 assert.NotContains(t, *got, "Webhook_user.id")
1129 }
1130
1131 // TestServer_serveHTTP_ExactAllowlistPermitsMixedHeader verifies that an
1132 // operator who explicitly vetted the exact mixed-separator spelling (not a
1133 // glob) can still allowlist it.
1134 func TestServer_serveHTTP_ExactAllowlistPermitsMixedHeader(t *testing.T) {
1135 got := &http.Header{}
1136 s := &Server{
1137 logger: zap.NewNop(),
1138 ExpectedUnderscoreHeaders: []string{"webhook_user.id"},
1139 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1140 *got = r.Header.Clone()
1141 return nil
1142 }),
1143 }
1144 require.NoError(t, s.provisionUnderscoreHeaders())
1145
1146 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1147 req.Header["Webhook_user.id"] = []string{"ok"}
1148
1149 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1150 assert.Equal(t, "ok", got.Get("Webhook_user.id"))
1151 }
1152
1153 // TestServer_serveHTTP_ExactAllowlistDropsMixedHeaderRepeatedValues verifies
1154 // the repeated-value guard also applies to an exactly-allowlisted mixed
1155 // header.
1156 func TestServer_serveHTTP_ExactAllowlistDropsMixedHeaderRepeatedValues(t *testing.T) {
1157 got := &http.Header{}
1158 s := &Server{
1159 logger: zap.NewNop(),
1160 ExpectedUnderscoreHeaders: []string{"webhook_user.id"},
1161 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1162 *got = r.Header.Clone()
1163 return nil
1164 }),
1165 }
1166 require.NoError(t, s.provisionUnderscoreHeaders())
1167
1168 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1169 req.Header["Webhook_user.id"] = []string{"ok", "injected"}
1170
1171 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1172 assert.NotContains(t, *got, "Webhook_user.id")
1173 }
1174
1175 // --- Combined allowlist ---
1176
1177 func TestServer_serveHTTP_ExactAndPrefixCoexist(t *testing.T) {
1178 got := &http.Header{}
1179 s := &Server{
1180 logger: zap.NewNop(),
1181 ExpectedUnderscoreHeaders: []string{"user_id", "webhook_*"},
1182 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1183 *got = r.Header.Clone()
1184 return nil
1185 }),
1186 }
1187 require.NoError(t, s.provisionUnderscoreHeaders())
1188
1189 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1190 req.Header["User_id"] = []string{"zeus"} // exact match → keep
1191 req.Header["Webhook_event"] = []string{"push"} // prefix match → keep
1192 req.Header["Other_field"] = []string{"bad"} // unlisted → drop
1193 req.Header.Set("User-Id", "attacker") // hyphenated exact → drop
1194 req.Header.Set("Webhook-Event", "attacker") // hyphenated prefix → drop
1195 req.Header.Set("X-Normal-Header", "ok") // no underscore, not a variant → pass through
1196
1197 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1198 assert.Equal(t, "zeus", got.Get("User_id"))
1199 assert.Equal(t, "push", got.Get("Webhook_event"))
1200 assert.Equal(t, "ok", got.Get("X-Normal-Header"))
1201 assert.NotContains(t, *got, "Other_field")
1202 assert.NotContains(t, *got, "User-Id")
1203 assert.NotContains(t, *got, "Webhook-Event")
1204 }
1205
1206 // --- Allowlist: repeated values ---
1207
1208 // TestServer_serveHTTP_AllowlistDropsRepeatedExact verifies that an
1209 // allowlisted header arriving with multiple values (repeated field)
1210 // is dropped entirely as a safeguard against header injection.
1211 func TestServer_serveHTTP_AllowlistDropsRepeatedExact(t *testing.T) {
1212 got := &http.Header{}
1213 s := &Server{
1214 logger: zap.NewNop(),
1215 ExpectedUnderscoreHeaders: []string{"user_id"},
1216 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1217 *got = r.Header.Clone()
1218 return nil
1219 }),
1220 }
1221 require.NoError(t, s.provisionUnderscoreHeaders())
1222
1223 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1224 req.Header["User_id"] = []string{"zeus", "injected"}
1225
1226 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1227 assert.NotContains(t, *got, "User_id")
1228 }
1229
1230 // TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob verifies that a
1231 // glob-matched header arriving with multiple values is dropped entirely.
1232 func TestServer_serveHTTP_AllowlistDropsRepeatedPrefixGlob(t *testing.T) {
1233 got := &http.Header{}
1234 s := &Server{
1235 logger: zap.NewNop(),
1236 ExpectedUnderscoreHeaders: []string{"webhook_*"},
1237 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1238 *got = r.Header.Clone()
1239 return nil
1240 }),
1241 }
1242 require.NoError(t, s.provisionUnderscoreHeaders())
1243
1244 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1245 req.Header["Webhook_event"] = []string{"push", "injected"}
1246
1247 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1248 assert.NotContains(t, *got, "Webhook_event")
1249 }
1250
1251 // TestServer_serveHTTP_LogsRepeatedValueDrop verifies that dropping an
1252 // allowlisted header with repeated values emits a warn-level log with
1253 // the header name and value count.
1254 func TestServer_serveHTTP_LogsRepeatedValueDrop(t *testing.T) {
1255 var buf bytes.Buffer
1256 s := &Server{
1257 logger: testLogger(buf.Write),
1258 ExpectedUnderscoreHeaders: []string{"user_id"},
1259 primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
1260 return nil
1261 }),
1262 }
1263 require.NoError(t, s.provisionUnderscoreHeaders())
1264
1265 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1266 req.Header["User_id"] = []string{"zeus", "injected"}
1267
1268 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1269 assert.Contains(t, buf.String(), `"level":"warn"`)
1270 assert.Contains(t, buf.String(), `"msg":"dropping allowlisted underscore header with repeated values (possible spoofing)"`)
1271 assert.Contains(t, buf.String(), `"header":"User_id"`)
1272 assert.Contains(t, buf.String(), `"count":2`)
1273 }
1274
1275 // TestServer_serveHTTP_LogsHyphenatedVariantDrop verifies that dropping a
1276 // hyphenated variant of an allowlisted header emits a debug-level log.
1277 func TestServer_serveHTTP_LogsHyphenatedVariantDrop(t *testing.T) {
1278 var buf bytes.Buffer
1279 s := &Server{
1280 logger: testLogger(buf.Write),
1281 ExpectedUnderscoreHeaders: []string{"user_id"},
1282 primaryHandlerChain: HandlerFunc(func(http.ResponseWriter, *http.Request) error {
1283 return nil
1284 }),
1285 }
1286 require.NoError(t, s.provisionUnderscoreHeaders())
1287
1288 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
1289 req.Header.Set("User-Id", "attacker")
1290
1291 require.NoError(t, s.serveHTTP(httptest.NewRecorder(), req))
1292 assert.Contains(t, buf.String(), `"level":"debug"`)
1293 assert.Contains(t, buf.String(), `"msg":"dropping hyphenated variant of expected underscore/dot header"`)
1294 assert.Contains(t, buf.String(), `"header":"User-Id"`)
1295 }
1296
1297 // --- Validation ---
1298
1299 func TestServer_provisionUnderscoreHeaders_EmptyListIsNoOp(t *testing.T) {
1300 s := &Server{ExpectedUnderscoreHeaders: []string{}}
1301 // Empty slice is treated as "no allowlist" — provisionUnderscoreHeaders
1302 // returns nil (no error) because len == 0 is a no-op.
1303 assert.NoError(t, s.provisionUnderscoreHeaders())
1304 }
1305
1306 func TestServer_provisionUnderscoreHeaders_RejectsNoUnderscore(t *testing.T) {
1307 s := &Server{ExpectedUnderscoreHeaders: []string{"content-type"}}
1308 assert.Error(t, s.provisionUnderscoreHeaders())
1309 }
1310
1311 func TestServer_provisionUnderscoreHeaders_RejectsBareWildcard(t *testing.T) {
1312 s := &Server{ExpectedUnderscoreHeaders: []string{"*"}}
1313 assert.Error(t, s.provisionUnderscoreHeaders())
1314 }
1315
1316 func TestServer_provisionUnderscoreHeaders_RejectsMidGlob(t *testing.T) {
1317 s := &Server{ExpectedUnderscoreHeaders: []string{"f*oo_bar"}}
1318 assert.Error(t, s.provisionUnderscoreHeaders())
1319 }
1320
1321 func TestServer_provisionUnderscoreHeaders_RejectsLeadingGlob(t *testing.T) {
1322 s := &Server{ExpectedUnderscoreHeaders: []string{"*_foo"}}
1323 assert.Error(t, s.provisionUnderscoreHeaders())
1324 }
1325
1326 func TestServer_provisionUnderscoreHeaders_RejectsNonASCII(t *testing.T) {
1327 s := &Server{ExpectedUnderscoreHeaders: []string{"uşer_id"}}
1328 assert.Error(t, s.provisionUnderscoreHeaders())
1329 }
1330
1331 func TestServer_provisionUnderscoreHeaders_ValidExact(t *testing.T) {
1332 s := &Server{ExpectedUnderscoreHeaders: []string{"user_id"}}
1333 assert.NoError(t, s.provisionUnderscoreHeaders())
1334 }
1335
1336 func TestServer_provisionUnderscoreHeaders_ValidGlob(t *testing.T) {
1337 s := &Server{ExpectedUnderscoreHeaders: []string{"webhook_*"}}
1338 assert.NoError(t, s.provisionUnderscoreHeaders())
1339 }
1340
1341 func TestServer_provisionUnderscoreHeaders_ValidMixed(t *testing.T) {
1342 s := &Server{ExpectedUnderscoreHeaders: []string{"__user-id"}}
1343 assert.NoError(t, s.provisionUnderscoreHeaders())
1344 }
1345
1346 func TestServer_provisionUnderscoreHeaders_DeduplicatesSilently(t *testing.T) {
1347 s := &Server{ExpectedUnderscoreHeaders: []string{"user_id", "user_id"}}
1348 require.NoError(t, s.provisionUnderscoreHeaders())
1349 assert.Len(t, s.underscoreExactAllow, 1)
1350 }
1351
1352 func TestServer_provisionDotHeaders_EmptyListIsNoOp(t *testing.T) {
1353 s := &Server{ExpectedDotHeaders: []string{}}
1354 assert.NoError(t, s.provisionDotHeaders())
1355 }
1356
1357 func TestServer_provisionDotHeaders_RejectsNoDot(t *testing.T) {
1358 s := &Server{ExpectedDotHeaders: []string{"content-type"}}
1359 assert.Error(t, s.provisionDotHeaders())
1360 }
1361
1362 func TestServer_provisionDotHeaders_RejectsBareWildcard(t *testing.T) {
1363 s := &Server{ExpectedDotHeaders: []string{"*"}}
1364 assert.Error(t, s.provisionDotHeaders())
1365 }
1366
1367 func TestServer_provisionDotHeaders_RejectsMidGlob(t *testing.T) {
1368 s := &Server{ExpectedDotHeaders: []string{"f*oo.bar"}}
1369 assert.Error(t, s.provisionDotHeaders())
1370 }
1371
1372 func TestServer_provisionDotHeaders_RejectsLeadingGlob(t *testing.T) {
1373 s := &Server{ExpectedDotHeaders: []string{"*.foo"}}
1374 assert.Error(t, s.provisionDotHeaders())
1375 }
1376
1377 func TestServer_provisionDotHeaders_RejectsNonASCII(t *testing.T) {
1378 s := &Server{ExpectedDotHeaders: []string{"uşer.id"}}
1379 assert.Error(t, s.provisionDotHeaders())
1380 }
1381
1382 func TestServer_provisionDotHeaders_ValidExact(t *testing.T) {
1383 s := &Server{ExpectedDotHeaders: []string{"user.id"}}
1384 assert.NoError(t, s.provisionDotHeaders())
1385 }
1386
1387 func TestServer_provisionDotHeaders_ValidGlob(t *testing.T) {
1388 s := &Server{ExpectedDotHeaders: []string{"webhook.*"}}
1389 assert.NoError(t, s.provisionDotHeaders())
1390 }
1391
1392 func TestServer_provisionDotHeaders_DeduplicatesSilently(t *testing.T) {
1393 s := &Server{ExpectedDotHeaders: []string{"user.id", "user.id"}}
1394 require.NoError(t, s.provisionDotHeaders())
1395 assert.Len(t, s.dotExactAllow, 1)
1396 }
1397
1398 // TestServer_SpaceInHeaderNameReturnsBadRequest documents why the underscore
1399 // filter does not also strip space-named headers: Go's HTTP parser rejects a
1400 // space in a field name with 400 before any handler runs, so such a request
1401 // can never reach Caddy's pipeline.
1402 func TestServer_SpaceInHeaderNameReturnsBadRequest(t *testing.T) {
1403 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1404 t.Errorf("handler must not be reached; got headers %v", r.Header)
1405 }))
1406 t.Cleanup(srv.Close)
1407
1408 addr := strings.TrimPrefix(srv.URL, "http://")
1409 conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
1410 require.NoError(t, err)
1411 t.Cleanup(func() { _ = conn.Close() })
1412 require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
1413
1414 _, err = conn.Write([]byte("GET / HTTP/1.1\r\n" +
1415 "Host: " + addr + "\r\n" +
1416 "Remote User: attacker\r\n" +
1417 "Connection: close\r\n\r\n"))
1418 require.NoError(t, err)
1419
1420 resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
1421 require.NoError(t, err)
1422 t.Cleanup(func() { _ = resp.Body.Close() })
1423 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
1424 }
1425
1426 func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) {
1427 localPrivatePrefix, _ := netip.ParsePrefix("10.0.0.0/8")
1428
1429 server := &Server{
1430 trustedProxies: &StaticIPRange{
1431 ranges: []netip.Prefix{localPrivatePrefix},
1432 },
1433 ClientIPHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
1434 TrustedProxiesStrict: 1,
1435 }
1436
1437 req := httptest.NewRequest("GET", "/", nil)
1438 req.RemoteAddr = "10.0.0.1:12345"
1439 req.Header.Set("CF-Connecting-IP", "10.0.0.1, 90.100.110.120, 10.0.0.2, 10.0.0.3")
1440 req.Header.Set("X-Forwarded-For", "30.30.30.30, 45.54.45.54, 10.0.0.4")
1441
1442 trusted, clientIP := determineTrustedProxy(req, server)
1443
1444 assert.True(t, trusted)
1445 assert.Equal(t, clientIP, "90.100.110.120")
1446 }
1447
1448 func TestServer_LogRequest_WithWriteError(t *testing.T) {
1449 s := &Server{}
1450 ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, new(ExtraLogFields))
1451 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
1452 rec := httptest.NewRecorder()
1453 wrec := NewResponseRecorder(rec, nil, nil)
1454 duration := 50 * time.Millisecond
1455 repl := NewTestReplacer(req)
1456 bodyReader := &lengthReader{Source: req.Body}
1457 buf := bytes.Buffer{}
1458 accLog := testLogger(buf.Write)
1459
1460 writeErr := errors.New("write tcp: i/o timeout")
1461 s.logRequest(accLog, req, wrec, &duration, &writeErr, repl, bodyReader, false)
1462
1463 assert.JSONEq(t, `{
1464 "msg":"handled request", "level":"info", "bytes_read":0,
1465 "duration":"50ms", "resp_headers": {}, "size":0,
1466 "status":0, "user_id":"",
1467 "write_error":"write tcp: i/o timeout"
1468 }`, buf.String())
1469 }
1470
1471 // newAccessLogTestServer starts a server whose access log is written to the
1472 // returned buffer. writeTimeout is the write timeout Caddy is configured with,
1473 // while realWriteTimeout is the deadline net/http actually enforces on the
1474 // connection (zero means no deadline, so writes always succeed).
1475 func newAccessLogTestServer(t *testing.T, writeTimeout, realWriteTimeout time.Duration, h HandlerFunc) (*httptest.Server, *syncBuffer) {
1476 t.Helper()
1477
1478 buf := new(syncBuffer)
1479 s := &Server{
1480 logger: zap.NewNop(),
1481 errorLogger: zap.NewNop(),
1482 accessLogger: testLogger(buf.Write),
1483 Logs: &ServerLogConfig{},
1484 WriteTimeout: caddy.Duration(writeTimeout),
1485 primaryHandlerChain: h,
1486 }
1487
1488 ts := httptest.NewUnstartedServer(http.HandlerFunc(s.ServeHTTP))
1489 ts.Config.WriteTimeout = realWriteTimeout
1490 ts.Start()
1491 t.Cleanup(ts.Close)
1492
1493 return ts, buf
1494 }
1495
1496 // eventuallyLogged waits for the access log entry to be written, since it is
1497 // emitted from a defer that runs after the client has already seen the response.
1498 func eventuallyLogged(t *testing.T, buf *syncBuffer, contains ...string) {
1499 t.Helper()
1500
1501 assert.Eventually(t, func() bool {
1502 logOutput := buf.String()
1503 for _, want := range contains {
1504 if !strings.Contains(logOutput, want) {
1505 return false
1506 }
1507 }
1508 return true
1509 }, 1*time.Second, 10*time.Millisecond, "expected log entry containing %q, got %q", contains, buf.String())
1510 }
1511
1512 func TestServer_ServeHTTP_WriteTimeoutLogsWriteError(t *testing.T) {
1513 ts, buf := newAccessLogTestServer(t, 150*time.Millisecond, 150*time.Millisecond,
1514 func(w http.ResponseWriter, r *http.Request) error {
1515 time.Sleep(300 * time.Millisecond)
1516 fmt.Fprint(w, "XX")
1517 return nil
1518 })
1519
1520 client := &http.Client{Timeout: 2 * time.Second}
1521 resp, err := client.Get(ts.URL)
1522 if err == nil {
1523 resp.Body.Close() // close immediately since we don't need body contents
1524 }
1525
1526 eventuallyLogged(t, buf, `"write_error"`, "i/o timeout", `"status":200`)
1527 }
1528
1529 // A handler that takes longer than the configured write timeout but whose
1530 // response is still written successfully must not be reported as a write error.
1531 func TestServer_ServeHTTP_DelayedSuccessLogsNoWriteError(t *testing.T) {
1532 ts, buf := newAccessLogTestServer(t, 10*time.Millisecond, 0,
1533 func(w http.ResponseWriter, r *http.Request) error {
1534 time.Sleep(50 * time.Millisecond)
1535 fmt.Fprint(w, "hello")
1536 return nil
1537 })
1538
1539 client := &http.Client{Timeout: 2 * time.Second}
1540 resp, err := client.Get(ts.URL)
1541 require.NoError(t, err)
1542 body, err := io.ReadAll(resp.Body)
1543 resp.Body.Close()
1544 require.NoError(t, err)
1545
1546 assert.Equal(t, http.StatusOK, resp.StatusCode)
1547 assert.Equal(t, "hello", string(body))
1548
1549 eventuallyLogged(t, buf, `"status":200`, `"size":5`)
1550 assert.NotContains(t, buf.String(), "write_error")
1551 }
1552
1553 // The flush probe must not commit a response before the handler error has been
1554 // turned into a response, otherwise the error status is lost (both on the wire
1555 // and in the access log).
1556 func TestServer_ServeHTTP_DelayedHandlerErrorWritesErrorStatus(t *testing.T) {
1557 for _, tc := range []struct {
1558 name string
1559 handler HandlerFunc
1560 }{
1561 {
1562 name: "error without writing",
1563 handler: func(w http.ResponseWriter, r *http.Request) error {
1564 time.Sleep(50 * time.Millisecond)
1565 return Error(http.StatusTeapot, errors.New("delayed failure"))
1566 },
1567 },
1568 {
1569 // 1xx responses are informational and pass straight through to the
1570 // client, which puts the recorder in streaming mode; flushing at that
1571 // point would commit a 200 before the error status can be written.
1572 name: "error after informational response",
1573 handler: func(w http.ResponseWriter, r *http.Request) error {
1574 w.WriteHeader(http.StatusEarlyHints)
1575 time.Sleep(50 * time.Millisecond)
1576 return Error(http.StatusTeapot, errors.New("delayed failure"))
1577 },
1578 },
1579 } {
1580 t.Run(tc.name, func(t *testing.T) {
1581 ts, buf := newAccessLogTestServer(t, 10*time.Millisecond, 0, tc.handler)
1582
1583 client := &http.Client{Timeout: 2 * time.Second}
1584 resp, err := client.Get(ts.URL)
1585 require.NoError(t, err)
1586 resp.Body.Close()
1587
1588 assert.Equal(t, http.StatusTeapot, resp.StatusCode)
1589
1590 eventuallyLogged(t, buf, `"status":418`)
1591 assert.NotContains(t, buf.String(), "write_error")
1592 })
1593 }
1594 }
1595
1596 func TestServer_ServeHTTP_IdleTimeouts(t *testing.T) {
1597 for _, tc := range []struct {
1598 name string
1599 timeout caddy.Duration
1600 applied bool
1601 }{
1602 {name: "positive", timeout: caddy.Duration(time.Minute), applied: true},
1603 {name: "negative disables", timeout: -1},
1604 } {
1605 t.Run(tc.name, func(t *testing.T) {
1606 var reader *IdleTimeoutReader
1607 var writer *IdleTimeoutWriter
1608 s := &Server{
1609 ReadIdleTimeout: tc.timeout,
1610 WriteIdleTimeout: tc.timeout,
1611 logger: zap.NewNop(),
1612 errorLogger: zap.NewNop(),
1613 primaryHandlerChain: HandlerFunc(func(_ http.ResponseWriter, r *http.Request) error {
1614 reader, writer = IdleTimeoutsFromContext(r.Context())
1615 return nil
1616 }),
1617 }
1618 ts := httptest.NewServer(http.HandlerFunc(s.ServeHTTP))
1619 t.Cleanup(ts.Close)
1620
1621 resp, err := ts.Client().Post(ts.URL, "text/plain", strings.NewReader("x"))
1622 require.NoError(t, err)
1623 resp.Body.Close()
1624
1625 assert.Equal(t, tc.applied, reader != nil)
1626 assert.Equal(t, tc.applied, writer != nil)
1627 })
1628 }
1629 }
1630
1631 // TestServer_ServeHTTP_HandlerReadDeadline checks that a read deadline
1632 // set by a handler through http.ResponseController still applies to a
1633 // stalled request body, instead of being replaced by the idle reader.
1634 func TestServer_ServeHTTP_HandlerReadDeadline(t *testing.T) {
1635 readErr := make(chan error, 1)
1636 s := &Server{
1637 ReadIdleTimeout: caddy.Duration(time.Minute),
1638 logger: zap.NewNop(),
1639 errorLogger: zap.NewNop(),
1640 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1641 if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
1642 readErr <- err
1643 return nil
1644 }
1645 _, err := io.ReadAll(r.Body)
1646 readErr <- err
1647 return nil
1648 }),
1649 }
1650 ts := httptest.NewServer(http.HandlerFunc(s.ServeHTTP))
1651 t.Cleanup(ts.Close)
1652
1653 conn, err := net.Dial("tcp", ts.Listener.Addr().String())
1654 require.NoError(t, err)
1655 t.Cleanup(func() { conn.Close() })
1656
1657 // announce a body, send one byte of it and stall
1658 _, err = io.WriteString(conn, "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\nx")
1659 require.NoError(t, err)
1660
1661 select {
1662 case err := <-readErr:
1663 require.ErrorIs(t, err, os.ErrDeadlineExceeded)
1664 case <-time.After(5 * time.Second):
1665 t.Fatal("the handler's read deadline was replaced by the idle timeout")
1666 }
1667 }
1668
1669 // TestServer_BuildHTTP3ServerEnablesWebTransport asserts that with
1670 // WebTransport set the http3.Server advertises WebTransport in
1671 // its SETTINGS and wires the prerequisites webtransport.Server.Upgrade
1672 // relies on: DATAGRAM support, a non-nil ConnContext hook (used to stash
1673 // the underlying *quic.Conn for Upgrade to retrieve), and QUIC
1674 // stream-reset partial delivery.
1675 func TestServer_BuildHTTP3ServerEnablesWebTransport(t *testing.T) {
1676 s := &Server{WebTransport: new(WebTransportConfig)}
1677 h3 := s.buildHTTP3Server(&tls.Config{})
1678
1679 assert.NotNil(t, h3, "expected non-nil http3.Server")
1680 assert.True(t, h3.EnableDatagrams, "EnableDatagrams must be true for WebTransport DATAGRAMs")
1681 assert.NotEmpty(t, h3.AdditionalSettings, "AdditionalSettings must advertise WebTransport enablement")
1682 assert.NotNil(t, h3.ConnContext, "ConnContext must be set so webtransport.Server.Upgrade can retrieve the *quic.Conn")
1683 assert.NotNil(t, h3.QUICConfig, "QUICConfig must be set")
1684 assert.True(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery is required by webtransport-go")
1685 }
1686
1687 // TestServer_BuildHTTP3ServerWithoutWebTransport asserts that with
1688 // WebTransport unset (the default) the http3.Server does NOT
1689 // advertise WebTransport and does not enable the related QUIC/HTTP/3
1690 // features. This is the load-bearing regression guard: non-WT HTTP/3
1691 // deployments must pay zero cost for the WebTransport feature.
1692 func TestServer_BuildHTTP3ServerWithoutWebTransport(t *testing.T) {
1693 s := &Server{}
1694 h3 := s.buildHTTP3Server(&tls.Config{})
1695
1696 assert.NotNil(t, h3)
1697 assert.False(t, h3.EnableDatagrams, "EnableDatagrams must be false when WebTransport is disabled")
1698 assert.Empty(t, h3.AdditionalSettings, "AdditionalSettings must be empty when WebTransport is disabled")
1699 assert.Nil(t, h3.ConnContext, "ConnContext must be nil when WebTransport is disabled")
1700 assert.NotNil(t, h3.QUICConfig)
1701 assert.False(t, h3.QUICConfig.EnableStreamResetPartialDelivery, "EnableStreamResetPartialDelivery must be false when WebTransport is disabled")
1702 }
1703
1704 // TestServer_BuildHTTP3ServerAppliesHandlerAndTLS is a smoke test for the
1705 // non-WebTransport fields of the constructed http3.Server, guarding against a
1706 // refactor accidentally dropping them.
1707 func TestServer_BuildHTTP3ServerAppliesHandlerAndTLS(t *testing.T) {
1708 s := &Server{MaxHeaderBytes: 4096}
1709 tlsCfg := &tls.Config{}
1710 h3 := s.buildHTTP3Server(tlsCfg)
1711
1712 assert.Same(t, s, h3.Handler, "http3.Server.Handler should be the caddyhttp.Server itself")
1713 assert.Same(t, tlsCfg, h3.TLSConfig, "http3.Server.TLSConfig should be the config passed in")
1714 assert.Equal(t, 4096, h3.MaxHeaderBytes)
1715 }
1716
1717 // TestServer_WebTransportServerNilUntilH3 asserts the accessor returns nil
1718 // when HTTP/3 has not been configured.
1719 func TestServer_WebTransportServerNilUntilH3(t *testing.T) {
1720 s := &Server{}
1721 assert.Nil(t, s.WebTransportServer(), "expected nil before HTTP/3 setup")
1722 }
1723
1724 // stubQUICListenerWithoutWT implements http3.QUICListener and reports that
1725 // WebTransport is not supported. Accept returns immediately with an error so
1726 // that h3server.ServeListener exits promptly when the fallback path is taken.
1727 type stubQUICListenerWithoutWT struct{}
1728
1729 func (stubQUICListenerWithoutWT) Accept(context.Context) (*quic.Conn, error) {
1730 return nil, errors.New("stub: no connections")
1731 }
1732
1733 func (stubQUICListenerWithoutWT) Addr() net.Addr {
1734 return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9443}
1735 }
1736
1737 func (stubQUICListenerWithoutWT) Close() error { return nil }
1738
1739 func (stubQUICListenerWithoutWT) SupportsWebTransport() bool { return false }
1740
1741 // TestServeH3AcceptLoopFallsBackWhenListenerLacksWebTransport verifies that
1742 // when WebTransport is enabled but the QUIC listener was created without
1743 // DATAGRAM support, serveH3AcceptLoop falls back to plain HTTP/3 with a
1744 // warning instead of calling webtransport.Server.ServeQUICConn. wtServer is
1745 // intentionally nil so the WebTransport branch cannot be silently taken; the
1746 // logged warning proves the fallback path was used.
1747 func TestServeH3AcceptLoopFallsBackWhenListenerLacksWebTransport(t *testing.T) {
1748 core, logs := observer.New(zapcore.WarnLevel)
1749 s := &Server{
1750 WebTransport: new(WebTransportConfig),
1751 logger: zap.New(core),
1752 h3server: &http3.Server{},
1753 // wtServer left nil: WebTransport path would nil-panic
1754 }
1755 // Must not panic: fallback uses a plain ServeListener, not wtServer.
1756 s.serveH3AcceptLoop(stubQUICListenerWithoutWT{})
1757
1758 if logs.FilterMessageSnippet("WebTransport unavailable").Len() != 1 {
1759 t.Errorf("expected fallback warning to be logged, got logs: %v", logs.All())
1760 }
1761 }
1762
1763 // TestHTTP3ServerForListenerOmitsWebTransportSettingsWhenListenerLacksSupport
1764 // is the SETTINGS half of the fallback: the server used for that listener
1765 // must not advertise WebTransport, even though the parent Server opted in.
1766 func TestHTTP3ServerForListenerOmitsWebTransportSettingsWhenListenerLacksSupport(t *testing.T) {
1767 core, logs := observer.New(zapcore.WarnLevel)
1768 tlsCfg := &tls.Config{}
1769 s := &Server{
1770 WebTransport: new(WebTransportConfig),
1771 logger: zap.New(core),
1772 }
1773 s.h3server = s.buildHTTP3Server(tlsCfg)
1774 assert.True(t, s.h3server.EnableDatagrams)
1775 assert.NotEmpty(t, s.h3server.AdditionalSettings)
1776
1777 plain := s.http3ServerForListener(stubQUICListenerWithoutWT{})
1778 if plain == s.h3server {
1779 t.Fatal("fallback must not reuse the WebTransport-configured HTTP/3 server")
1780 }
1781 assert.False(t, plain.EnableDatagrams, "fallback must not enable DATAGRAMs")
1782 assert.Empty(t, plain.AdditionalSettings, "fallback must not advertise WebTransport SETTINGS")
1783 assert.Nil(t, plain.ConnContext)
1784 assert.False(t, plain.QUICConfig.EnableStreamResetPartialDelivery)
1785 if logs.FilterMessageSnippet("WebTransport unavailable").Len() != 1 {
1786 t.Errorf("expected fallback warning to be logged, got logs: %v", logs.All())
1787 }
1788 }
1789
1790 func TestServerLogLevel(t *testing.T) {
1791 for _, tc := range []struct {
1792 name string
1793 level any
1794 status int
1795 minimum zapcore.Level
1796 want zapcore.Level
1797 skip bool
1798 count int
1799 }{
1800 {"default info", nil, 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1801 {"default error", nil, 500, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
1802 {"debug", "debug", 200, zapcore.DebugLevel, zapcore.DebugLevel, false, 1},
1803 {"warn", "WARN", 200, zapcore.DebugLevel, zapcore.WarnLevel, false, 1},
1804 {"error", "ERROR", 200, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
1805 {"override error", "INFO", 500, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1806 {"invalid", "invalid", 500, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
1807 {"unsafe level", "FATAL", 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1808 {"wrong type", 2, 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1809 {"filtered", "INFO", 200, zapcore.WarnLevel, zapcore.InfoLevel, false, 0},
1810 {"promoted", "WARN", 200, zapcore.WarnLevel, zapcore.WarnLevel, false, 1},
1811 {"skipped", "ERROR", 200, zapcore.DebugLevel, zapcore.ErrorLevel, true, 0},
1812 } {
1813 t.Run(tc.name, func(t *testing.T) {
1814 core, logs := observer.New(tc.minimum)
1815 ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, new(ExtraLogFields))
1816 ctx = context.WithValue(ctx, VarsCtxKey, map[string]any{LogLevelVar: tc.level, LogSkipVar: tc.skip})
1817 req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
1818 rec := NewResponseRecorder(httptest.NewRecorder(), nil, nil)
1819 rec.WriteHeader(tc.status)
1820 duration := time.Millisecond
1821 (&Server{}).logRequest(zap.New(core), req, rec, &duration, nil, NewTestReplacer(req), nil, false)
1822 require.Equal(t, tc.count, logs.Len())
1823 if tc.count > 0 {
1824 assert.Equal(t, tc.want, logs.All()[0].Level)
1825 }
1826 })
1827 }
1828 }
1829
1830 // TestServer_ClosedRequestBodyDoesNotDesyncHTTP1 verifies that an early
1831 // response after a handler closes its request body cannot leave the unread
1832 // body bytes to be parsed as the next request. This models a reverse proxy
1833 // transport which receives an early upstream response and then closes the
1834 // downstream request body.
1835 func TestServer_ClosedRequestBodyDoesNotDesyncHTTP1(t *testing.T) {
1836 const firstChunkSize = 1024
1837
1838 for _, tc := range []struct {
1839 name string
1840 chunked bool
1841 bodyLen int
1842 }{
1843 {name: "fixed length above drain limit", bodyLen: 1 << 20},
1844 {name: "chunked above drain limit", chunked: true, bodyLen: 1 << 20},
1845 } {
1846 t.Run(tc.name, func(t *testing.T) {
1847 secondRequest := make(chan struct{}, 1)
1848 server := &Server{
1849 ReadIdleTimeout: caddy.Duration(time.Minute),
1850 logger: zap.NewNop(),
1851 errorLogger: zap.NewNop(),
1852 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1853 switch r.URL.Path {
1854 case "/early":
1855 buf := make([]byte, firstChunkSize)
1856 if _, err := io.ReadFull(r.Body, buf); err != nil {
1857 return fmt.Errorf("read first request chunk: %w", err)
1858 }
1859 if err := r.Body.Close(); err != nil {
1860 return fmt.Errorf("close first request body: %w", err)
1861 }
1862 w.Header().Set("Content-Length", "0")
1863 w.WriteHeader(http.StatusLocked)
1864 case "/second":
1865 secondRequest <- struct{}{}
1866 w.Header().Set("Content-Length", "0")
1867 w.WriteHeader(http.StatusNoContent)
1868 default:
1869 return Error(http.StatusNotFound, nil)
1870 }
1871 return nil
1872 }),
1873 }
1874 ts := httptest.NewServer(http.HandlerFunc(server.ServeHTTP))
1875 t.Cleanup(ts.Close)
1876
1877 conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second)
1878 require.NoError(t, err)
1879 t.Cleanup(func() { _ = conn.Close() })
1880 require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
1881
1882 reader := bufio.NewReader(conn)
1883 if tc.chunked {
1884 _, err = fmt.Fprintf(conn, "PUT /early HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\n\r\n%x\r\n%s\r\n", ts.Listener.Addr(), firstChunkSize, bytes.Repeat([]byte{'a'}, firstChunkSize))
1885 } else {
1886 _, err = fmt.Fprintf(conn, "PUT /early HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\n\r\n%s", ts.Listener.Addr(), tc.bodyLen, bytes.Repeat([]byte{'a'}, firstChunkSize))
1887 }
1888 require.NoError(t, err)
1889
1890 writerDone := make(chan struct{})
1891 if tc.chunked {
1892 go func() {
1893 defer close(writerDone)
1894 for sent := firstChunkSize; sent < tc.bodyLen; {
1895 n := min(8<<10, tc.bodyLen-sent)
1896 if _, err := fmt.Fprintf(conn, "%x\r\n%s\r\n", n, bytes.Repeat([]byte{'b'}, n)); err != nil {
1897 return
1898 }
1899 sent += n
1900 time.Sleep(time.Millisecond)
1901 }
1902 _, _ = io.WriteString(conn, "0\r\n\r\n")
1903 }()
1904 }
1905
1906 first, err := http.ReadResponse(reader, &http.Request{Method: http.MethodPut})
1907 require.NoError(t, err)
1908 require.Equal(t, http.StatusLocked, first.StatusCode)
1909 require.NoError(t, first.Body.Close())
1910
1911 if tc.chunked {
1912 <-writerDone
1913 } else {
1914 _, _ = conn.Write(bytes.Repeat([]byte{'b'}, tc.bodyLen-firstChunkSize))
1915 }
1916 _, _ = fmt.Fprintf(conn, "GET /second HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n", ts.Listener.Addr())
1917
1918 second, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
1919 if err != nil {
1920 if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, net.ErrClosed) {
1921 return // The server safely closed the connection after the early response.
1922 }
1923 var netErr net.Error
1924 if errors.As(err, &netErr) && !netErr.Timeout() {
1925 return // The peer reset the connection after the early response.
1926 }
1927 t.Fatalf("read second response: %v", err)
1928 }
1929 defer second.Body.Close()
1930 if second.StatusCode != http.StatusNoContent {
1931 t.Fatalf("second response status = %d, want %d or a closed connection", second.StatusCode, http.StatusNoContent)
1932 }
1933 select {
1934 case <-secondRequest:
1935 case <-time.After(time.Second):
1936 t.Fatal("second request response was not produced by the handler")
1937 }
1938 })
1939 }
1940 }
1941
1942 func TestServer_FullyReadRequestBodyPreservesHTTP1Connection(t *testing.T) {
1943 for _, tc := range []struct {
1944 name string
1945 chunked bool
1946 }{
1947 {name: "fixed length"},
1948 {name: "chunked", chunked: true},
1949 } {
1950 t.Run(tc.name, func(t *testing.T) {
1951 remoteAddrs := make(chan string, 2)
1952 server := &Server{
1953 ReadIdleTimeout: caddy.Duration(time.Minute),
1954 logger: zap.NewNop(),
1955 errorLogger: zap.NewNop(),
1956 primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1957 remoteAddrs <- r.RemoteAddr
1958 if r.URL.Path == "/first" {
1959 if _, err := io.Copy(io.Discard, r.Body); err != nil {
1960 return err
1961 }
1962 }
1963 w.Header().Set("Content-Length", "0")
1964 w.WriteHeader(http.StatusNoContent)
1965 return nil
1966 }),
1967 }
1968 ts := httptest.NewServer(http.HandlerFunc(server.ServeHTTP))
1969 t.Cleanup(ts.Close)
1970
1971 conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second)
1972 require.NoError(t, err)
1973 t.Cleanup(func() { _ = conn.Close() })
1974 require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
1975
1976 if tc.chunked {
1977 _, err = fmt.Fprintf(conn, "POST /first HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", ts.Listener.Addr())
1978 } else {
1979 _, err = fmt.Fprintf(conn, "POST /first HTTP/1.1\r\nHost: %s\r\nContent-Length: 4\r\n\r\nbody", ts.Listener.Addr())
1980 }
1981 require.NoError(t, err)
1982 reader := bufio.NewReader(conn)
1983 first, err := http.ReadResponse(reader, &http.Request{Method: http.MethodPost})
1984 require.NoError(t, err)
1985 require.Equal(t, http.StatusNoContent, first.StatusCode)
1986 require.NoError(t, first.Body.Close())
1987
1988 _, err = fmt.Fprintf(conn, "GET /second HTTP/1.1\r\nHost: %s\r\n\r\n", ts.Listener.Addr())
1989 require.NoError(t, err)
1990 second, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
1991 require.NoError(t, err)
1992 require.Equal(t, http.StatusNoContent, second.StatusCode)
1993 require.NoError(t, second.Body.Close())
1994
1995 assert.Equal(t, <-remoteAddrs, <-remoteAddrs)
1996 })
1997 }
1998 }