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}) }