Skip to content

Commit 042b220

Browse files
committed
fix(plugin-reports): stop DELETE /reports/schedules/:scheduleId revealing 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
1 parent 245d1dc commit 042b220

7 files changed

Lines changed: 474 additions & 7 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/plugin-reports": patch
3+
"@objectstack/spec": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
fix(plugin-reports): `DELETE /api/v1/reports/schedules/:scheduleId` stops telling a caller whether a schedule id exists
8+
9+
`DELETE /api/v1/reports/schedules/:scheduleId` answered differently depending on
10+
whether the target id **existed**, which let any authenticated caller enumerate
11+
other owners' report schedules by probing ids and reading the status code:
12+
13+
| Target | Before | After |
14+
| --- | --- | --- |
15+
| Another owner's schedule id | `404 REPORT_NOT_FOUND` | `404 REPORT_NOT_FOUND` (unchanged) |
16+
| A schedule id that does not exist | `204 No Content` | `404 REPORT_NOT_FOUND` |
17+
| A schedule whose report row is gone | `404 REPORT_NOT_FOUND` | `404 REPORT_NOT_FOUND` (unchanged) |
18+
| Your own schedule | `204 No Content` | `204 No Content` (unchanged) |
19+
20+
This is the same defect #7523 closed on the sibling `DELETE /reports/:id`, in the
21+
costume that card explicitly warned about: there the split was 500-vs-204 and
22+
loud, here it was 404-vs-204 and read as correct. The route was in fact cited by
23+
#7523's investigation as the example of the *right* shape, because it does route
24+
its catch through `handleValidation` — which is why the cross-owner arm is a
25+
clean 404 rather than a 500. Only the cross-owner arm was ever probed (QA run
26+
#7515); the unknown-id arm was not, so the surviving half went unseen and
27+
`packages/rest/src/rest.test.ts` pinned its `204` green.
28+
29+
`ReportService.unscheduleReport()` carried the intent — *"others get a not-found
30+
so the delete neither fires nor reveals the schedule's existence"* — and a hole
31+
one line wide above it: `if (!schedule) return; // idempotent`. Idempotence is
32+
only harmless where every caller may see the row; with a cross-owner arm that
33+
throws, resolving quietly *is* the tell.
34+
35+
Both deny arms are now one decision, taken before the delete fires, by the
36+
predicate already blind to the difference between them: `canAccessReport` is
37+
false for a schedule that does not exist, for one whose report is gone, and for
38+
one owned by somebody else alike. A single throw site means a single message, so
39+
the route's single `handleValidation` call emits a single response — status and
40+
body cannot drift apart.
41+
42+
Unlike `deleteReport`, this could not be pre-empted in the route. That one
43+
collapses its arms with `getReport()`, which is already blind to the same
44+
difference (#2980); the caller here presents a `scheduleId`, and `IReportService`
45+
exposes no by-id schedule read to be blind with (`listSchedules` is keyed by
46+
`reportId`). The blinding therefore lives in the service, and
47+
`IReportService.unscheduleReport` now states it as a contract obligation rather
48+
than leaving each implementation to rediscover it.
49+
50+
Deleting a schedule you own still answers `204`. Deleting one you cannot see is
51+
now `404` instead of a silent `204` — the cost of closing the oracle, and in line
52+
with the cross-owner GET / run / upsert-overwrite / delete arms, which all
53+
already answer 404. A system/dispatcher context deleting an id with no row now
54+
gets `REPORT_NOT_FOUND` too, where it previously resolved; no caller in the repo
55+
relies on that (the route is the only production caller).
56+
57+
Tests assert the two deny arms' responses are **EQUAL** rather than pinning each
58+
arm's status separately, so the plausible half-fix cannot pass through them — a
59+
mutation that answers both arms 404 with different bodies leaves every per-arm
60+
status assertion green and turns the equality assertions red.

packages/plugins/plugin-reports/src/report-service.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,61 @@ describe('ReportService', () => {
454454
expect(engine._tables['sys_report_schedule'].length).toBe(0);
455455
});
456456

457-
it('unscheduleReport: an unknown schedule id is idempotent, not a leak', async () => {
458-
await expect(svc.unscheduleReport('rsch_nope', OTHER)).resolves.toBeUndefined();
457+
// [#7603] SUPERSEDES `unscheduleReport: an unknown schedule id is
458+
// idempotent, not a leak`, which asserted on this same input
459+
// (`'rsch_nope'` as OTHER) that the call `resolves.toBeUndefined()`.
460+
//
461+
// Its title stated the conclusion backwards. Idempotence is only "not a
462+
// leak" where every caller may see the row; here the sibling arm — another
463+
// owner's schedule — threw REPORT_NOT_FOUND, so resolving quietly was the
464+
// one behaviour that told a stranger apart "no such schedule" from "not
465+
// yours". The route turns that into 204-vs-404, which is an enumeration
466+
// oracle over other owners' schedule ids (#7523's defect on `deleteReport`,
467+
// in a quieter costume). Same input, opposite assertion: the unknown id now
468+
// throws, exactly as the cross-owner id does.
469+
it('unscheduleReport: an unknown schedule id is denied as not-found, not silently idempotent', async () => {
470+
await expect(svc.unscheduleReport('rsch_nope', OTHER)).rejects.toThrow(/REPORT_NOT_FOUND/);
471+
});
472+
473+
// The assertion that actually closes the oracle, and the one to keep if any
474+
// of these ever have to be merged: it compares the two deny arms to EACH
475+
// OTHER instead of pinning each one's outcome separately. #7523's mutation
476+
// table showed per-arm assertions cannot fail on the plausible half-fix
477+
// (one arm corrected, the other left alone) — an equality cannot pass
478+
// through one.
479+
it('unscheduleReport: the unknown-id and cross-owner deny arms are indistinguishable', async () => {
480+
const r = await svc.saveReport({ name: 'Mine', object: 'lead', query: {} }, CTX);
481+
const s = await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
482+
483+
// A prober holds one id at a time and can only compare what comes back.
484+
const outcome = async (scheduleId: string) => {
485+
try {
486+
await svc.unscheduleReport(scheduleId, OTHER);
487+
return { threw: false, message: null as string | null };
488+
} catch (err) {
489+
// The whole message, not a pattern: the route puts it in the 404 body
490+
// verbatim, so any difference here is a difference on the wire.
491+
return { threw: true, message: (err as Error).message };
492+
}
493+
};
494+
495+
// Same id on both sides, so this is literal equality with nothing
496+
// normalised away — the id that does not exist is `s.id` itself, in a
497+
// world where the schedule was never created.
498+
const crossOwner = await outcome(s.id);
499+
await svc.unscheduleReport(s.id, CTX); // owner drops it for real
500+
const unknownId = await outcome(s.id); // same id, now nonexistent
501+
502+
expect(unknownId).toEqual(crossOwner);
503+
expect(crossOwner).toEqual({ threw: true, message: `REPORT_NOT_FOUND: ${s.id}` });
504+
});
505+
506+
it('unscheduleReport: does not buy equal denials by refusing the owner too', async () => {
507+
// The cheap way to make two arms agree is to break the feature.
508+
const r = await svc.saveReport({ name: 'Mine', object: 'lead', query: {} }, CTX);
509+
const s = await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
510+
await expect(svc.unscheduleReport(s.id, CTX)).resolves.toBeUndefined();
511+
expect(engine._tables['sys_report_schedule'].length).toBe(0);
459512
});
460513

461514
it('listSchedules: a non-owner cannot see another user\'s schedules', async () => {

packages/plugins/plugin-reports/src/report-service.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -598,13 +598,34 @@ export class ReportService implements IReportService {
598598

599599
async unscheduleReport(scheduleId: string, context: ExecutionContext): Promise<void> {
600600
if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');
601-
const schedule = await this.loadScheduleRow(scheduleId);
602-
if (!schedule) return; // idempotent — nothing to drop (mirrors deleteReport)
603601
// A schedule is owned through its report (#2980): a caller may only delete
604602
// the schedules of a report they own. Others get a not-found so the delete
605603
// neither fires nor reveals the schedule's existence — deny-as-404, never a
606604
// cross-owner 2xx.
607-
const report = await this.loadReportRow(schedule.report_id);
605+
//
606+
// [#7603] That intent used to have a hole one line wide. An id with no row
607+
// behind it returned early and silently — `if (!schedule) return; //
608+
// idempotent` — while another owner's id threw. The route maps those to 204
609+
// and 404, so a caller who could delete neither still learned which of the
610+
// two they had hit: an enumeration oracle over other owners' schedule ids,
611+
// the same one #7523 closed on `DELETE /reports/:id` in its 500-vs-204
612+
// costume. Idempotence is only harmless where every caller may see the row;
613+
// here it was the tell.
614+
//
615+
// Both deny arms are now ONE decision, taken before the delete fires, by the
616+
// predicate that is already blind to the difference between them:
617+
// `canAccessReport` is false for a schedule that does not exist, for one
618+
// whose report is gone, and for one owned by somebody else alike. A single
619+
// throw site means a single message, so the route's single `handleValidation`
620+
// call emits a single response — status and body cannot drift apart.
621+
//
622+
// Unlike `deleteReport`, this cannot be pre-empted in the route: the caller
623+
// presents a scheduleId, and `IReportService` exposes no by-id schedule read
624+
// to be blind with (`listSchedules` is keyed by reportId). The blinding has
625+
// to live here, which is why the contract now states it as an obligation
626+
// rather than leaving it to each implementation.
627+
const schedule = await this.loadScheduleRow(scheduleId);
628+
const report = schedule ? await this.loadReportRow(schedule.report_id) : null;
608629
if (!this.canAccessReport(report, context)) {
609630
throw new Error(`REPORT_NOT_FOUND: ${scheduleId}`);
610631
}

packages/rest/src/rest-server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9533,6 +9533,24 @@ export class RestServer {
95339533
if (this.enforceAuth(req, res, context)) return;
95349534
const svc = await resolveService(environmentId);
95359535
if (!svc) return respond501(res);
9536+
// [#7603] Both deny arms — an unknown scheduleId and another
9537+
// owner's — reach the caller as the one 404 emitted by the
9538+
// single `handleValidation` call below, because
9539+
// `unscheduleReport` is contracted to throw the SAME
9540+
// `REPORT_NOT_FOUND: <scheduleId>` for both, before the delete
9541+
// fires. It used to resolve silently for the unknown id, which
9542+
// landed here as a 204 and let a prober read another owner's
9543+
// schedule ids off the status code (#7523's oracle, in the
9544+
// 404-vs-204 costume its card warned about).
9545+
//
9546+
// Unlike the sibling `DELETE /reports/:id`, this route cannot
9547+
// pre-empt the two arms itself: that one collapses them with
9548+
// `getReport()`, already blind to the difference (#2980),
9549+
// whereas the caller here presents a scheduleId and
9550+
// `IReportService` exposes no by-id schedule read to be blind
9551+
// with — `listSchedules` is keyed by reportId. So the blinding
9552+
// is the service's obligation (stated on the contract), and the
9553+
// route's job is to keep ONE emitter for whatever it throws.
95369554
await svc.unscheduleReport(req.params.scheduleId, context ?? {});
95379555
res.status(204).end();
95389556
} catch (error: any) {

packages/rest/src/rest.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1648,7 +1648,38 @@ describe('RestServer', () => {
16481648
expect(res.status).toHaveBeenCalledWith(201);
16491649
});
16501650

1651-
it('DELETE /reports/schedules/:scheduleId returns 204', async () => {
1651+
// [#7603] SUPERSEDES the former test `DELETE /reports/schedules/:scheduleId
1652+
// returns 204`, which stood right here and drove this same input —
1653+
// `{ scheduleId: 'rsch_1' }` against a service whose `unscheduleReport`
1654+
// resolved — asserting 204 as the route's answer for ANY schedule id.
1655+
//
1656+
// That expectation was wrong, and load-bearing in the wrong direction:
1657+
// resolving quietly was precisely what `unscheduleReport` did for an id that
1658+
// DOES NOT EXIST, while another owner's id threw REPORT_NOT_FOUND. So the 204
1659+
// pinned under the old title was one arm of an enumeration oracle — a caller
1660+
// who could delete neither schedule still read which of the two they had hit
1661+
// straight off the status code — and this pin held it green.
1662+
//
1663+
// Same input, opposite assertion. Post-#7603 the two deny arms are a single
1664+
// throw in the service, so the route answers 404 to both; a resolving
1665+
// `unscheduleReport` now means only "the caller owned it and it is gone",
1666+
// which is the second test below. That the two deny arms agree on the WHOLE
1667+
// response — body included, not just the status — is asserted in
1668+
// schedule-delete-enumeration-oracle.test.ts: a pair of per-arm status
1669+
// assertions like these cannot fail on a half-fix, which is why that file
1670+
// exists alongside this one.
1671+
it('DELETE /reports/schedules/:scheduleId returns 404 for a schedule the caller cannot see', async () => {
1672+
const unscheduleReport = vi.fn(async () => { throw new Error('REPORT_NOT_FOUND: rsch_1'); });
1673+
const rest = makeRest(async () => ({ unscheduleReport }));
1674+
const { unschedule } = getReportRoutes(rest);
1675+
const res = { json: vi.fn(), status: vi.fn().mockReturnThis(), end: vi.fn() };
1676+
await unschedule!.handler({ params: { scheduleId: 'rsch_1' } } as any, res as any);
1677+
expect(unscheduleReport).toHaveBeenCalledWith('rsch_1', expect.anything());
1678+
expect(res.status).toHaveBeenCalledWith(404);
1679+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'REPORT_NOT_FOUND' }));
1680+
});
1681+
1682+
it('DELETE /reports/schedules/:scheduleId returns 204 when the caller owned the schedule', async () => {
16521683
const unscheduleReport = vi.fn(async () => undefined);
16531684
const rest = makeRest(async () => ({ unscheduleReport }));
16541685
const { unschedule } = getReportRoutes(rest);

0 commit comments

Comments
 (0)