diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts index d9c3001c4..0b432e309 100644 --- a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts @@ -111,6 +111,25 @@ describe('sourceDisplayName', () => { } }); + it('drops a name holding a secret of a JSON config in the URL', () => { + for (const json of [ + '{"password":"hunter2"}', + '{"pass\\u0077ord":"hunter2"}', + '{"proxy":{"upstream":"http://joe:hunter2@origin.test/a"}}', + ]) { + const url = `https://proxy.test/list?config=${encodeURIComponent(json)}`; + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'proxy.test' + ); + } + expect( + sourceDisplayName({ + title: 'Mine', + url: 'https://proxy.test/list?config=%7B%22hd%22%3A1%7D', + }) + ).toBe('Mine'); + }); + it('keeps a name that only shares a letter with a decoded password', () => { // `%2542` is the password as written; decoded it is just `B`. const url = 'https://host.test/list?password=%2542'; diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.ts index 502a32211..949598abe 100644 --- a/libs/playlist/shared/util/src/lib/source-display/source-display.ts +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.ts @@ -73,6 +73,14 @@ export function sourceHost( return parseUrl(sourceEndpoint(playlist))?.host || null; } +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + /** How deep URLs nested in the source URL are searched for secrets. */ const MAX_SECRET_NESTING = 4; @@ -100,6 +108,10 @@ function collectUrlSecrets( if (value === null) { return; } + if (/^\s*[{[]/.test(value)) { + visitJson(parseJson(value), 0); + return; + } const nested = ABSOLUTE_URL.test(value.trim()) ? parseUrl(value) : null; if (nested) { collectUrlSecrets(nested, secrets, depth + 1); @@ -107,6 +119,28 @@ function collectUrlSecrets( visit(new URLSearchParams(value)); } }; + // A JSON config in a parameter: values under sensitive keys are + // secrets, and any other string may be a URL or a parameter list. + const visitJson = (value: unknown, level: number): void => { + if (level >= MAX_SECRET_NESTING) { + return; + } + if (typeof value === 'string') { + visitNested(value); + } else if (Array.isArray(value)) { + value.forEach((item) => visitJson(item, level + 1)); + } else if (value && typeof value === 'object') { + for (const [key, inner] of Object.entries(value)) { + const scalar = + typeof inner === 'string' || typeof inner === 'number'; + if (scalar && isDisplaySensitiveKey(key)) { + secrets.push(String(inner)); + } else { + visitJson(inner, level + 1); + } + } + } + }; const visit = (params: URLSearchParams): void => params.forEach((value, key) => { const normalized = decodeFully(key) diff --git a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts index fe73a1bc0..a7a335579 100644 --- a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts +++ b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts @@ -118,6 +118,31 @@ describe('stripUrlCredentials', () => { ).toBe('https://cdn.test/files%2Flive%2Flist.m3u'); }); + it('drops a JSON value and a quoted key that name a secret', () => { + for (const json of [ + '{"password":"hunter2"}', + '{"pass\\u0077ord":"hunter2"}', + '[{"auth":{"token":"hunter2"}}]', + ]) { + expect( + stripUrlCredentials( + `https://proxy.test/list?config=${encodeURIComponent(json)}&hd=1` + ) + ).toBe('https://proxy.test/list?hd=1'); + } + expect( + stripUrlCredentials( + `https://proxy.test/list?note=${encodeURIComponent( + "see 'token': 'hunter2'" + )}&hd=1` + ) + ).toBe('https://proxy.test/list?hd=1'); + // Brackets that are not JSON stay: `[1080p]` is a folder name. + expect(stripUrlCredentials('https://cdn.test/[1080p]/list.m3u')).toBe( + 'https://cdn.test/[1080p]/list.m3u' + ); + }); + it('cleans a fragment that holds a URL rather than parameters', () => { expect( stripUrlCredentials( diff --git a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts index f5729c1d0..b994ed940 100644 --- a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts +++ b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts @@ -22,7 +22,10 @@ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]{0,31}:\/\//iu; const EMBEDDED_URL = /(?]+/giu; /** `joe:hunter2@origin.test`: an authority's userinfo without a scheme. */ const BARE_USERINFO = /(?:^|[\s/])[^\s/:@]{1,256}:[^\s/@]{0,256}@[^\s/@]/u; -const KEYED_PAIR = /(?