From 4904da045954b65480c0022cb6e44f5ea59d5f85 Mon Sep 17 00:00:00 2001 From: Sonarly Claude Code Date: Sat, 25 Apr 2026 04:47:27 +0000 Subject: [PATCH] fix(rest): validate unknown query parameters and improve pagination parameter discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://sonarly.com/issue/30901?type=bug The REST API pagination parameter is `starting_after` (not `cursor`), but the API silently ignores unrecognized query parameters, causing users who guess the wrong parameter name to get an infinite loop of duplicate records. Fix: Added detection of commonly-misused pagination parameter names in both cursor parsers. When a user passes `cursor`, `after`, `lastCursor`, `last_cursor`, `startingAfter`, or `page_token` instead of `starting_after` (or `before`, `endingBefore`, `ending` instead of `ending_before`), the API now returns a 400 Bad Request with a clear error message like: "Unknown pagination parameter 'cursor'. Use 'starting_after' instead". Changes: 1. `parse-starting-after-rest-request.util.ts` — Added a check for 6 common wrong parameter names. When `starting_after` is absent but one of these is present, throws `RestInputRequestParserException` with the new `INVALID_CURSOR_QUERY_PARAM` code. When `starting_after` IS present, wrong names are ignored (no false positives). 2. `parse-ending-before-rest-request.util.ts` — Same pattern for 3 common wrong names for the ending_before parameter. 3. `rest-input-request-parser.exception.ts` — Added `INVALID_CURSOR_QUERY_PARAM` enum value and its user-friendly message in the switch statement. The existing `assertUnreachable` default ensures compile-time exhaustiveness. 4. Both test files updated with cases for wrong parameter detection and a case confirming no false positive when the correct parameter is also present. The existing exception handler already maps `RestInputRequestParserException` → `BadRequestException` (HTTP 400), so no handler changes were needed. --- ...se-ending-before-rest-request.util.spec.ts | 23 ++++++++++++++ .../parse-ending-before-rest-request.util.ts | 19 ++++++++++++ .../rest-input-request-parser.exception.ts | 3 ++ ...e-starting-after-rest-request.util.spec.ts | 30 +++++++++++++++++++ .../parse-starting-after-rest-request.util.ts | 22 ++++++++++++++ 5 files changed, 97 insertions(+) diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/__tests__/parse-ending-before-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/__tests__/parse-ending-before-rest-request.util.spec.ts index e761d82bab7..72bf0399c4e 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/__tests__/parse-ending-before-rest-request.util.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/__tests__/parse-ending-before-rest-request.util.spec.ts @@ -1,4 +1,5 @@ import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util'; +import { RestInputRequestParserExceptionCode } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; describe('parseEndingBeforeRestRequest', () => { it('should return default if ending_before missing', () => { @@ -12,4 +13,26 @@ describe('parseEndingBeforeRestRequest', () => { expect(parseEndingBeforeRestRequest(request)).toEqual('uuid'); }); + + it('should throw when using wrong pagination parameter name', () => { + const wrongNames = ['before', 'endingBefore', 'ending']; + + for (const wrongName of wrongNames) { + const request: any = { query: { [wrongName]: 'some-cursor' } }; + + expect(() => parseEndingBeforeRestRequest(request)).toThrow( + expect.objectContaining({ + code: RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM, + }), + ); + } + }); + + it('should not throw for wrong names when ending_before is also provided', () => { + const request: any = { + query: { ending_before: 'uuid', before: 'ignored' }, + }; + + expect(parseEndingBeforeRestRequest(request)).toEqual('uuid'); + }); }); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts index c3a46479c9a..07199accacf 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util.ts @@ -1,12 +1,31 @@ +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; +const ENDING_BEFORE_WRONG_PARAM_NAMES = [ + 'before', + 'endingBefore', + 'ending', +]; + export const parseEndingBeforeRestRequest = ( request: AuthenticatedRequest | RequestContext, ): string | undefined => { const cursorQuery = request.query?.ending_before; if (typeof cursorQuery !== 'string') { + for (const wrongName of ENDING_BEFORE_WRONG_PARAM_NAMES) { + if (typeof request.query?.[wrongName] === 'string') { + throw new RestInputRequestParserException( + `Unknown pagination parameter '${wrongName}'. Use 'ending_before' instead`, + RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM, + ); + } + } + return undefined; } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts index e9172be41ac..5908d20c280 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts @@ -12,6 +12,7 @@ export enum RestInputRequestParserExceptionCode { INVALID_DEPTH_QUERY_PARAM = 'INVALID_DEPTH_QUERY_PARAM', INVALID_LIMIT_QUERY_PARAM = 'INVALID_LIMIT_QUERY_PARAM', INVALID_FILTER_QUERY_PARAM = 'INVALID_FILTER_QUERY_PARAM', + INVALID_CURSOR_QUERY_PARAM = 'INVALID_CURSOR_QUERY_PARAM', } const getRestInputRequestParserExceptionUserFriendlyMessage = ( @@ -32,6 +33,8 @@ const getRestInputRequestParserExceptionUserFriendlyMessage = ( return msg`Invalid limit parameter.`; case RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM: return msg`Invalid filter parameter.`; + case RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM: + return msg`Invalid cursor parameter.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/__tests__/parse-starting-after-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/__tests__/parse-starting-after-rest-request.util.spec.ts index dae16e00618..444aef83f87 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/__tests__/parse-starting-after-rest-request.util.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/__tests__/parse-starting-after-rest-request.util.spec.ts @@ -1,3 +1,4 @@ +import { RestInputRequestParserExceptionCode } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util'; describe('parseStartingAfterRestRequest', () => { @@ -12,4 +13,33 @@ describe('parseStartingAfterRestRequest', () => { expect(parseStartingAfterRestRequest(request)).toEqual('uuid'); }); + + it('should throw when using wrong pagination parameter name', () => { + const wrongNames = [ + 'cursor', + 'after', + 'lastCursor', + 'last_cursor', + 'startingAfter', + 'page_token', + ]; + + for (const wrongName of wrongNames) { + const request: any = { query: { [wrongName]: 'some-cursor' } }; + + expect(() => parseStartingAfterRestRequest(request)).toThrow( + expect.objectContaining({ + code: RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM, + }), + ); + } + }); + + it('should not throw for wrong names when starting_after is also provided', () => { + const request: any = { + query: { starting_after: 'uuid', cursor: 'ignored' }, + }; + + expect(parseStartingAfterRestRequest(request)).toEqual('uuid'); + }); }); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts index ebd6365c027..c1acb6ab35c 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util.ts @@ -1,12 +1,34 @@ +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; +const STARTING_AFTER_WRONG_PARAM_NAMES = [ + 'cursor', + 'after', + 'lastCursor', + 'last_cursor', + 'startingAfter', + 'page_token', +]; + export const parseStartingAfterRestRequest = ( request: AuthenticatedRequest | RequestContext, ): string | undefined => { const cursorQuery = request.query?.starting_after; if (typeof cursorQuery !== 'string') { + for (const wrongName of STARTING_AFTER_WRONG_PARAM_NAMES) { + if (typeof request.query?.[wrongName] === 'string') { + throw new RestInputRequestParserException( + `Unknown pagination parameter '${wrongName}'. Use 'starting_after' instead`, + RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM, + ); + } + } + return undefined; }