Fix push SSRF, cross-user unsub, and personalization edge cases

Code review of the personalization/feeds/PWA/push work surfaced ten
confirmed issues, now fixed:

- Web Push delivery bypassed the SSRF guard (unguarded default client);
  now routes through safehttp.NewClient with a hard timeout, and the
  subscribe handler validates the endpoint URL.
- Push unsubscribe deleted by endpoint with no owner check; added
  RemovePushSubscriptionForUser scoped to the signed-in user.
- Byte-slice body/content truncation could split a UTF-8 rune and break
  the RSS content:encoded XML; added a rune-safe truncateUTF8 helper.
- Digest sender could permanently starve a user who hid a high-volume
  source; step the watermark past a full hidden-source scan window.
- Service worker cached personalized HTML navigations into a shared
  cache (identity leak across PWA users); navigations are now
  network-only, CACHE_VERSION bumped to v2 to purge stale pages.
- Public /api/article leaked discarded/unclassified bodies; filter to
  classified, non-sentinel stories.
- runLocal never started the push sender; digests now fire in -local.
- Push client had no timeout, so one hung endpoint stalled all sends.
- Reader migration resurrected cross-device-cleared reads; gate it
  behind a one-time flag so the server stays authoritative.
- Bookmarks count didn't match the classified list filter.
This commit is contained in:
prosolis
2026-07-07 01:08:42 -07:00
parent 71f7050f41
commit 8863b75916
12 changed files with 145 additions and 37 deletions

View File

@@ -64,6 +64,9 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
func TestGetStoryReaderText(t *testing.T) {
setupTestDB(t)
// Reader text is only served for classified, non-sentinel stories — the same
// filter the public /api/article endpoint applies so discarded/unclassified
// bodies can't be pulled by id enumeration.
s := &Story{
GUID: "reader-guid",
Headline: "Reader Headline",
@@ -71,6 +74,8 @@ func TestGetStoryReaderText(t *testing.T) {
Content: "First paragraph.\n\nSecond paragraph.",
ArticleURL: "https://example.com/reader",
Source: "Src",
Channel: "tech",
Classified: true,
SeenAt: 1700000000,
}
if err := InsertStory(s); err != nil {
@@ -97,7 +102,7 @@ func TestGetStoryReaderText(t *testing.T) {
// A story ingested before content capture (no content) is still found, with
// an empty content string so the reader falls back to the lede.
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", SeenAt: 1}
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", Channel: "tech", Classified: true, SeenAt: 1}
if err := InsertStory(bare); err != nil {
t.Fatal(err)
}
@@ -120,6 +125,19 @@ func TestGetStoryReaderText(t *testing.T) {
if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
}
// A discarded story must not be readable via /api/article, even by direct id.
discarded := &Story{GUID: "disc-guid", Headline: "H", Lede: "hidden", Content: "secret body", ArticleURL: "https://d.com", Source: "S", Channel: "_discarded", Classified: true, SeenAt: 1}
if err := InsertStory(discarded); err != nil {
t.Fatal(err)
}
var discID int64
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, discarded.GUID).Scan(&discID); err != nil {
t.Fatal(err)
}
if _, _, found, err = GetStoryReaderText(discID); err != nil || found {
t.Errorf("discarded story: found=%v err=%v, want found=false", found, err)
}
}
func TestInsertStoryDuplicateGUID(t *testing.T) {