From d2f972514940d2b6fcf576e97bd0aaeda0aa3ec5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 10:45:39 +0200 Subject: [PATCH] fix(downloads): restrict snapshot artwork --- .../events/database/download-artwork-url.ts | 79 +++++++++++- .../download-metadata-snapshot.spec.ts | 82 +++++++++++++ .../database/download-metadata-snapshot.ts | 32 ++++- .../database/download-metadata-update.ts | 19 ++- .../database/download-metadata.events.spec.ts | 92 +++++++++++++- .../events/database/download-requests.spec.ts | 112 +++++++++++++++++- .../app/events/database/download-requests.ts | 9 ++ 7 files changed, 406 insertions(+), 19 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 17329dd63..0daae2bd7 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 @@ -1,5 +1,22 @@ -const STREAM_PATH_EXTENSION = - /\.(?:avi|flv|m3u|m3u8|mkv|mov|mp4|mpeg|mpg|mpd|ts|webm|wmv)(?:\/|$)/i; +const IMAGE_PATH_EXTENSION = /\.(?:avif|bmp|gif|jpe?g|png|svg|webp)$/i; + +const TRUSTED_IMAGE_HOSTS = new Set(['image.tmdb.org', 'picsum.photos']); + +const IMAGE_PATH_HINTS = new Set([ + 'backdrop', + 'backdrops', + 'cover', + 'covers', + 'getimage', + 'image', + 'images', + 'logo', + 'logos', + 'poster', + 'posters', + 'still', + 'stills', +]); const CREDENTIAL_QUERY_TERMS = [ 'authentication', @@ -21,7 +38,10 @@ function invalidArtworkUrl(): never { } function isCredentialQueryKey(key: string): boolean { - const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); + return isCredentialToken(normalizeToken(key)); +} + +function isCredentialToken(normalized: string): boolean { if ( normalized === 'auth' || normalized === 'key' || @@ -44,6 +64,10 @@ function isCredentialQueryKey(key: string): boolean { ].some((term) => normalized.includes(term)); } +function normalizeToken(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + function getDecodedPathname(url: URL): string { try { return decodeURIComponent(url.pathname).replace(/\/+$/, ''); @@ -52,29 +76,74 @@ function getDecodedPathname(url: URL): string { } } +function hasPositiveImageSignal(url: URL, decodedPathname: string): boolean { + if ( + IMAGE_PATH_EXTENSION.test(decodedPathname) || + TRUSTED_IMAGE_HOSTS.has(url.hostname) + ) { + return true; + } + const hasImagePathHint = decodedPathname + .split('/') + .filter(Boolean) + .some((segment) => IMAGE_PATH_HINTS.has(normalizeToken(segment))); + if (hasImagePathHint) { + return true; + } + return [...url.searchParams.entries()].some( + ([key, value]) => + ['action', 'method'].includes(normalizeToken(key)) && + ['getimage', 'image'].includes(normalizeToken(value)) + ); +} + +export function getNormalizedDownloadUrlIdentity( + value: string +): string | undefined { + try { + const url = new URL(value.trim()); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return undefined; + } + return url.href; + } catch { + return undefined; + } +} + export function normalizeDownloadArtworkUrl( value: unknown ): string | undefined { if (value === undefined) { return undefined; } - if (typeof value !== 'string' || value.trim() === '') { + if (typeof value !== 'string') { return invalidArtworkUrl(); } const normalized = value.trim(); + if (normalized === '') { + return undefined; + } let url: URL; try { url = new URL(normalized); } catch { return invalidArtworkUrl(); } + const decodedPathname = getDecodedPathname(url); + const hasCredentialPathToken = decodedPathname + .split('/') + .filter(Boolean) + .some((segment) => isCredentialToken(normalizeToken(segment))); if ( (url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '' || + url.hash !== '' || [...url.searchParams.keys()].some(isCredentialQueryKey) || - STREAM_PATH_EXTENSION.test(getDecodedPathname(url)) + hasCredentialPathToken || + !hasPositiveImageSignal(url, decodedPathname) ) { return invalidArtworkUrl(); } 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 4f870ddcf..6023bc702 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 @@ -228,6 +228,55 @@ describe('download metadata snapshot', () => { ); }); + it('accepts trusted extensionless artwork hosts and image endpoints', () => { + const snapshot: DownloadMetadataSnapshot = { + ...validSnapshot, + posterUrl: 'https://image.tmdb.org/t/p/w500/extensionless-poster', + backdropUrl: 'https://picsum.photos/1280/720', + cast: [ + { + name: 'Actor', + profileUrl: + 'https://cdn.example.test/api?action=get_image&id=profile-1', + }, + ], + episode: { + episodeNumber: 2, + seasonNumber: 1, + stillUrl: 'https://cdn.example.test/images/still/episode-2', + }, + }; + + expect(JSON.parse(encodeDownloadMetadataSnapshot(snapshot))).toEqual( + snapshot + ); + }); + + it('normalizes blank optional artwork sentinels as absent', () => { + expect( + JSON.parse( + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + posterUrl: ' ', + backdropUrl: '\t', + cast: [{ name: 'Actor', profileUrl: '\n' }], + episode: { + episodeNumber: 2, + seasonNumber: 1, + stillUrl: ' ', + }, + }) + ) + ).toEqual({ + ...validSnapshot, + cast: [{ name: 'Actor' }], + episode: { + episodeNumber: 2, + seasonNumber: 1, + }, + }); + }); + it.each([ [ 'poster userinfo', @@ -272,6 +321,18 @@ describe('download metadata snapshot', () => { posterUrl: 'https://media.example.test/movie.mp4', }, ], + [ + 'extensionless stream endpoint', + { + posterUrl: 'https://streams.example.test/live/123', + }, + ], + [ + 'URL fragment', + { + posterUrl: 'https://images.example.test/poster.jpg#overview', + }, + ], [ 'relative artwork URL', { @@ -293,6 +354,27 @@ describe('download metadata snapshot', () => { ).toThrow('Invalid download metadata snapshot'); }); + it.each([ + 'access_token', + 'token', + 'password', + 'secret', + 'cookie', + 'session', + 'signature', + 'credential', + 'AUTH', + 'device_mac', + 'api-key', + ])('rejects credential-like artwork path token %s', (pathToken) => { + expect(() => + encodeDownloadMetadataSnapshot({ + ...validSnapshot, + posterUrl: `https://images.example.test/${pathToken}/value/poster.jpg`, + }) + ).toThrow('Invalid download metadata snapshot'); + }); + it.each([ 'token', 'AUTH', diff --git a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts index 5980360bd..02631ac31 100644 --- a/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts +++ b/apps/electron-backend/src/app/events/database/download-metadata-snapshot.ts @@ -3,7 +3,10 @@ import type { DownloadMetadataPerson, DownloadMetadataSnapshot, } from '@iptvnator/shared/interfaces'; -import { normalizeDownloadArtworkUrl } from './download-artwork-url'; +import { + getNormalizedDownloadUrlIdentity, + normalizeDownloadArtworkUrl, +} from './download-artwork-url'; import { assertSafeDownloadMetadataInput } from './download-metadata-input-safety'; export const DOWNLOAD_METADATA_MAX_BYTES = 128 * 1024; @@ -210,6 +213,33 @@ export function assertDownloadMetadataMatchesContentType( } } +export function assertDownloadMetadataArtworkDiffersFromStream( + metadataSnapshot: DownloadMetadataSnapshot, + streamUrl: string +): void { + const streamIdentity = getNormalizedDownloadUrlIdentity(streamUrl); + if (!streamIdentity) { + invalidSnapshot(); + } + const artworkUrls = [ + metadataSnapshot.posterUrl, + metadataSnapshot.backdropUrl, + ...(metadataSnapshot.cast?.map((person) => person.profileUrl) ?? []), + ...(metadataSnapshot.creators?.map((person) => person.profileUrl) ?? + []), + metadataSnapshot.episode?.stillUrl, + ]; + if ( + artworkUrls.some( + (artworkUrl) => + artworkUrl !== undefined && + getNormalizedDownloadUrlIdentity(artworkUrl) === streamIdentity + ) + ) { + invalidSnapshot(); + } +} + export function decodeDownloadMetadataSnapshot( value: string | null | undefined ): DownloadMetadataSnapshot | undefined { diff --git a/apps/electron-backend/src/app/events/database/download-metadata-update.ts b/apps/electron-backend/src/app/events/database/download-metadata-update.ts index 69c7bea78..bd33ad340 100644 --- a/apps/electron-backend/src/app/events/database/download-metadata-update.ts +++ b/apps/electron-backend/src/app/events/database/download-metadata-update.ts @@ -3,6 +3,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; import { + assertDownloadMetadataArtworkDiffersFromStream, assertDownloadMetadataMatchesContentType, decodeDownloadMetadataSnapshot, encodeDownloadMetadataSnapshot, @@ -77,6 +78,10 @@ export async function updateDownloadMetadataRequest( normalizedMetadataSnapshot, row.contentType ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + row.url + ); if ( row.contentType !== 'episode' || @@ -105,12 +110,18 @@ export async function updateDownloadMetadataRequest( .all(); const writes = members.map((member) => { const episode = getStoredEpisode(member); + const memberSnapshot = { + ...normalizedMetadataSnapshot, + episode, + }; + assertDownloadMetadataArtworkDiffersFromStream( + memberSnapshot, + member.url + ); return { id: member.id, - metadataSnapshot: encodeDownloadMetadataSnapshot({ - ...normalizedMetadataSnapshot, - episode, - }), + metadataSnapshot: + encodeDownloadMetadataSnapshot(memberSnapshot), }; }); 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 5173366f0..cf1b45274 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 @@ -15,7 +15,14 @@ const movieSnapshot: DownloadMetadataSnapshot = { }; function createSingleRowDatabase(row: Record | undefined) { - const get = jest.fn(() => row); + const storedRow = + row === undefined + ? undefined + : { + url: 'https://streams.example.test/items/default.mp4', + ...row, + }; + const get = jest.fn(() => storedRow); const limit = jest.fn(() => ({ get })); const transactionSelect = jest.fn(() => ({ from: jest.fn(() => ({ @@ -55,9 +62,17 @@ function createGroupedDatabase( representative: Record, members: Array> ) { - const get = jest.fn(() => representative); + const storedRepresentative = { + url: 'https://streams.example.test/episodes/representative.mp4', + ...representative, + }; + const storedMembers = members.map((member, index) => ({ + url: `https://streams.example.test/episodes/${index + 1}.mp4`, + ...member, + })); + const get = jest.fn(() => storedRepresentative); const limit = jest.fn(() => ({ get })); - const all = jest.fn(() => members); + const all = jest.fn(() => storedMembers); const groupWhere = jest.fn(() => ({ all })); const transactionSelect = jest .fn() @@ -352,6 +367,77 @@ 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'; + const database = createSingleRowDatabase({ + contentType: 'vod', + id: 42, + playlistId: 'playlist-a', + seriesXtreamId: null, + url, + }); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 42, { + ...movieSnapshot, + posterUrl: url, + }) + ).resolves.toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + + expect(database.transaction).toHaveBeenCalledTimes(1); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + }); + + 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 database = createGroupedDatabase( + { + contentType: 'episode', + id: 41, + playlistId: 'playlist-a', + seriesXtreamId: 700, + url: 'https://streams.example.test/episodes/1.mp4', + }, + [ + { + episodeNumber: 1, + id: 41, + metadataSnapshot: null, + seasonNumber: 1, + title: 'One', + url: 'https://streams.example.test/episodes/1.mp4', + }, + { + episodeNumber: 2, + id: 42, + metadataSnapshot: null, + seasonNumber: 1, + title: 'Two', + url: reusedUrl, + }, + ] + ); + + await expect( + getHandler('DOWNLOADS_UPDATE_METADATA')(null, 41, { + ...movieSnapshot, + mediaKind: 'series', + posterUrl: reusedUrl, + }) + ).resolves.toEqual({ + error: 'Invalid download metadata snapshot', + success: false, + }); + + expect(database.transactionSelect).toHaveBeenCalledTimes(2); + expect(database.transactionUpdate).not.toHaveBeenCalled(); + expect(database.db.update).not.toHaveBeenCalled(); + }); + it('rejects a grouped episode carrying movie metadata before loading members', async () => { const database = createGroupedDatabase( { 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 51c66a517..bc327086a 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 @@ -194,13 +194,13 @@ describe('download request metadata snapshots', () => { ); it.each([ - ['vod', 'episode', 'series'], - ['episode', 'vod', 'movie'], + ['episode', 'movie'], + ['vod', 'series'], ] as const)( - 'uses stored %s identity instead of renderer %s identity for restart metadata', - async (storedContentType, rendererContentType, mediaKind) => { + 'rejects a stored %s restart carrying %s metadata', + async (contentType, mediaKind) => { const request = await setupStartMetadataRequest({ - contentType: storedContentType, + contentType, filePath: null, id: 42, metadataSnapshot: null, @@ -215,7 +215,7 @@ describe('download request metadata snapshots', () => { request.startDownloadRequest( startPayload( { ...metadataSnapshot, mediaKind }, - rendererContentType + contentType ), request.authorizer ) @@ -226,6 +226,106 @@ describe('download request metadata snapshots', () => { expect(request.enqueueDownload).not.toHaveBeenCalled(); } ); + + it.each<[string, 'vod' | 'episode', DownloadMetadataSnapshot]>([ + [ + 'poster', + 'vod', + { + ...metadataSnapshot, + posterUrl: 'https://streams.example.test/images/live.jpg', + }, + ], + [ + 'backdrop', + 'vod', + { + ...metadataSnapshot, + backdropUrl: 'https://streams.example.test/images/live.jpg', + }, + ], + [ + 'person profile', + 'vod', + { + ...metadataSnapshot, + cast: [ + { + name: 'Actor', + profileUrl: + 'https://streams.example.test/images/live.jpg', + }, + ], + }, + ], + [ + 'episode still', + 'episode', + { + ...metadataSnapshot, + mediaKind: 'series', + episode: { + episodeNumber: 1, + seasonNumber: 1, + stillUrl: 'https://streams.example.test/images/live.jpg', + }, + }, + ], + ])( + 'rejects a new download whose stream URL is reused as %s artwork', + async (_label, contentType, snapshot) => { + const request = await setupStartMetadataRequest(undefined); + const url = 'https://streams.example.test/images/live.jpg'; + + await expect( + request.startDownloadRequest( + { + ...startPayload(snapshot, contentType), + url, + }, + 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'; + const posterUrl = 'https://streams.example.test/images/live.jpg'; + const request = await setupStartMetadataRequest({ + contentType: 'vod', + filePath: null, + id: 42, + metadataSnapshot: null, + playlistId: 'playlist-1', + status: 'canceled', + title: 'Offline Movie', + url: storedUrl, + xtreamId: 7, + }); + + await expect( + request.startDownloadRequest( + { + ...startPayload({ + ...metadataSnapshot, + posterUrl, + }), + url: 'https://replacement.example.test/movie.mp4', + }, + 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(); + }); }); describe('download requests resume', () => { diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 7ebbb228e..b592dadea 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -7,6 +7,7 @@ import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; import { removePartialDownloadFile } from './download-file-path'; import { + assertDownloadMetadataArtworkDiffersFromStream, assertDownloadMetadataMatchesContentType, decodeDownloadMetadataSnapshot, encodeDownloadMetadataSnapshot, @@ -166,6 +167,10 @@ export async function startDownloadRequest( normalizedMetadataSnapshot, item.contentType ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + item.url + ); } if (!['completed', 'failed', 'canceled'].includes(item.status)) { return { @@ -228,6 +233,10 @@ export async function startDownloadRequest( normalizedMetadataSnapshot, data.contentType ); + assertDownloadMetadataArtworkDiffersFromStream( + normalizedMetadataSnapshot, + data.url + ); } if (existingPlaylist.length === 0) { console.log(