fix(security): enforce project ownership on project + project-email routes (GHSA-wrg5, GHSA-93x4) (#960)

* 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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-02 21:24:38 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 3fc6463873
commit 7c0c0a5b7f
10 changed files with 764 additions and 24 deletions
+2 -2
View File
@@ -211,7 +211,7 @@ async function createContract(payload, adminId) {
}
const inserted = await trx('contracts').insert(row).returning('id');
if (row.project_id && row.deal_uuid) {
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx, { id: adminId });
}
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -313,7 +313,7 @@ async function updateContract(id, payload, adminId) {
// Cascade across the deal lineage (linked quote / event / invoices).
if (updates.project_id) {
const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first();
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx, { id: adminId });
}
// Replace inclusions only when the caller sent an explicit list.
+77 -9
View File
@@ -34,7 +34,7 @@ function transformProject(p) {
/** List projects with customer email + event count + rolled-up value.
* `perms` gates which document types feed the value (matches the cockpit):
* invoices need bills.view, quotes need quotes.view. */
async function listProjects({ search = '', status = null, perms = {} } = {}) {
async function listProjects({ search = '', status = null, perms = {}, projectIds = null } = {}) {
let q = db('projects')
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
.select(
@@ -43,6 +43,11 @@ async function listProjects({ search = '', status = null, perms = {} } = {}) {
db('events').count('* as c').whereRaw('events.project_id = projects.id').as('event_count'),
)
.orderBy('projects.updated_at', 'desc');
// Ownership allowlist (GHSA-wrg5). `null` = unrestricted; otherwise a knex
// SUBQUERY of allowed ids (a plain array also works). The subquery keeps a
// large project count off the driver's bind-parameter limit, and correctly
// yields no rows for an admin who owns nothing.
if (projectIds !== null) q = q.whereIn('projects.id', projectIds);
if (status) q = q.where('projects.status', status);
if (search) {
q = q.where(function () {
@@ -130,13 +135,21 @@ async function getProjectById(id) {
async function createProject({ name, customerAccountId = null }, adminId) {
if (!name || !String(name).trim()) throw new AppError('Project name is required', 400);
const inserted = await db('projects').insert({
const row = {
name: String(name).trim(),
customer_account_id: customerAccountId || null,
status: 'active',
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
};
// Record the owner (GHSA-wrg5). adminId was already passed in and silently
// discarded, which left a brand-new empty project with no derivable owner —
// it has no linked events to infer one from yet. Guarded so an instance that
// has not run migration 167 still creates projects.
if (adminId && await hasColumnCached('projects', 'created_by')) {
row.created_by = adminId;
}
const inserted = await db('projects').insert(row).returning('id');
const id = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
return getProjectById(id);
}
@@ -222,6 +235,26 @@ async function assignEvent(projectId, eventId) {
return { projectId, eventId };
}
/**
* Resolve `actor.roleName`, looking it up when the caller only had an admin id
* to hand (the quote/contract create+update paths thread `adminId`, not the
* full req.admin). Fails CLOSED — an unresolvable role is treated as scoped,
* never as super_admin.
*/
async function isSuperAdmin(actor, conn = db) {
if (!actor) return false;
if (actor.roleName !== undefined) return actor.roleName === 'super_admin';
try {
const row = await conn('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', actor.id)
.first('roles.name as role_name');
return row?.role_name === 'super_admin';
} catch (err) {
return false;
}
}
/**
* Cascade a project link across a whole deal's lineage. Given a deal_uuid, link
* every quote + contract in that deal to the project, re-point every event the
@@ -229,8 +262,12 @@ async function assignEvent(projectId, eventId) {
* adopt the deal's customer onto the project when it has none. This is what
* makes "drop a quote on an empty project" fill the cockpit with the linked
* contract, event and invoices. Idempotent; pass a trx to run inside a txn.
*
* `actor` (req.admin) enables the ownership guard below and MUST be supplied by
* any admin-facing caller — route-level project ownership only vets the
* destination, while this function re-points the deal's events into it.
*/
async function linkDealToProject(dealUuid, projectId, conn = db) {
async function linkDealToProject(dealUuid, projectId, conn = db, actor = null) {
if (!dealUuid || !projectId) return;
// Collect ALL the deal's customers across its quote/contract/invoice lineage
@@ -273,6 +310,31 @@ async function linkDealToProject(dealUuid, projectId, conn = db) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
// Ownership of the LINEAGE, not just the destination (GHSA-wrg5). The route
// guard (requireProjectOwnership) only vets `projectId`; the writes below
// re-point every event this deal produced into it. Without this check an
// editor could create an empty project, 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.
// An unassigned project offers no resistance either, since it ADOPTS the
// deal's customer below rather than rejecting it.
//
// Events are the only ownership signal a deal carries: quotes/contracts have
// no created_by in this schema, so a deal whose lineage produced no event
// still cannot be attributed to an admin — a pre-existing property of the CRM
// model, not something this guard can close.
if (actor?.id && eventIds.size && !(await isSuperAdmin(actor, conn))) {
const ownable = await conn('events')
.whereIn('id', Array.from(eventIds))
.andWhere((q) => q.whereNull('created_by').orWhere('created_by', actor.id))
.pluck('id');
if (ownable.length !== eventIds.size) {
throw new AppError(
'That deal includes events that are not yours to move', 403, 'DEAL_EVENT_FORBIDDEN',
);
}
}
// Cleared to write: link the deal's quotes/contracts, re-point its events so
// invoices/emails/gallery roll up automatically.
if (quotesHaveDeal && await hasColumnCached('quotes', 'project_id')) {
@@ -294,7 +356,7 @@ async function linkDealToProject(dealUuid, projectId, conn = db) {
/** Attach (or, with projectId=null, detach) a quote/contract to a project.
* Attaching cascades the link across the deal lineage (see linkDealToProject). */
async function assignDocument(table, projectId, documentId) {
async function assignDocument(table, projectId, documentId, actor = null) {
if (!(await hasColumnCached(table, 'project_id'))) {
throw new AppError('This instance has no project_id column yet — run migrations', 409);
}
@@ -317,15 +379,21 @@ async function assignDocument(table, projectId, documentId) {
) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
await db(table).where({ id: documentId }).update({ project_id: projectId || null });
// Cascade FIRST, then stamp this document. linkDealToProject runs the
// lineage-ownership guard and throws before it writes anything, so a refused
// attach leaves no half-applied link behind — the other order committed the
// foreign document into the caller's project and only then refused the
// cascade. It already stamps this row's project_id via the deal_uuid sweep;
// the update below covers the standalone (no-deal) document.
if (projectId && doc.deal_uuid) {
await linkDealToProject(doc.deal_uuid, projectId);
await linkDealToProject(doc.deal_uuid, projectId, db, actor);
}
await db(table).where({ id: documentId }).update({ project_id: projectId || null });
return { projectId: projectId || null, documentId };
}
const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId);
const assignQuote = (projectId, quoteId, actor) => assignDocument('quotes', projectId, quoteId, actor);
const assignContract = (projectId, contractId, actor) => assignDocument('contracts', projectId, contractId, actor);
/**
* Project valuation — "newest stage wins per deal, cumulative across events".
+2 -2
View File
@@ -594,7 +594,7 @@ async function createQuote(payload, adminId) {
// quote with no contract/event yet — just adopts the customer onto an
// empty project).
if (row.project_id) {
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx, { id: adminId });
}
if (totals.lineItems.length > 0) {
@@ -733,7 +733,7 @@ async function updateQuote(id, payload, adminId) {
// contract / event / invoices roll up into the same project automatically.
if (updates.project_id) {
const dealRow = await trx('quotes').where({ id }).select('deal_uuid').first();
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx, { id: adminId });
}
// Delete + reinsert keeps the editor flow simple: the frontend