fix(security): authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (#950)
* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging) * fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments - photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at check in the admin branch, so a deactivated admin or a pre-password-change token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff). - adminAuth logout: revoke req.token (the token adminAuth authenticated with, cookie OR header) instead of header-only, and clear the auth cookie — a cookie-based logout previously left the JWT live (GHSA-cjqh). - adminCustomers PUT /:id/events: preserve the customer's existing assignments to events the caller does NOT own, so a restricted admin can't revoke another admin's customer-event links via full-list replacement. * fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits The Manage-galleries dialog submits the full initial assignment list, so a restricted admin editing a customer that already has a foreign assignment hit the denied.length 403 before the preservation logic ran. Reject only NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is already assigned to (they can't be added or removed by a non-owner). --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
8f91c2ca99
commit
c2ce12c039
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
|
||||
* pending-moderation list are by-feedback-id (or global) and lacked ownership
|
||||
* scoping, so a restricted editor could act on / enumerate feedback for events
|
||||
* it does not own. super_admin keeps global access.
|
||||
*/
|
||||
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-fbown-')), 'db.sqlite',
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
describe('feedback ownership scoping', () => {
|
||||
let db; let cleanup; let app;
|
||||
let superTok; let editorTok; let editorId;
|
||||
let foreignFeedbackId;
|
||||
|
||||
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId: superId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, superId, 'super_admin');
|
||||
superTok = mintAdminToken(superId);
|
||||
|
||||
const ins = await db('admin_users').insert({
|
||||
username: 'editor', email: '[email protected]',
|
||||
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
editorId = ins[0]?.id ?? ins[0];
|
||||
await assignAdminRole(db, editorId, 'editor');
|
||||
editorTok = mintAdminToken(editorId);
|
||||
|
||||
// Event owned by super_admin (NOT the editor).
|
||||
const ev = await db('events').insert({
|
||||
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
|
||||
event_date: '2026-08-01', host_email: '[email protected]', admin_email: '[email protected]',
|
||||
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
|
||||
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
|
||||
created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const eventId = ev[0]?.id ?? ev[0];
|
||||
const ph = await db('photos').insert({
|
||||
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
const photoId = ph[0]?.id ?? ph[0];
|
||||
const fb = await db('photo_feedback').insert({
|
||||
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
|
||||
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
foreignFeedbackId = fb[0]?.id ?? fb[0];
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
|
||||
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
|
||||
expect(res.status).toBe(404);
|
||||
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
|
||||
expect([false, 0]).toContain(row.is_approved); // untouched
|
||||
});
|
||||
|
||||
it('editor cannot delete foreign feedback, row survives', async () => {
|
||||
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
|
||||
// Denied either at the events.delete permission layer (editor lacks it →
|
||||
// 403) or the ownership layer (404) — both must leave the row intact.
|
||||
expect([403, 404]).toContain(res.status);
|
||||
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
|
||||
});
|
||||
|
||||
it('editor sees no foreign feedback in pending-moderation', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('super_admin CAN moderate and see it', async () => {
|
||||
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
|
||||
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
|
||||
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user