b1f6c62a
test(boot): the Slowloris bounds and the HSTS window are pinned by literals
a73x 2026-08-23 11:12
Commit message
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 | } |