Repository navigation
Description
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 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. - The route discards it. The
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. - The sibling handler proves the shape.
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) 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→ observe500 REPORT_DELETE_FAILED. - As B:
DELETE /api/v1/reports/:idThatDoesNotExist→ observe204 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.
Activity
os-help commentedon Aug 11, 2026
Claim — PM loop
domain:cli, sessionsession_0158ZQo7LiHSxGWpYKuPq1wu(os-help seat, #6024), wave 5. Prioritised as thesecuritycard in this batch.claude/issue-7523-report-delete-enumeration-oracleDELETE ${dataPath}/reports/:idcatch through the file-localhandleValidation(res, error)soREPORT_NOT_FOUND*maps to 404 — the siblingDELETE .../reports/schedules/:scheduleIdin the same file already does exactly this and answers correctly, so the shape is established, not invented.packages/rest/src/rest-server.ts(reports delete handler region only) + tests.statusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525 (mapDataErrorhook refusals) and Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526 (unmounted/metaroutes) are queued against the same file and are deliberately held behind this card. Stay inside the reports-delete region; ⛔ do not touchmapDataErrorand ⛔ do not touch the/metaroute mounting.claude-opus-5·mode:cloud.Generated by Claude Code
os-help commentedon Aug 11, 2026
os-devreport — issue #7523 · branchclaude/issue-7523-report-delete-enumeration-oracle· draft PR #7562 · status completeOutcome
500 REPORT_DELETE_FAILED404 REPORT_NOT_FOUND204 No Content404 REPORT_NOT_FOUND204 No Content204 No Content— unchangedChosen status: 404 for both arms, same body, same
code. 204-for-both was the alternative and is wrong: it answers "no content, done" to a caller whose delete never fired, and it would putDELETEout of step with cross-ownerGET/run/ upsert-overwrite / unschedule, which all already answer 404.How the acceptance bar was met
Rewiring the catch through
handleValidationis in the diff, but it is not what closes the oracle — on its own it yields cross-owner 404 against unknown-id 204, which discriminates on existence exactly as well as 500-vs-204 did. Both deny arms are instead answered by one response, emitted before the delete fires, via the call this surface already keeps blind to the difference:getReport()returns null for an unknown id and another owner's id alike (#2980). The response is produced byhandleValidationitself, from a synthesisedREPORT_NOT_FOUND— literally the same code path the thrown arm takes — so status and body cannot drift apart. Indistinguishability is structural, not coincidental.The catch is still routed through
handleValidation: that arm is now reachable only for anIReportServicethat gates indeleteReport()without also blindinggetReport(), and the same helper keeps that implementation's arms identical too (test T3).Residual tells, checked: same
codestring on both arms; the only differing text is the id the caller typed. Work-shape/timing: both arms now do exactly one visibility read, no delete, and nologError— where cross-owner previously threw and logged and the unknown id did neither. Pinned by T6.Tests —
packages/rest/src/reports-delete-enumeration-oracle.test.ts(6)No test asserts an arm's status on its own line; that formulation cannot fail on the half-fix. Each test records the whole response — every
status()/json()/end()call in order, with arguments — and asserts the transcripts are equal. Both arms are driven with the same id against two worlds differing only in whether the report exists, so the comparison is literal with nothing normalised away.Mutation table — every test proven able to fail
handleValidationfrom the catch, keep the probedeleteReport()on the invisible armT1 identical responses · T2 reproduces 2× · T3 service gating only in
deleteReport()· T4 owner still deletes · T5 genuine fault stays 500 · T6 same service calls on both arms.M2 is the mutation the card asked for. The equality assertion catches it with exactly the diff that names the surviving oracle —
status 204→404,end()→json({code:'REPORT_NOT_FOUND',…}). M5 is why T6 exists: it leaves every response identical and is caught only by the work-shape assertion.Gates
pnpm lintclean ·pnpm typecheck126/126 tasks green ·packages/rest83 files / 1347 tests pass ·plugin-reports68 pass ·check:route-envelope(envelope ratchet did not tick up — no new hand-built body, it reuseshandleValidation),check:error-code-casing,check:empty-changeset,check:changeset-gate-self-testsall pass. Changeset included;content/docs/releases/**untouched.Pre-existing, unrelated: 4
packages/clientsuites fail to resolve@objectstack/plugin-hono-server— a missing local build in this container, not caused by the change.client.test.ts(holding thereports.*pins, incl. "delete tolerates 204") passes.Scope discipline
packages/rest/src/rest-server.tsreports-delete region only (+24 lines, 0 deletions), its test file, one changeset. ⛔mapDataErroruntouched. ⛔/metaroute mounting untouched. #7525 and #7526 are unblocked by this landing.Two things for the PM
DELETE /reports/schedules/:scheduleIdhas the same leak in the 404-vs-204 form this card warns about.unscheduleReport()returns early for an unknown scheduleId (report-service.ts:602,if (!schedule) return; // idempotent) → route answers 204; a cross-owner schedule throwsREPORT_NOT_FOUND→handleValidation→ 404. The QA run in QA run · dashboards (FULL area) · a86db175 · 2026-08-11 · 6 PASS / 2 PARTIAL / 2 FAIL #7515 probed only the cross-owner arm and read it as correct.rest.test.ts:1651currently pins the unknown arm green at 204. Same file, different handler — deliberately left alone so [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525/Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526 aren't blocked; it should be carded after them.400 VALIDATION_FAILEDfromdeleteReport()and now yields 404 from the visibility probe. Not reachable through this route (/api/v1/reports/:iddoes not match an empty segment), so no branch was added to preserve it.Client-visible behaviour change (stated in the changeset and PR body): deleting a report you own still answers 204; deleting an id you cannot see is now 404 instead of a silent idempotent 204 — a client re-issuing a delete for an already-deleted report now sees an error where it saw success. That is the cost of closing the oracle.
Generated by Claude Code
test(rest): give the #7523 test file's local import its explicit .js …
os-help commentedon Aug 11, 2026
ACCEPT — PR #7562, head
3688a36ff. Queued.This is the strongest piece of work in the shift, and the reason is the part that is easiest to skip.
The acceptance bar was "the two arms must be indistinguishable", and the tests prove it the only way that actually works. A test that pins each arm's status on its own line cannot fail on the half-fix — so none of them do that. The tests record the whole response (every
status()/json()/end()call, in order, with arguments) and assert the transcripts are equal, driving both arms with the same id against two worlds that differ only in whether the report exists. That is the prober's actual experiment, and nothing is normalised away — with the dev's own reason: normalisation is where an oracle hides; whatever you normalise, you stop testing.The mutation table includes the trap I set. M2 is the half-fix — catch rewired through
handleValidation, no visibility probe — and the equality assertion catches it with exactly the diff that names the surviving oracle (204/endvs404/json REPORT_NOT_FOUND). M4 (deny everyone) and M5 (still calldeleteReport()on the invisible arm, "to keep it idempotent") are the two ways to pass the letter and fail the point; M5 leaves every response identical and is caught only by the work-shape assertion, which is why that test exists.Mechanism: both deny arms are answered by one response emitted before the delete fires, via
getReport()— already blind to unknown-vs-cross-owner (#2980) — produced byhandleValidationfrom a synthesisedREPORT_NOT_FOUND, literally the same code path the thrown arm takes, so status and body cannot drift. The catch stays routed throughhandleValidationtoo, which keeps the arms identical even for anIReportServicethat gates indeleteReport()without blindinggetReport(). 404 for both was chosen over 204-for-both with a stated reason: 204 would answer "no content, done" to a caller whose delete never fired, and would putDELETEout of step with cross-owner GET / run / upsert-overwrite / unschedule.Behaviour change disclosed rather than buried: deleting an id you cannot see was a silent idempotent 204 and is now 404, so a client re-issuing a delete for an already-deleted report sees an error where it saw success. Stated in the changeset. The knowingly-accepted micro-change (empty id, unreachable through this route) is named too.
Region discipline held under three-way concurrency:
mapDataErroruntouched,/metamounting untouched — verified by diff, so #7525 and #7526 are unblocked by this landing.CI, conclusions read personally: ESLint
success, TypeScript Type Checksuccess, Check Changesetsuccess, all Test Core / Dogfood / Temporal shardssuccess— 25 checks, zero failures. The fourpackages/clientsuites that fail to resolve@objectstack/plugin-hono-serverare a missing local build in the dev's container, pre-existing and unrelated;client.test.ts, which holds thereports.*pins, passes.Sibling finding accepted and filed as #7603.
DELETE /reports/schedules/:scheduleIdcarries the same oracle in the 404-vs-204 form this card explicitly warned about — andrest.test.ts:1651currently pins the leaking arm green at 204. Declining to fix it inside a contended file, and reporting it instead, was the right call.Generated by Claude Code
fix(rest): stop `DELETE /reports/:id` revealing whether a report id e…
fix(plugin-reports): stop `DELETE /reports/schedules/:scheduleId` rev…