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:
co-authored by
Paul Nothaft
parent
8f91c2ca99
commit
c2ce12c039
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user