diff --git a/src/utils/cursor.utils.ts b/src/utils/cursor.utils.ts index 89dd7b3..78647bc 100644 --- a/src/utils/cursor.utils.ts +++ b/src/utils/cursor.utils.ts @@ -51,34 +51,41 @@ export function decodeCursor(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; diff --git a/src/utils/test/cursor.utils.test.ts b/src/utils/test/cursor.utils.test.ts index a32a848..5f0c64c 100644 --- a/src/utils/test/cursor.utils.test.ts +++ b/src/utils/test/cursor.utils.test.ts @@ -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(legacyCursor); + expect(decoded).toEqual(samplePayload); + }); + it('should throw CursorChecksumError when checksum is tampered', () => { const cursor = encodeCursor(samplePayload); const [payload, checksum] = cursor.split('.');