CSP violation reports: ingest endpoint, ClickHouse storage, UI view
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m3s

- POST /v1/csp collects browser CSP reports in both wire formats (legacy
  application/csp-report and Reporting API application/reports+json), with
  CORS limited to the operator's domains (any subdomain of k6n.net,
  tornberg.me, knatofs.se) and preflight handling.
- Reports are stored in a dedicated typed ClickHouse table (csp_reports,
  90-day TTL) with a JSONL file fallback (OTEL_CSP_PATH).
- New authenticated view endpoints /api/csp/reports and /api/csp/summary
  plus a 'CSP reports' screen in the UI (tab 12) with time window, site and
  directive filters and report drill-down.
- Documented in instrumentation.md; unit tests for parsing/handlers/file
  store and an env-gated ClickHouse integration test.
This commit is contained in:
developer committed 2026-09-23 16:50:43 +00:00
1 parent 9685de64da
commit 36f00d49b9
16 files changed
+1424 -1

No files matched your search

+62
View File
@@ -187,6 +187,50 @@ func withCORS(next http.HandlerFunc) http.HandlerFunc {
}
}
// reportingOriginSuffixes are the registrable domains allowed to post browser
// reports (CSP violations) cross-origin: any subdomain of these, plus the
// bare domains themselves.
var reportingOriginSuffixes = []string{"k6n.net", "tornberg.me", "knatofs.se"}
// reportingOriginAllowed reports whether an Origin header is one of the
// operator's own sites (any subdomain of reportingOriginSuffixes). Anything
// else gets no CORS grant; browsers then block the report delivery.
func reportingOriginAllowed(origin string) bool {
host := storage.URLLowerHost(origin)
if host == "" {
return false
}
for _, suffix := range reportingOriginSuffixes {
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
}
return false
}
// withReportingCORS gates the browser report ingest endpoints on the
// operator's own domains instead of "*". CSP report delivery is a
// non-simple CORS request (application/csp-report / application/reports+json),
// so preflights must be answered here too.
func withReportingCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// The response depends on Origin even when it is refused, so caches
// never serve one site's grant to another.
w.Header().Add("Vary", "Origin")
if origin := r.Header.Get("Origin"); reportingOriginAllowed(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
// dataPath resolves a small state file: the env override, else next to the
// telemetry log (a mounted volume in the cluster), else the working directory.
func dataPath(env, name string) string {
@@ -328,6 +372,17 @@ func main() {
if todoStore != nil {
coll.SetTodoStore(todoStore)
}
// CSP violation reports: the ClickHouse store keeps a dedicated typed
// table; without ClickHouse, fall back to a JSONL file next to the
// telemetry log so reports still land.
if cspStore, ok := store.(storage.CSPStore); ok {
coll.SetCSPStore(cspStore)
} else if st, err := storage.NewCSPFileStore(dataPath("OTEL_CSP_PATH", "otel-csp-reports.jsonl")); err != nil {
log.Printf("csp reports disabled: %v", err)
} else {
coll.SetCSPStore(st)
log.Printf("csp reports: file store (set OTEL_CLICKHOUSE_ADDR for ClickHouse)")
}
var statusStore issues.StatusStore
if chStore, ok := store.(*storage.ClickHouseStore); ok {
if st, err := issues.NewClickHouseStatusStore(context.Background(), chStore.Conn(), chStore.Database()); err != nil {
@@ -387,6 +442,8 @@ func main() {
otelMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs))
otelMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals))
otelMux.HandleFunc("/v1/csp", withReportingCORS(coll.HandleCSPIngest))
otelMux.HandleFunc("/v1/csp/", withReportingCORS(coll.HandleCSPIngest))
otelMux.HandleFunc("/v1/todos", withCORS(coll.HandleTodoCreate))
otelMux.HandleFunc("/health", coll.HandleHealth)
@@ -403,7 +460,12 @@ func main() {
uiMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs))
uiMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals))
uiMux.HandleFunc("/v1/todos", withCORS(coll.HandleTodoCreate))
uiMux.HandleFunc("/v1/csp", withReportingCORS(coll.HandleCSPIngest))
uiMux.HandleFunc("/v1/csp/", withReportingCORS(coll.HandleCSPIngest))
uiMux.HandleFunc("/health", coll.HandleHealth)
// CSP report views (read side) sit with the other protected API routes.
uiMux.Handle("GET /api/csp/reports", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleCSPReports)))
uiMux.Handle("GET /api/csp/summary", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleCSPSummary)))
uiMux.HandleFunc("GET /internal/honeypot/ips", honeypotTracker.HandleIPs)
// Public OIDC endpoints