Skip to content

SECURITY: DELETE /api/v1/reports/:id answers 500 for another owner's report but 204 for a nonexistent id — an enumeration oracle over report ids #7523

Closed

Description

@huangyiirene

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

  1. Boot the showcase. Authenticate as user A and create a saved report; note its id.
  2. Authenticate as user B (a different owner).
  3. As B: DELETE /api/v1/reports/:idOwnedByA → observe 500 REPORT_DELETE_FAILED.
  4. As B: DELETE /api/v1/reports/:idThatDoesNotExist → observe 204 No Content.
  5. 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

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 11, 2026
  2. self-assigned this
    on Aug 11, 2026
  3. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim — PM loop domain:cli, session session_0158ZQo7LiHSxGWpYKuPq1wu (os-help seat, #6024), wave 5. Prioritised as the security card in this batch.


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    os-dev report — issue #7523 · branch claude/issue-7523-report-delete-enumeration-oracle · draft PR #7562 · status complete

    Outcome

    Target Before After
    Another owner's report id 500 REPORT_DELETE_FAILED 404 REPORT_NOT_FOUND
    An id that does not exist 204 No Content 404 REPORT_NOT_FOUND
    Your own report 204 No Content 204 No Content — unchanged

    Chosen 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 put DELETE out of step with cross-owner GET / run / upsert-overwrite / unschedule, which all already answer 404.

    How the acceptance bar was met

    Rewiring the catch through handleValidation is 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 by handleValidation itself, from a synthesised REPORT_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 an IReportService that gates in deleteReport() without also blinding getReport(), and the same helper keeps that implementation's arms identical too (test T3).

    Residual tells, checked: same code string 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 no logError — 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

    # Mutation Red Green
    M1 Full revert to the shipped buggy handler T1, T2, T3, T6 T4, T5
    M2 The half-fix — catch rewired, no visibility probe T1, T2, T3, T6 T4, T5
    M3 Drop handleValidation from the catch, keep the probe T3 rest
    M4 Deny everyone — "fix" the oracle by breaking the feature T4, T5 rest
    M5 Still call deleteReport() on the invisible arm T6 rest

    T1 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 lint clean · pnpm typecheck 126/126 tasks green · packages/rest 83 files / 1347 tests pass · plugin-reports 68 pass · check:route-envelope (envelope ratchet did not tick up — no new hand-built body, it reuses handleValidation), check:error-code-casing, check:empty-changeset, check:changeset-gate-self-tests all pass. Changeset included; content/docs/releases/** untouched.

    Pre-existing, unrelated: 4 packages/client suites fail to resolve @objectstack/plugin-hono-server — a missing local build in this container, not caused by the change. client.test.ts (holding the reports.* pins, incl. "delete tolerates 204") passes.

    Scope discipline

    packages/rest/src/rest-server.ts reports-delete region only (+24 lines, 0 deletions), its test file, one changeset. ⛔ mapDataError untouched. ⛔ /meta route mounting untouched. #7525 and #7526 are unblocked by this landing.

    Two things for the PM

    1. Sibling oracle, not fixed — wants its own card. DELETE /reports/schedules/:scheduleId has 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 throws REPORT_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:1651 currently pins the unknown arm green at 204. Same file, different handler — deliberately left alone so [rest] Hook refusals carrying an explicit statusCode are not mapped by /api/v1/data — they leak as HTTP 500 INTERNAL_ERROR with no located guidance #7525/Three ledgered /meta routes are never mounted and die in the /meta/:type catch-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.
    2. Accepted micro-change. An empty id previously surfaced 400 VALIDATION_FAILED from deleteReport() and now yields 404 from the visibility probe. Not reachable through this route (/api/v1/reports/:id does 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

  5. added a commit that references this issue on Aug 11, 2026
  6. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    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/end vs 404/json REPORT_NOT_FOUND). M4 (deny everyone) and M5 (still call deleteReport() 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 by handleValidation from a synthesised REPORT_NOT_FOUND, literally the same code path the thrown arm takes, so status and body cannot drift. The catch stays routed through handleValidation too, which keeps the arms identical even for an IReportService that gates in deleteReport() without blinding getReport(). 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 put DELETE out 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: mapDataError untouched, /meta mounting untouched — verified by diff, so #7525 and #7526 are unblocked by this landing.

    CI, conclusions read personally: ESLint success, TypeScript Type Check success, Check Changeset success, all Test Core / Dogfood / Temporal shards success — 25 checks, zero failures. The four packages/client suites that fail to resolve @objectstack/plugin-hono-server are a missing local build in the dev's container, pre-existing and unrelated; client.test.ts, which holds the reports.* pins, passes.

    Sibling finding accepted and filed as #7603. DELETE /reports/schedules/:scheduleId carries the same oracle in the 404-vs-204 form this card explicitly warned about — and rest.test.ts:1651 currently 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatchsecurity

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions