fix(security): authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (#950)

* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging)

* fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments

- photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at
  check in the admin branch, so a deactivated admin or a pre-password-change
  token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff).
- adminAuth logout: revoke req.token (the token adminAuth authenticated with,
  cookie OR header) instead of header-only, and clear the auth cookie — a
  cookie-based logout previously left the JWT live (GHSA-cjqh).
- adminCustomers PUT /:id/events: preserve the customer's existing
  assignments to events the caller does NOT own, so a restricted admin can't
  revoke another admin's customer-event links via full-list replacement.

* fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits

The Manage-galleries dialog submits the full initial assignment list, so a
restricted admin editing a customer that already has a foreign assignment hit
the denied.length 403 before the preservation logic ran. Reject only
NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is
already assigned to (they can't be added or removed by a non-owner).

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-02 08:38:24 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 8f91c2ca99
commit c2ce12c039
8 changed files with 234 additions and 15 deletions
+6 -2
View File
@@ -504,7 +504,7 @@ class FeedbackService {
/**
* Get feedback requiring moderation
*/
async getPendingModeration(eventId = null) {
async getPendingModeration(eventId = null, ownedEventIds = null) {
try {
let query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
@@ -512,9 +512,13 @@ class FeedbackService {
.where('photo_feedback.is_approved', false)
.where('photo_feedback.is_hidden', false)
.where('photo_feedback.feedback_type', 'comment');
if (eventId) {
query = query.where('photo_feedback.event_id', eventId);
} else if (Array.isArray(ownedEventIds)) {
// Scope to the caller's owned events (GHSA-3335) — an empty set
// matches nothing, so a restricted admin sees only their own.
query = query.whereIn('photo_feedback.event_id', ownedEventIds.length ? ownedEventIds : [-1]);
}
const pending = await query
+5 -2
View File
@@ -1029,7 +1029,9 @@ async function sendQuote(id, adminId) {
});
try {
await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`);
// Do NOT log the raw bearer token — it grants quote actions and the
// activity log is readable later (GHSA-prch). The quoteId is the audit key.
await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {}
// Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
@@ -1254,7 +1256,8 @@ async function recordResponse({ token, action, ip, tosAccepted }) {
});
try {
await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public');
// Raw bearer token must not reach the activity log (GHSA-prch).
await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public');
} catch (_) {}
// Defer the workflow emit until the 15-min toggle window locks — so accepting