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

* 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:39:32 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent e5dccf1664
commit 5d5db4e766
8 changed files with 233 additions and 15 deletions
+13 -4
View File
@@ -8,7 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { setAdminAuthCookie, clearAdminAuthCookie } = require('../utils/tokenUtils');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const mfaService = require('../services/mfaService');
const router = express.Router();
@@ -168,12 +168,21 @@ router.post('/change-password', [
// Logout
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
// Use the token adminAuth actually authenticated with (req.token) — it may
// have come from the admin_token cookie, not the Authorization header. The
// old header-only read skipped revocation entirely for cookie-based logout,
// leaving the JWT valid until expiry while reporting a successful logout.
const token = req.token;
if (token) {
// End the session
// End the in-memory session AND revoke the JWT (GHSA-cjqh) — the token
// is otherwise valid until expiry, so photoAuth/adminAuth would keep
// honouring it after logout. isTokenRevoked() checks this store.
endSession(token);
const { revokeToken } = require('../utils/tokenRevocation');
await revokeToken(token, 'logout');
}
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
clearAdminAuthCookie(res);
// Log activity
await logActivity('admin_logout',
+40 -2
View File
@@ -11,6 +11,8 @@ const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { filterOwnedEventIds } = require('../middleware/ownership');
const { db } = require('../database/db');
// Hour-entry routes are gated by the hoursLogging master so a direct API hit
// can't read/edit/delete/bill logged hours while the feature is off (the
@@ -528,9 +530,45 @@ router.put('/:id/events', [
body('event_ids.*').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const customerId = parseInt(req.params.id, 10);
const submitted = req.body.event_ids.map(Number);
// The customer's CURRENT assignments. The "Manage galleries" dialog submits
// the full initial list back — including any events owned by OTHER admins —
// so we need this to tell "retain an existing foreign assignment" apart from
// "newly grant a foreign event".
const existingEventIds = (await db('event_customer_assignments')
.where('customer_account_id', customerId)
.pluck('event_id')).map(Number);
const existingSet = new Set(existingEventIds);
// Events the caller may act on (GHSA-xr6x). A denied id is only acceptable
// when the customer ALREADY has that assignment (a foreign event the caller
// is merely keeping); a denied id that isn't already assigned is a fresh
// attempt to mint access to a foreign/nonexistent event → reject.
const { allowed } = await filterOwnedEventIds(req.admin, submitted);
const allowedSet = new Set(allowed.map(Number));
const illegalNew = submitted.filter((id) => !allowedSet.has(id) && !existingSet.has(id));
if (illegalNew.length) {
return res.status(403).json({ error: 'One or more events are not yours to assign' });
}
// setAssignmentsForCustomer replaces the FULL assignment list, deleting any
// existing row not in the submitted set. A restricted admin must not be able
// to revoke another admin's customer↔event links that way, so always retain
// the customer's existing assignments to events the caller does NOT own —
// regardless of whether the client echoed them back. super_admin owns
// everything, so nothing is force-preserved for them.
let finalEventIds = allowed.map(Number);
if (req.admin.roleName !== 'super_admin' && existingEventIds.length) {
const { allowed: ownedExisting } = await filterOwnedEventIds(req.admin, existingEventIds);
const ownedExistingSet = new Set(ownedExisting.map(Number));
const foreignExisting = existingEventIds.filter((id) => !ownedExistingSet.has(id));
finalEventIds = [...new Set([...finalEventIds, ...foreignExisting])];
}
const result = await customerAccountsService.setAssignmentsForCustomer(
parseInt(req.params.id, 10),
req.body.event_ids,
customerId,
finalEventIds,
req.admin.id,
);
successResponse(res, result);
+32 -4
View File
@@ -165,6 +165,24 @@ router.get('/events/:eventId/feedback',
}
);
// Ownership guard for by-feedback-id routes (GHSA-2qc2 / GHSA-32h4). These
// take a :feedbackId (not :eventId), so requireEventOwnership can't apply —
// resolve the feedback's event and enforce the same rule (super_admin sees
// all; others need to own the event, or it's ownerless/legacy). Returns
// false and sends a 404 (not 403 — don't leak which feedback ids exist)
// when the caller may not act on it.
async function assertOwnsFeedback(req, res, feedbackId) {
if (req.admin.roleName === 'super_admin') return true;
const fb = await db('photo_feedback').where('id', feedbackId).first('event_id');
if (!fb) { res.status(404).json({ error: 'Feedback not found' }); return false; }
const event = await db('events').where('id', fb.event_id).first('created_by');
if (event && event.created_by && event.created_by !== req.admin.id) {
res.status(404).json({ error: 'Feedback not found' });
return false;
}
return true;
}
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
@@ -172,11 +190,12 @@ router.put('/feedback/:feedbackId/:action',
async (req, res) => {
try {
const { feedbackId, action } = req.params;
if (!['approve', 'hide', 'reject'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
res.json({ success: true });
@@ -194,7 +213,8 @@ router.delete('/feedback/:feedbackId',
async (req, res) => {
try {
const { feedbackId } = req.params;
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
res.json({ success: true });
@@ -348,7 +368,15 @@ router.get('/feedback/pending-moderation',
requirePermission('events.view'),
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
// Scope to the caller's owned events unless super_admin (GHSA-3335).
let ownedEventIds = null;
if (req.admin.roleName !== 'super_admin') {
const rows = await db('events')
.where((q) => q.whereNull('created_by').orWhere('created_by', req.admin.id))
.select('id');
ownedEventIds = rows.map((r) => r.id);
}
const pending = await feedbackService.getPendingModeration(null, ownedEventIds);
res.json(pending);
} catch (error) {
logger.error('Error getting pending moderation:', error);
+8
View File
@@ -338,6 +338,14 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Verify photo exists
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })