5d5db4e766
* 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 <paul@MacStudio-von-Paul.local>
102 lines
4.6 KiB
JavaScript
102 lines
4.6 KiB
JavaScript
/**
|
|
* 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: 'editor@example.com',
|
|
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: 'h@e.com', admin_email: 'a@e.com',
|
|
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);
|
|
});
|
|
});
|