mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
fix(downloads): restrict snapshot artwork
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user