fix(rest): validate unknown query parameters and improve pagination parameter discoverability
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.
This commit is contained in:
+23
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+30
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user