Redact invite code from analytics

This commit is contained in:
Jonathon Leight
2026-07-05 12:28:10 -04:00
parent 3158b0b7b1
commit d940e769a0
2 changed files with 60 additions and 1 deletions
+20 -1
View File
@@ -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 {
+40
View File
@@ -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) {}))