CSP violation reports: ingest endpoint, ClickHouse storage, UI view
- 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:
1 parent
9685de64da
commit
36f00d49b9
16 files changed
+1424
-1
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user