fix(backend): check revocation on the admin-preview gallery token too

isAdminPreview() decoded the ?preview= admin JWT but never checked
isTokenRevoked — a revoked admin session kept working via a preview
link indefinitely. Same gap this branch already closed for the main
gallery-token path (GHSA-q7f7-gjx8-mf6h), just in the sibling
admin-preview check within the same file.
This commit is contained in:
Paul Nothaft
2026-09-10 23:05:51 +02:00
parent 37125fadd2
commit d193555686
3 changed files with 116 additions and 7 deletions
@@ -50,7 +50,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
function makeRes() {
const res = {};
@@ -149,6 +149,103 @@ describe('verifyGalleryAccess — revoked token', () => {
});
});
// ---- revoked admin-preview token (?preview=<adminJWT>) ------------------
//
// isAdminPreview() decodes the ?preview= admin JWT independently of the
// main gallery-token flow above, and previously never checked
// isTokenRevoked — a revoked admin session kept granting preview access
// via a bookmarked/shared preview link indefinitely. Same gap as
// GHSA-q7f7-gjx8-mf6h, just in this sibling code path.
describe('isAdminPreview — token revocation', () => {
it('returns false for a revoked admin token', async () => {
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
isTokenRevoked.mockResolvedValue(true);
const result = await isAdminPreview({ query: { preview: 'revoked-admin-jwt' } });
expect(result).toBe(false);
expect(isTokenRevoked).toHaveBeenCalledWith(
expect.objectContaining({ type: 'admin' }),
);
});
it('returns true for a valid, non-revoked admin token', async () => {
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
isTokenRevoked.mockResolvedValue(false);
const result = await isAdminPreview({ query: { preview: 'valid-admin-jwt' } });
expect(result).toBe(true);
});
});
describe('verifyGalleryAccess — revoked admin preview token', () => {
it('does not grant preview access; falls through to the normal flow and 401s', async () => {
getGalleryTokenFromRequest.mockReturnValue(undefined); // no gallery-scoped token
jwt.verify.mockReturnValue({ type: 'admin', id: 1 }); // decoded ?preview= token
isTokenRevoked.mockResolvedValue(true);
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,
is_draft: false, require_password: true,
});
db.mockImplementationOnce(() => eventsChain);
const req = makeReq();
req.query = { preview: 'revoked-admin-jwt' };
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(isTokenRevoked).toHaveBeenCalledWith(
expect.objectContaining({ type: 'admin' }),
);
// adminPreview resolved to false, so the code took the extra
// is_draft:false where() call it only skips for a real preview.
expect(eventsChain.where).toHaveBeenCalledTimes(2);
expect(eventsChain.where).toHaveBeenNthCalledWith(2, { is_draft: 0 });
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'No token provided' }),
);
});
it('a non-revoked admin preview token still works', async () => {
getGalleryTokenFromRequest.mockReturnValue(undefined);
jwt.verify.mockReturnValue({ type: 'admin', id: 1 });
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,
is_draft: true, require_password: false,
});
db.mockImplementationOnce(() => eventsChain);
const req = makeReq();
req.query = { preview: 'valid-admin-jwt' };
const res = makeRes();
const next = jest.fn();
await verifyGalleryAccess(req, res, next);
expect(isTokenRevoked).toHaveBeenCalledWith(
expect.objectContaining({ type: 'admin' }),
);
// adminPreview resolved to true, so no is_draft filter was applied.
expect(eventsChain.where).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
expect(req.event).toEqual(expect.objectContaining({ id: 42 }));
});
});
// ---- customer-minted JWT, assignment intact ----------------------------
describe('verifyGalleryAccess — customer-minted JWT with active assignment', () => {
+17 -5
View File
@@ -6,12 +6,24 @@ const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
// Check if the request carries a valid admin preview token (Feature 3)
function isAdminPreview(req) {
async function isAdminPreview(req) {
const previewToken = req.query?.preview;
if (!previewToken) return false;
try {
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
return decoded.type === 'admin';
if (decoded.type !== 'admin') return false;
// Same gap this file already closed for the main gallery-token path
// (GHSA-q7f7-gjx8-mf6h): a revoked admin session must not keep
// granting preview access via a bookmarked/shared ?preview= link.
// Treat a revoked token the same as an invalid one -- fail the
// preview check, don't throw, so callers fall through to the normal
// gallery-token flow.
if (await isTokenRevoked(decoded)) {
return false;
}
return true;
} catch {
return false;
}
@@ -29,7 +41,7 @@ async function verifyGalleryAccess(req, res, next) {
return res.status(401).json({ error: 'No token provided' });
}
const adminPreview = isAdminPreview(req);
const adminPreview = await isAdminPreview(req);
event = await withRetry(async () => {
const q = db('events')
.where({
@@ -101,7 +113,7 @@ async function verifyGalleryAccess(req, res, next) {
// If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
const adminPreviewToken = isAdminPreview(req);
const adminPreviewToken = await isAdminPreview(req);
event = await withRetry(async () => {
const q = db('events')
.where({
@@ -121,7 +133,7 @@ async function verifyGalleryAccess(req, res, next) {
}
} else {
// Fallback to using eventId from token
const adminPreviewFallback = isAdminPreview(req);
const adminPreviewFallback = await isAdminPreview(req);
event = await withRetry(async () => {
const q = db('events')
.where({
+1 -1
View File
@@ -238,7 +238,7 @@ router.get('/:slug/info', async (req, res) => {
}
// Check if event is a draft (allow admin preview)
if (event.is_draft && !isAdminPreview(req)) {
if (event.is_draft && !(await isAdminPreview(req))) {
return res.status(404).json({ error: 'Gallery is not yet published' });
}