a73x

b1f6c62a

test(boot): the Slowloris bounds and the HSTS window are pinned by literals

a73x   2026-08-23 11:12

Commit message
test(boot): the Slowloris bounds and the HSTS window are pinned by literals

Both were asserted against the constants that set them, so the suite
passed with ReadHeaderTimeout and IdleTimeout at 0s and the header at
max-age=0 — the exact values an attacker would pick. The header value is
now parsed out of the served response and held to a 180-day floor; the
timeouts are held between literal bounds. ReadTimeout stays 0 so SSE and
console streams survive.

internal/server/boot/headers_test.go
Old New
@@ -4,6 +4,7 @@ import (
4 "crypto/tls" 4 "crypto/tls"
5 "net/http" 5 "net/http"
6 "net/http/httptest" 6 "net/http/httptest"
7 "strconv"
7 "strings" 8 "strings"
8 "testing" 9 "testing"
9 10
@@ -42,11 +43,44 @@ func TestHSTSOnlyOverTLS(t *testing.T) {
42 43
43 proxied := httptest.NewRequest("GET", "/", nil) 44 proxied := httptest.NewRequest("GET", "/", nil)
44 proxied.Header.Set("X-Forwarded-Proto", "https") 45 proxied.Header.Set("X-Forwarded-Proto", "https")
45 assert.Equal(t, hstsMaxAge, serveWrapped(t, proxied).Header.Get("Strict-Transport-Security")) 46 assertPinsHTTPSFor(t, hstsSeconds(t, serveWrapped(t, proxied)), "behind a TLS-terminating proxy")
46 47
47 direct := httptest.NewRequest("GET", "/", nil) 48 direct := httptest.NewRequest("GET", "/", nil)
48 direct.TLS = &tls.ConnectionState{} 49 direct.TLS = &tls.ConnectionState{}
49 assert.Equal(t, hstsMaxAge, serveWrapped(t, direct).Header.Get("Strict-Transport-Security")) 50 assertPinsHTTPSFor(t, hstsSeconds(t, serveWrapped(t, direct)), "on a direct TLS connection")
51 }
52
53 // hstsSeconds reads the max-age the browser is actually told, rather than the
54 // constant the server was built from — the number is the whole content of the
55 // header, so the test has to carry its own copy of it.
56 func hstsSeconds(t *testing.T, resp *http.Response) int {
57 t.Helper()
58 v := resp.Header.Get("Strict-Transport-Security")
59 require.NotEmpty(t, v, "no Strict-Transport-Security header at all")
60 for _, d := range strings.Split(v, ";") {
61 d = strings.TrimSpace(d)
62 if after, ok := strings.CutPrefix(d, "max-age="); ok {
63 n, err := strconv.Atoi(after)
64 require.NoError(t, err, "max-age must be a number of seconds: %q", v)
65 return n
66 }
67 }
68 t.Fatalf("Strict-Transport-Security carries no max-age directive: %q", v)
69 return 0
70 }
71
72 // hstsFloorSeconds is 180 days, the minimum the browser preload lists accept.
73 // It is a literal here on purpose: a max-age asserted against the constant that
74 // produced it passes at zero.
75 const hstsFloorSeconds = 15552000
76
77 func assertPinsHTTPSFor(t *testing.T, seconds int, where string) {
78 t.Helper()
79 assert.GreaterOrEqual(t, seconds, hstsFloorSeconds,
80 "HSTS max-age is the window in which the browser refuses to speak plaintext to this plane, %s. At 0 the header is an "+
81 "instruction to FORGET the pin, so the next visit can be downgraded to http by anyone on the path and the session "+
82 "cookie goes out in clear; anything under %d seconds (180 days) is below what the preload lists accept, so the "+
83 "pin expires between one operator visit and the next", where, hstsFloorSeconds)
50 } 84 }
51 85
52 // TestCSPScriptSrcIsHashedNotUnsafeInline is what makes the policy worth having: 86 // TestCSPScriptSrcIsHashedNotUnsafeInline is what makes the policy worth having:
internal/server/boot/httpserver_test.go
Old New
@@ -3,6 +3,7 @@ package boot
3 import ( 3 import (
4 "net/http" 4 "net/http"
5 "testing" 5 "testing"
6 "time"
6 7
7 "github.com/stretchr/testify/assert" 8 "github.com/stretchr/testify/assert"
8 ) 9 )
@@ -12,11 +13,28 @@ import (
12 // (Slowloris defense, fd reclamation), while ReadTimeout and WriteTimeout stay 13 // (Slowloris defense, fd reclamation), while ReadTimeout and WriteTimeout stay
13 // off so the SSE event stream and the serial-console WebSocket — both long-lived 14 // off so the SSE event stream and the serial-console WebSocket — both long-lived
14 // by design — are never cut at a deadline. 15 // by design — are never cut at a deadline.
16 //
17 // The two bounded values are asserted against literals, not against the
18 // constants that set them: a deadline compared to itself passes at zero.
15 func TestHTTPServerBoundsSlowClientsWithoutCappingStreams(t *testing.T) { 19 func TestHTTPServerBoundsSlowClientsWithoutCappingStreams(t *testing.T) {
16 srv := httpServer(":0", http.NewServeMux()) 20 srv := httpServer(":0", http.NewServeMux())
17 21
18 assert.Equal(t, httpServerReadHeaderTimeout, srv.ReadHeaderTimeout, "the Slowloris defense is on") 22 assert.GreaterOrEqual(t, srv.ReadHeaderTimeout, time.Second,
19 assert.Equal(t, httpServerIdleTimeout, srv.IdleTimeout, "idle keep-alives are reclaimed") 23 "ReadHeaderTimeout must bound how long a client may dribble request headers: at 0 there is no deadline at all, "+
24 "a single host holds a goroutine and an fd open forever, and a few hundred of them exhaust the listener. "+
25 "This is the Slowloris guard and it is the only one — ReadTimeout is deliberately left 0 so SSE and console "+
26 "WebSocket streams survive, so nothing else bounds the header phase")
27 assert.LessOrEqual(t, srv.ReadHeaderTimeout, 30*time.Second,
28 "a header phase measured in minutes is as good as unbounded: the attack is cheap precisely because an unfinished "+
29 "request costs the client nothing to hold")
30
31 assert.GreaterOrEqual(t, srv.IdleTimeout, 10*time.Second,
32 "IdleTimeout must reclaim a keep-alive connection that has gone quiet between requests: at 0 net/http never closes "+
33 "an idle connection, so every probe that opens one and walks away costs an fd until the process restarts. "+
34 "It applies only while idle, never mid-request, so a live stream is untouched")
35 assert.LessOrEqual(t, srv.IdleTimeout, 10*time.Minute,
36 "an idle window measured in hours reclaims nothing on the timescale a flood works at")
37
20 assert.Zero(t, srv.ReadTimeout, "a whole-request read deadline would cancel the SSE stream") 38 assert.Zero(t, srv.ReadTimeout, "a whole-request read deadline would cancel the SSE stream")
21 assert.Zero(t, srv.WriteTimeout, "a whole-request write deadline would sever a live console") 39 assert.Zero(t, srv.WriteTimeout, "a whole-request write deadline would sever a live console")
22 } 40 }