@@ -23,8 +23,11 @@ import ( |
| 23 | 23 | "github.com/go-chi/chi/v5" |
| 24 | 24 | "github.com/jackc/pgx/v5/pgxpool" |
| 25 | 25 | |
| 26 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 27 | + issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 26 | 28 | "github.com/tenseleyFlow/shithub/internal/notif" |
| 27 | 29 | notifdb "github.com/tenseleyFlow/shithub/internal/notif/sqlc" |
| 30 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 28 | 31 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 29 | 32 | "github.com/tenseleyFlow/shithub/internal/web/render" |
| 30 | 33 | ) |
@@ -190,6 +193,17 @@ func (h *Handlers) unsubscribe(w http.ResponseWriter, r *http.Request) { |
| 190 | 193 | // inserts (or updates) a row with subscribed=true; `false` flips it |
| 191 | 194 | // off. The fan-out worker honors the explicit row over the auto-sub |
| 192 | 195 | // derivation. |
| 196 | +// |
| 197 | +// SR2 H7: pre-fix this handler accepted any (kind, id) pair without |
| 198 | +// checking that the thread existed or that the viewer could read |
| 199 | +// the parent repo, letting any logged-in user pollute the thread |
| 200 | +// table with rows pointing at private/non-existent issues. Now we |
| 201 | +// load the issue, confirm the kind matches, and run the policy |
| 202 | +// visibility gate before upserting. |
| 203 | +// |
| 204 | +// SR2 L4: Referer is origin-checked before redirecting (was open- |
| 205 | +// redirect-shaped on Referer manipulation if the cookie surface |
| 206 | +// ever relaxed SameSite). |
| 193 | 207 | func (h *Handlers) threadAction(w http.ResponseWriter, r *http.Request, subscribed bool) { |
| 194 | 208 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 195 | 209 | if viewer.IsAnonymous() { |
@@ -202,10 +216,40 @@ func (h *Handlers) threadAction(w http.ResponseWriter, r *http.Request, subscrib |
| 202 | 216 | return |
| 203 | 217 | } |
| 204 | 218 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 205 | | - if err != nil { |
| 219 | + if err != nil || id <= 0 { |
| 206 | 220 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 207 | 221 | return |
| 208 | 222 | } |
| 223 | + |
| 224 | + // Existence + kind match. Issues and PRs share the issues table; |
| 225 | + // the kind column distinguishes. Mismatched kind (e.g. /pr/<issue-id>) |
| 226 | + // is treated as not-found, same as a non-existent id. |
| 227 | + issue, err := issuesdb.New().GetIssueByID(r.Context(), h.d.Pool, id) |
| 228 | + if err != nil { |
| 229 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 230 | + return |
| 231 | + } |
| 232 | + wantKind := issuesdb.IssueKindIssue |
| 233 | + if kindStr == "pr" { |
| 234 | + wantKind = issuesdb.IssueKindPr |
| 235 | + } |
| 236 | + if issue.Kind != wantKind { |
| 237 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 238 | + return |
| 239 | + } |
| 240 | + |
| 241 | + // Visibility — viewer must be able to read the parent repo. |
| 242 | + repo, err := reposdb.New().GetRepoByID(r.Context(), h.d.Pool, issue.RepoID) |
| 243 | + if err != nil { |
| 244 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 245 | + return |
| 246 | + } |
| 247 | + pdeps := policy.Deps{Pool: h.d.Pool} |
| 248 | + if !policy.IsVisibleTo(r.Context(), pdeps, viewer.PolicyActor(), policy.NewRepoRefFromRepo(repo)) { |
| 249 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 250 | + return |
| 251 | + } |
| 252 | + |
| 209 | 253 | reason := "manual" |
| 210 | 254 | if !subscribed { |
| 211 | 255 | reason = "manual_unsubscribe" |
@@ -220,13 +264,7 @@ func (h *Handlers) threadAction(w http.ResponseWriter, r *http.Request, subscrib |
| 220 | 264 | h.d.Logger.WarnContext(r.Context(), "notifications: thread action", |
| 221 | 265 | "kind", kindStr, "id", id, "subscribed", subscribed, "error", err) |
| 222 | 266 | } |
| 223 | | - // Bounce back to the thread the viewer was on. Best-effort: when |
| 224 | | - // the Referer is missing, fall back to /notifications. |
| 225 | | - dest := r.Header.Get("Referer") |
| 226 | | - if dest == "" { |
| 227 | | - dest = "/notifications" |
| 228 | | - } |
| 229 | | - http.Redirect(w, r, dest, http.StatusSeeOther) |
| 267 | + http.Redirect(w, r, notificationReturnPath(r), http.StatusSeeOther) |
| 230 | 268 | } |
| 231 | 269 | |
| 232 | 270 | // unsubscribeViaToken handles the email's one-click List-Unsubscribe |
@@ -246,11 +284,13 @@ func (h *Handlers) unsubscribeViaToken(w http.ResponseWriter, r *http.Request) { |
| 246 | 284 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 247 | 285 | return |
| 248 | 286 | } |
| 249 | | - if !notif.VerifyUnsubscribe(h.d.UnsubscribeKey, uid, tk, tid, sig) { |
| 287 | + // SR2 L5: kind check before HMAC compare so an unknown kind |
| 288 | + // fast-fails without burning a constant-time compare. |
| 289 | + if tk != "issue" && tk != "pr" { |
| 250 | 290 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 251 | 291 | return |
| 252 | 292 | } |
| 253 | | - if tk != "issue" && tk != "pr" { |
| 293 | + if !notif.VerifyUnsubscribe(h.d.UnsubscribeKey, uid, tk, tid, sig) { |
| 254 | 294 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 255 | 295 | return |
| 256 | 296 | } |