- 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.
92 lines
3.1 KiB
Go
92 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|