a73x

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'",
	}, "; ")
}