Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code cefb580e61 fix(rest-api): warn or alias cursor param to starting_after in pagination
https://sonarly.com/issue/30901?type=bug

The REST API's cursor-based pagination uses `starting_after` as the query parameter name, but the API silently ignores any unrecognized parameter (including `cursor`), causing the first page to be returned repeatedly when users guess the wrong parameter name.

Fix: The REST API silently ignores unrecognized query parameters like `cursor`, `after`, and `last_cursor`, causing users who guess the wrong pagination parameter name to get stuck in an infinite loop receiving the same first page repeatedly.

**Changes:**

1. **`parse-starting-after-rest-request.util.ts`** — After checking for the correct `starting_after` parameter, the parser now checks for common cursor aliases (`cursor`, `after`, `last_cursor`). If any of these are found as string values in the query, it throws a `RestInputRequestParserException` with a clear error message: `"'cursor' is not a valid query parameter. Use 'starting_after' for forward pagination or 'ending_before' for backward pagination."` When the correct `starting_after` is present, the alias check is skipped entirely (no behavioral change for correct usage).

2. **`parse-ending-before-rest-request.util.ts`** — Same pattern for the `ending_before` parameter, detecting the `before` alias.

3. **`rest-input-request-parser.exception.ts`** — Added `INVALID_CURSOR_QUERY_PARAM` to the exception code enum with a corresponding user-friendly message, following the exact same pattern as all other exception codes.

4. **Tests updated** for both parsers to verify the new alias detection behavior, including edge cases (alias present alongside correct param should not throw).

This fix turns a silent failure (infinite pagination loop with no feedback) into an immediate 400 error with actionable guidance, which is the standard pattern used by all other REST API input parsers in this codebase.
2026-04-25 04:47:45 +00:00
5 changed files with 97 additions and 8 deletions
@@ -1,7 +1,8 @@
import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
describe('parseEndingBeforeRestRequest', () => {
it('should return default if ending_before missing', () => {
it('should return undefined if ending_before missing', () => {
const request: any = { query: {} };
expect(parseEndingBeforeRestRequest(request)).toEqual(undefined);
@@ -12,4 +13,23 @@ describe('parseEndingBeforeRestRequest', () => {
expect(parseEndingBeforeRestRequest(request)).toEqual('uuid');
});
it('should throw when before is used instead of ending_before', () => {
const request: any = { query: { before: 'uuid' } };
expect(() => parseEndingBeforeRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseEndingBeforeRestRequest(request)).toThrow(
"'before' is not a valid query parameter. Use 'ending_before' for backward pagination or 'starting_after' for forward pagination.",
);
});
it('should not throw when ending_before is provided alongside an alias', () => {
const request: any = {
query: { ending_before: 'uuid', before: 'other' },
};
expect(parseEndingBeforeRestRequest(request)).toEqual('uuid');
});
});
@@ -1,14 +1,29 @@
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_ALIASES = ['before'];
export const parseEndingBeforeRestRequest = (
request: AuthenticatedRequest | RequestContext,
): string | undefined => {
const cursorQuery = request.query?.ending_before;
if (typeof cursorQuery !== 'string') {
return undefined;
if (typeof cursorQuery === 'string') {
return cursorQuery;
}
return cursorQuery;
for (const alias of ENDING_BEFORE_ALIASES) {
if (typeof request.query?.[alias] === 'string') {
throw new RestInputRequestParserException(
`'${alias}' is not a valid query parameter. Use 'ending_before' for backward pagination or 'starting_after' for forward pagination.`,
RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM,
);
}
}
return undefined;
};
@@ -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);
}
@@ -1,7 +1,8 @@
import { RestInputRequestParserException } 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', () => {
it('should return default if starting_after missing', () => {
it('should return undefined if starting_after missing', () => {
const request: any = { query: {} };
expect(parseStartingAfterRestRequest(request)).toEqual(undefined);
@@ -12,4 +13,39 @@ describe('parseStartingAfterRestRequest', () => {
expect(parseStartingAfterRestRequest(request)).toEqual('uuid');
});
it('should throw when cursor is used instead of starting_after', () => {
const request: any = { query: { cursor: 'uuid' } };
expect(() => parseStartingAfterRestRequest(request)).toThrow(
RestInputRequestParserException,
);
expect(() => parseStartingAfterRestRequest(request)).toThrow(
"'cursor' is not a valid query parameter. Use 'starting_after' for forward pagination or 'ending_before' for backward pagination.",
);
});
it('should throw when after is used instead of starting_after', () => {
const request: any = { query: { after: 'uuid' } };
expect(() => parseStartingAfterRestRequest(request)).toThrow(
RestInputRequestParserException,
);
});
it('should throw when last_cursor is used instead of starting_after', () => {
const request: any = { query: { last_cursor: 'uuid' } };
expect(() => parseStartingAfterRestRequest(request)).toThrow(
RestInputRequestParserException,
);
});
it('should not throw when starting_after is provided alongside an alias', () => {
const request: any = {
query: { starting_after: 'uuid', cursor: 'other' },
};
expect(parseStartingAfterRestRequest(request)).toEqual('uuid');
});
});
@@ -1,14 +1,29 @@
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_ALIASES = ['cursor', 'after', 'last_cursor'];
export const parseStartingAfterRestRequest = (
request: AuthenticatedRequest | RequestContext,
): string | undefined => {
const cursorQuery = request.query?.starting_after;
if (typeof cursorQuery !== 'string') {
return undefined;
if (typeof cursorQuery === 'string') {
return cursorQuery;
}
return cursorQuery;
for (const alias of STARTING_AFTER_ALIASES) {
if (typeof request.query?.[alias] === 'string') {
throw new RestInputRequestParserException(
`'${alias}' is not a valid query parameter. Use 'starting_after' for forward pagination or 'ending_before' for backward pagination.`,
RestInputRequestParserExceptionCode.INVALID_CURSOR_QUERY_PARAM,
);
}
}
return undefined;
};