Files
picpeak/backend/__tests__/middleware/securityHardeningBatch2.test.js
T
Paul Nothaft f0e6d2dfb1 fix: enforce gallery access and consolidate gallery workflows (#1357)
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
2026-09-08 15:34:09 +02:00

137 lines
6.9 KiB
JavaScript

/**
* Second security sweep on the same branch as the password-strength DoS fix.
* Each block pins one gap the audit found:
*
* - maintenance gate classified paths case-sensitively while Express routes
* case-insensitively, so /API/... bypassed maintenance mode
* - the general rate limiter skipped anyone holding ANY verified JWT,
* including a gallery token minted for free on password-less galleries
* - the admin gallery preview trusted a verified signature alone, ignoring
* revocation, deactivation and password changes
* - the multipart branch of the CSRF Content-Type gate accepted cross-site
* form posts
*/
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'hardening-batch2-secret';
const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin: { id: 1, password_changed_at: null } };
jest.mock('../../src/database/db', () => {
const db = jest.fn((table) => {
const q = {
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn(async () => {
if (table === 'app_settings') {
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
}
if (table === 'admin_users') return fake.admin;
if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 };
return null;
}),
};
return q;
});
return { db, withRetry: (fn) => fn() };
});
jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) }));
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) }));
jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) }));
jest.mock('../../src/utils/frontendUrl', () => ({ getFrontendBaseUrlSync: () => 'https://photos.example.com' }));
const { maintenanceMiddleware, clearMaintenanceCache } = require('../../src/middleware/maintenance');
const { isAuthenticated } = require('../../src/services/rateLimitService');
const { verifyAdminPreview, isAdminPreview } = require('../../src/middleware/gallery');
const { multipartOriginAllowed } = require('../../src/utils/requestOrigin');
const iat = Math.floor(Date.now() / 1000) - 10;
const adminToken = (extra = {}) => jwt.sign({ type: 'admin', id: 1, iat, ...extra }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1, iat }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
describe('maintenance gate is case-insensitive', () => {
async function run(path) {
clearMaintenanceCache();
const req = { path, method: 'GET', headers: {} };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
const next = jest.fn();
await maintenanceMiddleware(req, res, next);
return next.mock.calls.length === 1;
}
it('gates /API/gallery/... exactly like /api/gallery/...', async () => {
expect(await run('/api/gallery/x/download-all')).toBe(false);
expect(await run('/API/gallery/x/download-all')).toBe(false);
expect(await run('/Og/gallery/x')).toBe(false);
});
});
describe('general rate limiter skip', () => {
const req = (token) => ({ path: '/api/gallery/x/photos', headers: { authorization: `Bearer ${token}` }, cookies: {} });
it('is granted to an admin session', () => {
expect(isAuthenticated(req(adminToken()))).toBe(true);
});
it('is NOT granted to a gallery token', () => {
expect(isAuthenticated(req(galleryToken()))).toBe(false);
});
});
describe('admin preview requires a live admin session', () => {
const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} });
beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; });
it('passes for a live session and sets req.isAdminPreview', async () => {
const r = req(adminToken());
expect(isAdminPreview(r)).toBe(true);
expect(await verifyAdminPreview(r)).toBe(true);
expect(r.isAdminPreview).toBe(true);
});
it('fails for a revoked token', async () => {
fake.revoked = true;
const r = req(adminToken());
expect(await verifyAdminPreview(r)).toBe(false);
expect(r.isAdminPreview).toBeUndefined();
});
it('fails after the restore cutoff', async () => {
fake.beforeCutoff = true;
expect(await verifyAdminPreview(req(adminToken()))).toBe(false);
});
it('fails for a deactivated or deleted admin', async () => {
fake.admin = null;
expect(await verifyAdminPreview(req(adminToken()))).toBe(false);
});
it('fails for a token minted before the last password change', async () => {
fake.admin = { id: 1, password_changed_at: new Date((iat + 5) * 1000).toISOString() };
expect(await verifyAdminPreview(req(adminToken()))).toBe(false);
});
});
describe('multipart origin gate', () => {
const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } });
it('accepts same-origin, same-site and non-browser requests', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true);
expect(multipartOriginAllowed(req({}))).toBe(true);
expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true);
// Same-origin install without FRONTEND_URL: Origin matches the Host.
expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true);
});
it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => {
// TLS terminated upstream without X-Forwarded-Proto: req.protocol is http
// while the browser's Origin is https. Login must still work.
// gallery.local is not in the configured allowlist, so only the Host/scheme
// comparison or Fetch Metadata can admit it.
const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } };
expect(multipartOriginAllowed(proxied)).toBe(true);
const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } };
expect(multipartOriginAllowed(legacyBrowser)).toBe(false);
});
it('rejects cross-site form posts', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'null' }))).toBe(false);
});
});