diff --git a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js index 92a9fb8f..7129669e 100644 --- a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js +++ b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js @@ -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=) ------------------ +// +// 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', () => { diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index dad803f0..0c78ba4b 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -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({ diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index c9d95470..1a63deb1 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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' }); }