Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 28 additions & 21 deletions src/utils/cursor.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,34 +51,41 @@ export function decodeCursor<T>(cursor: string): T {
}

const parts = cursor.split('.');
if (parts.length !== 2) {
if (parts.length > 2) {
throw new CursorChecksumError('Invalid cursor format. Expected base64payload.checksum');
}

const [base64Payload, providedChecksum] = parts;

if (!base64Payload || !providedChecksum) {
throw new CursorChecksumError('Cursor payload or checksum cannot be empty');
if (!base64Payload) {
throw new CursorChecksumError('Cursor payload cannot be empty');
}

const expectedChecksum = generateCursorChecksum(base64Payload);

// Use timing-safe equal to prevent timing attacks comparing checksums
let expectedBuffer: Buffer;
let providedBuffer: Buffer;

try {
expectedBuffer = Buffer.from(expectedChecksum, 'hex');
providedBuffer = Buffer.from(providedChecksum, 'hex');
} catch {
throw new CursorChecksumError('Invalid checksum format');
}

if (
expectedBuffer.length !== providedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, providedBuffer)
) {
throw new CursorChecksumError('Cursor checksum mismatch');
// Backward compatibility: allow parsing without checksum if no dot was present
if (providedChecksum !== undefined) {
if (!providedChecksum) {
throw new CursorChecksumError('Cursor checksum cannot be empty if signature separator is present');
}

const expectedChecksum = generateCursorChecksum(base64Payload);

// Use timing-safe equal to prevent timing attacks comparing checksums
let expectedBuffer: Buffer;
let providedBuffer: Buffer;

try {
expectedBuffer = Buffer.from(expectedChecksum, 'hex');
providedBuffer = Buffer.from(providedChecksum, 'hex');
} catch {
throw new CursorChecksumError('Invalid checksum format');
}

if (
expectedBuffer.length !== providedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, providedBuffer)
) {
throw new CursorChecksumError('Cursor checksum mismatch');
}
}

let payloadStr: string;
Expand Down
9 changes: 7 additions & 2 deletions src/utils/test/cursor.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ describe('Cursor Utils', () => {
expect(decoded).toEqual(samplePayload);
});

it('should throw CursorChecksumError for invalid formats', () => {
expect(() => decodeCursor('not_a_valid_cursor')).toThrow(CursorChecksumError);
it('should throw CursorChecksumError for invalid formats with multiple dots', () => {
expect(() => decodeCursor('foo.bar.baz')).toThrow(CursorChecksumError);
});

it('should correctly decode a valid legacy cursor without a checksum', () => {
const legacyCursor = Buffer.from(JSON.stringify(samplePayload)).toString('base64url');
const decoded = decodeCursor<typeof samplePayload>(legacyCursor);
expect(decoded).toEqual(samplePayload);
});

it('should throw CursorChecksumError when checksum is tampered', () => {
const cursor = encodeCursor(samplePayload);
const [payload, checksum] = cursor.split('.');
Expand Down
Loading