fix(backend): check the revocation store on gallery access

Gallery logout wrote to revoked_tokens correctly, but
verifyGalleryAccess never read it back — a logged-out gallery JWT
kept working until natural expiry. Admin auth already calls
isTokenRevoked(); gallery was the outlier.

Note: this gap was independently closed on main via a broader gallery-
access refactor (PR #1357), so no main-branch fix is needed there —
this is a stable-only backport of the same protection.
This commit is contained in:
Paul Nothaft
2026-09-10 20:49:41 +02:00
parent 1316ed05b3
commit 37125fadd2
2 changed files with 63 additions and 0 deletions
@@ -38,6 +38,10 @@ jest.mock('../utils/tokenUtils', () => ({
getGalleryTokenFromRequest: jest.fn(), getGalleryTokenFromRequest: jest.fn(),
})); }));
jest.mock('../utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn().mockResolvedValue(false),
}));
jest.mock('../utils/dbCompat', () => ({ jest.mock('../utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0), formatBoolean: (v) => (v ? 1 : 0),
})); }));
@@ -45,6 +49,7 @@ jest.mock('../utils/dbCompat', () => ({
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
function makeRes() { function makeRes() {
@@ -93,6 +98,55 @@ beforeEach(() => {
db.mockReset(); db.mockReset();
jwt.verify.mockReset(); jwt.verify.mockReset();
getGalleryTokenFromRequest.mockReset(); getGalleryTokenFromRequest.mockReset();
isTokenRevoked.mockReset();
isTokenRevoked.mockResolvedValue(false);
});
// ---- revoked gallery token (GHSA-q7f7-gjx8-mf6h) -----------------------
describe('verifyGalleryAccess — revoked token', () => {
it('returns 401 TOKEN_REVOKED and never reaches the events query when revoked', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 });
isTokenRevoked.mockResolvedValue(true);
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'TOKEN_REVOKED' }),
);
expect(db).not.toHaveBeenCalled();
});
it('proceeds normally when the token is not revoked', async () => {
getGalleryTokenFromRequest.mockReturnValue('tkn');
jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 });
isTokenRevoked.mockResolvedValue(false);
const eventsChain = {};
eventsChain.where = jest.fn().mockReturnValue(eventsChain);
eventsChain.select = jest.fn().mockReturnValue(eventsChain);
eventsChain.first = jest.fn().mockResolvedValue({
id: 42, slug: 'test-event', is_active: true, is_archived: false,
});
db.mockImplementationOnce(() => eventsChain);
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(isTokenRevoked).toHaveBeenCalledWith(
expect.objectContaining({ type: 'gallery', eventId: 42 }),
);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});
}); });
// ---- customer-minted JWT, assignment intact ---------------------------- // ---- customer-minted JWT, assignment intact ----------------------------
+9
View File
@@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
// Check if the request carries a valid admin preview token (Feature 3) // Check if the request carries a valid admin preview token (Feature 3)
@@ -89,6 +90,14 @@ async function verifyGalleryAccess(req, res, next) {
return res.status(403).json({ error: 'Invalid token type for gallery access' }); return res.status(403).json({ error: 'Invalid token type for gallery access' });
} }
// Gallery logout writes to the revocation store (see routes/auth.js),
// but nothing on this path ever read it back (GHSA-q7f7-gjx8-mf6h) — a
// logged-out gallery JWT kept working until natural expiry.
if (await isTokenRevoked(decoded)) {
logger.warn('[verifyGalleryAccess] Revoked token used', { eventId: decoded.eventId });
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// If we have a slug in the URL params or from pre-middleware, verify it matches // If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) { if (requestedSlug) {
// Verify by slug and ensure it matches the token's event // Verify by slug and ensure it matches the token's event