From 36f00d49b92efbe91093546b4955088aff5ced1c Mon Sep 17 00:00:00 2001 From: developer Date: Wed, 23 Sep 2026 16:50:43 +0000 Subject: [PATCH] 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. --- cmd/server/main.go | 62 +++ instrumentation.md | 23 ++ internal/collector/collector.go | 1 + internal/collector/csp.go | 257 ++++++++++++ internal/collector/csp_test.go | 187 +++++++++ internal/storage/clickhouse.go | 3 + internal/storage/csp.go | 368 ++++++++++++++++++ internal/storage/csp_integration_test.go | 77 ++++ internal/storage/csp_test.go | 91 +++++ otel-ui/src/App.tsx | 2 +- .../src/components/CspReportsVisualizer.tsx | 249 ++++++++++++ otel-ui/src/components/Tabs.tsx | 4 + otel-ui/src/components/layout/IconRail.tsx | 2 + otel-ui/src/hooks/useCspReports.ts | 46 +++ otel-ui/src/lib/csp-url.ts | 18 + otel-ui/src/lib/types.ts | 35 ++ 16 files changed, 1424 insertions(+), 1 deletion(-) create mode 100644 internal/collector/csp.go create mode 100644 internal/collector/csp_test.go create mode 100644 internal/storage/csp.go create mode 100644 internal/storage/csp_integration_test.go create mode 100644 internal/storage/csp_test.go create mode 100644 otel-ui/src/components/CspReportsVisualizer.tsx create mode 100644 otel-ui/src/hooks/useCspReports.ts create mode 100644 otel-ui/src/lib/csp-url.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index b24702c..5956ec9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/instrumentation.md b/instrumentation.md index 235685a..8d648d8 100644 --- a/instrumentation.md +++ b/instrumentation.md @@ -117,6 +117,29 @@ options (on the RUM or todo script, or `OtelRUM.init({ todo: {...} })`): `data-s Todos go to ClickHouse when configured, else to the JSONL file at `OTEL_TODOS_PATH` (default `otel-todos.jsonl`). +## CSP violation reports + +Browsers report Content-Security-Policy violations straight to the collector. +Point the policy's `report-uri` (or a Reporting API `report-to` endpoint) at +`/v1/csp`; both the legacy `application/csp-report` object and the Reporting +API's `application/reports+json` batch are accepted: + +```html +policy: default-src 'self'; report-uri https://otel.example.com/v1/csp?key=... +``` + +Delivery is allowed cross-origin from `*.k6n.net`, `*.tornberg.me` and +`*.knatofs.se` (the collector answers CORS preflights and echoes the origin +for those domains only). Like the vitals beacon, the optional ingest key +travels as `?key=` on the reporting URL, since browsers cannot attach headers +to violation reports. Storage follows the configured store: a dedicated typed +ClickHouse table (`csp_reports`, 90-day TTL), else the JSONL file at +`OTEL_CSP_PATH` (default `otel-csp-reports.jsonl`). + +- `POST /v1/csp` (ingest key as `?key=`, CORS) — browser-reported, send nothing by hand +- `GET /api/csp/reports?site=&directive=&q=&hours=&limit=` (authenticated), newest first +- `GET /api/csp/summary?hours=24` (authenticated) — totals plus top directives, blocked hosts and sites + ## Backend instrumentation Instrument backend services with an OpenTelemetry SDK or auto-instrumentation diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 484af74..c39c758 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -47,6 +47,7 @@ type Collector struct { ingestKey string todoStore todos.Store sourceMapKey string + cspStore storage.CSPStore notifyIssuesFn func() diff --git a/internal/collector/csp.go b/internal/collector/csp.go new file mode 100644 index 0000000..786fc8d --- /dev/null +++ b/internal/collector/csp.go @@ -0,0 +1,257 @@ +package collector + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "git.tornberg.me/go-otel/internal/storage" +) + +const ( + // CSP reports are small; URLs and the echoed policy are the long parts. + maxCSPBody = 16 << 10 + maxCSPURL = 512 + maxCSPPolicy = 2048 + maxCSPSample = 300 + maxCSPBatch = 50 + defaultCSPWindow = 24 +) + +// SetCSPStore enables CSP violation report collection. Both the ClickHouse +// store and the file fallback satisfy the interface; nil disables the +// endpoints. +func (c *Collector) SetCSPStore(s storage.CSPStore) { c.cspStore = s } + +// cspReportBody mirrors the legacy report-uri payload +// (Content-Type: application/csp-report). +type cspReportBody struct { + CSPReport struct { + DocumentURI string `json:"document-uri"` + Referrer string `json:"referrer"` + BlockedURI string `json:"blocked-uri"` + ViolatedDirective string `json:"violated-directive"` + EffectiveDirective string `json:"effective-directive"` + OriginalPolicy string `json:"original-policy"` + Disposition string `json:"disposition"` + StatusCode int `json:"status-code"` + ScriptSample string `json:"script-sample"` + } `json:"csp-report"` +} + +// cspAPIReport mirrors one entry of the Reporting API payload +// (Content-Type: application/reports+json); body repeats the csp-report +// fields in camelCase. +type cspAPIReport struct { + Type string `json:"type"` + UserAgent string `json:"user_agent"` + Body struct { + DocumentURL string `json:"documentURL"` + Referrer string `json:"referrer"` + BlockedURL string `json:"blockedURL"` + ViolatedDirective string `json:"violatedDirective"` + EffectiveDirective string `json:"effectiveDirective"` + OriginalPolicy string `json:"originalPolicy"` + Disposition string `json:"disposition"` + StatusCode int `json:"statusCode"` + Sample string `json:"sample"` + } `json:"body"` +} + +func clipCSP(s string, max int) string { + if len(s) > max { + return s[:max] + } + return s +} + +// parseCSPReports decodes either wire format into normalized reports; an +// unrecognized shape returns nil (the handler rejects those). +func parseCSPReports(body []byte) []storage.CSPReport { + var reports []storage.CSPReport + if len(body) > 0 && body[0] == '[' { + var api []cspAPIReport + if err := json.Unmarshal(body, &api); err != nil || len(api) == 0 { + return nil + } + if len(api) > maxCSPBatch { + api = api[:maxCSPBatch] + } + for _, r := range api { + // The endpoint only serves CSP; skip other report types instead + // of guessing at their bodies, and drop empty entries. + if r.Type != "" && r.Type != "csp-violation" { + continue + } + if r.Body.DocumentURL == "" && r.Body.BlockedURL == "" && r.Body.EffectiveDirective == "" && + r.Body.ViolatedDirective == "" && r.Body.Sample == "" { + continue + } + reports = append(reports, storage.CSPReport{ + DocumentURL: clipCSP(r.Body.DocumentURL, maxCSPURL), + Referrer: clipCSP(r.Body.Referrer, maxCSPURL), + BlockedURL: clipCSP(r.Body.BlockedURL, maxCSPURL), + ViolatedDirective: clipCSP(r.Body.ViolatedDirective, maxCSPSample), + EffectiveDirective: clipCSP(r.Body.EffectiveDirective, maxCSPSample), + OriginalPolicy: clipCSP(r.Body.OriginalPolicy, maxCSPPolicy), + Disposition: clipCSP(r.Body.Disposition, 32), + StatusCode: r.Body.StatusCode, + Sample: clipCSP(r.Body.Sample, maxCSPSample), + ReportType: clipCSP(r.Type, 64), + UserAgent: clipCSP(r.UserAgent, maxCSPSample), + }) + } + return reports + } + + var legacy cspReportBody + if err := json.Unmarshal(body, &legacy); err != nil { + return nil + } + v := legacy.CSPReport + if v.DocumentURI == "" && v.BlockedURI == "" && v.ViolatedDirective == "" { + return nil + } + return []storage.CSPReport{{ + DocumentURL: clipCSP(v.DocumentURI, maxCSPURL), + Referrer: clipCSP(v.Referrer, maxCSPURL), + BlockedURL: clipCSP(v.BlockedURI, maxCSPURL), + ViolatedDirective: clipCSP(v.ViolatedDirective, maxCSPSample), + EffectiveDirective: clipCSP(v.EffectiveDirective, maxCSPSample), + OriginalPolicy: clipCSP(v.OriginalPolicy, maxCSPPolicy), + Disposition: clipCSP(v.Disposition, 32), + StatusCode: v.StatusCode, + Sample: clipCSP(v.ScriptSample, maxCSPSample), + }} +} + +// finishCSPReport derives site/blocked host and a stable content id, and +// stamps server-side context (time, peer IP, request UA when the report +// carries none). +func finishCSPReport(r storage.CSPReport, now time.Time, peerIP, requestUA string) storage.CSPReport { + if r.Site = storage.URLLowerHost(r.DocumentURL); r.Site == "" { + r.Site = "unknown" + } + if r.ReportType == "" { + r.ReportType = "csp-report" + } + if r.UserAgent == "" { + r.UserAgent = clipCSP(requestUA, maxCSPSample) + } + r.SourceIP = peerIP + r.Timestamp = now.UTC() + // Deterministic id over the report content: a stable UI key that also + // makes duplicate re-deliveries of one violation identifiable. + hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%s|%s|%s", + r.Site, r.DocumentURL, r.BlockedURL, r.EffectiveDirective, r.Sample, r.Timestamp.Format(time.RFC3339Nano)))) + r.ID = hex.EncodeToString(hash[:8]) + return r +} + +// HandleCSPIngest collects Content-Security-Policy violation reports posted +// by browsers (report-uri / report-to). Both the legacy application/csp-report +// object and the Reporting API's application/reports+json array are accepted. +// Browsers cannot attach auth headers, so like /v1/vitals the optional ingest +// key is accepted as ?key= on the configured reporting URL; CORS is limited +// to the operator's own domains (withReportingCORS in cmd/server). +func (c *Collector) HandleCSPIngest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !c.checkIngest(w, r) { + return + } + if c.cspStore == nil { + http.Error(w, "csp report storage not configured", http.StatusServiceUnavailable) + return + } + // Browsers post application/csp-report or application/reports+json; some + // proxies rewrite the type, so decode regardless of content type. + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCSPBody)) + if err != nil { + http.Error(w, "invalid csp report payload", http.StatusBadRequest) + return + } + reports := parseCSPReports(raw) + if len(reports) == 0 { + http.Error(w, "no csp reports in payload", http.StatusBadRequest) + return + } + now := time.Now() + peerIP := clientIP(r) + requestUA := r.UserAgent() + for i := range reports { + reports[i] = finishCSPReport(reports[i], now, peerIP, requestUA) + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + for _, report := range reports { + if err := c.cspStore.AppendCSPReport(ctx, report); err != nil { + log.Printf("csp: failed to store report: %v", err) + http.Error(w, "failed to store report", http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +// HandleCSPReports serves the view's list. +// Query: site, directive, q (substring), hours, limit. +func (c *Collector) HandleCSPReports(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if c.cspStore == nil { + http.Error(w, "csp report storage not configured", http.StatusNotImplemented) + return + } + q := storage.CSPReportsQuery{ + Site: r.URL.Query().Get("site"), + Directive: r.URL.Query().Get("directive"), + Search: r.URL.Query().Get("q"), + Limit: parseLimit(r, 200), + } + if _, hours := parseStatsCommon(r); r.URL.Query().Get("hours") != "" { + q.Since = time.Now().Add(-time.Duration(hours) * time.Hour) + } + reports, err := c.cspStore.CSPReports(r.Context(), q) + if err != nil { + log.Printf("csp: list failed: %v", err) + http.Error(w, "failed to list csp reports", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"reports": reports}) +} + +// HandleCSPSummary serves the view's aggregates. Query: hours (default 24). +func (c *Collector) HandleCSPSummary(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if c.cspStore == nil { + http.Error(w, "csp report storage not configured", http.StatusNotImplemented) + return + } + _, hours := parseStatsCommon(r) + if r.URL.Query().Get("hours") == "" { + hours = defaultCSPWindow + } + summary, err := c.cspStore.CSPSummary(r.Context(), hours) + if err != nil { + log.Printf("csp: summary failed: %v", err) + http.Error(w, "failed to summarize csp reports", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"summary": summary}) +} diff --git a/internal/collector/csp_test.go b/internal/collector/csp_test.go new file mode 100644 index 0000000..a7b3025 --- /dev/null +++ b/internal/collector/csp_test.go @@ -0,0 +1,187 @@ +package collector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.tornberg.me/go-otel/internal/storage" +) + +// cspMemStore records ingested reports and serves canned query results. +type cspMemStore struct { + reports []storage.CSPReport +} + +func (s *cspMemStore) AppendCSPReport(_ context.Context, r storage.CSPReport) error { + s.reports = append(s.reports, r) + return nil +} + +func (s *cspMemStore) CSPReports(_ context.Context, q storage.CSPReportsQuery) ([]storage.CSPReport, error) { + return s.reports, nil +} + +func (s *cspMemStore) CSPSummary(_ context.Context, _ int) (storage.CSPSummary, error) { + return storage.CSPSummary{Total: uint64(len(s.reports))}, nil +} + +func TestParseCSPReportsLegacy(t *testing.T) { + body := []byte(`{"csp-report":{ + "document-uri":"https://app.k6n.net/page?x=1", + "referrer":"https://k6n.net/", + "blocked-uri":"https://evil.example.com/tracker.js", + "violated-directive":"script-src-elem", + "effective-directive":"script-src-elem", + "original-policy":"script-src 'self'; default-src 'self'", + "disposition":"enforce", + "status-code":200, + "script-sample":"evil()"}}`) + reports := parseCSPReports(body) + if len(reports) != 1 { + t.Fatalf("want 1 report, got %d", len(reports)) + } + r := reports[0] + if r.DocumentURL != "https://app.k6n.net/page?x=1" || r.BlockedURL != "https://evil.example.com/tracker.js" { + t.Errorf("urls not parsed: %+v", r) + } + if r.EffectiveDirective != "script-src-elem" || r.Disposition != "enforce" || r.StatusCode != 200 { + t.Errorf("directives/disposition/status not parsed: %+v", r) + } + if r.OriginalPolicy != "script-src 'self'; default-src 'self'" || r.Sample != "evil()" { + t.Errorf("policy/sample not parsed: %+v", r) + } +} + +func TestParseCSPReportsReportingAPI(t *testing.T) { + body := []byte(`[ + {"age":1,"type":"csp-violation","url":"https://docs.tornberg.me/a","user_agent":"Mozilla/5.0", + "body":{"documentURL":"https://docs.tornberg.me/a","blockedURL":"inline","effectiveDirective":"style-src", + "disposition":"report","statusCode":200,"sample":"color:red","sourceFile":"x.js"}}, + {"age":2,"type":"deprecation","body":{}}]`) + reports := parseCSPReports(body) + if len(reports) != 1 { + t.Fatalf("want only the csp-violation report, got %d", len(reports)) + } + r := reports[0] + if r.EffectiveDirective != "style-src" || r.UserAgent != "Mozilla/5.0" || r.ReportType != "csp-violation" { + t.Errorf("reporting-api fields not parsed: %+v", r) + } +} + +func TestParseCSPReportsRejectsGarbage(t *testing.T) { + for _, body := range []string{"", "not json", `{"nope":1}`, `[]`, `[{"body":{}}]`} { + if got := parseCSPReports([]byte(body)); got != nil { + t.Errorf("parseCSPReports(%q) = %+v, want nil", body, got) + } + } +} + +func TestHandleCSPIngest(t *testing.T) { + store := &cspMemStore{} + c := New(nil, 10, nil) + c.SetCSPStore(store) + + // Legacy payload via sendBeacon (text/plain, no UA in body). + req := httptest.NewRequest(http.MethodPost, "/v1/csp", strings.NewReader( + `{"csp-report":{"document-uri":"https://app.k6n.net/p","blocked-uri":"https://ads.knatofs.se/x.js", + "effective-directive":"script-src","disposition":"enforce"}}`)) + req.Header.Set("User-Agent", "TestUA/1.0") + req.RemoteAddr = "203.0.113.7:1234" + rec := httptest.NewRecorder() + c.HandleCSPIngest(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("legacy ingest: code=%d body=%s", rec.Code, rec.Body.String()) + } + if len(store.reports) != 1 { + t.Fatalf("legacy ingest: stored %d reports", len(store.reports)) + } + got := store.reports[0] + if got.Site != "app.k6n.net" || got.SourceIP != "203.0.113.7" { + t.Errorf("site/source ip not derived: %+v", got) + } + if got.UserAgent != "TestUA/1.0" || got.ReportType != "csp-report" || got.ID == "" { + t.Errorf("ua/type/id not finished: %+v", got) + } + + // Reporting API batch. + req = httptest.NewRequest(http.MethodPost, "/v1/csp", strings.NewReader( + `[{"type":"csp-violation","body":{"documentURL":"https://www.tornberg.me/","blockedURL":"https://b.example/"}}]`)) + req.Header.Set("X-Forwarded-For", "198.51.100.9, 10.0.0.1") + rec = httptest.NewRecorder() + c.HandleCSPIngest(rec, req) + if rec.Code != http.StatusNoContent || len(store.reports) != 2 { + t.Fatalf("api ingest: code=%d stored=%d", rec.Code, len(store.reports)) + } + if got := store.reports[1]; got.Site != "www.tornberg.me" || got.SourceIP != "198.51.100.9" { + t.Errorf("api ingest normalization: %+v", got) + } + + // Garbage is rejected; reads stay 405. + if rec := post(c, "/v1/csp", "garbage"); rec.Code != http.StatusBadRequest { + t.Errorf("garbage: code=%d", rec.Code) + } + getRec := httptest.NewRecorder() + c.HandleCSPIngest(getRec, httptest.NewRequest(http.MethodGet, "/v1/csp", nil)) + if getRec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET: code=%d", getRec.Code) + } +} + +func post(c *Collector, path, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c.HandleCSPIngest(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))) + return rec +} + +func TestHandleCSPViews(t *testing.T) { + store := &cspMemStore{reports: []storage.CSPReport{{ + ID: "abc", Timestamp: time.Now(), Site: "app.k6n.net", EffectiveDirective: "script-src", + }}} + c := New(nil, 10, nil) + c.SetCSPStore(store) + + rec := httptest.NewRecorder() + c.HandleCSPReports(rec, httptest.NewRequest(http.MethodGet, "/api/csp/reports?site=app.k6n.net&hours=24", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("reports: code=%d", rec.Code) + } + var list struct { + Reports []storage.CSPReport `json:"reports"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Reports) != 1 { + t.Fatalf("reports: err=%v body=%s", err, rec.Body.String()) + } + + rec = httptest.NewRecorder() + c.HandleCSPSummary(rec, httptest.NewRequest(http.MethodGet, "/api/csp/summary?hours=168", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"total":1`) { + t.Fatalf("summary: code=%d body=%s", rec.Code, rec.Body.String()) + } + + // Without a store both views answer 501 politely. + c2 := New(nil, 10, nil) + rec = httptest.NewRecorder() + c2.HandleCSPReports(rec, httptest.NewRequest(http.MethodGet, "/api/csp/reports", nil)) + if rec.Code != http.StatusNotImplemented { + t.Fatalf("no store: code=%d", rec.Code) + } +} + +func TestFinishCSPReportIDStable(t *testing.T) { + now := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + a := finishCSPReport(storage.CSPReport{DocumentURL: "https://a.k6n.net/", BlockedURL: "https://b/"}, now, "1.2.3.4", "UA") + b := finishCSPReport(storage.CSPReport{DocumentURL: "https://a.k6n.net/", BlockedURL: "https://b/"}, now, "5.6.7.8", "Other") + if a.ID != b.ID { + // The id intentionally covers report content only, not delivery + // context, so duplicate re-deliveries share it. + t.Errorf("ids differ across delivery context: %s vs %s", a.ID, b.ID) + } + if a.Site != "a.k6n.net" || a.Timestamp != now.UTC() { + t.Errorf("finish: %+v", a) + } +} diff --git a/internal/storage/clickhouse.go b/internal/storage/clickhouse.go index 5c97713..01be4f5 100644 --- a/internal/storage/clickhouse.go +++ b/internal/storage/clickhouse.go @@ -159,6 +159,9 @@ func (s *ClickHouseStore) initSchema(ctx context.Context) error { if err := EnsureSourceIPSchema(ctx, s.conn, s.db); err != nil { return fmt.Errorf("source ip schema: %w", err) } + if err := EnsureCSPSchema(ctx, s.conn, s.db); err != nil { + return fmt.Errorf("csp report schema: %w", err) + } if err := s.conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.entries ( timestamp DateTime64(9, 'UTC') NOT NULL, type LowCardinality(String) NOT NULL, diff --git a/internal/storage/csp.go b/internal/storage/csp.go new file mode 100644 index 0000000..11a5466 --- /dev/null +++ b/internal/storage/csp.go @@ -0,0 +1,368 @@ +package storage + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +) + +// CSPReport is one Content-Security-Policy violation report, normalized from +// either the legacy report-uri format ({"csp-report": {...}}) or the newer +// Reporting API (application/reports+json). +type CSPReport struct { + // ID is a stable content hash, computed at ingest, usable as a UI key. + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + Site string `json:"site"` // host of document_url, e.g. app.k6n.net + DocumentURL string `json:"documentUrl"` + Referrer string `json:"referrer,omitempty"` + BlockedURL string `json:"blockedUrl,omitempty"` + ViolatedDirective string `json:"violatedDirective,omitempty"` + EffectiveDirective string `json:"effectiveDirective,omitempty"` + OriginalPolicy string `json:"originalPolicy,omitempty"` + Disposition string `json:"disposition,omitempty"` // enforce | report + StatusCode int `json:"statusCode,omitempty"` + Sample string `json:"sample,omitempty"` + // ReportType is the Reporting API type ("csp-violation"); empty for + // legacy report-uri payloads. + ReportType string `json:"reportType,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + SourceIP string `json:"sourceIP,omitempty"` +} + +// CSPReportsQuery filters the stored reports for the UI list. +type CSPReportsQuery struct { + // Site filters on the document host ("app.k6n.net"); empty = all. + Site string + // Directive filters on the effective directive ("script-src"); empty = all. + Directive string + // Search is a substring match across urls, referrer and sample. + Search string + // Since (zero = all time) drops reports older than the given instant. + Since time.Time + Limit int +} + +// CSPCount is one named bucket of the summary (directive, host, site). +type CSPCount struct { + Name string `json:"name"` + Count uint64 `json:"count"` +} + +// CSPSummary aggregates reports over one window for the view's overview cards. +type CSPSummary struct { + Total uint64 `json:"total"` + Sites uint64 `json:"sites"` + ByDirective []CSPCount `json:"byDirective"` + ByBlockedURL []CSPCount `json:"byBlockedHost"` + BySite []CSPCount `json:"bySite"` +} + +// CSPStore persists CSP violation reports and answers the view's queries. +// ClickHouseStore implements it; CSPFileStore is the fallback when no +// ClickHouse is configured. +type CSPStore interface { + AppendCSPReport(ctx context.Context, r CSPReport) error + CSPReports(ctx context.Context, q CSPReportsQuery) ([]CSPReport, error) + CSPSummary(ctx context.Context, hours int) (CSPSummary, error) +} + +// EnsureCSPSchema creates the dedicated csp_reports table: one row per +// browser report, typed columns instead of a JSON blob so the view can +// aggregate server-side. Idempotent. +func EnsureCSPSchema(ctx context.Context, conn driver.Conn, db string) error { + return conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.csp_reports ( + id String, + timestamp DateTime64(9, 'UTC'), + site LowCardinality(String), + document_url String, + referrer String, + blocked_url String, + violated_directive LowCardinality(String), + effective_directive LowCardinality(String), + original_policy String, + disposition LowCardinality(String), + status_code UInt16, + sample String, + report_type LowCardinality(String), + user_agent String, + source_ip String + ) ENGINE = MergeTree + PARTITION BY toYYYYMM(timestamp) + ORDER BY (site, effective_directive, timestamp) + TTL toDateTime(timestamp) + INTERVAL 90 DAY`, db)) +} + +func (s *ClickHouseStore) AppendCSPReport(ctx context.Context, r CSPReport) error { + return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.csp_reports + (id, timestamp, site, document_url, referrer, blocked_url, violated_directive, + effective_directive, original_policy, disposition, status_code, sample, + report_type, user_agent, source_ip) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.db), + r.ID, r.Timestamp.UTC(), r.Site, r.DocumentURL, r.Referrer, r.BlockedURL, + r.ViolatedDirective, r.EffectiveDirective, r.OriginalPolicy, r.Disposition, + r.StatusCode, r.Sample, r.ReportType, r.UserAgent, r.SourceIP) +} + +func (s *ClickHouseStore) CSPReports(ctx context.Context, q CSPReportsQuery) ([]CSPReport, error) { + if q.Limit <= 0 || q.Limit > 1000 { + q.Limit = 200 + } + where, args := "1", []any{} + if q.Site != "" { + where += " AND site = ?" + args = append(args, q.Site) + } + if q.Directive != "" { + where += " AND effective_directive = ?" + args = append(args, q.Directive) + } + if q.Search != "" { + where += " AND (position(document_url, ?) > 0 OR position(blocked_url, ?) > 0 OR position(referrer, ?) > 0 OR position(sample, ?) > 0)" + for i := 0; i < 4; i++ { + args = append(args, q.Search) + } + } + if !q.Since.IsZero() { + where += " AND timestamp >= ?" + args = append(args, q.Since.UTC()) + } + args = append(args, q.Limit) + rows, err := s.conn.Query(ctx, fmt.Sprintf(`SELECT id, timestamp, site, document_url, referrer, blocked_url, + violated_directive, effective_directive, original_policy, disposition, status_code, + sample, report_type, user_agent, source_ip + FROM %s.csp_reports WHERE %s ORDER BY timestamp DESC LIMIT ?`, s.db, where), args...) + if err != nil { + return nil, fmt.Errorf("clickhouse csp reports: %w", err) + } + defer rows.Close() + out := []CSPReport{} + for rows.Next() { + var r CSPReport + var code uint16 + if err := rows.Scan(&r.ID, &r.Timestamp, &r.Site, &r.DocumentURL, &r.Referrer, + &r.BlockedURL, &r.ViolatedDirective, &r.EffectiveDirective, &r.OriginalPolicy, + &r.Disposition, &code, &r.Sample, &r.ReportType, &r.UserAgent, &r.SourceIP); err != nil { + return nil, err + } + r.StatusCode = int(code) + out = append(out, r) + } + return out, rows.Err() +} + +func (s *ClickHouseStore) CSPSummary(ctx context.Context, hours int) (CSPSummary, error) { + if hours <= 0 { + hours = 24 + } + since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour) + var sum CSPSummary + if err := s.conn.QueryRow(ctx, fmt.Sprintf( + `SELECT count(), uniqExact(site) FROM %s.csp_reports WHERE timestamp >= ?`, s.db), + since).Scan(&sum.Total, &sum.Sites); err != nil { + return sum, fmt.Errorf("clickhouse csp summary: %w", err) + } + counts := func(expr string) ([]CSPCount, error) { + rows, err := s.conn.Query(ctx, fmt.Sprintf( + `SELECT %s AS name, count() AS c FROM %s.csp_reports + WHERE timestamp >= ? GROUP BY name ORDER BY c DESC LIMIT 20`, expr, s.db), since) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CSPCount + for rows.Next() { + var v CSPCount + if err := rows.Scan(&v.Name, &v.Count); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() + } + var err error + if sum.ByDirective, err = counts("effective_directive"); err != nil { + return sum, err + } + if sum.ByBlockedURL, err = counts("domain(blocked_url)"); err != nil { + return sum, err + } + if sum.BySite, err = counts("site"); err != nil { + return sum, err + } + return sum, nil +} + +// URLLowerHost extracts the lowercase host (port stripped) from a URL-ish +// string without failing on scheme-relative or malformed input; shared by +// ingest normalization and the file-store summary. +func URLLowerHost(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if i := strings.IndexAny(raw, "?#"); i != -1 { + raw = raw[:i] + } + raw = strings.TrimPrefix(strings.TrimPrefix(raw, "https://"), "http://") + raw = strings.TrimPrefix(raw, "//") + if i := strings.IndexByte(raw, '/'); i != -1 { + raw = raw[:i] + } + if i := strings.LastIndexByte(raw, '@'); i != -1 { + raw = raw[i+1:] + } + if i := strings.LastIndexByte(raw, ':'); i != -1 && strings.LastIndexByte(raw, ']') < i { + raw = raw[:i] + } + return strings.ToLower(raw) +} + +// CSPFileStore is the no-ClickHouse fallback: newline-delimited JSON next to +// the telemetry log, read back with in-memory filtering. +type CSPFileStore struct { + filename string + mu sync.Mutex +} + +func NewCSPFileStore(filename string) (*CSPFileStore, error) { + if dir := filepath.Dir(filename); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + } + return &CSPFileStore{filename: filename}, nil +} + +func (fs *CSPFileStore) AppendCSPReport(_ context.Context, r CSPReport) error { + fs.mu.Lock() + defer fs.mu.Unlock() + f, err := os.OpenFile(fs.filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + return json.NewEncoder(f).Encode(r) +} + +// readAll loads every stored report, oldest first. +func (fs *CSPFileStore) readAll() ([]CSPReport, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + f, err := os.Open(fs.filename) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + var out []CSPReport + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var r CSPReport + if err := json.Unmarshal([]byte(line), &r); err == nil { + out = append(out, r) + } + } + return out, sc.Err() +} + +func (fs *CSPFileStore) CSPReports(_ context.Context, q CSPReportsQuery) ([]CSPReport, error) { + if q.Limit <= 0 || q.Limit > 1000 { + q.Limit = 200 + } + all, err := fs.readAll() + if err != nil { + return nil, err + } + match := func(hay, needle string) bool { return needle == "" || strings.Contains(hay, needle) } + out := []CSPReport{} + for _, r := range all { + if q.Site != "" && r.Site != q.Site { + continue + } + if q.Directive != "" && r.EffectiveDirective != q.Directive { + continue + } + if !q.Since.IsZero() && r.Timestamp.Before(q.Since) { + continue + } + if q.Search != "" && + !match(r.DocumentURL, q.Search) && !match(r.BlockedURL, q.Search) && + !match(r.Referrer, q.Search) && !match(r.Sample, q.Search) { + continue + } + out = append(out, r) + } + // Newest first, bounded. + sort.Slice(out, func(i, j int) bool { return out[i].Timestamp.After(out[j].Timestamp) }) + if len(out) > q.Limit { + out = out[:q.Limit] + } + return out, nil +} + +func (fs *CSPFileStore) CSPSummary(_ context.Context, hours int) (CSPSummary, error) { + if hours <= 0 { + hours = 24 + } + since := time.Now().Add(-time.Duration(hours) * time.Hour) + all, err := fs.readAll() + if err != nil { + return CSPSummary{}, err + } + var sum CSPSummary + directive, blocked, site := map[string]uint64{}, map[string]uint64{}, map[string]uint64{} + sites := map[string]bool{} + add := func(m map[string]uint64, name string) { + if name != "" { + m[name]++ + } + } + for _, r := range all { + if r.Timestamp.Before(since) { + continue + } + sum.Total++ + sites[r.Site] = true + add(directive, r.EffectiveDirective) + add(blocked, URLLowerHost(r.BlockedURL)) + add(site, r.Site) + } + sum.Sites = uint64(len(sites)) + sum.ByDirective = topCounts(directive) + sum.ByBlockedURL = topCounts(blocked) + sum.BySite = topCounts(site) + return sum, nil +} + +// topCounts sorts a counter map, heaviest first, capped like the ClickHouse +// summary so both stores return comparable payloads. +func topCounts(m map[string]uint64) []CSPCount { + out := make([]CSPCount, 0, len(m)) + for name, count := range m { + out = append(out, CSPCount{Name: name, Count: count}) + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Count > out[j-1].Count; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + if len(out) > 20 { + out = out[:20] + } + return out +} diff --git a/internal/storage/csp_integration_test.go b/internal/storage/csp_integration_test.go new file mode 100644 index 0000000..d82c7e2 --- /dev/null +++ b/internal/storage/csp_integration_test.go @@ -0,0 +1,77 @@ +package storage + +import ( + "context" + "os" + "testing" + "time" +) + +// TestCSPReportsIntegration is skipped unless OTEL_CLICKHOUSE_TEST_ADDR +// points at a disposable ClickHouse (native port 9000). It exercises the +// real SQL: schema creation, typed insert and the view's queries. +func TestCSPReportsIntegration(t *testing.T) { + addr := os.Getenv("OTEL_CLICKHOUSE_TEST_ADDR") + if addr == "" { + t.Skip("set OTEL_CLICKHOUSE_TEST_ADDR=host:9000 (with a disposable DB) to run") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + db := "otel_csp_test_" + time.Now().UTC().Format("150405") + s, err := NewClickHouseStore(ctx, ClickHouseConfig{Addr: addr, Database: db, Username: os.Getenv("OTEL_CLICKHOUSE_TEST_USER"), Password: os.Getenv("OTEL_CLICKHOUSE_TEST_PASSWORD")}) + if err != nil { + t.Skipf("ClickHouse not reachable at %s: %v", addr, err) + } + defer func() { + _ = s.conn.Exec(ctx, "DROP DATABASE IF EXISTS "+db) + _ = s.Close() + }() + + now := time.Now().UTC().Truncate(time.Millisecond) + reports := []CSPReport{ + {ID: "r1", Timestamp: now, Site: "app.k6n.net", DocumentURL: "https://app.k6n.net/p", BlockedURL: "https://ads.example/x.js", EffectiveDirective: "script-src", Disposition: "enforce", StatusCode: 200, Sample: "evil()", ReportType: "csp-report", UserAgent: "UA/1", SourceIP: "203.0.113.1"}, + {ID: "r2", Timestamp: now.Add(-time.Minute), Site: "docs.tornberg.me", DocumentURL: "https://docs.tornberg.me/", BlockedURL: "inline", EffectiveDirective: "style-src", Disposition: "report", ReportType: "csp-violation"}, + } + for _, r := range reports { + if err := s.AppendCSPReport(ctx, r); err != nil { + t.Fatalf("append: %v", err) + } + } + + got, err := s.CSPReports(ctx, CSPReportsQuery{Limit: 10}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 2 || got[0].ID != "r1" { + t.Fatalf("list: got %+v", got) + } + if got[0].Site != "app.k6n.net" || got[0].StatusCode != 200 || got[0].Sample != "evil()" || got[0].Timestamp != now { + t.Errorf("round-trip mismatch: %+v", got[0]) + } + if got, err = s.CSPReports(ctx, CSPReportsQuery{Site: "docs.tornberg.me"}); err != nil || len(got) != 1 { + t.Errorf("site filter: %v %+v", err, got) + } + if got, err = s.CSPReports(ctx, CSPReportsQuery{Directive: "style-src"}); err != nil || len(got) != 1 || got[0].ID != "r2" { + t.Errorf("directive filter: %v %+v", err, got) + } + if got, err = s.CSPReports(ctx, CSPReportsQuery{Search: "ads.example"}); err != nil || len(got) != 1 { + t.Errorf("search filter: %v %+v", err, got) + } + + sum, err := s.CSPSummary(ctx, 24) + if err != nil { + t.Fatalf("summary: %v", err) + } + if sum.Total != 2 || sum.Sites != 2 { + t.Errorf("summary: %+v", sum) + } + if len(sum.ByDirective) != 2 { + t.Errorf("summary directives: %+v", sum.ByDirective) + } + if len(sum.ByBlockedURL) != 2 || sum.ByBlockedURL[0].Name != "ads.example" { + t.Errorf("summary blocked hosts: %+v", sum.ByBlockedURL) + } + if len(sum.BySite) != 2 || sum.BySite[0].Count != 1 { + t.Errorf("summary sites: %+v", sum.BySite) + } +} diff --git a/internal/storage/csp_test.go b/internal/storage/csp_test.go new file mode 100644 index 0000000..1ca9831 --- /dev/null +++ b/internal/storage/csp_test.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestURLLowerHost(t *testing.T) { + cases := map[string]string{ + "https://App.K6N.net/page?x=1#f": "app.k6n.net", + "http://localhost:8080/a": "localhost", + "//cdn.knatofs.se/x.js": "cdn.knatofs.se", + "inline": "inline", + "": "", + "https://user:pw@host.tornberg.me/p": "host.tornberg.me", + } + for in, want := range cases { + if got := URLLowerHost(in); got != want { + t.Errorf("URLLowerHost(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCSPFileStoreRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "csp", "reports.jsonl") + fs, err := NewCSPFileStore(path) + if err != nil { + t.Fatalf("open: %v", err) + } + now := time.Now().UTC() + old := now.Add(-48 * time.Hour) + reports := []CSPReport{ + {ID: "1", Timestamp: now, Site: "app.k6n.net", DocumentURL: "https://app.k6n.net/", BlockedURL: "https://ads.example/x.js", EffectiveDirective: "script-src"}, + {ID: "2", Timestamp: now, Site: "docs.tornberg.me", DocumentURL: "https://docs.tornberg.me/", BlockedURL: "inline", EffectiveDirective: "style-src", Sample: "color:red"}, + {ID: "3", Timestamp: old, Site: "app.k6n.net", DocumentURL: "https://app.k6n.net/old", EffectiveDirective: "img-src"}, + } + for _, r := range reports { + if err := fs.AppendCSPReport(context.Background(), r); err != nil { + t.Fatalf("append: %v", err) + } + } + + // All reports, newest first. + got, err := fs.CSPReports(context.Background(), CSPReportsQuery{Limit: 10}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 3 || got[0].ID != "1" { + t.Errorf("list order: %+v", got) + } + + // Filters: directive and since. + if got, _ = fs.CSPReports(context.Background(), CSPReportsQuery{Directive: "script-src"}); len(got) != 1 || got[0].ID != "1" { + t.Errorf("directive filter: %+v", got) + } + if got, _ = fs.CSPReports(context.Background(), CSPReportsQuery{Since: now.Add(-time.Hour), Limit: 10}); len(got) != 2 { + t.Errorf("since filter: %+v", got) + } + if got, _ = fs.CSPReports(context.Background(), CSPReportsQuery{Search: "color:red"}); len(got) != 1 || got[0].ID != "2" { + t.Errorf("search filter: %+v", got) + } + + sum, err := fs.CSPSummary(context.Background(), 24) + if err != nil { + t.Fatalf("summary: %v", err) + } + if sum.Total != 2 || sum.Sites != 2 { + t.Errorf("summary totals: %+v", sum) + } + if len(sum.ByDirective) != 2 || sum.ByDirective[0].Name != "script-src" { + t.Errorf("summary directives: %+v", sum.ByDirective) + } + if len(sum.ByBlockedURL) != 2 || sum.ByBlockedURL[0].Name != "ads.example" { + t.Errorf("summary blocked hosts: %+v", sum.ByBlockedURL) + } + + // A fresh store over a missing file answers empty instead of failing. + empty, err := NewCSPFileStore(filepath.Join(t.TempDir(), "none.jsonl")) + if err != nil { + t.Fatalf("open empty: %v", err) + } + if got, _ = empty.CSPReports(context.Background(), CSPReportsQuery{}); len(got) != 0 { + t.Errorf("missing file: %+v", got) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("store file missing after appends: %v", err) + } +} diff --git a/otel-ui/src/App.tsx b/otel-ui/src/App.tsx index 73dd4e4..041b736 100644 --- a/otel-ui/src/App.tsx +++ b/otel-ui/src/App.tsx @@ -74,7 +74,7 @@ function App() { // The redesigned screens (issues, logs, RUM sessions, rollouts, metrics) own // their own filter bar and cards, so the legacy anomaly banner and volume // waveform above them would just duplicate that UI in a different visual language. - const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7" || activeTab === "tab9" || activeTab === "tab10" || activeTab === "tab2"; + const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7" || activeTab === "tab9" || activeTab === "tab10" || activeTab === "tab12" || activeTab === "tab2"; if (isCallback) { return ; diff --git a/otel-ui/src/components/CspReportsVisualizer.tsx b/otel-ui/src/components/CspReportsVisualizer.tsx new file mode 100644 index 0000000..2391ed4 --- /dev/null +++ b/otel-ui/src/components/CspReportsVisualizer.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState } from "react" +import { RefreshCw, ShieldAlert } from "lucide-react" +import { useCspReports, useCspSummary } from "../hooks/useCspReports" +import { useObservabilityData } from "../hooks/useObservabilityData" +import { formatRelativeTime } from "../lib/timeUtils" +import { URLLowerHost } from "../lib/csp-url" +import { Segmented } from "./common/Segmented" +import { DetailField, SectionLabel } from "./common/DetailField" +import { FilterBar, type FilterChip } from "./FilterBar" +import { MasterDetail } from "./MasterDetail" +import type { CspReport } from "../lib/types" + +type Window = "24h" | "7d" | "30d" | "all" + +const WINDOW_HOURS: Record = { "24h": 24, "7d": 24 * 7, "30d": 24 * 30, all: null } + +const dateTime = (iso: string) => + new Date(iso).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + +/** Neutral directive badge; tone only escalates for enforced blocks. */ +const DirectiveBadge = ({ report }: { report: CspReport }) => { + const directive = report.effectiveDirective || report.violatedDirective || "unknown" + const enforced = report.disposition === "enforce" + return ( + + {directive} + + ) +} + +const StatCard = ({ label, value }: { label: string; value: string | number }) => ( +
+ {value} + {label} +
+) + +const CspListItem = ({ report }: { report: CspReport }) => ( +
+
+ + + {URLLowerHost(report.blockedUrl ?? "") || report.blockedUrl || "(no blocked URL)"} + +
+
+ {report.site} + {formatRelativeTime(Date.parse(report.timestamp))} +
+
+) + +const CspReportDetail = ({ report }: { report: CspReport }) => ( +
+
+ + {report.disposition && ( + + {report.disposition} + + )} + {report.reportType && ( + + {report.reportType} + + )} + {dateTime(report.timestamp)} +
+ +
+ Violation +
+ + + + {report.violatedDirective && report.violatedDirective !== report.effectiveDirective && ( + + )} + {report.statusCode ? : null} +
+
+ + {report.sample && ( +
+ Script sample +
+          {report.sample}
+        
+
+ )} + + {report.originalPolicy && ( +
+ Policy +
+          {report.originalPolicy}
+        
+
+ )} + +
+ Context +
+ + + + +
+
+
+) + +export function CspReportsVisualizer() { + const [window, setWindow] = useState("7d") + const [site, setSite] = useState(null) + const [directive, setDirective] = useState(null) + const { searchQuery, setSearchQuery } = useObservabilityData() + + const hours = WINDOW_HOURS[window] + const { reports, loading, error, refresh } = useCspReports({ site: site ?? undefined, directive: directive ?? undefined, hours: hours ?? undefined }) + const { summary } = useCspSummary(hours ?? 24 * 365) + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return reports + return reports.filter((r) => + `${r.site} ${r.documentUrl} ${r.blockedUrl ?? ""} ${r.referrer ?? ""} ${r.sample ?? ""} ${r.effectiveDirective ?? ""}` + .toLowerCase() + .includes(q), + ) + }, [reports, searchQuery]) + + const selected = filtered[0] ?? null + + const chip = (id: string, filterKey: string, value: string, onRemove: () => void): FilterChip => ({ id, filterKey, value, onRemove }) + const chips: FilterChip[] = [ + ...(searchQuery ? [chip("query", "query", searchQuery, () => setSearchQuery(""))] : []), + ...(site ? [chip("site", "site", site, () => setSite(null))] : []), + ...(directive ? [chip("directive", "directive", directive, () => setDirective(null))] : []), + ] + const addFilterOptions = [ + ...(summary?.bySite ?? []).filter((s) => s.name !== site).slice(0, 6).map((s) => ({ value: `site:${s.name}`, label: `site=${s.name} (${s.count})` })), + ...(summary?.byDirective ?? []).filter((d) => d.name && d.name !== directive).slice(0, 8).map((d) => ({ value: `dir:${d.name}`, label: `directive=${d.name} (${d.count})` })), + ] + const onAddFilter = (value: string) => { + if (value.startsWith("site:")) setSite(value.slice(5)) + else if (value.startsWith("dir:")) setDirective(value.slice(4)) + } + const clearAll = () => { + setSearchQuery("") + setSite(null) + setDirective(null) + } + const matchText = `Matching ${filtered.length}${summary ? ` of ${summary.total}` : ""} reports · ${chips.length} filter${chips.length === 1 ? "" : "s"}` + + if (error) { + return ( +
+
+ ) + } + if (loading && reports.length === 0) { + return
Loading CSP reports…
+ } + + return ( +
+
+ + + + +
+ + 0 ? addFilterOptions : undefined} + onAddFilter={addFilterOptions.length > 0 ? onAddFilter : undefined} + matchText={matchText} + onClearAll={chips.length > 0 ? clearAll : undefined} + /> + + {filtered.length === 0 ? ( +
+
+ ) : ( + r.id} + selectedKey={selected?.id ?? null} + onSelect={() => {}} + label="CSP reports" + storageKey="csp-reports" + listSize={{ default: 380, min: 300, max: 560 }} + listHeader={ +
+

CSP violations

+ + +
+ } + renderItem={(report) => } + renderDetail={(report) => } + /> + )} +
+ ) +} + +export default CspReportsVisualizer diff --git a/otel-ui/src/components/Tabs.tsx b/otel-ui/src/components/Tabs.tsx index 21e7061..55e10a7 100644 --- a/otel-ui/src/components/Tabs.tsx +++ b/otel-ui/src/components/Tabs.tsx @@ -29,6 +29,7 @@ import { TelemetryFilters } from "./TelemetryFilters" import { PatternsVisualizer } from "./PatternsVisualizer" import { RolloutsVisualizer } from "./RolloutsVisualizer" import { SourceIpsVisualizer } from "./SourceIpsVisualizer" +import { CspReportsVisualizer } from "./CspReportsVisualizer" import { TodosVisualizer } from "./TodosVisualizer" @@ -354,6 +355,9 @@ export const TabsComponent = () => { {activeTab === "tab10" && } + + {activeTab === "tab12" && } + {activeTab === "tab11" && } diff --git a/otel-ui/src/components/layout/IconRail.tsx b/otel-ui/src/components/layout/IconRail.tsx index 5a95fdc..fee0181 100644 --- a/otel-ui/src/components/layout/IconRail.tsx +++ b/otel-ui/src/components/layout/IconRail.tsx @@ -10,6 +10,7 @@ import { ListTodo, Radar, ScrollText, + Shield, TrendingUp, Waypoints, Wrench, @@ -65,6 +66,7 @@ export const IconRail: React.FC = () => { { tab: "tab2", label: "Metrics & APM", Icon: LineChart }, { tab: "tab7", label: "RUM sessions", Icon: Globe }, { tab: "tab10", label: "Source IPs", Icon: Fingerprint }, + { tab: "tab12", label: "CSP reports", Icon: Shield }, { tab: "tab11", label: "Todos from the site", Icon: ListTodo }, ...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []), { tab: "tab6", label: "Archive & trap", Icon: Radar }, diff --git a/otel-ui/src/hooks/useCspReports.ts b/otel-ui/src/hooks/useCspReports.ts new file mode 100644 index 0000000..1d9aec5 --- /dev/null +++ b/otel-ui/src/hooks/useCspReports.ts @@ -0,0 +1,46 @@ +import useSWR from "swr" +import { authSWRFetcher, tokens } from "../lib/auth" +import type { CspReport, CspSummary } from "../lib/types" + +export interface CspReportQuery { + site?: string + directive?: string + q?: string + /** hours back from now; omit for all time */ + hours?: number +} + +function cspQueryString(query: CspReportQuery): string { + const params = new URLSearchParams() + if (query.site) params.set("site", query.site) + if (query.directive) params.set("directive", query.directive) + if (query.q) params.set("q", query.q) + if (query.hours) params.set("hours", String(query.hours)) + params.set("limit", "500") + const qs = params.toString() + return qs ? `?${qs}` : "" +} + +/** CSP violation reports for the view's list, newest first. */ +export function useCspReports(query: CspReportQuery) { + const key = tokens() ? `/api/csp/reports${cspQueryString(query)}` : null + const state = useSWR<{ reports: CspReport[] }>(key, authSWRFetcher, { + refreshInterval: 60_000, + keepPreviousData: true, + revalidateOnFocus: false, + shouldRetryOnError: false, + }) + return { reports: state.data?.reports ?? [], loading: state.isLoading, error: state.error, refresh: state.mutate } +} + +/** Aggregates over the same window as the list: totals, top directives, blocked hosts, sites. */ +export function useCspSummary(hours: number) { + const key = tokens() ? `/api/csp/summary?hours=${hours}` : null + const state = useSWR<{ summary: CspSummary }>(key, authSWRFetcher, { + refreshInterval: 60_000, + keepPreviousData: true, + revalidateOnFocus: false, + shouldRetryOnError: false, + }) + return { summary: state.data?.summary, loading: state.isLoading, error: state.error, refresh: state.mutate } +} diff --git a/otel-ui/src/lib/csp-url.ts b/otel-ui/src/lib/csp-url.ts new file mode 100644 index 0000000..f71b7f4 --- /dev/null +++ b/otel-ui/src/lib/csp-url.ts @@ -0,0 +1,18 @@ +/** + * Normalizes a URL-ish string (blocked-uri values include "inline", + * "data:...", scheme-relative and full URLs) into a lowercase host with the + * port stripped. Mirrors internal/storage.URLLowerHost in the Go backend - + * keep the two in sync. + */ +export function URLLowerHost(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return "" + let rest = trimmed.split(/[?#]/)[0] + rest = rest.replace(/^https:\/\//, "").replace(/^http:\/\//, "").replace(/^\/\//, "") + rest = rest.split("/")[0] + const at = rest.lastIndexOf("@") + if (at !== -1) rest = rest.slice(at + 1) + const colon = rest.lastIndexOf(":") + if (colon > rest.lastIndexOf("]")) rest = rest.slice(0, colon) + return rest.toLowerCase() +} diff --git a/otel-ui/src/lib/types.ts b/otel-ui/src/lib/types.ts index 58f9b92..2d11944 100644 --- a/otel-ui/src/lib/types.ts +++ b/otel-ui/src/lib/types.ts @@ -353,3 +353,38 @@ export interface Todo { done: boolean; doneAt?: string; } + +/** One Content-Security-Policy violation report (internal/storage.CSPReport). */ +export interface CspReport { + id: string + timestamp: string + /** host of documentUrl, e.g. app.k6n.net */ + site: string + documentUrl: string + referrer?: string + blockedUrl?: string + violatedDirective?: string + effectiveDirective?: string + originalPolicy?: string + /** enforce | report */ + disposition?: string + statusCode?: number + sample?: string + /** csp-report (legacy report-uri) or csp-violation (Reporting API) */ + reportType?: string + userAgent?: string + sourceIP?: string +} + +export interface CspCount { + name: string + count: number +} + +export interface CspSummary { + total: number + sites: number + byDirective: CspCount[] + byBlockedHost: CspCount[] + bySite: CspCount[] +}