Skip to content

Commit dc07b91

Browse files
heavygeecursoragent
andcommitted
fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR tiann#896)
The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 390dfba commit dc07b91

2 files changed

Lines changed: 24 additions & 1 deletion

File tree

hub/src/web/routes/sessions-scratchlist.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,21 @@ describe('POST /api/sessions/:id/scratchlist', () => {
286286
expect(res.status).toBe(409)
287287
})
288288

289+
it('rejects oversized entryId with 400 (HAPI Bot, PR #896 follow-up)', async () => {
290+
// Server-side guard for the SQLite primary key: an authenticated
291+
// client could otherwise grow the table and its index with
292+
// arbitrarily large keys. 129 chars is one over the 128-char
293+
// cap defined in SCRATCHLIST_MAX_ENTRY_ID_LENGTH.
294+
const session = createSession()
295+
const app = createApp(session)
296+
const res = await app.request('/api/sessions/session-1/scratchlist', {
297+
method: 'POST',
298+
headers: { 'content-type': 'application/json' },
299+
body: JSON.stringify({ text: 'oversized id', entryId: 'x'.repeat(129) })
300+
})
301+
expect(res.status).toBe(400)
302+
})
303+
289304
it('returns 404 when the engine reports session-not-found post-auth', async () => {
290305
// This path covers a race: auth said the session was visible
291306
// (resolveSessionAccess.ok), but by the time we INSERT the row the

shared/src/apiTypes.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ export type RenameSessionRequest = z.infer<typeof RenameSessionRequestSchema>
164164
*/
165165
export const SCRATCHLIST_MAX_ENTRIES = 200
166166
export const SCRATCHLIST_MAX_TEXT_LENGTH = 10_000
167+
/**
168+
* Hard cap on client-supplied entry id length. The id is persisted as
169+
* part of the SQLite primary key, so without a bound an authenticated
170+
* client could grow the table and its index with arbitrarily large
171+
* keys. 128 chars comfortably fits a UUID (36) plus any prefix scheme
172+
* we might layer on later.
173+
*/
174+
export const SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128
167175

168176
export const ScratchlistEntryCreateRequestSchema = z.object({
169177
/**
@@ -173,7 +181,7 @@ export const ScratchlistEntryCreateRequestSchema = z.object({
173181
* tree. New entries created post-v2 can omit this and let the hub
174182
* generate one.
175183
*/
176-
entryId: z.string().min(1).optional(),
184+
entryId: z.string().min(1).max(SCRATCHLIST_MAX_ENTRY_ID_LENGTH).optional(),
177185
text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH),
178186
/**
179187
* Optional client-supplied createdAt. Used by the migration path to

0 commit comments

Comments
 (0)