7c0c0a5b7f
* fix(security): enforce project ownership (GHSA-wrg5, GHSA-93x4) Project routes authorized on generic events.view / events.edit with NO ownership check, so an editor-like admin could enumerate, read, update and aggregate projects belonging to other admins' events. The project email endpoints keyed on an email_queue id alone — any admin with events.view / email.send could preview, resend, cancel or retry ANY queued mail by walking ids. The earlier 'needs a migration, deferred' assessment was wrong in one direction and right in another: ownership IS derivable transitively via events.project_id -> events.created_by, but only for projects that already have a linked event. A brand-new EMPTY project has no derivable owner, which is exactly where the create -> attach flow starts. So migration 167 adds projects.created_by (backfilled from the single linked event owner, skipping ambiguous multi-owner projects) and createProject finally persists the adminId it was already being passed. - ownedProjectIds(): union of the stored owner and the transitive path, so pre-167 rows and new empty projects both resolve. Reads created_by defensively so an instance that hasn't run 167 falls back to the transitive rule instead of throwing. - requireProjectOwnership on detail/update/attach-event/attach-quote/ attach-contract/overview; list filtered by an id allowlist (empty array means 'owns nothing' and must return no rows, hence null-vs-[] care). - POST /:id/events also validates the INCOMING eventId — owning the project is not enough, or an editor could pull a foreign event in and read its rolled-up documents via /:id/overview. - Queued-email routes scoped via email_queue.event_id. CRM document mail has event_id NULL and no ownable parent here, so a scoped caller is denied rather than guessed into access. 404 (not 403) so it isn't an id oracle. Note: adminEmail.js:315/332 let any email.view/edit admin archive or delete any email_queue row — the same class, pre-existing and outside these two advisories. Left untouched and reported rather than silently widened. * fix(security): codex round 2 — make the stored project owner authoritative (GHSA-wrg5) The first predicate union'd 'any linked event I can see' with the stored owner, which opened two holes: - A project owned by admin B containing ONE legacy ownerless event became readable by every admin — and /:id/overview aggregates B's other events, invoices and emails, so a single legacy event exposed the whole project. - Migration 167 deliberately leaves multi-owner (ambiguous) projects NULL rather than guessing an owner. A NULL owner was then treated as 'everyone's', so exactly those mixed projects became globally accessible. Now: the stored created_by wins outright, and a project without a usable stored owner only derives access when EVERY linked event is accessible (and at least one exists). A created_by pointing at a hard-deleted admin degrades to 'no usable owner' so the project falls back to its events instead of being locked away — no ON DELETE SET NULL migration needed. A project with neither a usable owner nor linked events stays super_admin-only: failing closed beats failing open, and a super_admin can reassign it. Also returns a knex SUBQUERY rather than a materialised id list, so a large project count can't hit the driver's bind-parameter limit. * fix(security): codex round 3 — enforce deal-lineage ownership on project attach (GHSA-wrg5) requireProjectOwnership vets only the DESTINATION project, while attaching a quote or contract cascades through linkDealToProject — which re-points every event the deal produced into that project. An editor could therefore create an empty project of their own, attach another admin's quote, and pull that admin's events (plus the invoices, emails and gallery that roll up with them) into a project they own and can read via /:id/overview. The single-customer guard did not stand in the way: an unassigned project ADOPTS the deal's customer rather than rejecting it. linkDealToProject now refuses to move lineage events the actor cannot own, and assignDocument cascades BEFORE stamping the document so a refused attach leaves nothing half-applied (the old order committed the foreign document into the caller's project and only then declined the cascade). The quote/contract create+update paths, which reach the same cascade with an arbitrary project_id, thread their adminId through as well; isSuperAdmin() resolves the role for them and fails closed when it cannot. Events are the only ownership signal a deal carries — quotes and contracts have no created_by in this schema — so a lineage that produced no event still cannot be attributed. That is a property of the CRM model, noted in the code. Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me * docs(security): drop the stale ownership JSDoc left by the rebase (GHSA-wrg5) Rebasing onto main (which had gained scopeEventsQuery from #957) replayed the round-1 doc block above round-2's replacement, leaving a comment that describes the ORIGINAL union rule — "a project is the caller's when … it has at least one linked event they own" — directly above the code that deliberately no longer does that. That union is the hole round 2 closed; a comment asserting it is worse than none. Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
116 lines
4.5 KiB
JavaScript
116 lines
4.5 KiB
JavaScript
/**
|
|
* Project ownership edge cases (GHSA-wrg5, codex round 2).
|
|
*
|
|
* The first predicate union'd "any linked event I can see" with the stored
|
|
* owner, which opened two holes:
|
|
* - a project owned by B containing ONE legacy ownerless event became
|
|
* readable by everyone (and /overview aggregates B's other events,
|
|
* invoices and emails);
|
|
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
|
|
* owner was treated as "everyone's".
|
|
* The stored owner is now authoritative, and a NULL owner only derives access
|
|
* when EVERY linked event is accessible.
|
|
*/
|
|
|
|
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-projedge-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
|
|
|
|
const bcrypt3 = require('bcrypt');
|
|
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
|
|
|
|
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
|
|
let db3; let cleanup3; let ownership; let editorA; let editorB;
|
|
|
|
const mkAdmin3 = async (username, roleName) => {
|
|
const role = await db3('roles').where({ name: roleName }).first();
|
|
const r = await db3('admin_users').insert({
|
|
username, email: `${username}@example.com`,
|
|
password_hash: await bcrypt3.hash('Passw0rd!', 4),
|
|
role_id: role.id, is_active: 1,
|
|
created_at: new Date(), updated_at: new Date(),
|
|
}).returning('id');
|
|
return r[0]?.id ?? r[0];
|
|
};
|
|
const mkProject3 = async (name, createdBy) => {
|
|
const r = await db3('projects').insert({
|
|
name, status: 'active', created_by: createdBy,
|
|
created_at: new Date(), updated_at: new Date(),
|
|
}).returning('id');
|
|
return r[0]?.id ?? r[0];
|
|
};
|
|
const mkEvent3 = async (slug, createdBy, projectId) => {
|
|
const r = await db3('events').insert({
|
|
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
|
|
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
|
|
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
|
|
created_by: createdBy, project_id: projectId,
|
|
expires_at: new Date(Date.now() + 864e5).toISOString(),
|
|
is_active: 1, is_archived: 0, is_draft: 0,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
return r[0]?.id ?? r[0];
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
({ db: db3, cleanup: cleanup3 } = await boot3());
|
|
await seed3(db3);
|
|
ownership = require('../../src/middleware/ownership');
|
|
editorA = await mkAdmin3('edge-a', 'editor');
|
|
editorB = await mkAdmin3('edge-b', 'editor');
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup3) await cleanup3(); });
|
|
|
|
it('one ownerless event in B\'s project does not expose it to A', async () => {
|
|
const pid = await mkProject3('b-project', editorB);
|
|
await mkEvent3('b-owned-ev', editorB, pid);
|
|
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
|
|
|
|
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
|
|
expect(idsA).not.toContain(Number(pid));
|
|
|
|
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
|
|
expect(idsB).toContain(Number(pid));
|
|
});
|
|
|
|
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
|
|
const pid = await mkProject3('ambiguous', null);
|
|
await mkEvent3('mix-a-ev', editorA, pid);
|
|
await mkEvent3('mix-b-ev', editorB, pid);
|
|
|
|
for (const who of [editorA, editorB]) {
|
|
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
|
|
expect(ids).not.toContain(Number(pid));
|
|
}
|
|
});
|
|
|
|
it('a NULL-owner project whose events are all mine IS mine', async () => {
|
|
const pid = await mkProject3('legacy-mine', null);
|
|
await mkEvent3('mine-ev', editorA, pid);
|
|
|
|
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
|
|
expect(ids).toContain(Number(pid));
|
|
});
|
|
|
|
it('a project whose creator was deleted falls back to its events', async () => {
|
|
const ghost = await mkAdmin3('ghost-admin', 'editor');
|
|
const pid = await mkProject3('orphaned', ghost);
|
|
await mkEvent3('orphan-ev', editorA, pid);
|
|
await db3('admin_users').where({ id: ghost }).del();
|
|
|
|
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
|
|
expect(ids).toContain(Number(pid));
|
|
});
|
|
|
|
it('super_admin stays unrestricted', async () => {
|
|
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
|
|
});
|
|
});
|