fix(downloads): refine artwork URL checks

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 3e7de1ebc9
commit 1d20a36640
4 files changed
+92 -14

No files matched your search

@@ -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 !== '' ||
@@ -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({
@@ -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',
@@ -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,
},