From 9ab9f2cf239854306d117e5996c5760943eba3a2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 05:41:55 -0700 Subject: [PATCH 1/2] Add tests for inbox cursor scope validation (CL-7206) Reproduces CL-7206: GET / decodes a caller's pagination cursor but never checks it was minted under the same group/status filter as the current request. Paging ?group=action, then replaying that nextCursor against ?group=mention, currently reaches the mailbox store and 500s on the stubbed db rather than being rejected before any DB access. --- packages/inbox/test/routes.test.ts | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/inbox/test/routes.test.ts diff --git a/packages/inbox/test/routes.test.ts b/packages/inbox/test/routes.test.ts new file mode 100644 index 000000000..e96e82789 --- /dev/null +++ b/packages/inbox/test/routes.test.ts @@ -0,0 +1,73 @@ +// Route-level tests for the parts of `GET /` that reject before touching the +// database — the cursor/filter cross-check (CL-7206) chiefly. `db` is a +// stub that throws on any call, which is itself the assertion that a +// rejected request never reaches the mailbox store. +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import { + createInMemoryMailboxEventBus, + type MailboxDb, +} from "@corbits/mailbox"; +import { createInboxRoutes } from "../src/routes"; + +const TENANT = { id: "tnt_1" }; +const PRINCIPAL = { id: "prn_1" }; + +function neverCalledDb(): MailboxDb { + return new Proxy( + {}, + { + get() { + throw new Error("db should not be reached on a rejected request"); + }, + }, + ) as MailboxDb; +} + +function mount(): Hono { + const routes = createInboxRoutes({ + db: neverCalledDb(), + bus: createInMemoryMailboxEventBus(), + }); + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", TENANT as never); + c.set("principal", PRINCIPAL as never); + await next(); + }); + app.route("/", routes); + return app; +} + +describe("GET / cursor/filter cross-check", () => { + test("a cursor minted under ?group=action is rejected when replayed under ?group=mention", async () => { + const app = mount(); + // Minted the same way `listUserMailbox` mints one: base64url JSON with + // view/sort/filter embedded, filter canonicalized to `classification=action`. + const payload = { + createdAt: "2026-01-01T00:00:00.000000Z", + id: "msg_1", + view: "all", + sort: "date", + filter: "classification=action", + }; + const cursor = Buffer.from(JSON.stringify(payload)).toString("base64url"); + + const response = await app.request( + `/?group=mention&cursor=${encodeURIComponent(cursor)}`, + ); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("cursor does not match inbox filter"); + }); + + test("a well-formed cursor with no query params is malformed-rejected without a filter mismatch masking it", async () => { + const app = mount(); + const response = await app.request("/?cursor=not-valid-base64url!!!"); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("malformed cursor"); + }); +}); From 526bd5075906e20d4df6bf4da8ba874b9877373d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 05:42:41 -0700 Subject: [PATCH 2/2] Reject an inbox pagination cursor minted under a different filter @corbits/mailbox embeds the view/sort/filter a cursor was minted under specifically so a caller can reject a cross-set replay -- GET / decoded the cursor but never performed that check, so a mention-tab fetch reusing an action-tab cursor silently seeked into the wrong result set and served a 200 with newer mentions skipped. cursorScopeMismatch() (packages/inbox/src/cursor.ts) reuses @corbits/mailbox's own canonicalMailboxFilter and mirrors the error vocabulary its mount.ts route already uses ("cursor does not match inbox "), so the two routes answer identically. GET /'s listMailboxOpts now pins sort: "date" explicitly rather than relying on @corbits/mailbox's own default, since the new check asserts against that exact value. --- packages/inbox/src/cursor.ts | 41 ++++++++++++++++++++ packages/inbox/src/routes.ts | 18 +++++++++ packages/inbox/test/cursor.test.ts | 62 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 packages/inbox/src/cursor.ts create mode 100644 packages/inbox/test/cursor.test.ts diff --git a/packages/inbox/src/cursor.ts b/packages/inbox/src/cursor.ts new file mode 100644 index 000000000..a8903158d --- /dev/null +++ b/packages/inbox/src/cursor.ts @@ -0,0 +1,41 @@ +// A decoded pagination cursor is only meaningful against the exact +// view/sort/filter it was minted under — `@corbits/mailbox` embeds all +// three in every cursor it mints precisely so a caller can reject a +// cross-set replay (paging `?group=action`, then reusing that cursor +// under `?group=mention`) instead of silently seeking into the wrong +// result set. `decodeMailboxListCursor` only checks the cursor is +// well-formed; this is the cross-check `@corbits/mailbox`'s own +// `mount.ts` route performs and every other caller must perform itself. +import { + canonicalMailboxFilter, + type MailboxFilter, + type MailboxInboxView, + type MailboxListCursor, + type MailboxSort, +} from "@corbits/mailbox"; + +export type CursorScope = { + view: MailboxInboxView; + sort: MailboxSort; + filter: MailboxFilter; +}; + +export type CursorMismatch = "view" | "sort" | "filter" | null; + +/** + * Which field of `cursor` disagrees with `expected`, or `null` when the + * cursor was minted for exactly this scope. Checked in the same order + * `@corbits/mailbox`'s `mount.ts` checks it, so a caller can reuse its + * exact error strings (`cursor does not match inbox `). + */ +export function cursorScopeMismatch( + cursor: MailboxListCursor, + expected: CursorScope, +): CursorMismatch { + if (cursor.view !== expected.view) return "view"; + if (cursor.sort !== expected.sort) return "sort"; + if (cursor.filter !== canonicalMailboxFilter(expected.filter)) { + return "filter"; + } + return null; +} diff --git a/packages/inbox/src/routes.ts b/packages/inbox/src/routes.ts index bdeaa0d91..21123b3bb 100644 --- a/packages/inbox/src/routes.ts +++ b/packages/inbox/src/routes.ts @@ -21,6 +21,7 @@ import type { TenantEnv } from "@intx/hub-api"; import { getLogger } from "@intx/log"; import { Hono } from "hono"; +import { cursorScopeMismatch } from "./cursor"; import { isInboxGroup, type InboxGroup } from "./group"; import { itemsEligibleForClearDone, itemsEligibleForMarkAllRead } from "./bulk"; import { @@ -212,6 +213,22 @@ export function createInboxRoutes( if (decoded === null) { return c.json({ error: "malformed cursor" }, 400); } + // A cursor is only meaningful against the exact view/sort/filter it + // was minted under (CL-7206) — paging `?group=action` then replaying + // that cursor under `?group=mention` must be rejected, not silently + // seek into the wrong result set. Same error vocabulary as + // `@corbits/mailbox`'s own `mount.ts` cross-check. + const mismatch = cursorScopeMismatch(decoded, { + view: "all", + sort: "date", + filter, + }); + if (mismatch !== null) { + return c.json( + { error: `cursor does not match inbox ${mismatch}` }, + 400, + ); + } cursor = decoded; } @@ -219,6 +236,7 @@ export function createInboxRoutes( tenantId: tenant.id, principalId: principal.id, view: "all" as const, + sort: "date" as const, limit, priorities: WORKBENCH_INBOX_PRIORITIES, }; diff --git a/packages/inbox/test/cursor.test.ts b/packages/inbox/test/cursor.test.ts new file mode 100644 index 000000000..3edd0b583 --- /dev/null +++ b/packages/inbox/test/cursor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import type { MailboxListCursor } from "@corbits/mailbox"; +import { cursorScopeMismatch } from "../src/cursor"; + +function cursor(overrides: Partial = {}): MailboxListCursor { + return { + createdAt: "2026-01-01T00:00:00.000000Z", + id: "msg_1", + view: "all", + sort: "date", + filter: "classification=action", + ...overrides, + }; +} + +describe("cursorScopeMismatch", () => { + test("matching cursor: no mismatch", () => { + const mismatch = cursorScopeMismatch(cursor(), { + view: "all", + sort: "date", + filter: { classification: "action" }, + }); + expect(mismatch).toBeNull(); + }); + + test("cursor minted under a different group filter is rejected", () => { + // Paged `?group=action`, then replayed that cursor under `?group=mention`. + const actionCursor = cursor({ filter: "classification=action" }); + const mismatch = cursorScopeMismatch(actionCursor, { + view: "all", + sort: "date", + filter: { classification: "mention" }, + }); + expect(mismatch).toBe("filter"); + }); + + test("cursor minted under a different view is rejected", () => { + const mismatch = cursorScopeMismatch(cursor({ view: "unread" }), { + view: "all", + sort: "date", + filter: {}, + }); + expect(mismatch).toBe("view"); + }); + + test("cursor minted under a different sort is rejected", () => { + const mismatch = cursorScopeMismatch(cursor({ sort: "priority" }), { + view: "all", + sort: "date", + filter: {}, + }); + expect(mismatch).toBe("sort"); + }); + + test("view is checked before sort and filter", () => { + const mismatch = cursorScopeMismatch( + cursor({ view: "unread", sort: "priority", filter: "status=done" }), + { view: "all", sort: "date", filter: {} }, + ); + expect(mismatch).toBe("view"); + }); +});