Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=()"
Expand Down
12 changes: 6 additions & 6 deletions internal/api/handler_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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.
Expand All @@ -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"])
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"])
}
3 changes: 2 additions & 1 deletion internal/server/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion internal/server/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions internal/server/lambda_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

Expand Down Expand Up @@ -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"))
}
Loading