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

@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"pete/internal/safehttp"
"pete/internal/storage"
webpush "github.com/SherClockHolmes/webpush-go"
@@ -16,6 +18,11 @@ import (
// pass. Well past MinStories; the digest only needs a count and one headline.
const digestScan = 60
// pushSendTimeout bounds one push delivery. The endpoint is user-supplied, so a
// hostile or dead push service must not be able to wedge the (serial) digest
// loop and starve every other subscriber.
const pushSendTimeout = 15 * time.Second
// runPushSender periodically builds and delivers a "N new stories" digest to
// each subscriber, respecting their disabled-sources preference. It's started
// only when push is configured. Best-effort throughout: a failed send never
@@ -80,6 +87,18 @@ func (s *Server) sendDigests() {
count++
}
if count < s.cfg.Push.MinStories {
// If a full scan window filled entirely with stories the user has
// hidden, the non-hidden count can stay below the threshold forever
// while the watermark never advances — the same hidden window is
// re-scanned every pass and the subscriber is permanently starved of
// digests. When the window was capped (a genuine glut, not a quiet
// spell), step the watermark past it so the next pass sees fresh
// stories. A non-full window is a real lull; leave it to accumulate.
if len(stories) == digestScan {
if derr := storage.TouchPushSubscription(sub.Endpoint, stories[0].SeenAt); derr != nil {
slog.Error("push: advance watermark past hidden glut failed", "err", derr)
}
}
continue
}
@@ -133,6 +152,11 @@ func (s *Server) sendPush(sub storage.PushSubscription, payload []byte) (gone bo
Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
}, &webpush.Options{
// The endpoint URL comes from the browser and is attacker-influenceable,
// so deliver through the SSRF-guarded client (blocks loopback/RFC1918/
// link-local/metadata targets) with a hard timeout — never the library's
// unguarded default http.Client.
HTTPClient: s.pushClient(),
Subscriber: s.cfg.Push.Subject,
VAPIDPublicKey: s.cfg.Push.VAPIDPublicKey,
VAPIDPrivateKey: s.cfg.Push.VAPIDPrivateKey,
@@ -189,6 +213,16 @@ func disabledSourcesFor(sub string) map[string]bool {
return out
}
// pushClient returns the shared SSRF-guarded, timeout-bounded HTTP client used
// for Web Push delivery, building it once on first use. The digest loop is a
// single goroutine, so the lazy init needs no lock.
func (s *Server) pushClient() *http.Client {
if s.pushHTTP == nil {
s.pushHTTP = safehttp.NewClient(pushSendTimeout)
}
return s.pushHTTP
}
// StartPushSender launches the digest loop if push is enabled. Safe to call
// unconditionally; it's a no-op when push is off.
func (s *Server) StartPushSender(ctx context.Context) {

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"pete/internal/safehttp"
"pete/internal/storage"
)
@@ -38,6 +39,14 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"incomplete subscription"}`, http.StatusBadRequest)
return
}
// The endpoint is delivered to server-side; reject non-http(s) schemes here so
// a client can't stash a file:// or gopher:// target. The digest sender's
// SSRF-guarded client blocks non-public hosts at dial time, but keeping bad
// endpoints out of the table avoids storing garbage in the first place.
if err := safehttp.ValidateURL(req.Endpoint); err != nil {
http.Error(w, `{"error":"invalid endpoint"}`, http.StatusBadRequest)
return
}
if err := storage.AddPushSubscription(u.Sub, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
slog.Error("push: subscribe failed", "sub", u.Sub, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
@@ -46,9 +55,9 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handlePushUnsubscribe drops a stored subscription by endpoint. It doesn't
// require the endpoint to belong to the caller beyond being signed in; the
// endpoint is an unguessable capability URL, and dropping a stale one is benign.
// handlePushUnsubscribe drops the caller's own stored subscription by endpoint.
// The delete is scoped to the signed-in user so one account can't remove
// another's subscription by presenting its endpoint string.
func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
@@ -64,7 +73,7 @@ func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing endpoint"}`, http.StatusBadRequest)
return
}
if err := storage.RemovePushSubscription(req.Endpoint); err != nil {
if err := storage.RemovePushSubscriptionForUser(u.Sub, req.Endpoint); err != nil {
slog.Error("push: unsubscribe failed", "sub", u.Sub, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return

View File

@@ -59,6 +59,7 @@ type Server struct {
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
auth *Authenticator // nil when sign-in is disabled or unavailable
adminSubs map[string]bool // OIDC subjects allowed to view /status
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
// Guarded by metricsMu; never persisted (see metrics.go).

View File

@@ -143,10 +143,20 @@
serverRead[id] = 1; readSet[id] = 1; paintCard(id, true);
});
(data.bookmarked || []).forEach(function (id) { setBookmarkQuiet(id, true); });
// Push up reads made on this device before the account knew them.
ids.forEach(function (id) {
if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
});
// Migrate device-local reads the account doesn't have yet — but only
// once per device. After the first sync the server is authoritative, so
// a story the user later marks unread on another device stays unread
// instead of being perpetually resurrected from this device's stale
// local set on every page load.
var MIGRATED_KEY = "pete.readMigrated.v1";
var migrated = false;
try { migrated = localStorage.getItem(MIGRATED_KEY) === "1"; } catch (e) {}
if (!migrated) {
ids.forEach(function (id) {
if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
});
try { localStorage.setItem(MIGRATED_KEY, "1"); } catch (e) {}
}
saveRead(readSet);
})
.catch(function () {});

View File

@@ -4,7 +4,7 @@
//
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
// drops every cache that doesn't match the current version.
var CACHE_VERSION = "v1";
var CACHE_VERSION = "v2";
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
@@ -132,23 +132,18 @@ self.addEventListener("fetch", function (event) {
return;
}
// Page navigations: network-first, fall back to a cached copy of the same page,
// then to the offline card. Successful HTML is cached so revisits work offline.
// Page navigations: network-only, falling back to the offline card when the
// network is unreachable. We deliberately do NOT cache HTML responses: pages
// are personalized (they embed the signed-in user's name/email and a "For you"
// rail), and the runtime cache is shared across everyone who uses this
// installed PWA. Caching a navigation would let a signed-out visitor — or a
// second person on the same device — be served the previous user's identity
// and personalized stories offline. Offline reading still works: the reader
// fetches cached /api/article JSON on top of the cached static shell.
if (req.mode === "navigate") {
event.respondWith(
fetch(req).then(function (res) {
if (res && res.ok) {
var copy = res.clone();
caches.open(RUNTIME_CACHE).then(function (cache) {
cache.put(req, copy);
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
});
}
return res;
}).catch(function () {
return caches.match(req).then(function (hit) {
return hit || offlineFallback();
});
fetch(req).catch(function () {
return offlineFallback();
})
);
return;