From 22dc238ce9138a078689c307db69fcb4f068b803 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 06:45:24 -0500 Subject: [PATCH] =?UTF-8?q?fix(timeentry):=20return=20404=20(not=20403)=20?= =?UTF-8?q?on=20cross-tenant=20access=20=E2=80=94=20close=20tenant-enumera?= =?UTF-8?q?tion=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class as the 13 prior secure-404 fixes, applied to TimeEntry's getById / update / remove handlers. TimeEntry is direct-company- scoped via `teCompId`. Collapse 403 "exists but not yours" into 404 "Not found." so scoped callers can't enumerate `teId` populations across the whole tenant table by status code. Pinned in `tests/api/timeentry.test.js` with three controller-level unit tests spying on `auth.isMaster` / `auth.getCompanyId`. This completes the secure-404 series for the 14 single-resource domain entities (#174 / #188 / #192 / #196 / #200 / #204 / #210 / #214 / #218 / #222 / #226 / #230 / #234). Two domain controllers remain outside the series: VersionInfo (master-gated; different auth shape) and Customer (large refactor; the `findAndRespond` helper short-circuits to 200+null on a non-existent id which is a separate bug worth its own PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/timeentrycontroller.js | 13 +++- tests/api/timeentry.test.js | 84 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/app/controllers/timeentrycontroller.js b/app/controllers/timeentrycontroller.js index d05badc..49d0105 100644 --- a/app/controllers/timeentrycontroller.js +++ b/app/controllers/timeentrycontroller.js @@ -135,8 +135,13 @@ exports.getById = async (req, res) => { const isMaster = await IsMaster(authKey); if (!isMaster) { const companyId = await GetCompanyId(authKey); + // Cross-tenant access is reported as 404, not 403 — otherwise + // a scoped caller can enumerate which TimeEntry ids are + // populated across the whole tenant table by status code. + // Same secure-404 pattern as the prior 13 entities (#174 + // through #234). if (companyId === -1 || entry.teCompId !== companyId) { - return res.status(403).json({ message: "Invalid Authorization Key." }); + return res.status(404).json({ message: "Not found." }); } } return res.status(200).json({ message: "Found.", timeEntry: entry }); @@ -244,8 +249,9 @@ exports.update = async (req, res) => { const isMaster = await IsMaster(authKey); if (!isMaster) { const companyId = await GetCompanyId(authKey); + // Secure-404 on PATCH for the same reason as GET. if (companyId === -1 || entry.teCompId !== companyId) { - return res.status(403).json({ message: "Invalid Authorization Key." }); + return res.status(404).json({ message: "Not found." }); } } @@ -313,8 +319,9 @@ exports.remove = async (req, res) => { const isMaster = await IsMaster(authKey); if (!isMaster) { const companyId = await GetCompanyId(authKey); + // Secure-404 on DELETE for the same reason as GET / PATCH. if (companyId === -1 || entry.teCompId !== companyId) { - return res.status(403).json({ message: "Invalid Authorization Key." }); + return res.status(404).json({ message: "Not found." }); } } diff --git a/tests/api/timeentry.test.js b/tests/api/timeentry.test.js index 600cab0..f3f9493 100644 --- a/tests/api/timeentry.test.js +++ b/tests/api/timeentry.test.js @@ -269,3 +269,87 @@ describe('computeMinutes helper', () => { expect(computeMinutes('not-a-date', 'also-not')).toBe(null); }); }); + +describe('TimeEntry tenant-enumeration defense (secure 404)', () => { + // Direct-company-scoped via teCompId. Spy on auth.isMaster / + // auth.getCompanyId so the caller appears scoped to a different + // company than the entry's teCompId. + test('controller getById: existing-but-not-yours returns 404 to non-master', async () => { + const auth = require('../../app/middleware/auth.js'); + const controller = require('../../app/controllers/timeentrycontroller.js'); + const isMasterSpy = vi.spyOn(auth, 'isMaster').mockResolvedValue(false); + const getCompanyIdSpy = vi.spyOn(auth, 'getCompanyId').mockResolvedValue(7); + try { + const db = require('../../app/config/db.config.js'); + db.TimeEntry.findByPk = vi.fn().mockResolvedValue({ + teId: 42, teCompId: 99, teArch: false, + }); + const req = { get: (h) => (h === 'authKey' ? 'scoped-to-7' : undefined), params: { id: 42 } }; + let captured = null; + const res = { + status(code) { this._code = code; return this; }, + json(body) { captured = { code: this._code, body }; return this; }, + }; + await controller.getById(req, res); + expect(captured.code).toBe(404); + expect(captured.body.message).toMatch(/not found/i); + } finally { + isMasterSpy.mockRestore(); + getCompanyIdSpy.mockRestore(); + } + }); + + test('controller update: existing-but-not-yours returns 404 to non-master', async () => { + const auth = require('../../app/middleware/auth.js'); + const controller = require('../../app/controllers/timeentrycontroller.js'); + const isMasterSpy = vi.spyOn(auth, 'isMaster').mockResolvedValue(false); + const getCompanyIdSpy = vi.spyOn(auth, 'getCompanyId').mockResolvedValue(7); + try { + const db = require('../../app/config/db.config.js'); + db.TimeEntry.findByPk = vi.fn().mockResolvedValue({ + teId: 42, teCompId: 99, teArch: false, update: vi.fn(), + }); + const req = { + get: (h) => (h === 'authKey' ? 'scoped-to-7' : undefined), + params: { id: 42 }, + body: { teDescription: 'X' }, + }; + let captured = null; + const res = { + status(code) { this._code = code; return this; }, + json(body) { captured = { code: this._code, body }; return this; }, + }; + await controller.update(req, res); + expect(captured.code).toBe(404); + expect(captured.body.message).toMatch(/not found/i); + } finally { + isMasterSpy.mockRestore(); + getCompanyIdSpy.mockRestore(); + } + }); + + test('controller remove: existing-but-not-yours returns 404 to non-master', async () => { + const auth = require('../../app/middleware/auth.js'); + const controller = require('../../app/controllers/timeentrycontroller.js'); + const isMasterSpy = vi.spyOn(auth, 'isMaster').mockResolvedValue(false); + const getCompanyIdSpy = vi.spyOn(auth, 'getCompanyId').mockResolvedValue(7); + try { + const db = require('../../app/config/db.config.js'); + db.TimeEntry.findByPk = vi.fn().mockResolvedValue({ + teId: 42, teCompId: 99, teArch: false, update: vi.fn(), + }); + const req = { get: (h) => (h === 'authKey' ? 'scoped-to-7' : undefined), params: { id: 42 } }; + let captured = null; + const res = { + status(code) { this._code = code; return this; }, + json(body) { captured = { code: this._code, body }; return this; }, + }; + await controller.remove(req, res); + expect(captured.code).toBe(404); + expect(captured.body.message).toMatch(/not found/i); + } finally { + isMasterSpy.mockRestore(); + getCompanyIdSpy.mockRestore(); + } + }); +});