internal/server/boot/headers.go
Ref: Size: 3.2 KiB History
package boot
import (
"net/http"
"strings"
"github.com/a73x/eitri/internal/server/web"
)
// hstsMaxAge is two years in seconds, the value the preload lists expect.
//
// includeSubDomains is deliberately absent. This plane answers on one hostname,
// and the directive would bind every sibling under the registered domain —
// including hosts this server knows nothing about and does not serve. That is a
// commitment for an operator to make on purpose, not a side effect of turning
// on a header.
const hstsMaxAge = "max-age=63072000"
// securityHeaders sets the response headers the browser-facing surface needs.
// It wraps the ROOT mux, so the SPA, the API and the sign-in redirects are all
// covered — a header set on only some responses protects only some of them.
//
// Go's http.Error already sends nosniff on error replies, which is why the
// absence of these was easy to miss: the 4xx a probe sees looks defended while
// every 200 goes out bare.
func securityHeaders(next http.Handler) http.Handler {
csp := contentSecurityPolicy()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("X-Content-Type-Options", "nosniff")
// frame-ancestors in the CSP is the modern control and covers this; the
// legacy header stays for clients that honour only the old one.
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// HSTS is meaningful only once the browser is already on TLS, and it is
// what closes the window the plain-HTTP redirect leaves open: without it
// the FIRST request of a session still goes out in the clear. TLS is
// terminated by the proxy in front, so trust its forwarded scheme and
// fall back to whether this hop was itself TLS.
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
h.Set("Strict-Transport-Security", hstsMaxAge)
}
next.ServeHTTP(w, r)
})
}
// contentSecurityPolicy builds the policy served with every response.
//
// script-src carries the hash of the SPA's inline entry point rather than
// 'unsafe-inline' — with a hash present a browser IGNORES 'unsafe-inline', so
// injected script is refused while the console still boots. The hashes are
// computed from the embedded index.html at startup, so a rebuild that changes
// that script needs no change here.
//
// style-src keeps 'unsafe-inline' because the SPA sets style attributes on
// elements, which no hash can cover. An inline style cannot execute script, so
// this is the cheap half of the policy to concede.
//
// connect-src stays 'self': the console talks to its own origin for the API,
// the SSE stream and the console WebSocket (ws: over the same host is covered
// by 'self' in modern browsers).
func contentSecurityPolicy() string {
script := "'self'"
for _, h := range web.InlineScriptHashes() {
script += " '" + h + "'"
}
return strings.Join([]string{
"default-src 'self'",
"script-src " + script,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
}, "; ")
}