From f24bd19cb7e4fa18b9f287d0be1714b618db9bb4 Mon Sep 17 00:00:00 2001 From: Jonathan Finley Date: Fri, 13 Mar 2026 21:22:38 -0400 Subject: [PATCH] fix: harden browse cursor validation --- server/utils/queries/libraryBrowseCursor.js | 16 ++++++++++++++-- test/server/utils/libraryBrowseCursor.test.js | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/server/utils/queries/libraryBrowseCursor.js b/server/utils/queries/libraryBrowseCursor.js index b7062c429..15a33ff04 100644 --- a/server/utils/queries/libraryBrowseCursor.js +++ b/server/utils/queries/libraryBrowseCursor.js @@ -2,14 +2,26 @@ function encodeBrowseCursor(payload) { return Buffer.from(JSON.stringify(payload)).toString('base64url') } +function invalidBrowseCursor() { + return new Error('Invalid browse cursor') +} + function decodeBrowseCursor(cursor) { - const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + let decoded + + try { + decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + } catch (error) { + throw invalidBrowseCursor() + } if (!Array.isArray(decoded.keys) || !Array.isArray(decoded.values) || decoded.keys.length !== decoded.values.length) { throw new Error('Cursor is missing the full ordered key set') } - if (!decoded.keys.length || decoded.keys[decoded.keys.length - 1] !== 'id') { + const tieBreakerIndex = decoded.keys.length - 1 + + if (!decoded.keys.length || decoded.keys[tieBreakerIndex] !== 'id' || decoded.values[tieBreakerIndex] == null || decoded.values[tieBreakerIndex] === '') { throw new Error('Cursor is missing the tie-breaker value') } diff --git a/test/server/utils/libraryBrowseCursor.test.js b/test/server/utils/libraryBrowseCursor.test.js index b7241fc7a..2a48a1f3b 100644 --- a/test/server/utils/libraryBrowseCursor.test.js +++ b/test/server/utils/libraryBrowseCursor.test.js @@ -40,4 +40,23 @@ describe('libraryBrowseCursor', () => { values: ['Dune'] }))).to.throw('tie-breaker') }) + + it('rejects a cursor when the id tie-breaker value is null', () => { + expect(() => decodeBrowseCursor(encodeBrowseCursor({ + sortBy: 'media.metadata.title', + desc: false, + keys: ['titleIgnorePrefix', 'id'], + values: ['Dune', null] + }))).to.throw('tie-breaker') + }) + + it('rejects a malformed cursor string with a controlled invalid-cursor error', () => { + expect(() => decodeBrowseCursor('%%%not-base64%%%')).to.throw('Invalid browse cursor') + }) + + it('rejects malformed cursor json with a controlled invalid-cursor error', () => { + const malformedJsonCursor = Buffer.from('{"sortBy":', 'utf8').toString('base64url') + + expect(() => decodeBrowseCursor(malformedJsonCursor)).to.throw('Invalid browse cursor') + }) })