From 1d20a366407e24e48290892937a2a57d1b001d87 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 10:56:59 +0200 Subject: [PATCH] fix(downloads): refine artwork URL checks --- .../events/database/download-artwork-url.ts | 44 +++++++++++++++++-- .../download-metadata-snapshot.spec.ts | 18 ++++++++ .../database/download-metadata.events.spec.ts | 14 +++--- .../events/database/download-requests.spec.ts | 30 +++++++++++-- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/apps/electron-backend/src/app/events/database/download-artwork-url.ts b/apps/electron-backend/src/app/events/database/download-artwork-url.ts index 0daae2bd7..4ae636492 100644 --- a/apps/electron-backend/src/app/events/database/download-artwork-url.ts +++ b/apps/electron-backend/src/app/events/database/download-artwork-url.ts @@ -33,15 +33,43 @@ const CREDENTIAL_QUERY_TERMS = [ 'token', ]; +const CREDENTIAL_PATH_KEYS = new Set([ + 'accesskey', + 'accesstoken', + 'apikey', + 'auth', + 'authentication', + 'authorization', + 'cookie', + 'credential', + 'credentials', + 'devicemac', + 'key', + 'mac', + 'macaddress', + 'oauth', + 'password', + 'passwd', + 'privatekey', + 'refreshtoken', + 'secret', + 'secretkey', + 'session', + 'sig', + 'signature', + 'signingkey', + 'token', +]); + function invalidArtworkUrl(): never { throw new Error('Invalid download metadata snapshot'); } function isCredentialQueryKey(key: string): boolean { - return isCredentialToken(normalizeToken(key)); + return isCredentialQueryToken(normalizeToken(key)); } -function isCredentialToken(normalized: string): boolean { +function isCredentialQueryToken(normalized: string): boolean { if ( normalized === 'auth' || normalized === 'key' || @@ -64,6 +92,15 @@ function isCredentialToken(normalized: string): boolean { ].some((term) => normalized.includes(term)); } +function isCredentialPathSegment(segment: string): boolean { + const keyValueSeparator = segment.search(/[=:]/); + const key = + keyValueSeparator === -1 + ? segment + : segment.slice(0, keyValueSeparator); + return CREDENTIAL_PATH_KEYS.has(normalizeToken(key)); +} + function normalizeToken(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]/g, ''); } @@ -105,6 +142,7 @@ export function getNormalizedDownloadUrlIdentity( if (url.protocol !== 'http:' && url.protocol !== 'https:') { return undefined; } + url.hash = ''; return url.href; } catch { return undefined; @@ -135,7 +173,7 @@ export function normalizeDownloadArtworkUrl( const hasCredentialPathToken = decodedPathname .split('/') .filter(Boolean) - .some((segment) => isCredentialToken(normalizeToken(segment))); + .some(isCredentialPathSegment); if ( (url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts index 6023bc702..f2790d3b8 100644 --- a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.spec.ts @@ -252,6 +252,22 @@ describe('download metadata snapshot', () => { ); }); + it('accepts artwork filenames containing title-like credential words', () => { + const snapshot: DownloadMetadataSnapshot = { + ...validSnapshot, + posterUrl: 'https://images.example.test/posters/secret-garden.jpg', + episode: { + episodeNumber: 9, + seasonNumber: 1, + stillUrl: 'https://images.example.test/stills/session-9.jpg', + }, + }; + + expect(JSON.parse(encodeDownloadMetadataSnapshot(snapshot))).toEqual( + snapshot + ); + }); + it('normalizes blank optional artwork sentinels as absent', () => { expect( JSON.parse( @@ -366,6 +382,8 @@ describe('download metadata snapshot', () => { 'AUTH', 'device_mac', 'api-key', + 'access_token=secret', + 'token:abc', ])('rejects credential-like artwork path token %s', (pathToken) => { expect(() => encodeDownloadMetadataSnapshot({ diff --git a/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts b/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts index cf1b45274..80179b758 100644 --- a/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-metadata.events.spec.ts @@ -367,20 +367,20 @@ describe('downloads events: managed metadata updates', () => { } ); - it('rejects metadata artwork that reuses the stored stream URL', async () => { - const url = 'https://streams.example.test/images/live.jpg'; + it('rejects metadata artwork when the stored stream differs only by a fragment', async () => { + const artworkUrl = 'https://streams.example.test/images/live.jpg'; const database = createSingleRowDatabase({ contentType: 'vod', id: 42, playlistId: 'playlist-a', seriesXtreamId: null, - url, + url: `${artworkUrl}#player`, }); await expect( getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, { ...movieSnapshot, - posterUrl: url, + posterUrl: artworkUrl, }) ).resolves.toEqual({ error: 'Invalid download metadata snapshot', @@ -393,7 +393,7 @@ describe('downloads events: managed metadata updates', () => { }); it('rejects a group update before writing when artwork reuses a member stream URL', async () => { - const reusedUrl = 'https://streams.example.test/images/episode-2.jpg'; + const artworkUrl = 'https://streams.example.test/images/episode-2.jpg'; const database = createGroupedDatabase( { contentType: 'episode', @@ -417,7 +417,7 @@ describe('downloads events: managed metadata updates', () => { metadataSnapshot: null, seasonNumber: 1, title: 'Two', - url: reusedUrl, + url: `${artworkUrl}#player`, }, ] ); @@ -426,7 +426,7 @@ describe('downloads events: managed metadata updates', () => { getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, { ...movieSnapshot, mediaKind: 'series', - posterUrl: reusedUrl, + posterUrl: artworkUrl, }) ).resolves.toEqual({ error: 'Invalid download metadata snapshot', diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index fc4640519..4f6cd4650 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -293,9 +293,31 @@ describe('download request metadata snapshots', () => { } ); + it('rejects a new download when its stream differs from artwork only by a fragment', async () => { + const artworkUrl = 'https://streams.example.test/images/live.jpg'; + const request = await setupStartMetadataRequest(undefined); + + await expect( + request.startDownloadRequest( + { + ...startPayload({ + ...metadataSnapshot, + posterUrl: artworkUrl, + }), + url: `${artworkUrl}#player`, + }, + request.authorizer + ) + ).rejects.toThrow('Invalid download metadata snapshot'); + + expect(request.db.insert).not.toHaveBeenCalled(); + expect(request.db.update).not.toHaveBeenCalled(); + expect(request.enqueueDownload).not.toHaveBeenCalled(); + }); + it('rejects a restart whose poster reuses the normalized stored stream URL', async () => { const storedUrl = - 'https://STREAMS.example.test:443/images/section/../live.jpg'; + 'https://STREAMS.example.test:443/images/section/../live.jpg#player'; const posterUrl = 'https://streams.example.test/images/live.jpg'; const request = await setupStartMetadataRequest({ contentType: 'vod', @@ -328,8 +350,8 @@ describe('download request metadata snapshots', () => { }); it('rejects a restart whose poster reuses the replacement stream URL', async () => { - const replacementUrl = - 'https://replacement.example.test/images/live.jpg'; + const artworkUrl = 'https://replacement.example.test/images/live.jpg'; + const replacementUrl = `${artworkUrl}#player`; const request = await setupStartMetadataRequest({ contentType: 'vod', filePath: null, @@ -347,7 +369,7 @@ describe('download request metadata snapshots', () => { { ...startPayload({ ...metadataSnapshot, - posterUrl: replacementUrl, + posterUrl: artworkUrl, }), url: replacementUrl, },