Skip to content

Commit 94e1ea2

Browse files
committed
fix: add pagination limit validation on trpc endpoints
1 parent 5cd07ee commit 94e1ea2

9 files changed

Lines changed: 52 additions & 36 deletions

File tree

‎packages/api/middlewares/trpcAdapter.ts‎

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,26 @@
11
import { TRPCError } from "@trpc/server";
2+
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
23
import { createMiddleware } from "hono/factory";
34
import { HTTPException } from "hono/http-exception";
45

5-
function trpcCodeToHttpCode(code: TRPCError["code"]) {
6-
switch (code) {
7-
case "BAD_REQUEST":
8-
case "PARSE_ERROR":
9-
return 400;
10-
case "UNAUTHORIZED":
11-
return 401;
12-
case "FORBIDDEN":
13-
return 403;
14-
case "NOT_FOUND":
15-
return 404;
16-
case "METHOD_NOT_SUPPORTED":
17-
return 405;
18-
case "TIMEOUT":
19-
return 408;
20-
case "PAYLOAD_TOO_LARGE":
21-
return 413;
22-
case "TOO_MANY_REQUESTS":
23-
return 429;
24-
case "INTERNAL_SERVER_ERROR":
25-
return 500;
26-
default:
27-
return 500;
28-
}
6+
import type { ContentfulStatusCode } from "hono/utils/http-status";
7+
8+
// trpc maps CLIENT_CLOSED_REQUEST to 499, which isn't part of hono's status
9+
// code union; everything else it emits is a standard contentful 4xx/5xx.
10+
function isContentfulStatusCode(
11+
status: number,
12+
): status is ContentfulStatusCode {
13+
return status >= 400 && status <= 511 && status !== 499;
2914
}
3015

3116
const trpcAdapter = createMiddleware(async (c, next) => {
3217
await next();
3318
const e = c.error;
3419
if (e instanceof TRPCError) {
35-
const code = trpcCodeToHttpCode(e.code);
20+
const status = getHTTPStatusCodeFromError(e);
3621
const isInternalError = e.code === "INTERNAL_SERVER_ERROR";
3722
const isProd = process.env.NODE_ENV === "production";
38-
throw new HTTPException(code, {
23+
throw new HTTPException(isContentfulStatusCode(status) ? status : 500, {
3924
message: isInternalError && isProd ? "Internal server error" : e.message,
4025
cause: isInternalError && isProd ? undefined : e.cause,
4126
});

‎packages/api/utils/pagination.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
44
import { zCursorV2 } from "@karakeep/shared/types/pagination";
55

66
export const zPagination = z.object({
7-
limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
7+
limit: z.coerce
8+
.number()
9+
.int()
10+
.min(1)
11+
.max(MAX_NUM_BOOKMARKS_PER_PAGE)
12+
.optional(),
813
cursor: z
914
.string()
1015
.refine((val) => val.includes("_"), "Must be a valid cursor")

‎packages/open-api/karakeep-openapi-spec.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3120,8 +3120,8 @@
31203120
},
31213121
{
31223122
"schema": {
3123-
"type": "number",
3124-
"nullable": true
3123+
"type": "integer",
3124+
"minimum": 1
31253125
},
31263126
"required": false,
31273127
"name": "limit",

‎packages/shared/types/bookmarks.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ export const zGetBookmarksRequestSchema = z.object({
291291
tagId: z.string().optional(),
292292
listId: z.string().optional(),
293293
rssFeedId: z.string().optional(),
294-
limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
294+
limit: z.number().int().min(1).max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
295295
cursor: zCursorV2.nullish(),
296296
// TODO: This was done for backward comptability. At this point, all clients should be settings this to true.
297297
// The value is currently not being used, but keeping it so that client can still set it to true for older
@@ -361,7 +361,7 @@ export const zBookmarkSearchMode = z.enum(["fts", "semantic", "hybrid"]);
361361
export type ZBookmarkSearchMode = z.infer<typeof zBookmarkSearchMode>;
362362
export const zSearchBookmarksRequestSchema = z.object({
363363
text: z.string(),
364-
limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
364+
limit: z.number().int().min(1).max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
365365
cursor: zSearchBookmarksCursor.nullish(),
366366
sortOrder: zSortOrder.optional().default("relevance"),
367367
searchMode: zBookmarkSearchMode.optional().default("fts"),

‎packages/shared/types/importSessions.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { z } from "zod";
22

3+
export const DEFAULT_NUM_IMPORT_SESSION_RESULTS_PER_PAGE = 50;
4+
export const MAX_NUM_IMPORT_SESSION_RESULTS_PER_PAGE = 100;
5+
36
export const zImportSessionStatusSchema = z.enum([
47
"staging",
58
"pending",

‎packages/shared/types/tags.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export const zTagListQueryParamsSchema = z.object({
9090
})
9191
.optional()
9292
.pipe(zTagListRequestSchema.shape.cursor),
93-
limit: z.coerce.number().optional(),
93+
limit: z.coerce.number().int().min(1).optional(),
9494
});
9595

9696
export const zTagListApiResultSchema = z.object({

‎packages/trpc/routers/highlights.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ export const highlightsAppRouter = router({
7979
.input(
8080
z.object({
8181
cursor: z.any().nullish(),
82-
limit: z.number().optional().default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE),
82+
limit: z
83+
.number()
84+
.int()
85+
.min(1)
86+
.optional()
87+
.default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE),
8388
}),
8489
)
8590
.output(zGetAllHighlightsResponseSchema)
@@ -95,7 +100,12 @@ export const highlightsAppRouter = router({
95100
z.object({
96101
text: z.string(),
97102
cursor: zCursorV2.nullish(),
98-
limit: z.number().optional().default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE),
103+
limit: z
104+
.number()
105+
.int()
106+
.min(1)
107+
.optional()
108+
.default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE),
99109
}),
100110
)
101111
.output(zGetAllHighlightsResponseSchema)

‎packages/trpc/routers/importSessions.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { experimental_trpcMiddleware } from "@trpc/server";
22
import { z } from "zod";
33

44
import {
5+
DEFAULT_NUM_IMPORT_SESSION_RESULTS_PER_PAGE,
6+
MAX_NUM_IMPORT_SESSION_RESULTS_PER_PAGE,
57
zCreateImportSessionRequestSchema,
68
zDeleteImportSessionRequestSchema,
79
zGetImportSessionStatsRequestSchema,
@@ -141,7 +143,12 @@ export const importSessionsRouter = router({
141143
.enum(["all", "accepted", "rejected", "skipped_duplicate", "pending"])
142144
.optional(),
143145
cursor: z.string().optional(),
144-
limit: z.number().default(50),
146+
limit: z
147+
.number()
148+
.int()
149+
.min(1)
150+
.max(MAX_NUM_IMPORT_SESSION_RESULTS_PER_PAGE)
151+
.default(DEFAULT_NUM_IMPORT_SESSION_RESULTS_PER_PAGE),
145152
}),
146153
)
147154
.use(ensureImportSessionAccess)

‎packages/trpc/routers/publicBookmarks.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod";
22

33
import {
4+
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
45
MAX_NUM_BOOKMARKS_PER_PAGE,
56
zPublicBookmarkSchema,
67
zSortOrder,
@@ -39,7 +40,12 @@ export const publicBookmarks = router({
3940
z.object({
4041
listId: z.string(),
4142
cursor: zCursorV2.nullish(),
42-
limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).default(20),
43+
limit: z
44+
.number()
45+
.int()
46+
.min(1)
47+
.max(MAX_NUM_BOOKMARKS_PER_PAGE)
48+
.default(DEFAULT_NUM_BOOKMARKS_PER_PAGE),
4349
sortOrder: zSortOrder.exclude(["relevance"]).optional().default("desc"),
4450
}),
4551
)

0 commit comments

Comments
 (0)