diff --git a/internal/api/handler.go b/internal/api/handler.go index 1bfcb9d9..380dc37e 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -712,13 +712,21 @@ func (h *Handler) withRegisteredAccountNames(ctx context.Context, scope auth.Acc } // setSecurityHeaders adds comprehensive security headers to the response. +// HSTSHeaderValue is the Strict-Transport-Security value for every response +// path: API responses here, and the HTTP-mode static, Lambda static and +// health responses in internal/server. One constant keeps the modes from +// drifting apart again (issue #14: Lambda mode had dropped "preload"). +// "preload" only makes the domain eligible for the browser preload list; +// submitting it to hstspreload.org is a separate, manual step. +const HSTSHeaderValue = "max-age=31536000; includeSubDomains; preload" + func setSecurityHeaders(headers map[string]string) map[string]string { // Content Security Policy - restrictive for API responses // Only allow connections to same origin, block all other resources headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'" // Strict Transport Security - enforce HTTPS for 1 year including subdomains - headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + headers["Strict-Transport-Security"] = HSTSHeaderValue // Permissions Policy - disable all browser features headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" diff --git a/internal/api/handler_security_test.go b/internal/api/handler_security_test.go index 6a0039d8..03bdc28b 100644 --- a/internal/api/handler_security_test.go +++ b/internal/api/handler_security_test.go @@ -19,7 +19,7 @@ func TestSetSecurityHeaders(t *testing.T) { assert.Equal(t, "DENY", headers["X-Frame-Options"], "X-Frame-Options should be DENY") // X-XSS-Protection has been removed: it is deprecated and a no-op in modern browsers assert.Empty(t, headers["X-XSS-Protection"], "X-XSS-Protection should not be set") - assert.Equal(t, "max-age=31536000; includeSubDomains", headers["Strict-Transport-Security"], "HSTS should be set with 1 year max-age") + assert.Equal(t, "max-age=31536000; includeSubDomains; preload", headers["Strict-Transport-Security"], "HSTS should be set with 1 year max-age") assert.Equal(t, "strict-origin-when-cross-origin", headers["Referrer-Policy"], "Referrer-Policy should be strict-origin-when-cross-origin") assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", headers["Content-Security-Policy"], "CSP should be restrictive") assert.Equal(t, "geolocation=(), microphone=(), camera=()", headers["Permissions-Policy"], "Permissions-Policy should disable browser features") @@ -62,7 +62,7 @@ func TestHandleRequest_SecurityHeaders(t *testing.T) { assert.Equal(t, "DENY", resp.Headers["X-Frame-Options"]) // X-XSS-Protection has been removed: deprecated and no-op in modern browsers assert.Empty(t, resp.Headers["X-XSS-Protection"]) - assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Headers["Strict-Transport-Security"]) + assert.Equal(t, "max-age=31536000; includeSubDomains; preload", resp.Headers["Strict-Transport-Security"]) assert.Equal(t, "strict-origin-when-cross-origin", resp.Headers["Referrer-Policy"]) assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", resp.Headers["Content-Security-Policy"]) assert.Equal(t, "geolocation=(), microphone=(), camera=()", resp.Headers["Permissions-Policy"]) @@ -90,7 +90,7 @@ func TestHandleRequest_SecurityHeaders_OPTIONS(t *testing.T) { // Verify security headers are present in OPTIONS response assert.Equal(t, "nosniff", resp.Headers["X-Content-Type-Options"]) assert.Equal(t, "DENY", resp.Headers["X-Frame-Options"]) - assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Headers["Strict-Transport-Security"]) + assert.Equal(t, "max-age=31536000; includeSubDomains; preload", resp.Headers["Strict-Transport-Security"]) } // TestHandleRequest_SecurityHeaders_ErrorResponse verifies error responses include security headers. @@ -117,7 +117,7 @@ func TestHandleRequest_SecurityHeaders_ErrorResponse(t *testing.T) { // Verify security headers are present even in error responses assert.Equal(t, "nosniff", resp.Headers["X-Content-Type-Options"]) assert.Equal(t, "DENY", resp.Headers["X-Frame-Options"]) - assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Headers["Strict-Transport-Security"]) + assert.Equal(t, "max-age=31536000; includeSubDomains; preload", resp.Headers["Strict-Transport-Security"]) assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", resp.Headers["Content-Security-Policy"]) assert.Equal(t, "geolocation=(), microphone=(), camera=()", resp.Headers["Permissions-Policy"]) } @@ -154,7 +154,7 @@ func TestServeDocsUI_RelaxedCSP(t *testing.T) { // Other security headers stay strict. assert.Equal(t, "nosniff", resp.Headers["X-Content-Type-Options"]) assert.Equal(t, "DENY", resp.Headers["X-Frame-Options"]) - assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Headers["Strict-Transport-Security"]) + assert.Equal(t, "max-age=31536000; includeSubDomains; preload", resp.Headers["Strict-Transport-Security"]) } func TestDocsHandler_HEAD_ReturnsHeaders(t *testing.T) { @@ -331,5 +331,5 @@ func TestHandleRequest_SecurityHeaders_RequestTooLarge(t *testing.T) { // Verify security headers are present in 413 response assert.Equal(t, "nosniff", resp.Headers["X-Content-Type-Options"]) - assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Headers["Strict-Transport-Security"]) + assert.Equal(t, "max-age=31536000; includeSubDomains; preload", resp.Headers["Strict-Transport-Security"]) } diff --git a/internal/server/health.go b/internal/server/health.go index b4ccfa09..d425cab8 100644 --- a/internal/server/health.go +++ b/internal/server/health.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/LeanerCloud/cloud-commitments-platform/internal/api" "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" "github.com/LeanerCloud/cloud-commitments-platform/internal/database" @@ -210,7 +211,7 @@ func setHealthResponseHeaders(w http.ResponseWriter, corsOrigin string) { w.Header().Set("Content-Type", "application/json") // Security headers - w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + w.Header().Set("Strict-Transport-Security", api.HSTSHeaderValue) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") diff --git a/internal/server/http.go b/internal/server/http.go index 8bbf2261..e14a3d82 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -255,7 +255,7 @@ const htmlCSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-sr // to come from the server since static files are served directly from the container. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + w.Header().Set("Strict-Transport-Security", api.HSTSHeaderValue) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") diff --git a/internal/server/lambda.go b/internal/server/lambda.go index c5158afc..6d8e7ebd 100644 --- a/internal/server/lambda.go +++ b/internal/server/lambda.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/LeanerCloud/cloud-commitments-platform/internal/api" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ) @@ -163,7 +164,7 @@ func (app *Application) handleLambdaHTTPEvent(ctx context.Context, rawEvent json // unprotected. func lambdaSecurityHeaders() map[string]string { return map[string]string{ - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Strict-Transport-Security": api.HSTSHeaderValue, "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", diff --git a/internal/server/lambda_coverage_test.go b/internal/server/lambda_coverage_test.go index d1349c4e..ac4a853d 100644 --- a/internal/server/lambda_coverage_test.go +++ b/internal/server/lambda_coverage_test.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "encoding/json" "errors" + "net/http" + "net/http/httptest" "strings" "testing" @@ -231,3 +233,21 @@ func (sessionOnlyAuth) ValidateCSRFToken(_ context.Context, sessionToken, csrfTo } return nil } + +// TestSecurityHeaders_HSTSMatchesAcrossModes pins every response path to the +// same Strict-Transport-Security value (issue #14: Lambda mode had dropped +// "preload" while HTTP mode sent it). +func TestSecurityHeaders_HSTSMatchesAcrossModes(t *testing.T) { + testutil.AssertContains(t, api.HSTSHeaderValue, "preload") + + testutil.AssertEqual(t, api.HSTSHeaderValue, lambdaSecurityHeaders()["Strict-Transport-Security"]) + + rec := httptest.NewRecorder() + securityHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})). + ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + testutil.AssertEqual(t, api.HSTSHeaderValue, rec.Header().Get("Strict-Transport-Security")) + + rec = httptest.NewRecorder() + setHealthResponseHeaders(rec, "") + testutil.AssertEqual(t, api.HSTSHeaderValue, rec.Header().Get("Strict-Transport-Security")) +}