Symptom
DELETE /api/v1/reports/:id answers differently depending on whether the target id exists:
| Target |
Response |
| Another owner's report id |
500 REPORT_DELETE_FAILED |
| An id that does not exist |
204 No Content |
The 500-vs-204 split is an enumeration oracle over other users' saved-report ids: an authenticated caller can probe ids and read existence straight off the status code. That is precisely what the deny-as-404 posture exists to prevent, and it is the one hole in an otherwise clean surface — in the same run, cross-owner GET / run / upsert-overwrite / unschedule all answered 404, schedule list returned empty, ownerId could not be spoofed on create, and anonymous access was 401. No cross-owner 2xx anywhere; the leak is purely in the status-code discrimination.
Reproduced 2× on two distinct reports.
Root cause
The route, not the service.
- The service layer is already correct.
deleteReport() returns early for an unknown id and throws REPORT_NOT_FOUND for a cross-owner id, commented "others get a not-found so the delete neither fires nor reveals the report's existence". The intent is written down and implemented.
- The route discards it. The
DELETE ${dataPath}/reports/:id handler in packages/rest/src/rest-server.ts has a catch that goes straight to res.status(500) with REPORT_DELETE_FAILED. It never calls the file-local handleValidation(res, error) helper that maps REPORT_NOT_FOUND* → 404.
- The sibling handler proves the shape.
DELETE .../reports/schedules/:scheduleId, in the same file, does call handleValidation — which is why that route answers 404 correctly. One file, two handlers, only one of them wired through the mapper.
Not a stale-dist artifact.
Fix: one line — route the delete handler's catch through handleValidation(res, error) so REPORT_NOT_FOUND* becomes 404. Then align the unknown-id arm to the same status so the two arms are byte-indistinguishable; otherwise 404-vs-204 remains the same oracle in a quieter costume.
Reproduction
- Boot the showcase. Authenticate as user A and create a saved report; note its id.
- Authenticate as user B (a different owner).
- As B:
DELETE /api/v1/reports/:idOwnedByA → observe 500 REPORT_DELETE_FAILED.
- As B:
DELETE /api/v1/reports/:idThatDoesNotExist → observe 204 No Content.
- The differing status codes are the oracle. Repeat with a second report owned by A — reproduced 2×.
Re-check the seam directly:
rg -n "REPORT_DELETE_FAILED|handleValidation" packages/rest/src/rest-server.ts
Source
Extracted from the QA run #7515 (framework a86db17). The item is saved-report-ownership; #6683's owner gate is otherwise live and the item's revision-2 positive-deny assertions all hold.
Symptom
DELETE /api/v1/reports/:idanswers differently depending on whether the target id exists:500 REPORT_DELETE_FAILED204 No ContentThe 500-vs-204 split is an enumeration oracle over other users' saved-report ids: an authenticated caller can probe ids and read existence straight off the status code. That is precisely what the deny-as-404 posture exists to prevent, and it is the one hole in an otherwise clean surface — in the same run, cross-owner GET / run / upsert-overwrite / unschedule all answered 404, schedule list returned empty,
ownerIdcould not be spoofed on create, and anonymous access was 401. No cross-owner 2xx anywhere; the leak is purely in the status-code discrimination.Reproduced 2× on two distinct reports.
Root cause
The route, not the service.
deleteReport()returns early for an unknown id and throwsREPORT_NOT_FOUNDfor a cross-owner id, commented "others get a not-found so the delete neither fires nor reveals the report's existence". The intent is written down and implemented.DELETE ${dataPath}/reports/:idhandler inpackages/rest/src/rest-server.tshas a catch that goes straight tores.status(500)withREPORT_DELETE_FAILED. It never calls the file-localhandleValidation(res, error)helper that mapsREPORT_NOT_FOUND*→ 404.DELETE .../reports/schedules/:scheduleId, in the same file, does callhandleValidation— which is why that route answers 404 correctly. One file, two handlers, only one of them wired through the mapper.Not a stale-dist artifact.
Fix: one line — route the delete handler's catch through
handleValidation(res, error)soREPORT_NOT_FOUND*becomes 404. Then align the unknown-id arm to the same status so the two arms are byte-indistinguishable; otherwise 404-vs-204 remains the same oracle in a quieter costume.Reproduction
DELETE /api/v1/reports/:idOwnedByA→ observe500 REPORT_DELETE_FAILED.DELETE /api/v1/reports/:idThatDoesNotExist→ observe204 No Content.Re-check the seam directly:
Source
Extracted from the QA run #7515 (framework a86db17). The item is
saved-report-ownership; #6683's owner gate is otherwise live and the item's revision-2 positive-deny assertions all hold.