fix(sources): search URLs nested in path segments for name secrets

A proxy URL can carry a whole source URL, or a token pair, in one path
segment. The display stripper already cleans such segments; the name
check now collects their secrets too, so a name holding them collapses
to the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 22:49:03 +02:00
1 parent 8323314853
commit ea5bca7e96
2 files changed
+44 -23

No files matched your search

@@ -102,6 +102,22 @@ describe('sourceDisplayName', () => {
expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe(
'host.tv'
);
expect(
sourceDisplayName({
title: 'My hunter2',
url: 'https://host.test/live/joe/hunter2/list.m3u',
})
).toBe('host.test');
});
it('drops a name holding a secret of a URL nested in a path segment', () => {
const url = `https://proxy.test/fetch/${encodeURIComponent(
'http://origin.test/live/joe/hunter2/1.ts'
)}/token=tk98765`;
for (const title of ['Mine hunter2', 'Mine joe', 'Mine tk98765']) {
expect(sourceDisplayName({ title, url })).toBe('proxy.test');
}
expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine');
});
it('returns null for a local file without a name', () => {
@@ -76,10 +76,11 @@ export function sourceHost(
const MAX_SECRET_NESTING = 4;
/**
* The secrets a URL carries: its userinfo password, every sensitive query or
* fragment parameter (the source's own account name excepted), and the same
* again in any URL or parameter list nested in another parameter, whose
* account names count as secrets too.
* The secrets a URL carries: its userinfo password, the password of an
* Xtream stream path, every sensitive query or fragment parameter (the
* source's own account name excepted), and the same again in any URL or
* parameter list nested in a parameter or path segment, whose account names
* count as secrets too.
*/
function collectUrlSecrets(
url: URL,
@@ -93,18 +94,14 @@ function collectUrlSecrets(
if (depth > 0) {
secrets.push(safeDecode(url.username));
}
// An Xtream stream path carries the account: `/live/<user>/<password>/`.
const segments = url.pathname.split('/').map(safeDecode);
for (let index = 0; index + 2 < segments.length; index += 1) {
if (
XTREAM_CREDENTIAL_PATH_SEGMENTS.has(segments[index].toLowerCase())
) {
secrets.push(segments[index + 2]);
if (depth > 0) {
secrets.push(segments[index + 1]);
}
const visitNested = (value: string): void => {
const nested = ABSOLUTE_URL.test(value.trim()) ? parseUrl(value) : null;
if (nested) {
collectUrlSecrets(nested, secrets, depth + 1);
} else if (value.includes('=')) {
visit(new URLSearchParams(value));
}
}
};
const visit = (params: URLSearchParams): void =>
params.forEach((value, key) => {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -114,15 +111,23 @@ function collectUrlSecrets(
}
return;
}
const nested = ABSOLUTE_URL.test(value.trim())
? parseUrl(value)
: null;
if (nested) {
collectUrlSecrets(nested, secrets, depth + 1);
} else if (value.includes('=')) {
visit(new URLSearchParams(value));
}
visitNested(value);
});
// An Xtream stream path carries the account: `/live/<user>/<password>/`.
// A proxy may carry a whole URL in one segment.
const segments = url.pathname.split('/').map(safeDecode);
segments.forEach((segment, index) => {
if (
XTREAM_CREDENTIAL_PATH_SEGMENTS.has(segment.toLowerCase()) &&
index + 2 < segments.length
) {
secrets.push(segments[index + 2]);
if (depth > 0) {
secrets.push(segments[index + 1]);
}
}
visitNested(segment);
});
visit(url.searchParams);
const fragment = url.hash.slice(1);
if (fragment.includes('=')) {