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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user