Skip to content

Commit 2d00a01

Browse files
stewartjarodclaude
andcommitted
fix(api): reject malformed cursors with 400 instead of listing page one
decodeCursor returned null for an undecodable cursor and the callers treated null as "no cursor", so GET /v1/templates, GET /v1/templates/pull and GET /v1/email/logs answered 200 with the first page. A client following a stale or corrupted cursor looped over page one with no error. - decodeCursor (packages/db) now accepts only a value encodeCursor could produce: the decoded date must re-encode to the input. This rejects lookalikes such as "5|abc", which new Date() parses as a valid date. - apps/api/src/lib/cursor.ts parseCursorParam: absent cursor is the first page; a present but invalid cursor sets 400 and throws, which the standard error handler reports as { error, code: "BAD_REQUEST" }. - The three cursor-paginated routes use it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxJAQNUP4FKZHL1yzPDsGd
1 parent 1d36f9f commit 2d00a01

8 files changed

Lines changed: 186 additions & 16 deletions

File tree

‎apps/api/src/__tests__/email-logs.test.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { Elysia } from "elysia";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { AuthContext } from "../middleware/auth";
4+
import { createErrorHarness } from "./error-handler-harness";
45

56
const { mockListEmailLogs, mockGetEmailLogByMessageId } = vi.hoisted(() => ({
67
mockListEmailLogs: vi.fn(),
78
mockGetEmailLogByMessageId: vi.fn(),
89
}));
910

10-
vi.mock("@wraps/db", () => ({
11+
vi.mock("@wraps/db", async (importOriginal) => ({
12+
// Real decodeCursor: the route validates cursors with it before the mocked
13+
// repository call.
14+
...(await importOriginal<typeof import("@wraps/db")>()),
1115
listEmailLogs: mockListEmailLogs,
1216
getEmailLogByMessageId: mockGetEmailLogByMessageId,
1317
}));
@@ -130,19 +134,38 @@ describe("Email Logs Route", () => {
130134
total: 0,
131135
nextCursor: null,
132136
});
137+
const cursor = Buffer.from(
138+
"2026-05-20T10:00:00.000Z|log-1",
139+
"utf8"
140+
).toString("base64url");
133141

134142
const app = createApp();
135143
const response = await app.handle(
136-
authedGet("/v1/email/logs?cursor=2026-05-20T10%3A00%3A00.000Z")
144+
authedGet(`/v1/email/logs?cursor=${encodeURIComponent(cursor)}`)
137145
);
138146
const body = await response.json();
139147

140148
expect(response.status).toBe(200);
141149
expect(body).toHaveProperty("logs");
142150
expect(mockListEmailLogs).toHaveBeenCalledWith(
143151
mockAuthContext.organizationId,
144-
expect.objectContaining({ cursor: "2026-05-20T10:00:00.000Z" })
152+
expect.objectContaining({ cursor })
153+
);
154+
});
155+
156+
it("rejects a malformed cursor with 400 instead of listing page one", async () => {
157+
// Mount the real onError handler so the body is the API error shape.
158+
const { app: harnessApp } = createErrorHarness();
159+
const app = harnessApp.use(emailLogsRoutes);
160+
const response = await app.handle(
161+
authedGet("/v1/email/logs?cursor=2026-05-20T10%3A00%3A00.000Z")
145162
);
163+
164+
expect(response.status).toBe(400);
165+
const body = await response.json();
166+
expect(body.code).toBe("BAD_REQUEST");
167+
expect(body.error).toMatch(/cursor/i);
168+
expect(mockListEmailLogs).not.toHaveBeenCalled();
146169
});
147170

148171
it("passes limit to repository", async () => {

‎apps/api/src/__tests__/templates-api-db.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,106 @@ describe("GET /v1/templates/pull on the combined app", () => {
591591
});
592592
});
593593

594+
describe("cursor validation on the template list routes", () => {
595+
const b64 = (raw: string) => Buffer.from(raw).toString("base64url");
596+
const malformedCursors = [
597+
"not-a-real-cursor!!",
598+
"%%%",
599+
"",
600+
b64('{"createdAt":"2026-05-20T10:00:00.000Z","id":"x"}'),
601+
b64("5|abc"),
602+
b64("2026-05-20T10:00:00Z|abc"),
603+
];
604+
605+
for (const path of ["/v1/templates", "/v1/templates/pull"]) {
606+
it(`${path} rejects a malformed cursor with 400 BAD_REQUEST`, async () => {
607+
await db.insert(template).values(
608+
templateRow(`${PREFIX}-bad-cursor`, ids.org, {
609+
slug: "bad-cursor",
610+
source: "// bad",
611+
})
612+
);
613+
const app = appFor(ids.org, ids.user);
614+
615+
for (const cursor of malformedCursors) {
616+
const res = await app.handle(
617+
new Request(
618+
`http://localhost${path}?limit=1&cursor=${encodeURIComponent(cursor)}`
619+
)
620+
);
621+
expect(res.status, `cursor=${JSON.stringify(cursor)}`).toBe(400);
622+
const body = await res.json();
623+
expect(body.code).toBe("BAD_REQUEST");
624+
expect(body.error).toMatch(/cursor/i);
625+
}
626+
});
627+
}
628+
629+
it("/v1/templates/pull rejects a malformed cursor with 400 even without limit", async () => {
630+
const app = appFor(ids.org, ids.user);
631+
for (const cursor of malformedCursors) {
632+
const res = await app.handle(
633+
new Request(
634+
`http://localhost/v1/templates/pull?cursor=${encodeURIComponent(cursor)}`
635+
)
636+
);
637+
expect(res.status, `cursor=${JSON.stringify(cursor)}`).toBe(400);
638+
const body = await res.json();
639+
expect(body.code).toBe("BAD_REQUEST");
640+
}
641+
});
642+
643+
it("/v1/templates/pull walks every page with limit=1 and no row repeats or drops", async () => {
644+
const now = Date.now();
645+
await db.insert(template).values([
646+
templateRow(`${PREFIX}-walk-1`, ids.org, {
647+
slug: "walk-1",
648+
source: "// 1",
649+
updatedAt: new Date(now - 3000),
650+
}),
651+
templateRow(`${PREFIX}-walk-2`, ids.org, {
652+
slug: "walk-2",
653+
source: "// 2",
654+
updatedAt: new Date(now - 2000),
655+
}),
656+
templateRow(`${PREFIX}-walk-3`, ids.org, {
657+
slug: "walk-3",
658+
source: "// 3",
659+
updatedAt: new Date(now - 1000),
660+
}),
661+
]);
662+
663+
const app = appFor(ids.org, ids.user);
664+
const seen: string[] = [];
665+
let cursor: string | null = null;
666+
for (let page = 0; page < 20; page++) {
667+
const qs = cursor
668+
? `limit=1&cursor=${encodeURIComponent(cursor)}`
669+
: "limit=1";
670+
const res = await app.handle(
671+
new Request(`http://localhost/v1/templates/pull?${qs}`)
672+
);
673+
expect(res.status).toBe(200);
674+
const body = await res.json();
675+
for (const row of body.templates) {
676+
if (row.id.startsWith(`${PREFIX}-walk-`)) {
677+
seen.push(row.id);
678+
}
679+
}
680+
cursor = body.nextCursor;
681+
if (!cursor) {
682+
break;
683+
}
684+
}
685+
686+
expect(seen).toEqual([
687+
`${PREFIX}-walk-3`,
688+
`${PREFIX}-walk-2`,
689+
`${PREFIX}-walk-1`,
690+
]);
691+
});
692+
});
693+
594694
describe("POST /v1/templates/:id/duplicate", () => {
595695
it("copies channel and previewText, and resets slug/sesTemplateName/publishedAt/status", async () => {
596696
const id = `${PREFIX}-sms-original`;

‎apps/api/src/lib/cursor.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { decodeCursor } from "@wraps/db";
2+
3+
export const INVALID_CURSOR_MESSAGE =
4+
"Invalid cursor. Pass the nextCursor from a previous page unchanged, or omit cursor to start from the first page.";
5+
6+
/**
7+
* Decode a client-supplied keyset cursor from a query string.
8+
*
9+
* An absent cursor is the first page and returns null. A present cursor that
10+
* does not decode is a 400: treating it as the first page makes a client that
11+
* follows a stale or corrupted cursor loop over page one with no error.
12+
*/
13+
export function parseCursorParam(
14+
cursor: string | undefined,
15+
set: { status?: unknown }
16+
): { createdAt: Date; id: string } | null {
17+
if (cursor === undefined) {
18+
return null;
19+
}
20+
const decoded = decodeCursor(cursor);
21+
if (decoded === null) {
22+
set.status = 400;
23+
throw new Error(INVALID_CURSOR_MESSAGE);
24+
}
25+
return decoded;
26+
}

‎apps/api/src/routes/email-logs.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getEmailLogByMessageId, listEmailLogs } from "@wraps/db";
22
import { t } from "elysia";
3+
import { parseCursorParam } from "../lib/cursor";
34
import { createAuthenticatedRoutes, getAuth } from "../middleware/auth";
45

56
export const emailLogsRoutes = createAuthenticatedRoutes("/v1/email/logs")
@@ -9,6 +10,8 @@ export const emailLogsRoutes = createAuthenticatedRoutes("/v1/email/logs")
910
async (ctx) => {
1011
const authContext = getAuth(ctx);
1112
const { status, limit, cursor } = ctx.query;
13+
// Reject a malformed cursor here; listEmailLogs would silently list page one.
14+
parseCursorParam(cursor, ctx.set);
1215

1316
const result = await listEmailLogs(authContext.organizationId, {
1417
status,

‎apps/api/src/routes/templates-sync.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,11 @@
88
* GET /v1/templates/pull - List all code-pushed templates with source
99
*/
1010

11-
import {
12-
and,
13-
db,
14-
decodeCursor,
15-
desc,
16-
encodeCursor,
17-
eq,
18-
or,
19-
template,
20-
} from "@wraps/db";
11+
import { and, db, desc, encodeCursor, eq, or, template } from "@wraps/db";
2112
import { lt } from "drizzle-orm";
2213
import { t } from "elysia";
2314
import { trackFirstResourceCreated } from "../lib/activation-tracking";
15+
import { parseCursorParam } from "../lib/cursor";
2416
import type { AuthContext } from "../middleware/auth";
2517
import { createAuthenticatedRoutes, getAuth } from "../middleware/auth";
2618

@@ -234,6 +226,8 @@ export const templatesSyncRoutes = createAuthenticatedRoutes("/v1/templates")
234226
const authContext = getAuth(ctx);
235227
const { limit, cursor, source } = ctx.query;
236228
const includeSource = source ?? true;
229+
// Validate before branching: a bad cursor is a 400 with or without `limit`.
230+
const decoded = parseCursorParam(cursor, ctx.set);
237231

238232
type PullRow = {
239233
id: string;
@@ -296,7 +290,6 @@ export const templatesSyncRoutes = createAuthenticatedRoutes("/v1/templates")
296290
eq(template.organizationId, authContext.organizationId),
297291
eq(template.sourceFormat, "react-email"),
298292
];
299-
const decoded = cursor ? decodeCursor(cursor) : null;
300293
if (decoded) {
301294
const keyset = or(
302295
lt(template.updatedAt, decoded.createdAt),

‎apps/api/src/routes/templates.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
and,
2727
awsAccount,
2828
db,
29-
decodeCursor,
3029
desc,
3130
encodeCursor,
3231
eq,
@@ -45,6 +44,7 @@ import { normalizePlainTextForSes } from "@wraps/template-render";
4544
import { ilike, lt } from "drizzle-orm";
4645
import { t } from "elysia";
4746
import { trackFirstResourceCreated } from "../lib/activation-tracking";
47+
import { parseCursorParam } from "../lib/cursor";
4848
import { createAuthenticatedRoutes, getAuth } from "../middleware/auth";
4949
import { rateLimitMiddleware } from "../middleware/rate-limit";
5050
import { getCredentials } from "../services/credentials";
@@ -354,7 +354,7 @@ export const templatesRoutes = createAuthenticatedRoutes("/v1/templates")
354354
if (search) {
355355
conditions.push(ilike(template.name, `%${escapeIlike(search)}%`));
356356
}
357-
const decoded = cursor ? decodeCursor(cursor) : null;
357+
const decoded = parseCursorParam(cursor, ctx.set);
358358
if (decoded) {
359359
const keyset = or(
360360
lt(template.updatedAt, decoded.createdAt),

‎packages/db/src/__tests__/repository-message-sends.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,22 @@ describe("Repository: listEmailLogs", () => {
230230
id: "abc",
231231
});
232232
});
233+
234+
it("rejects base64 payloads that only look like a cursor", () => {
235+
const b64 = (s: string) => Buffer.from(s).toString("base64url");
236+
// JSON with no separator
237+
expect(
238+
decodeCursor(b64('{"createdAt":"2026-05-20T10:00:00.000Z","id":"abc"}'))
239+
).toBeNull();
240+
// new Date("5") is a valid date, so a lenient parse would accept this
241+
expect(decodeCursor(b64("5|abc"))).toBeNull();
242+
// Parses, but is not the canonical form encodeCursor emits
243+
expect(decodeCursor(b64("2026-05-20T10:00:00Z|abc"))).toBeNull();
244+
// Empty id
245+
expect(decodeCursor(b64("2026-05-20T10:00:00.000Z|"))).toBeNull();
246+
// Not base64 at all
247+
expect(decodeCursor("%%%")).toBeNull();
248+
});
233249
});
234250

235251
describe("Repository: getEmailLogByMessageId", () => {

‎packages/db/src/repositories/message-sends.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export function encodeCursor(createdAt: Date, id: string): string {
2020
return Buffer.from(`${createdAt.toISOString()}|${id}`).toString("base64url");
2121
}
2222

23+
// Returns null for anything encodeCursor could not have produced. Accepting a
24+
// lookalike is worse than rejecting it: `new Date("5")` parses, so a lenient
25+
// decode turns "5|abc" into a 2001 keyset position, and a client following a
26+
// stale or corrupted cursor silently loops over the first page. Callers decide
27+
// what null means; the API routes answer 400.
2328
export function decodeCursor(
2429
cursor: string
2530
): { createdAt: Date; id: string } | null {
@@ -33,6 +38,10 @@ export function decodeCursor(
3338
if (Number.isNaN(createdAt.getTime()) || !id) {
3439
return null;
3540
}
41+
// Canonical-form check: re-encoding must reproduce the input exactly.
42+
if (encodeCursor(createdAt, id) !== cursor) {
43+
return null;
44+
}
3645
return { createdAt, id };
3746
}
3847

0 commit comments

Comments
 (0)