a73x

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")
}