mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 10:03:43 +00:00
266 lines
13 KiB
Go
266 lines
13 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// nonceCtxKey keys the per-request CSP nonce in the request context.
|
|
type nonceCtxKey struct{}
|
|
|
|
// cspDirectives is the Content-Security-Policy, minus the per-request script
|
|
// nonce which is appended at request time. Scripts are locked to same-origin plus
|
|
// a per-request nonce (so inline scripts must be nonce'd and inline on*= handlers
|
|
// are rejected — the guard against injected script). Styles keep 'unsafe-inline'
|
|
// (low XSS risk, and avoids nonce'ing every style= attribute + htmx's indicator
|
|
// style). The only external resource is the CARTO basemap.
|
|
//
|
|
// That basemap is why connect-src, not img-src, names CARTO: the vector basemaps
|
|
// that replaced their deprecated raster tiles are fetched — the style JSON, the
|
|
// .mvt tiles, the sprite and the glyph PBFs all go out as XHR/fetch, and none of
|
|
// them is an image load. img-src stays 'self' as a result.
|
|
//
|
|
// worker-src is spelled out rather than left to fall through to default-src:
|
|
// MapLibre runs its tile decoding in a worker, and the reason we vendor its CSP
|
|
// build is that the default bundle spawns that worker from a blob: URL, which
|
|
// would mean allowing blob: here. The CSP build loads it from a same-origin URL
|
|
// instead (see basemap.js), so 'self' is enough — don't "fix" a future worker
|
|
// error by adding blob:; check that data-maplibre-worker is reaching the page.
|
|
var cspDirectives = strings.Join([]string{
|
|
"default-src 'self'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"connect-src 'self' https://*.basemaps.cartocdn.com",
|
|
"worker-src 'self'",
|
|
"font-src 'self' data:",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"object-src 'none'",
|
|
// form-action is NOT here: it has to name the sibling surfaces, and their origins
|
|
// include the request's port, so it's built per request. See formAction.
|
|
}, "; ")
|
|
|
|
// formAction builds the form-action directive: 'self' plus the other two surface
|
|
// origins.
|
|
//
|
|
// 'self' alone is wrong here, and silently so. A credential POST lands on the auth
|
|
// host and answers 303 to the app host's handoff callback — and **Chrome enforces
|
|
// form-action across the redirect chain**, not just on the initial request (the CSP
|
|
// spec says it shouldn't, and Firefox doesn't, which is exactly why this survived).
|
|
//
|
|
// The symptom is why it went unnoticed for a month in production. The POST itself
|
|
// arrives normally — the handler runs, the account is created, the session is set, and
|
|
// the log shows a clean 303 — and then the browser refuses to follow that redirect,
|
|
// reporting it only to the console. To the user the button simply does nothing, so it
|
|
// reads as flakiness; to the server everything looks successful. Analytics for the
|
|
// affected window shows the tell: five sign-up POSTs in six minutes, all 303, from
|
|
// someone pressing a dead button.
|
|
//
|
|
// So this must list every surface a form on one host may redirect to. Origins are
|
|
// computed from the request, since dev serves all three on a non-default port and a
|
|
// source expression without a port only matches 443.
|
|
func (e *Env) formAction(r *http.Request) string {
|
|
sources := []string{"'self'"}
|
|
if e.Cfg != nil {
|
|
for _, host := range []string{e.Cfg.PrimaryHost, e.Cfg.AuthHost, e.Cfg.RootHost} {
|
|
if host == "" {
|
|
continue
|
|
}
|
|
origin := originFor(e.Cfg, r, host)
|
|
if !slices.Contains(sources, origin) {
|
|
sources = append(sources, origin)
|
|
}
|
|
}
|
|
}
|
|
return "form-action " + strings.Join(sources, " ")
|
|
}
|
|
|
|
// permissionsPolicy denies powerful browser features we don't use and explicitly
|
|
// allows the three we do: WebSerial and WebUSB (both reach the KISS modem on the
|
|
// confirm/console pages — see serial-port.js, which falls back to WebUSB where
|
|
// WebSerial is absent, notably Android) and WebAuthn (passkeys). Unknown tokens
|
|
// are ignored by browsers that don't implement them.
|
|
//
|
|
// usb was denied here until WebUSB became a real transport, and flipping it back
|
|
// to () would silently break Android: the failure is a SecurityError from
|
|
// requestDevice, not a CSP violation, so neither the CSP report endpoint nor the
|
|
// e2e harness would notice.
|
|
const permissionsPolicy = "serial=(self), usb=(self), " +
|
|
"publickey-credentials-get=(self), publickey-credentials-create=(self), " +
|
|
"geolocation=(), camera=(), microphone=(), payment=()"
|
|
|
|
// securityHeaders sets a strict Content-Security-Policy (with a fresh per-request
|
|
// script nonce, also exposed via the context for templates) plus companion
|
|
// hardening headers, on every response.
|
|
func (e *Env) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nonce := newNonce()
|
|
h := w.Header()
|
|
policy := cspDirectives + "; " + e.formAction(r) +
|
|
"; script-src 'self' 'nonce-" + nonce + "'"
|
|
if e.csp != nil {
|
|
// report-uri ONLY, deliberately — not the modern report-to /
|
|
// Reporting-Endpoints pair, despite report-uri being deprecated.
|
|
//
|
|
// The trap is that these two don't compose: a browser that supports
|
|
// report-to IGNORES report-uri when both are present. So advertising both
|
|
// doesn't broaden coverage, it hands Chrome (the majority browser) over to
|
|
// the report-to path exclusively. If that path doesn't deliver, Chrome
|
|
// reports nothing at all — and an empty report table is indistinguishable
|
|
// from a clean one, the worst failure mode a monitoring feature can have.
|
|
//
|
|
// TestCSPViolationIsReportedByARealBrowser measures which actually works.
|
|
// With report-uri alone, Chrome delivers a genuine violation within a
|
|
// second. With report-to advertised, it delivered nothing at all in the
|
|
// same environment — explained by the self-signed certificate the e2e
|
|
// suite serves (Security.setIgnoreCertificateErrors is scoped to the
|
|
// inspected target and doesn't cover the network service's out-of-band
|
|
// report uploads), which is a test artifact rather than a production one.
|
|
//
|
|
// That's the point: report-uri is verified end to end in the browser
|
|
// engine we serve, and report-to is not verifiable here. Adding it would
|
|
// trade a working path for an unverified one. Revisit once it can be
|
|
// tested against a trusted certificate; parseCSPReports already handles
|
|
// the Reporting-API wire format, so enabling it is a header change.
|
|
policy += "; report-uri " + CSPReportPath
|
|
}
|
|
h.Set("Content-Security-Policy", policy)
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
h.Set("X-Frame-Options", "DENY") // old-browser fallback for frame-ancestors 'none'
|
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
h.Set("Permissions-Policy", permissionsPolicy)
|
|
// HSTS only over TLS — never on plain-http localhost dev, where it would
|
|
// pin the browser to https for the dev host.
|
|
if e.Cfg != nil && e.Cfg.Secure {
|
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), nonceCtxKey{}, nonce)))
|
|
})
|
|
}
|
|
|
|
// unsafeMethod reports whether a method can change server state. The safe set is
|
|
// closed (per RFC 9110), so anything unrecognized — including a method added by a
|
|
// future router — counts as unsafe and gets checked.
|
|
func unsafeMethod(method string) bool {
|
|
switch method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// blockCrossSiteWrites rejects state-changing requests that the browser tells us
|
|
// came from another site. It is the second layer of CSRF defense: the first is the
|
|
// session cookie's SameSite=Lax, which browsers use to withhold the cookie from a
|
|
// cross-site POST. Lax is sound but it is a *single* control, and it is the wrong
|
|
// shape for two failure modes — a browser or embedded webview that mishandles
|
|
// SameSite re-opens every mutation, and a state-changing GET (easy to add by
|
|
// accident) is forgeable outright, because Lax deliberately permits top-level GET
|
|
// navigation.
|
|
//
|
|
// Sec-Fetch-Site is set by the browser and cannot be forged by page JavaScript (it
|
|
// is a forbidden header name), so it is trustworthy when present. Values are
|
|
// handled as follows:
|
|
//
|
|
// - "cross-site" — rejected. This is the CSRF case: an attacker's page driving a
|
|
// write against us.
|
|
// - "same-origin" / "same-site" — allowed. Every form action and fetch() in the
|
|
// app is a relative path, and the CSP's form-action allows only this app's own
|
|
// surfaces (see formAction), so real writes are same-origin or between our own
|
|
// hosts. "same-site" is also allowed because it is exactly
|
|
// what Lax cookies already permit (sibling subdomains), so rejecting it would
|
|
// buy nothing this doesn't already concede.
|
|
// - "none" — allowed. It means the user initiated the request directly (address
|
|
// bar, bookmark), which an attacker cannot arrange; rejecting it would add no
|
|
// security and risk odd client behavior.
|
|
// - missing / unrecognized — allowed. Pre-2020 browsers and non-browser clients
|
|
// send nothing, and this is defense in depth layered on SameSite, not a
|
|
// replacement for it. Failing closed here would break those clients for no
|
|
// gain against an attacker, who cannot suppress the header in a real browser.
|
|
//
|
|
// Rejections are logged at Warn: this is a new control, and if it ever fires on
|
|
// legitimate traffic we want to find out from the logs rather than a bug report.
|
|
//
|
|
// Note this is a header check by design, which is what makes it cheap. The
|
|
// alternative — a synchronizer token in every form — would put a secret in an HTML
|
|
// body, and HTML is compressed (see compressHTML), which is the BREACH
|
|
// precondition. Keeping the check in headers sidesteps that entirely.
|
|
//
|
|
// The violation-report endpoint is exempt. Reports are POSTs the browser generates
|
|
// itself, out-of-band from the document that triggered them, and the Sec-Fetch-Site
|
|
// value on that delivery is not something the CSP or Reporting API specifications
|
|
// pin down — so a report could arrive labeled "cross-site" and be silently
|
|
// discarded, leaving reporting looking merely quiet. Exempting it costs nothing:
|
|
// the endpoint takes no session, performs no authenticated action, and its only
|
|
// effect is incrementing a counter on an aggregate row (see CSPCollector).
|
|
func blockCrossSiteWrites(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == CSPReportPath {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if unsafeMethod(r.Method) && strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
|
|
slog.Warn("blocked cross-site write",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"host", r.Host,
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
"client_ip", clientIP(r),
|
|
)
|
|
http.Error(w, "This request appears to have come from another site and was blocked.",
|
|
http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// NoStore marks a response as never-cacheable. Without it a response carrying no
|
|
// Cache-Control and no Expires is *heuristically* cacheable, so the browser's
|
|
// back button re-renders a signed-in page after sign-out — on a shared machine
|
|
// that hands the next person the previous user's dashboard, repeater list and
|
|
// command log. scs's Vary: Cookie stops a shared cache cross-serving one user's
|
|
// page to another, so this closes the local/history exposure that Vary can't.
|
|
//
|
|
// Applied to the session-bearing route groups (see each surface's sessionMW), not
|
|
// blanket per host: static assets and /healthz are registered ahead of the session
|
|
// middleware and must keep their own immutable caching. Anything outside those
|
|
// groups has no session, so it can't render user data in the first place.
|
|
//
|
|
// Note this also opts those pages out of the back/forward cache — bfcache is
|
|
// disabled by no-store specifically (no-cache would not do it) — so back
|
|
// navigation is a real request rather than an instant restore. That's the point.
|
|
func NoStore(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// NonceFromContext returns the per-request CSP script nonce, or "" if unset.
|
|
func NonceFromContext(ctx context.Context) string {
|
|
if v, ok := ctx.Value(nonceCtxKey{}).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// newNonce returns a fresh base64 CSP nonce (16 random bytes).
|
|
func newNonce() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand failure is fatal for security; a static fallback would defeat
|
|
// the nonce, so panic (matches how the app treats other rand failures).
|
|
panic("csp nonce: " + err.Error())
|
|
}
|
|
return base64.RawStdEncoding.EncodeToString(b[:])
|
|
}
|