diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 300fc86..77adb7c 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -115,7 +115,7 @@ func (rec *Recorder) record(r *http.Request, status int) { Ts: time.Now(), Surface: rec.surface(host), Host: host, - Path: r.URL.Path, + Path: redactPath(r.URL.Path), Method: r.Method, Status: status, Visitor: rec.visitor(r), @@ -126,6 +126,25 @@ func (rec *Recorder) record(r *http.Request, status int) { } } +// redactPath replaces a secret path segment with a placeholder before an event +// is recorded, so a share-link/invite token — a live secret until the invite is +// accepted — doesn't sit in the raw events table for the retention window. The +// invite token is the only secret carried in a URL *path*; the login-handoff +// codes travel in the query string, which we never record. Templatizing (rather +// than hashing) also keeps the aggregate meaningful: every invite hit rolls up +// under one path. +func redactPath(p string) string { + rest, ok := strings.CutPrefix(p, "/invite/") + if !ok || rest == "" { + return p + } + // /invite/{token} → /invite/:token; /invite/{token}/accept keeps the tail. + if i := strings.IndexByte(rest, '/'); i >= 0 { + return "/invite/:token" + rest[i:] + } + return "/invite/:token" +} + // surface classifies a request host into one of the known surfaces. func (rec *Recorder) surface(host string) string { switch { diff --git a/internal/analytics/analytics_test.go b/internal/analytics/analytics_test.go index 5e541b5..06e68b4 100644 --- a/internal/analytics/analytics_test.go +++ b/internal/analytics/analytics_test.go @@ -30,6 +30,46 @@ func TestHandlerRecordsRequest(t *testing.T) { } } +// TestRedactPath: the secret invite/share token is templatized so it never +// reaches the raw events table; non-invite paths pass through untouched. +// Regression for the pre-release audit finding that live tokens were recorded. +func TestRedactPath(t *testing.T) { + cases := []struct{ in, want string }{ + {"/invite/abc123secret", "/invite/:token"}, + {"/invite/abc123secret/accept", "/invite/:token/accept"}, + {"/invite/", "/invite/"}, // no token, nothing to redact + {"/invite", "/invite"}, + {"/dashboard", "/dashboard"}, + {"/r/pub-id-not-secret", "/r/pub-id-not-secret"}, + {"/orgs/some-slug", "/orgs/some-slug"}, + } + for _, c := range cases { + if got := redactPath(c.in); got != c.want { + t.Errorf("redactPath(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestHandlerRedactsInviteToken confirms the redaction happens on the real +// recording path (the enqueued event), not just in the helper. +func TestHandlerRedactsInviteToken(t *testing.T) { + rec := testRecorder() + h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + req := httptest.NewRequest(http.MethodGet, "http://app.x/invite/S3cr3tShareToken", nil) + req.Header.Set("User-Agent", "Mozilla/5.0") + h.ServeHTTP(httptest.NewRecorder(), req) + + select { + case e := <-rec.ch: + if e.Path != "/invite/:token" { + t.Fatalf("recorded path = %q, want the token redacted to /invite/:token", e.Path) + } + default: + t.Fatal("expected an event to be enqueued") + } +} + func TestHandlerSkips(t *testing.T) { rec := testRecorder() h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))