From f6c434246826eb0e05d249f454f9015ef79836f2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 9 Sep 2026 09:13:26 -0500 Subject: [PATCH] Exclude password-protected pastes from public listings (#65) ListPublic and its COUNT query now filter password_hash IS NULL, so /api/public (and any page backed by it) no longer leaks metadata (title, slug, existence) of password-protected pastes. Unlisted pastes were already excluded. Adds regression test covering both. --- internal/api/main_test.go | 40 +++++++++++++++++++++++++++++++++++++++ internal/store/store.go | 8 ++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/api/main_test.go b/internal/api/main_test.go index 1ece18c..06d4ab3 100644 --- a/internal/api/main_test.go +++ b/internal/api/main_test.go @@ -175,6 +175,46 @@ func TestListPublicExcludesUnlisted(t *testing.T) { } } +func TestListPublicExcludesPasswordAndUnlisted(t *testing.T) { + s := testServer(t) + h := s.routes() + + bodies := []string{ + `{"content":"open","visibility":"public"}`, + `{"content":"locked","visibility":"public","password":"hunter2"}`, + `{"content":"hidden","visibility":"unlisted"}`, + } + for _, body := range bodies { + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create %s: got %d", body, rec.Code) + } + } + + req := httptest.NewRequest("GET", "/api/public", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("list public: got %d", rec.Code) + } + var resp struct { + Total int `json:"total"` + Items []map[string]any `json:"items"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Total != 1 || len(resp.Items) != 1 { + t.Fatalf("expected only the 1 public paste, got total=%d items=%d", resp.Total, len(resp.Items)) + } + // password-protected and unlisted pastes must not appear (no metadata leak) + for _, secret := range []string{"hunter2", "locked", "hidden"} { + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("leaked %q in /api/public response", secret) + } + } +} + func TestSweepSoftDeletesAfterGrace(t *testing.T) { s := testServer(t) h := s.routes() diff --git a/internal/store/store.go b/internal/store/store.go index a323038..060e9ef 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -252,9 +252,13 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { return &r, err } +// ListPublic backs /api/public and the public listing page. Visibility rules +// mirror the history page: only non-deleted, non-expired, non-can pastes are +// listed, and password-protected pastes are excluded at the query level +// (#65) so their metadata (title, slug, existence) never leaks. func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes - WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) + WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset) if err != nil { return nil, 0, err @@ -273,7 +277,7 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { out = append(out, r) } var total int - s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total) + s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total) return out, total, nil }