fix(security): close four middleware gaps around the API edge
- maintenance mode classified paths case-sensitively while Express routes case-insensitively, so /API/... walked past the gate - the general rate limiter skipped anyone holding any verified JWT; a gallery token is minted for free on password-less galleries and slideshow links, so that was an unlimited budget for every /api route. Only admin sessions skip now - ?admin_preview=1 trusted a verified signature alone; it now applies the same revocation, restore-cutoff, deactivation and password-change checks adminAuth does, and reveal-mode reads the verified flag instead of re-decoding the token - the 50mb JSON limit is scoped to /api/admin and /api/v1; everything else gets 2mb, so an unauthenticated body can no longer stall JSON.parse - the CSRF Content-Type gate accepted multipart from any origin; cross-site form posts are now rejected via Sec-Fetch-Site / Origin, with a Host match fallback for same-origin installs that leave FRONTEND_URL unset
This commit is contained in:
@@ -3,6 +3,8 @@ const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
|
||||
/**
|
||||
* True when a logged-in admin is explicitly previewing this gallery (#868).
|
||||
@@ -24,8 +26,8 @@ const logger = require('../utils/logger');
|
||||
* Fails closed on any verification error. Replaces the old `?preview=<raw-JWT>`
|
||||
* scheme, which leaked a 24h admin token into the address bar.
|
||||
*/
|
||||
function isAdminPreview(req) {
|
||||
if (req.query?.admin_preview !== '1') return false;
|
||||
function decodeAdminPreview(req) {
|
||||
if (req.query?.admin_preview !== '1') return null;
|
||||
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
|
||||
const candidates = [];
|
||||
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
|
||||
@@ -34,10 +36,46 @@ function isAdminPreview(req) {
|
||||
for (const token of candidates) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
if (decoded.type === 'admin') return true;
|
||||
if (decoded.type === 'admin') return decoded;
|
||||
} catch { /* try the next candidate */ }
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAdminPreview(req) {
|
||||
return decodeAdminPreview(req) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full session check behind the preview bypass. A verified signature is
|
||||
* not a live session: adminAuth also rejects revoked tokens, tokens issued
|
||||
* before the restore cutoff, deactivated admins and tokens minted before the
|
||||
* admin's last password change. Without those a logged-out or deactivated
|
||||
* admin token kept unlocking every draft and password gallery until `exp`
|
||||
* (30 days with remember-me). Sets req.isAdminPreview on success so the
|
||||
* downstream reveal-mode and logging checks read one verified flag.
|
||||
*/
|
||||
async function verifyAdminPreview(req) {
|
||||
if (req.isAdminPreview === true) return true;
|
||||
const decoded = decodeAdminPreview(req);
|
||||
if (!decoded) return false;
|
||||
try {
|
||||
if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false;
|
||||
const admin = await withRetry(async () => db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'password_changed_at')
|
||||
.first());
|
||||
if (!admin) return false;
|
||||
if (admin.password_changed_at) {
|
||||
const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000);
|
||||
if (decoded.iat < changedSeconds) return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Admin preview session check failed', { error: err.message });
|
||||
return false;
|
||||
}
|
||||
req.isAdminPreview = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Middleware to verify gallery access
|
||||
@@ -51,7 +89,7 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
// below. Per-request bypass — draft + password relaxed, NO gallery JWT
|
||||
// minted (a lingering guest cookie would muddy the coexisting-cookies case).
|
||||
// req.isAdminPreview flags downstream logging to keep it out of guest stats.
|
||||
if (isAdminPreview(req)) {
|
||||
if (await verifyAdminPreview(req)) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -247,5 +285,6 @@ function denySlideshowToken(req, res, next) {
|
||||
module.exports = {
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
isAdminPreview
|
||||
isAdminPreview,
|
||||
verifyAdminPreview
|
||||
};
|
||||
|
||||
@@ -129,8 +129,12 @@ async function maintenanceMiddleware(req, res, next) {
|
||||
'/apple-touch-icon.png',
|
||||
'/apple-touch-icon-precomposed.png'
|
||||
];
|
||||
const isBackendRendered = BACKEND_RENDERED_EXACT.includes(req.path)
|
||||
|| BACKEND_RENDERED_PREFIXES.some((prefix) => req.path.startsWith(prefix));
|
||||
// Express routes case-insensitively, so `/API/gallery/...` still reaches the
|
||||
// API router; classify on the lowercased path or that spelling is treated
|
||||
// as the SPA shell and walks straight past the gate.
|
||||
const requestPath = String(req.path || '').toLowerCase();
|
||||
const isBackendRendered = BACKEND_RENDERED_EXACT.includes(requestPath)
|
||||
|| BACKEND_RENDERED_PREFIXES.some((prefix) => requestPath.startsWith(prefix));
|
||||
const isSpaShell = req.method === 'GET' && !isBackendRendered;
|
||||
|
||||
// Allow admin routes if admin is authenticated
|
||||
|
||||
@@ -25,7 +25,7 @@ function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||
}
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
||||
const { verifyGalleryAccess, denySlideshowToken, verifyAdminPreview } = require('../middleware/gallery');
|
||||
// Preserve the admin-preview flag across internal photo redirects (#981 review).
|
||||
// The redirected request carries no gallery JWT, so without the flag it would
|
||||
// fall back to the draft/password gate and 404 the derivative.
|
||||
@@ -335,7 +335,7 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// Admin preview (#868) bypasses both the draft gate and — below — the
|
||||
// password gate. Computed once and reused.
|
||||
const adminPreview = isAdminPreview(req);
|
||||
const adminPreview = await verifyAdminPreview(req);
|
||||
// Check if event is a draft (allow admin preview)
|
||||
if (event.is_draft && !adminPreview) {
|
||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||
|
||||
@@ -115,8 +115,13 @@ function isAuthenticated(req) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid token found - check type
|
||||
req.tokenType = decoded.type; // 'admin' or 'gallery'
|
||||
// Only an admin session earns the skip. A gallery token is minted for
|
||||
// free on password-less galleries and slideshow links, so treating it as
|
||||
// "authenticated" handed anyone an unlimited budget on every /api route.
|
||||
if (decoded.type !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
req.tokenType = decoded.type;
|
||||
req.tokenPayload = decoded;
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Origin allow-listing shared by the CORS options and the multipart CSRF gate
|
||||
* in server.js. Kept apart from server.js so it can be unit-tested without
|
||||
* booting the app.
|
||||
*/
|
||||
const { getFrontendBaseUrlSync } = require('./frontendUrl');
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
const allowedOrigins = [
|
||||
getFrontendBaseUrlSync() || 'http://localhost:3005',
|
||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||
];
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
allowedOrigins.push(
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3002', // Backend server
|
||||
'http://localhost:3001', // For API testing
|
||||
'http://localhost:3000' // Direct backend access
|
||||
);
|
||||
}
|
||||
return allowedOrigins.indexOf(origin) !== -1;
|
||||
}
|
||||
|
||||
// Origin check for multipart bodies (see the Content-Type gate below).
|
||||
// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL
|
||||
// set, so an Origin matching the request Host is accepted alongside the CORS
|
||||
// allowlist; Sec-Fetch-Site is authoritative when a browser sends it.
|
||||
function multipartOriginAllowed(req) {
|
||||
const site = req.headers['sec-fetch-site'];
|
||||
if (site) return site !== 'cross-site';
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
if (isAllowedOrigin(origin)) return true;
|
||||
try {
|
||||
return new URL(origin).host === req.headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = { isAllowedOrigin, multipartOriginAllowed };
|
||||
@@ -48,10 +48,11 @@ function isGalleryHidden(event, now = new Date()) {
|
||||
function bypassesReveal(req) {
|
||||
if (req.accessLevel === 'slideshow' || req.accessLevel === 'client') return true;
|
||||
if (req.viaCustomer) return true;
|
||||
// Lazy require avoids a cycle: middleware/gallery requires nothing from
|
||||
// here, but keeping the import local makes that permanent.
|
||||
const { isAdminPreview } = require('../middleware/gallery');
|
||||
return Boolean(isAdminPreview(req));
|
||||
// req.isAdminPreview is set by verifyAdminPreview() only after the full
|
||||
// session check (revocation, deactivation, password change). Re-decoding
|
||||
// the token here would re-grant the bypass to a session that check just
|
||||
// rejected.
|
||||
return req.isAdminPreview === true;
|
||||
}
|
||||
|
||||
/** Route guard result: is THIS request blocked by reveal mode? */
|
||||
|
||||
Reference in New Issue
Block a user