Files
go-otel/internal/collector/csp.go
T
developer 36f00d49b9
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m3s
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.
2026-09-23 16:50:43 +00:00

258 lines
8.8 KiB
Go

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