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:
@@ -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