diff --git a/cmd/levelrail/main.go b/cmd/levelrail/main.go index e7472cf6..b27683a9 100644 --- a/cmd/levelrail/main.go +++ b/cmd/levelrail/main.go @@ -1725,6 +1725,7 @@ func rootHandler(logger *slog.Logger, b *brand.Brand, db *store.DB, telemetryDB api.WithNotificationDeliveries(alertingDB), api.WithSessionTTL(sessionTTL(logger)), api.WithAutoPlacement(autoPlacementEnabled(logger)), + api.WithHSTS(hstsEnabled(logger)), api.WithAPIRateLimit(apiRateLimitReadRPM(logger), apiRateLimitWriteRPM(logger)), api.WithDataDir(dataDir), api.WithDockerPinger(client), @@ -1922,13 +1923,33 @@ func rootHandler(logger *slog.Logger, b *brand.Brand, db *store.DB, telemetryDB } rt := api.NewRouter(logger, b, db, opts...) + return composeMux(rt.Handler(), webhookHandler, web.Handler()), rt +} + +// composeMux wires the three top-level handlers rootHandler serves +// behind one *http.Server into a single mux. Pulled out of rootHandler +// so the routing precedence itself, not just rootHandler's much larger +// dependency graph, is directly unit-testable. +// +// "/healthz" is registered ahead of "/api/" and "/" as its own +// exact-path pattern so a plain GET /healthz (what a systemd unit, +// container orchestrator, or load balancer actually probes, see +// handleHealthz's own doc comment) reaches apiHandler's own "GET +// /healthz" route. Without this explicit entry, "/healthz" has no +// "/api/" prefix, so it would fall through to the "/" SPA fallback and +// get back a 200 with the dashboard's index.html body instead of the +// {"status":"ok"} JSON a prober actually expects. webhookHandler is +// nil-able: a control plane started without git-webhook config just +// serves no POST /webhook route. +func composeMux(apiHandler http.Handler, webhookHandler http.Handler, webHandler http.Handler) *http.ServeMux { mux := http.NewServeMux() - mux.Handle("/api/", rt.Handler()) + mux.Handle("/healthz", apiHandler) + mux.Handle("/api/", apiHandler) if webhookHandler != nil { mux.Handle("POST /webhook", webhookHandler) } - mux.Handle("/", web.Handler()) - return mux, rt + mux.Handle("/", webHandler) + return mux } // backupSchedulerInterval reads APP_BACKUP_SCHEDULER_INTERVAL as a Go @@ -2042,6 +2063,25 @@ func autoPlacementEnabled(logger *slog.Logger) bool { return v } +// hstsEnabled reads APP_ENABLE_HSTS as a bool, api.WithHSTS's own +// enabled param. Defaults to false (unlike autoPlacementEnabled above): +// Strict-Transport-Security is safe only once an operator has real, +// browser-trusted certificates (APP_PUBLIC_HOST plus ACMEEnabled, see +// Router.hstsEnabled's own doc comment), which this process has no way +// to confirm on its own, so it's opt-in rather than assumed. +func hstsEnabled(logger *slog.Logger) bool { + raw := os.Getenv("APP_ENABLE_HSTS") + if raw == "" { + return false + } + v, err := strconv.ParseBool(raw) + if err != nil { + logger.Warn("invalid APP_ENABLE_HSTS, defaulting to disabled", slog.String("value", raw), slog.String("error", err.Error())) + return false + } + return v +} + // certExpiryWarningWindow reads APP_CERT_EXPIRY_WARNING_WINDOW as a Go // duration string, the same env-var-with-default shape sessionTTL above // already uses for api.WithSessionTTL, applied here to both diff --git a/cmd/levelrail/routing_test.go b/cmd/levelrail/routing_test.go new file mode 100644 index 00000000..51527610 --- /dev/null +++ b/cmd/levelrail/routing_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestComposeMux_HealthzReachesAPIHandlerNotSPAFallback(t *testing.T) { + apiHit := false + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiHit = true + if r.URL.Path != "/healthz" { + t.Errorf("apiHandler saw path %q, want /healthz unchanged", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + webHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("spa shell")) + }) + + mux := composeMux(apiHandler, nil, webHandler) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + mux.ServeHTTP(rec, req) + + if !apiHit { + t.Fatal("GET /healthz did not reach apiHandler, want it exact-matched ahead of the \"/\" SPA fallback") + } + if body := rec.Body.String(); body != `{"status":"ok"}` { + t.Errorf("body = %q, want the API handler's JSON, not the SPA shell", body) + } +} + +func TestComposeMux_APIPrefixReachesAPIHandlerWithFullPath(t *testing.T) { + var gotPath string + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + }) + webHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mux := composeMux(apiHandler, nil, webHandler) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/brand", nil) + mux.ServeHTTP(rec, req) + + if gotPath != "/api/v1/brand" { + t.Errorf("apiHandler saw path %q, want the full original path unchanged", gotPath) + } +} + +func TestComposeMux_UnmatchedPathFallsBackToWebHandler(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("api")) + }) + webHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("spa")) + }) + + mux := composeMux(apiHandler, nil, webHandler) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/apps/my-app", nil) + mux.ServeHTTP(rec, req) + + if got := rec.Body.String(); got != "spa" { + t.Errorf("body = %q, want the SPA fallback to serve an unmatched client-side route", got) + } +} + +func TestComposeMux_NilWebhookHandler_NoWebhookRoute(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + webHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("spa")) + }) + + mux := composeMux(apiHandler, nil, webHandler) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/webhook", nil) + mux.ServeHTTP(rec, req) + + if got := rec.Body.String(); got != "spa" { + t.Errorf("POST /webhook body = %q, want the SPA fallback since no webhookHandler was configured", got) + } +} + +func TestComposeMux_WebhookHandlerConfigured_ReceivesPost(t *testing.T) { + webhookHit := false + webhookHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + webhookHit = true + w.WriteHeader(http.StatusOK) + }) + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + webHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + + mux := composeMux(apiHandler, webhookHandler, webHandler) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/webhook", nil) + mux.ServeHTTP(rec, req) + + if !webhookHit { + t.Error("POST /webhook did not reach webhookHandler") + } +} diff --git a/docs/domains-and-ingress.md b/docs/domains-and-ingress.md index a03bfa0b..08cd6a8d 100644 --- a/docs/domains-and-ingress.md +++ b/docs/domains-and-ingress.md @@ -136,6 +136,17 @@ Be clear-eyed about where this stands, because it's easy to overstate: by step. Don't take "toggle exists" as "proven to work at internet scale" until that runbook (or your own experience) confirms it. +- **HTTP Strict Transport Security (HSTS): opt-in, once you're on real + certificates.** Set `APP_ENABLE_HSTS=true` on the control plane to send + `Strict-Transport-Security` on every response. It defaults to off on + purpose: HSTS tells a browser to refuse plain HTTP and refuse to let a + visitor click through a certificate warning on this host for the next + 180 days, so turning it on before `ACMEEnabled` is true (i.e. while + you're still on Caddy's self-signed internal issuer above) can lock + you out of your own dashboard the next time that self-signed cert + looks untrusted. Only enable it once real, browser-trusted certificates + are actually issuing. + - **Bring your own certificate.** If ACME can't reach a domain (an internal-only host, an externally issued wildcard, a cert already provisioned before DNS cuts over), you can upload your own diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 0e448dc2..5e3bbc92 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -4,9 +4,11 @@ import ( "context" "crypto/rand" "encoding/base64" + "fmt" "log/slog" "net/http" "runtime/debug" + "time" ) // requestIDContextKey is unexported, the standard "don't collide with @@ -92,22 +94,57 @@ func panicRecoveryMiddleware(logger *slog.Logger) func(http.Handler) http.Handle } } +// contentSecurityPolicy locks script execution down to the app's own +// same-origin bundle: no inline script survives web/index.html's own +// theme-init.js extraction, so script-src needs no 'unsafe-inline' or +// 'unsafe-eval'. style-src keeps 'unsafe-inline' because @xterm/xterm +// (AppTerminal.tsx's exec terminal) injects its own