detached fd75b8f

feat(middleware) add HSTS and Content-Security-Policy headers

commit
fd75b8f39a6dbc5632e910d344990b4a1476afaf
author
bnema · · 2026-02-06 21:28 UTC
parent
5018b1d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +45 -0
M internal/adapters/in/http/middleware/security.go +10 -0
@@ -23,6 +23,16 @@ func SecurityHeaders(next http.Handler) http.Handler {
2323 // Restrict browser features
2424 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
2525
26+ // HSTS: Enforce HTTPS-only for browsers. Only set when the request
27+ // arrived over TLS to avoid issues with plain HTTP development setups.
28+ if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
29+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
30+ }
31+
32+ // CSP for proxy-generated error pages. Backends set their own CSP
33+ // which will override this for proxied content.
34+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
35+
2636 next.ServeHTTP(w, r)
2737 })
2838 }
@@ -23,6 +23,16 @@ func SecurityHeaders(next http.Handler) http.Handler {
23 // Restrict browser features23 // Restrict browser features
24 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")24 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
25 25
26+ // HSTS: Enforce HTTPS-only for browsers. Only set when the request
27+ // arrived over TLS to avoid issues with plain HTTP development setups.
28+ if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
29+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
30+ }
31+
32+ // CSP for proxy-generated error pages. Backends set their own CSP
33+ // which will override this for proxied content.
34+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
35+
26 next.ServeHTTP(w, r)36 next.ServeHTTP(w, r)
27 })37 })
28 }38 }
M internal/adapters/in/http/middleware/security_test.go +35 -0
@@ -1,6 +1,7 @@
11 package middleware
22
33 import (
4+ "crypto/tls"
45 "net/http"
56 "net/http/httptest"
67 "testing"
@@ -27,6 +28,40 @@ func TestSecurityHeaders(t *testing.T) {
2728 assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection"))
2829 assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy"))
2930 assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy"))
31+ assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", rec.Header().Get("Content-Security-Policy"))
32+
33+ // HSTS should NOT be set on plain HTTP
34+ assert.Empty(t, rec.Header().Get("Strict-Transport-Security"))
35+}
36+
37+func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) {
38+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
39+ w.WriteHeader(http.StatusOK)
40+ })
41+
42+ wrapped := SecurityHeaders(handler)
43+
44+ req := httptest.NewRequest(http.MethodGet, "/", nil)
45+ req.TLS = &tls.ConnectionState{} // Simulate TLS connection
46+ rec := httptest.NewRecorder()
47+ wrapped.ServeHTTP(rec, req)
48+
49+ assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
50+}
51+
52+func TestSecurityHeaders_HSTS_WithForwardedProto(t *testing.T) {
53+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
54+ w.WriteHeader(http.StatusOK)
55+ })
56+
57+ wrapped := SecurityHeaders(handler)
58+
59+ req := httptest.NewRequest(http.MethodGet, "/", nil)
60+ req.Header.Set("X-Forwarded-Proto", "https")
61+ rec := httptest.NewRecorder()
62+ wrapped.ServeHTTP(rec, req)
63+
64+ assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
3065 }
3166
3267 func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
@@ -1,6 +1,7 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4+ "crypto/tls"
4 "net/http"5 "net/http"
5 "net/http/httptest"6 "net/http/httptest"
6 "testing"7 "testing"
@@ -27,6 +28,40 @@ func TestSecurityHeaders(t *testing.T) {
27 assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection"))28 assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection"))
28 assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy"))29 assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy"))
29 assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy"))30 assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy"))
31+ assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", rec.Header().Get("Content-Security-Policy"))
32+
33+ // HSTS should NOT be set on plain HTTP
34+ assert.Empty(t, rec.Header().Get("Strict-Transport-Security"))
35+}
36+
37+func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) {
38+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
39+ w.WriteHeader(http.StatusOK)
40+ })
41+
42+ wrapped := SecurityHeaders(handler)
43+
44+ req := httptest.NewRequest(http.MethodGet, "/", nil)
45+ req.TLS = &tls.ConnectionState{} // Simulate TLS connection
46+ rec := httptest.NewRecorder()
47+ wrapped.ServeHTTP(rec, req)
48+
49+ assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
50+}
51+
52+func TestSecurityHeaders_HSTS_WithForwardedProto(t *testing.T) {
53+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
54+ w.WriteHeader(http.StatusOK)
55+ })
56+
57+ wrapped := SecurityHeaders(handler)
58+
59+ req := httptest.NewRequest(http.MethodGet, "/", nil)
60+ req.Header.Set("X-Forwarded-Proto", "https")
61+ rec := httptest.NewRecorder()
62+ wrapped.ServeHTTP(rec, req)
63+
64+ assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
30 }65 }
31 66
32 func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {67 func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close