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,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
|
||||
|
||||
Reference in New Issue
Block a user