- 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.
78 lines
3.0 KiB
Go
78 lines
3.0 KiB
Go
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)
|
|
}
|
|
}
|