Skip to content

fix(rest): stop DELETE /reports/:id revealing whether a report id exists (#7523) - #7562

Merged
os-help merged 2 commits into
mainfrom
claude/issue-7523-report-delete-enumeration-oracle
Aug 11, 2026
Merged

fix(rest): stop DELETE /reports/:id revealing whether a report id exists (#7523)#7562
os-help merged 2 commits into
mainfrom
claude/issue-7523-report-delete-enumeration-oracle

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7523

What was leaking

DELETE /api/v1/reports/:id answered differently depending on whether the target id existed, which let any authenticated caller enumerate other users' saved reports by probing ids and reading the status code.

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

Root cause, and why the one-line fix is not the fix

The service layer was never wrong. deleteReport() returns early for an unknown id and throws REPORT_NOT_FOUND for a report the caller does not own, with the intent written down in the source: "others get a not-found so the delete neither fires nor reveals the report's existence". The route discarded it — its catch went straight to res.status(500) and never reached the file-local handleValidation, which maps REPORT_NOT_FOUND* to 404. The sibling DELETE /reports/schedules/:scheduleId in the same file does call it, which is why that route was already correct; the shape here is established, not invented.

Rewiring that catch is necessary but not sufficient. It maps cross-owner to 404 while an unknown id still answers 204 — and 404-vs-204 discriminates on existence exactly as well as 500-vs-204 did. The oracle would survive in a quieter costume.

So the two deny arms are now answered by one response, emitted before the delete fires, using the call this surface already keeps blind to the difference: getReport() returns null for an unknown id and for another owner's id alike (#2980). That response is produced by handleValidation from a synthesised REPORT_NOT_FOUND — literally the same code path the thrown arm takes — so status and body cannot drift apart. The catch is still routed through handleValidation as well: that arm is now reachable only for an IReportService that gates in deleteReport() without also blinding getReport(), and routing it through the same helper keeps that implementation's arms identical too.

Chosen status: 404 for both arms. 204 for both was the alternative and is wrong — it would mean answering "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 for the same input.

No residual tells. Same status, same code, same error string modulo the id the caller typed. Both arms also now do identical work — one visibility read, no delete, no logError — where cross-owner previously threw and logged and the unknown id did neither.

Behaviour change for existing clients

Deleting a report you own still answers 204; the SDK's reports.delete() is untouched on that path (client.test.ts's "delete tolerates 204" pin stays green). What changes is deleting an id you cannot see: previously a silent idempotent 204, now 404 REPORT_NOT_FOUND. A client that re-issues a delete for a report already deleted now sees an error where it saw success. That is the price of closing the oracle, and it is stated in the changeset.

One knowingly-accepted micro-change: an empty id would previously have surfaced 400 VALIDATION_FAILED from deleteReport() and now yields 404 from the visibility probe. It is not reachable through this route — /api/v1/reports/:id does not match an empty segment — so no branch was added to preserve it.

Tests — and why they assert equality, not statuses

packages/rest/src/reports-delete-enumeration-oracle.test.ts (6 tests). A test that pins each arm's status on its own line cannot fail on the half-fix, so none of these do that. They record the whole response — every status()/json()/end() call, in order, with arguments — and assert the transcripts are equal.

Both arms are driven with the same id against two worlds that differ only in whether the report is there, which is the prober's actual experiment and makes the comparison literal — nothing is normalised away. (Normalisation is where an oracle hides: whatever you normalise, you stop testing.)

Mutation table — every test proven able to fail

Each mutation applied to the source, suite run, then reverted.

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

T1 identical responses · T2 reproduces 2× · T3 service that only gates in deleteReport() · T4 owner still deletes · T5 genuine fault stays 500 · T6 same service calls on both arms.

M2 is the one the card asked for. The equality assertion catches it with exactly the diff that names the surviving oracle:

- Expected            + Received
      "status", [ -  204, +  404, ],
-     "end", [],
+     "json", [ { "code": "REPORT_NOT_FOUND", "error": "REPORT_NOT_FOUND: rpt_owned_by_a" } ],

M5 is why T6 exists: it leaves every response identical and is caught only by the work-shape assertion.

Gates

  • pnpm lint — clean (exit 0)
  • pnpm typecheck / @objectstack/rest — clean
  • packages/rest83 files, 1347 tests, all passing
  • packages/plugins/plugin-reports — 3 files, 68 tests passing
  • pnpm check:route-envelope, check:error-code-casing, check:empty-changeset, check:changeset-gate-self-tests — all pass (the rest-server envelope ratchet did not tick up: the fix adds no new hand-built error body, it reuses handleValidation)
  • 4 packages/client suites fail to resolve @objectstack/plugin-hono-server — a missing local build in this container, present before the change and unrelated to it. client.test.ts, which holds the reports.* pins, passes.

Scope

Confined to the reports-delete region of packages/rest/src/rest-server.ts (+24 lines, no deletions), its test file, and a changeset. mapDataError untouched; /meta route mounting untouched#7525 and #7526 are unblocked by this landing.

Sibling finding — not fixed here, filing to the PM

DELETE /reports/schedules/:scheduleId has the same oracle in the 404-vs-204 form this card warns about. unscheduleReport() returns early for an unknown scheduleId (if (!schedule) return; // idempotent) → route answers 204, but throws REPORT_NOT_FOUND for a cross-owner schedule → handleValidation404. The QA run saw only the cross-owner arm and read it as correct. rest.test.ts:1651 currently pins the unknown arm green at 204. This is a different handler in a contended file, so it is deliberately not touched here — it wants its own card, after #7525/#7526.


Generated by Claude Code

…xists (#7523)

`DELETE /api/v1/reports/:id` answered `500 REPORT_DELETE_FAILED` for another
owner's report but `204 No Content` for an id that does not exist. The split is
an enumeration oracle over other users' saved-report ids: an authenticated
caller probes ids and reads existence straight off the status code.

The service layer was already correct. `deleteReport()` returns early for an
unknown id and throws `REPORT_NOT_FOUND` for a cross-owner id, with the intent
written down — "others get a not-found so the delete neither fires nor reveals
the report's existence". The route discarded it: its catch went straight to
`res.status(500)` and never reached the file-local `handleValidation`, which
maps `REPORT_NOT_FOUND*` to 404. The sibling `DELETE /reports/schedules/:id`
in the same file does call it, which is why that route answers correctly.

Rewiring that catch is necessary but not sufficient — cross-owner 404 against
an unknown-id 204 discriminates on existence exactly as well as 500-vs-204 did.
So both deny arms are now answered by ONE response, before the delete fires,
via the call this surface already keeps blind to the difference: `getReport()`
returns null for an unknown id and for another owner's id alike (#2980). The
response is emitted by `handleValidation` from a synthesised REPORT_NOT_FOUND —
the same code path the thrown arm takes — so status and body cannot drift
apart. Both arms now also do identical work (one visibility read, no delete, no
`logError`), where cross-owner previously threw and logged and unknown did not.

Deleting a report you own still answers 204. Deleting an id you cannot see is
now 404 instead of a silent idempotent 204 — the cost of closing the oracle,
and in line with cross-owner GET / run / upsert-overwrite / unschedule, which
all already answer 404.

Tests assert the two arms' responses are EQUAL rather than pinning each arm's
status separately, so the plausible half-fix cannot pass through them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MPZfgGSLM2jHwD7vBqzKd
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 5:39am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/permissions/system-context.mdx (via packages/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…extension

`check:type-check-debt` measures the test layer that the package's own
`tsc --noEmit` excludes, and the new file's `from './rest-server'` was one
TS2835 under `moduleResolution: nodenext` — pushing @objectstack/rest's
TEST_DEBT from its ledgered 155 to 156. The ledger is a ratchet that may only
shrink, so this fixes the error rather than raising the entry. `.js` is what
the package's newer test files already use (direct-mount-*.test.ts).

@objectstack/rest TEST_DEBT re-measures at 155 again; the 6 tests stay green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MPZfgGSLM2jHwD7vBqzKd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants