6cd546e86a
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
- GET /api/events → every gallery's bcrypt password_hash, share_token, and
client name/email (the list handler selects * and mapEventForApi keeps
those columns),
- PUT /api/events/:id → reset any gallery's password (full takeover),
- DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.
Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).
Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
120 lines
4.7 KiB
JavaScript
120 lines
4.7 KiB
JavaScript
/**
|
|
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
|
|
* /api/events router.
|
|
*
|
|
* The legacy router exposed create/list/update/delete/extend guarded by
|
|
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
|
|
* back-office account — down to a read-only viewer — could read every gallery's
|
|
* password_hash/share_token and take over any gallery. The fix removes that
|
|
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
|
|
* canonical /api/admin/events mount, where it inherits the permission +
|
|
* ownership guards.
|
|
*
|
|
* This test pins two invariants:
|
|
* 1. The legacy source file is gone (nothing can re-mount it).
|
|
* 2. The migrated extend route enforces ownership — a non-owning editor gets
|
|
* 403, the owner succeeds.
|
|
*/
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-legacy-acl-')), 'db.sqlite'
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
|
|
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const request = require('supertest');
|
|
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
|
|
|
async function insertEvent(db, ownerId, over = {}) {
|
|
const base = {
|
|
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
|
event_type: 'wedding',
|
|
event_name: 'Owner Gallery',
|
|
event_date: '2026-05-29',
|
|
host_email: 'host@example.com',
|
|
admin_email: 'admin@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
|
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
|
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0,
|
|
created_by: ownerId,
|
|
created_at: new Date().toISOString(),
|
|
...over,
|
|
};
|
|
const r = await db('events').insert(base).returning('id');
|
|
return r[0]?.id ?? r[0];
|
|
}
|
|
|
|
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
|
|
it('the legacy events router source file no longer exists', () => {
|
|
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
|
|
});
|
|
|
|
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
|
|
let db; let cleanup; let app;
|
|
let ownerId; let ownerToken;
|
|
let editorId; let editorToken;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
({ adminId: ownerId } = await seedMinimal(db));
|
|
await assignAdminRole(db, ownerId, 'super_admin');
|
|
ownerToken = mintAdminToken(ownerId);
|
|
|
|
// A second, non-owning account with the low-trust editor role.
|
|
[editorId] = await db('admin_users').insert({
|
|
username: 'editor1', email: 'editor1@example.com',
|
|
password_hash: 'x', is_active: 1,
|
|
}).returning('id');
|
|
editorId = editorId?.id ?? editorId;
|
|
await assignAdminRole(db, editorId, 'editor');
|
|
editorToken = mintAdminToken(editorId);
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
|
});
|
|
}, 120000);
|
|
|
|
afterAll(async () => { await cleanup(); });
|
|
|
|
it('lets the owner extend their own gallery', async () => {
|
|
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
|
|
const res = await request(app)
|
|
.post(`/api/admin/events/${id}/extend`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({ days: 10 });
|
|
expect(res.status).toBe(200);
|
|
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
|
|
});
|
|
|
|
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
|
|
const id = await insertEvent(db, ownerId); // owned by the super_admin
|
|
const res = await request(app)
|
|
.post(`/api/admin/events/${id}/extend`)
|
|
.set('Authorization', `Bearer ${editorToken}`)
|
|
.send({ days: 30 });
|
|
expect(res.status).toBe(403); // requireEventOwnership blocks it
|
|
});
|
|
|
|
it('validates the days field', async () => {
|
|
const id = await insertEvent(db, ownerId);
|
|
const res = await request(app)
|
|
.post(`/api/admin/events/${id}/extend`)
|
|
.set('Authorization', `Bearer ${ownerToken}`)
|
|
.send({ days: 9999 });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
});
|