fix(plugin-reports): stop DELETE /reports/schedules/:scheduleId revealing whether a schedule id exists (#7603) - #7688
Conversation
…ealing whether a schedule id exists (#7603) `DELETE /api/v1/reports/schedules/:scheduleId` answered `404 REPORT_NOT_FOUND` for another owner's schedule but `204 No Content` for a schedule id that does not exist. The caller can delete neither, yet still reads which of the two they hit straight off the status code — an enumeration oracle over other owners' report schedules. This is the defect #7523 closed on the sibling `DELETE /reports/:id`, in the costume that card explicitly warned about: there the split was 500-vs-204 and loud, here 404-vs-204 and quiet. The route was in fact cited by #7523's investigation as the example of the RIGHT shape, because it does route its catch through `handleValidation` — which is why the cross-owner arm is a clean 404 rather than a 500. Only that arm was ever probed (QA run #7515); the unknown-id arm was not, so the surviving half went unseen and `rest.test.ts:1651` pinned its 204 green. `unscheduleReport()` carried the intent — "others get a not-found so the delete neither fires nor reveals the schedule's existence" — and a hole one line wide above it: `if (!schedule) return; // idempotent`. Idempotence is only harmless where every caller may see the row; with a cross-owner arm that throws, resolving quietly IS the tell. Both deny arms are now one decision, taken before the delete fires, by the predicate already blind to the difference between them: `canAccessReport` is false for a schedule that does not exist, for one whose report is gone, and for one owned by somebody else alike. A single throw site means a single message, so the route's single `handleValidation` call emits a single response — status and body cannot drift apart. Unlike `deleteReport`, this could NOT be pre-empted in the route. That one collapses its arms with `getReport()`, already blind to the same difference (#2980); the caller here presents a scheduleId and `IReportService` exposes no by-id schedule read to be blind with (`listSchedules` is keyed by reportId). The blinding therefore lives in the service, and `IReportService.unscheduleReport` now states it as a contract obligation rather than leaving each implementation to rediscover it. The route keeps its half of the composition: ONE emitter for whatever the service throws. Deleting a schedule you own still answers 204. Deleting one you cannot see is now 404 instead of a silent 204 — the cost of closing the oracle, and in line with the cross-owner GET / run / upsert-overwrite / delete arms, which all already answer 404. A system context deleting an id with no row now gets REPORT_NOT_FOUND too; the route is the only production caller. Two pins asserted the leaking arm and are superseded IN PLACE, same input, opposite assertion — `rest.test.ts`'s "DELETE /reports/schedules/:scheduleId returns 204" and `report-service.test.ts`'s "an unknown schedule id is idempotent, not a leak", whose title stated the conclusion backwards. Tests assert the two deny arms' whole responses are EQUAL rather than pinning each arm's status separately: a mutation answering both arms 404 with different bodies leaves every per-arm status assertion green and turns the equality assertions red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4Zy4fY7Kj8t27QAEzr9G1
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31486806594 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #7603
Root cause
DELETE /api/v1/reports/schedules/:scheduleIddiscriminated on existence:404 REPORT_NOT_FOUND404 REPORT_NOT_FOUND(unchanged)204 No Content404 REPORT_NOT_FOUND404 REPORT_NOT_FOUND404 REPORT_NOT_FOUND(unchanged)204 No Content204 No Content(unchanged)The caller can delete neither of the first two, yet still read which one they hit straight off the status code — an enumeration oracle over other owners' report schedules.
The leak was in the service, not the route.
packages/plugins/plugin-reports/src/report-service.ts:602(pre-fix):The intent was written down directly beneath the line that broke it.
packages/rest/src/rest-server.ts:9536maps the early return to204and the throw to404viahandleValidation. Idempotence is only harmless where every caller may see the row; with a cross-owner arm that throws, resolving quietly is the tell.Why it was read as correct: this route was cited by #7523's own investigation as proof of the right shape, because it does route its catch through
handleValidation— which is why the cross-owner arm is a clean 404 and not a 500. QA run #7515 exercised only that arm. The unknown-id arm was never probed, andpackages/rest/src/rest.test.ts:1651pinned its 204 green.The fix
Both deny arms are now one decision, taken before the delete fires, by the predicate already blind to the difference between them —
canAccessReportis false for a schedule that does not exist, for one whose report is gone, and for one owned by somebody else alike:A single throw site means a single message, so the route's single
handleValidationcall emits a single response — status and body cannot drift apart.The precedent's mechanism does not hold on this surface — as the card asked me to verify
#7523 leans on
getReport()being blind to the existence/ownership difference (#2980), which lets the route pre-empt both arms. That is unavailable here: the caller presents ascheduleId, andIReportServiceexposes no by-id schedule read at all —listSchedulesis keyed byreportId, and there is nogetSchedule. There is nothing in the route for the route to be blind with.So the blinding has to live in the service, and
IReportService.unscheduleReportnow states it as a contract obligation (packages/spec/src/contracts/report-service.ts, JSDoc only — no logic in spec) rather than leaving each implementation to rediscover it. The route keeps its half of the composition and documents it: one emitter for whatever the service throws.Layers, and what pins each:
IReportServicecontractREPORT_NOT_FOUND: <scheduleId>, before the deleteReportService(real impl)report-service.test.ts— "the unknown-id and cross-owner deny arms are indistinguishable"schedule-delete-enumeration-oracle.test.ts— whole-response equalityBehaviour changes beyond the deny arms
204.404instead of a silent204— the cost of closing the oracle, and in line with the cross-owner GET / run / upsert-overwrite / delete arms, which all already answer 404.REPORT_NOT_FOUNDwhere it previously resolved. The route is the only production caller ofunscheduleReport(verified by grep across the repo); nothing relies on the old behaviour.Superseded pins — called out by name
Both are replaced in place, same input, opposite assertion, with a comment in the diff explaining why the old expectation was wrong. Neither was deleted quietly.
1.
packages/rest/src/rest.test.ts:1651—DELETE /reports/schedules/:scheduleId returns 204(the pin the issue names).Drove
{ scheduleId: 'rsch_1' }against a service whoseunscheduleReportresolved, and asserted204as the route's answer for any schedule id. Resolving quietly was precisely whatunscheduleReportdid for an id that does not exist, so the pinned 204 was one arm of the oracle. Now split into two tests on that same input: an id the caller cannot see → 404 (REPORT_NOT_FOUND), and a resolvingunscheduleReport→204, which post-fix means only "the caller owned it and it is gone".2.
packages/plugins/plugin-reports/src/report-service.test.ts:457—unscheduleReport: an unknown schedule id is idempotent, not a leak.Not named in the issue, but the deeper pin: same input (
'rsch_nope'as a stranger), assertingresolves.toBeUndefined(). Its title stated the conclusion backwards — that resolution was the leak. Now assertsrejects.toThrow(/REPORT_NOT_FOUND/), renamed toan unknown schedule id is denied as not-found, not silently idempotent.Acceptance target — whole-response equality, and the mutation table
Per the card, the tests assert the two deny arms' whole responses are equal (transcript of every
status()/json()/end()call with arguments, driven with the same id against two worlds differing only in whether the schedule exists — nothing normalised away), not each arm's status separately.Directions predicted before running. Each mutation applied to a clean tree, then reverted.
report-service.test.tsschedule-delete-enumeration-oracle.test.tsrest.test.tsif (!schedule) return;inReportService.unscheduleReport(the exact pre-fix line)if (handleValidation(res, error)) return;from the route (remove the single emitter)VALIDATION_FAILED → 400REPORT_NOT_FOUND→ both answer 404, but the unknown arm carries a different bodyM4 is the result the card asked for. Both arms answer 404, so every per-arm status assertion — including the one this PR just wrote into
rest.test.ts— stays green, while the bodies differ and the response still discriminates on existence. Only the whole-response equality assertions catch it. That is the concrete demonstration that a per-arm assertion cannot fail on a half-fix.M3 is worth stating precisely, and cuts the other way. Removing the emitter makes both arms
500with an identical body, so the equality assertions alone would have passed. What catches it is the companion assertion that the response they agree on is the right one, pinned in full:Equality and the pinned transcript are each insufficient alone and are both required; M3 and M4 are the two mutations that prove it.
Also covered, so the fix is not bought by breaking the feature or over-reaching: the owner still deletes their own schedule (
204), a genuine fault is still500 SCHEDULE_DELETE_FAILED,VALIDATION_FAILEDis still400, and both deny arms do identical work (one call, no delete — the shape a timing side channel would take).Sibling-handler sweep — "does any other idempotent-early-return handler on this surface have the same split?"
Enumerated every
204-answering DELETE inrest-server.ts(grep -n "status(204)"→ 4 hits) and read each one's service method:DELETE /reports/schedules/:scheduleIdunscheduleReportsilent 204→ 404DELETE /reports/:iddeleteReportaf5918b2b)DELETE /data/:object/:id/shares/:shareIdSharingService.revokeNOT_FOUNDNOT_FOUNDfor a record you cannot see;PERMISSION_DENIEDonly for a record you can already see but may not manageDELETE /sharing/rules/:idOrNamedeleteRuleif (!row) return;→ 204getRuleis gated only by the globalassertCanManageRules(context)and is not per-rule ownership-filtered, so there is no cross-owner arm for the early return to split against. The early return exists; it has nothing to discriminate from. Worth re-checking if per-rule visibility is ever introduced.Nothing new to file, so no separate issues opened. The reports surface itself has exactly the two handlers (
deleteReport,unscheduleReport) — the first was #7523's, the second is this one.Verification
packages/rest— targeted tests (232 passed)packages/plugins/plugin-reports— full suite (70 passed)eslint— clean on changed files(
--no-inline-configmatches the rootlintscript. Without it,rest-server.ts:4265reports a pre-existingDefinition for rule '@typescript-eslint/ban-ts-comment' was not foundfrom an inline disable comment on an untouched line.)pnpm typecheck— clean on all three changed packagespnpm check:type-check-debt— the ratchet #7562 tripped, run after a fullpnpm buildThe new test file imports
./rest-server.jswith its explicit extension, which is what #7562's follow-up commit had to fix after@objectstack/rest'sTEST_DEBTrose from 155 to 156. No entry rose here:@objectstack/restis not among the entries the run flags as lowerable, i.e. it re-measures at exactly its recorded number.Scope fences
Untouched, as instructed: the
/metaroute registration block (#7584), the meta app-list handler (#7566),mapDataError/ the/api/v1/dataerror mapping (#7575), and the package registrar /package-routes.ts(#7563). The only edit inrest-server.tsis inside the reports-schedule delete handler — a comment block; its control flow is unchanged.content/docs/releases/anddocs/adr/**untouched; a changeset is the release-notes input.Files
packages/plugins/plugin-reports/src/report-service.tscanAccessReportdecisionpackages/spec/src/contracts/report-service.tspackages/rest/src/rest-server.tspackages/rest/src/schedule-delete-enumeration-oracle.test.tspackages/rest/src/rest.test.tspackages/plugins/plugin-reports/src/report-service.test.ts.changeset/schedule-delete-enumeration-oracle.mdGenerated by Claude Code