internal/server/boot/headers_test.go
Ref: Size: 3.9 KiB History
package boot
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func serveWrapped(t *testing.T, r *http.Request) *http.Response {
t.Helper()
rec := httptest.NewRecorder()
securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, r)
return rec.Result()
}
// TestSecurityHeadersOnSuccess is the finding this closes: a 200 used to carry
// none of these. Error replies looked defended only because http.Error adds
// nosniff of its own.
func TestSecurityHeadersOnSuccess(t *testing.T) {
resp := serveWrapped(t, httptest.NewRequest("GET", "/", nil))
h := resp.Header
assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
assert.Equal(t, "DENY", h.Get("X-Frame-Options"))
assert.Equal(t, "strict-origin-when-cross-origin", h.Get("Referrer-Policy"))
assert.Contains(t, h.Get("Content-Security-Policy"), "frame-ancestors 'none'")
assert.Contains(t, h.Get("Content-Security-Policy"), "object-src 'none'")
}
// TestHSTSOnlyOverTLS pins that the header is sent when the browser is on TLS —
// directly, or via the proxy that terminates it — and withheld otherwise, where
// it would mean nothing.
func TestHSTSOnlyOverTLS(t *testing.T) {
plain := httptest.NewRequest("GET", "/", nil)
assert.Empty(t, serveWrapped(t, plain).Header.Get("Strict-Transport-Security"))
proxied := httptest.NewRequest("GET", "/", nil)
proxied.Header.Set("X-Forwarded-Proto", "https")
assertPinsHTTPSFor(t, hstsSeconds(t, serveWrapped(t, proxied)), "behind a TLS-terminating proxy")
direct := httptest.NewRequest("GET", "/", nil)
direct.TLS = &tls.ConnectionState{}
assertPinsHTTPSFor(t, hstsSeconds(t, serveWrapped(t, direct)), "on a direct TLS connection")
}
// hstsSeconds reads the max-age the browser is actually told, rather than the
// constant the server was built from — the number is the whole content of the
// header, so the test has to carry its own copy of it.
func hstsSeconds(t *testing.T, resp *http.Response) int {
t.Helper()
v := resp.Header.Get("Strict-Transport-Security")
require.NotEmpty(t, v, "no Strict-Transport-Security header at all")
for _, d := range strings.Split(v, ";") {
d = strings.TrimSpace(d)
if after, ok := strings.CutPrefix(d, "max-age="); ok {
n, err := strconv.Atoi(after)
require.NoError(t, err, "max-age must be a number of seconds: %q", v)
return n
}
}
t.Fatalf("Strict-Transport-Security carries no max-age directive: %q", v)
return 0
}
// hstsFloorSeconds is 180 days, the minimum the browser preload lists accept.
// It is a literal here on purpose: a max-age asserted against the constant that
// produced it passes at zero.
const hstsFloorSeconds = 15552000
func assertPinsHTTPSFor(t *testing.T, seconds int, where string) {
t.Helper()
assert.GreaterOrEqual(t, seconds, hstsFloorSeconds,
"HSTS max-age is the window in which the browser refuses to speak plaintext to this plane, %s. At 0 the header is an "+
"instruction to FORGET the pin, so the next visit can be downgraded to http by anyone on the path and the session "+
"cookie goes out in clear; anything under %d seconds (180 days) is below what the preload lists accept, so the "+
"pin expires between one operator visit and the next", where, hstsFloorSeconds)
}
// TestCSPScriptSrcIsHashedNotUnsafeInline is what makes the policy worth having:
// 'unsafe-inline' in script-src would let injected script run, and a browser
// ignores it entirely once a hash is present.
func TestCSPScriptSrcIsHashedNotUnsafeInline(t *testing.T) {
csp := contentSecurityPolicy()
var scriptSrc string
for _, d := range strings.Split(csp, "; ") {
if strings.HasPrefix(d, "script-src ") {
scriptSrc = d
}
}
require.NotEmpty(t, scriptSrc)
assert.NotContains(t, scriptSrc, "unsafe-inline")
assert.NotContains(t, scriptSrc, "unsafe-eval")
}