Skip to content

security(HIGH): scope teCustId on POST /v1/timeentry to caller's company#364

Open
CryptoJones wants to merge 1 commit into
masterfrom
security/timeentry-create-cross-tenant-custid
Open

security(HIGH): scope teCustId on POST /v1/timeentry to caller's company#364
CryptoJones wants to merge 1 commit into
masterfrom
security/timeentry-create-cross-tenant-custid

Conversation

@CryptoJones
Copy link
Copy Markdown
Owner

timeentrycontroller.create accepted any integer-shaped teCustId from a
non-master caller without verifying the referenced Customer belongs to
the caller's company. Effect:

  • Non-master scoped to company 7 sends { teCustId: 13 }
  • Customer 13 lives in company 99
  • The new TimeEntry row gets teCompId=7 (forced server-side) AND
    teCustId=13 (kept from body) — a permanent cross-tenant FK.

The list/get/update/delete handlers all filter by teCompId, so the
anomalous row stays invisible to the OTHER tenant on the API surface.
But:

  • Any operator-side report or UI that joins TimeEntry -> Customer
    (via the FK) surfaces company 99's customer inside company 7's view.
  • It's a permanent integrity corruption that the API itself created.

Every other create handler with a cross-company FK (jobcontroller,
invoicecontroller, customerpaymentcontroller, productentrycontroller)
already does this check via GetCompanyIdByCustomerId / GetCompanyIdByJobId
/ GetCompanyIdByPovId. timeentry was the only outlier.

Fix follows the existing pattern: after teCustId integer validation
and before payload.teCompId assignment, non-master callers run
GetCompanyIdByCustomerId(payload.teCustId) and 403 if the
resolved company differs (or is -1, i.e. customer missing/archived).
Master keys still skip this check by design.

Tests added (4, all in tests/api/timeentry.test.js — a new
"tenant-scoped teCustId (cross-tenant FK)" describe):

  • cross-tenant create returns 403, TimeEntry.create NOT called
  • missing/archived customer returns 403, TimeEntry.create NOT called
  • same-company create returns 201 (positive control)
  • master keys bypass the check (create succeeds even cross-tenant)

The new tests drive the controller through the file-top db.config
vi.mock (mutating ApiKey/ApiMaster/Customer per scenario) rather than
vi.spyOn on the auth helpers — the existing spy-based tenant-defense
tests for invoice/etc. only pass because the empty ApiKey: {}
mock makes auth.getCompanyId() return -1, which short-circuits at
"Invalid Authorization Key." before the spy is ever consulted. The
db-mock approach actually exercises the cross-tenant comparison.

800 tests pass (was 796); lint clean.

Self-review caveats (be aware before merging):

  • The existing 'tenant-enumeration defense' tests for invoice/etc. pass for the wrong reason — the empty ApiKey: {} mock in those test files makes auth.getCompanyId() short-circuit to -1 (DB throw → catch → -1), so the controller returns 'Invalid Authorization Key.' before the cross-tenant comparison is ever reached. The vi.spyOn(auth, 'isMaster') etc. in those tests doesn't take effect on the controller's call sites because the controller captures const IsMaster = auth.isMaster at module load (a value-not-reference capture, by Node module semantics). The new tests here mock the db layer directly (which the controller DOES reach via auth.getDb()), so they actually exercise the cross-tenant comparison. The other tenant-defense tests are still useful as routing/auth-fail smoke tests; they're just not what they look like.

  • The fix doesn't add a unique constraint at the DB level to prevent the same class of bug from a direct INSERT (e.g. a future migration script that runs raw SQL). Defense-in-depth would be a CHECK constraint or trigger: (teCompId, teCustId) must satisfy teCustId IN (SELECT custId FROM Customer WHERE custCompId = teCompId). Out of scope for this PR.

  • Did NOT audit if existing data has cross-tenant teCustId rows from the time before this fix. Would need a one-shot SQL query: SELECT te.* FROM TimeEntry te JOIN Customer c ON te.teCustId = c.custId WHERE te.teCompId != c.custCompId. Recommended as a follow-up.

`timeentrycontroller.create` accepted any integer-shaped teCustId from a
non-master caller without verifying the referenced Customer belongs to
the caller's company. Effect:

  - Non-master scoped to company 7 sends `{ teCustId: 13 }`
  - Customer 13 lives in company 99
  - The new TimeEntry row gets teCompId=7 (forced server-side) AND
    teCustId=13 (kept from body) — a permanent cross-tenant FK.

The list/get/update/delete handlers all filter by teCompId, so the
anomalous row stays invisible to the OTHER tenant on the API surface.
But:

  - Any operator-side report or UI that joins TimeEntry -> Customer
    (via the FK) surfaces company 99's customer inside company 7's view.
  - It's a permanent integrity corruption that the API itself created.

Every other create handler with a cross-company FK (jobcontroller,
invoicecontroller, customerpaymentcontroller, productentrycontroller)
already does this check via `GetCompanyIdByCustomerId` / `GetCompanyIdByJobId`
/ `GetCompanyIdByPovId`. timeentry was the only outlier.

Fix follows the existing pattern: after teCustId integer validation
and before payload.teCompId assignment, non-master callers run
`GetCompanyIdByCustomerId(payload.teCustId)` and 403 if the
resolved company differs (or is -1, i.e. customer missing/archived).
Master keys still skip this check by design.

Tests added (4, all in tests/api/timeentry.test.js — a new
"tenant-scoped teCustId (cross-tenant FK)" describe):

  - cross-tenant create returns 403, TimeEntry.create NOT called
  - missing/archived customer returns 403, TimeEntry.create NOT called
  - same-company create returns 201 (positive control)
  - master keys bypass the check (create succeeds even cross-tenant)

The new tests drive the controller through the file-top db.config
vi.mock (mutating ApiKey/ApiMaster/Customer per scenario) rather than
vi.spyOn on the auth helpers — the existing spy-based tenant-defense
tests for invoice/etc. only pass because the empty `ApiKey: {}`
mock makes auth.getCompanyId() return -1, which short-circuits at
"Invalid Authorization Key." before the spy is ever consulted. The
db-mock approach actually exercises the cross-tenant comparison.

800 tests pass (was 796); lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant