fix(downloads): restrict snapshot artwork

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 0cb7df6809
commit d2f9725149
7 files changed
+406 -19

No files matched your search

@@ -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();
}
@@ -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',
@@ -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 {
@@ -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),
};
});
@@ -15,7 +15,14 @@ const movieSnapshot: DownloadMetadataSnapshot = {
};
function createSingleRowDatabase(row: Record<string, unknown> | 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<string, unknown>,
members: Array<Record<string, unknown>>
) {
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(
{
@@ -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', () => {
@@ -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(