From 64271ba50f3ab867ed814d698e14a785540c6f0f Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 00:31:37 +0200 Subject: [PATCH] fix(sources): read Xtream paths decoded; keep short decoded forms out - An Xtream account could hide behind encoded separators (`live%2Fjoe%2Fhunter2%2F1.ts` is one raw segment). `findXtreamAccount` reads the path as decoded parts, maps them back to raw segments, and is the one scanner for the log redactor, the on-screen URL and the name check. Account segments go whole, joined ones included. - The name check required a minimum length only of the raw secret, so a decoded form as short as one letter (`%2542` is `B`) hid ordinary names. Each form must now be long enough on its own. - The decoding helpers move to `url-credential-parts.ts`, which keeps the redactor under the line limit. Co-Authored-By: Claude Opus 5.5 --- .../lib/source-display/source-display.spec.ts | 14 ++++ .../src/lib/source-display/source-display.ts | 38 +++++---- .../strip-url-credentials.spec.ts | 14 ++++ .../source-display/strip-url-credentials.ts | 34 +++----- libs/shared/logging/src/index.ts | 4 +- .../src/lib/redact-sensitive-data.spec.ts | 19 ++++- .../logging/src/lib/redact-sensitive-data.ts | 53 +----------- .../logging/src/lib/url-credential-parts.ts | 82 +++++++++++++++++++ 8 files changed, 167 insertions(+), 91 deletions(-) create mode 100644 libs/shared/logging/src/lib/url-credential-parts.ts 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 8a3448043..b240e11ff 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 @@ -100,6 +100,20 @@ describe('sourceDisplayName', () => { ); }); + it('drops a name holding an account hidden behind encoded separators', () => { + const url = 'https://origin.test/live%2Fjoe%2Fhunter2%2F1.ts'; + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'origin.test' + ); + }); + + 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'; + expect(sourceDisplayName({ title: 'Bay News', url })).toBe('Bay News'); + expect(sourceDisplayName({ title: 'Mine %42', url })).toBe('host.test'); + }); + it('drops a name holding a password under a short account key', () => { for (const key of ['pass', 'pas', 'PASS']) { const url = `https://host.test/list?user=joe&${key}=hunter2`; 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 12d2aa5e3..502a32211 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 @@ -4,10 +4,9 @@ import { playlistNameHasCredentials, resolvePlaylistSourceKind, } from '@iptvnator/shared/interfaces'; -import { decodeFully } from '@iptvnator/shared/logging'; +import { decodeFully, findXtreamAccount } from '@iptvnator/shared/logging'; import { isDisplaySensitiveKey, - isXtreamAccountPath, stripUrlCredentials, unwrapNestedValue, } from './strip-url-credentials'; @@ -123,17 +122,16 @@ function collectUrlSecrets( }); // An Xtream stream path carries the account: // `/live///`. - // A proxy may carry a whole URL in one segment. - const segments = url.pathname.split('/').map(safeDecode); - segments.forEach((segment, index) => { - if (isXtreamAccountPath(segments, index)) { - secrets.push(segments[index + 2]); - if (depth > 0) { - secrets.push(segments[index + 1]); - } + const segments = url.pathname.split('/'); + const { accounts } = findXtreamAccount(segments, { streamFollows: true }); + for (const [user, password] of accounts) { + secrets.push(password); + if (depth > 0) { + secrets.push(user); } - visitNested(segment); - }); + } + // A proxy may carry a whole URL in one segment. + segments.map(safeDecode).forEach(visitNested); visit(url.searchParams); // A fragment may hold a parameter list or a whole URL. visitNested(url.hash.slice(1)); @@ -152,9 +150,19 @@ function sourceSecrets(playlist: SourceDisplayFields): string[] { } const shown: string[] = []; for (const secret of secrets) { - if (secret && secret.length >= MIN_SECRET_LENGTH) { - // A value encoded more than once reads as itself in a name. - shown.push(secret, encodeURIComponent(secret), decodeFully(secret)); + if (!secret) { + continue; + } + // A value encoded more than once reads as itself in a name. Each + // form must be long enough on its own: `%2542` decodes to `B`. + for (const form of [ + secret, + encodeURIComponent(secret), + decodeFully(secret), + ]) { + if (form.length >= MIN_SECRET_LENGTH) { + shown.push(form); + } } } return shown; 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 e8e591ed7..24c51e6df 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 @@ -101,6 +101,20 @@ describe('stripUrlCredentials', () => { ).not.toMatch(/hunter2|joe/); }); + it('drops an Xtream account hidden behind encoded separators', () => { + for (const url of [ + 'https://origin.test/live%2Fjoe%2Fhunter2%2F1.ts', + 'https://origin.test/live%2Fjoe/hunter2/1.ts', + 'https://origin.test/live%252Fjoe%252Fhunter2%252F1.ts', + ]) { + expect(stripUrlCredentials(url)).not.toMatch(/hunter2|joe/); + } + // Separators alone are no account: the path stays as written. + expect( + stripUrlCredentials('https://cdn.test/files%2Flive%2Flist.m3u') + ).toBe('https://cdn.test/files%2Flive%2Flist.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 c626b32cf..f5729c1d0 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 @@ -1,7 +1,7 @@ import { decodeFully, + findXtreamAccount, isSensitiveKey, - isXtreamCredentialMarker, looksLikeSearchParams, } from '@iptvnator/shared/logging'; @@ -56,20 +56,6 @@ export function unwrapNestedValue(value: string): string | null { } } -/** - * The two segments after a `/live/`-style marker are an Xtream account only - * when a stream follows them (`/live///.ts`); a playlist - * path such as `/live/news/list.m3u` names no account. - */ -export function isXtreamAccountPath( - segments: readonly string[], - index: number -): boolean { - return ( - isXtreamCredentialMarker(segments[index]) && index + 3 < segments.length - ); -} - /** * A parameter whose value must not be on screen: every key the log redactor * treats as sensitive, plus the short account keys (`pass`, `usr`, …). @@ -157,21 +143,21 @@ function stripUrl(value: string, depth: number): string | null { url.username = ''; url.password = ''; + // The account segments of an Xtream stream path go whole, however the + // marker is spelled and even when encoded separators join them into one. const segments = url.pathname.split('/'); - const decodedSegments = segments.map(decodeSegment); + const account = findXtreamAccount(segments, { streamFollows: true }); const kept: string[] = []; - for (let index = 0; index < segments.length; index += 1) { - const segment = segments[index]; - const decoded = decodedSegments[index]; + segments.forEach((segment, index) => { + if (account.segments.has(index)) { + return; + } + const decoded = decodeSegment(segment); const clean = sanitizeNested(decoded, depth); if (clean !== null) { kept.push(clean === decoded ? segment : encodeURIComponent(clean)); } - // `/LIVE/…` and `/%6Cive/…` mark account segments as `/live/…` does. - if (isXtreamAccountPath(decodedSegments, index)) { - index += 2; - } - } + }); url.pathname = kept.join('/'); const search = withoutSensitiveParams(url.searchParams, depth); diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts index 8c6c4977f..75735bbe7 100644 --- a/libs/shared/logging/src/index.ts +++ b/libs/shared/logging/src/index.ts @@ -1,11 +1,11 @@ export { - decodeFully, isSensitiveKey, - isXtreamCredentialMarker, looksLikeSearchParams, REDACTED_VALUE, redactSensitiveData, } from './lib/redact-sensitive-data'; +export { decodeFully, findXtreamAccount } from './lib/url-credential-parts'; +export type { XtreamAccountScan } from './lib/url-credential-parts'; export type { RedactionOptions } from './lib/redact-sensitive-data'; export { SQL_TRACE_STATEMENT_TYPE, diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts index 3eb568257..536d7beb1 100644 --- a/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts +++ b/libs/shared/logging/src/lib/redact-sensitive-data.spec.ts @@ -1,9 +1,9 @@ import { - decodeFully, isSensitiveKey, REDACTED_VALUE, redactSensitiveData, } from './redact-sensitive-data'; +import { decodeFully } from './url-credential-parts'; const TEST_SECRETS = [ 'settings-api-key-secret', @@ -330,6 +330,23 @@ describe('redactSensitiveData', () => { expect(output).toContain('hd=1'); }); + it('redacts an Xtream account hidden behind encoded separators', () => { + const output = serialized( + redactSensitiveData([ + 'https://example.com/live%2Fsep-user-secret%2Fsep-pass-secret%2F1.ts', + 'https://example.com/live%252Fsep2-user-secret/sep2-pass-secret/1.ts', + ]) + ); + for (const secret of [ + 'sep-user-secret', + 'sep-pass-secret', + 'sep2-user-secret', + 'sep2-pass-secret', + ]) { + expect(output).not.toContain(secret); + } + }); + it('decodes within a bound and leaves a stray percent sign alone', () => { expect(decodeFully('%25252570')).toBe('p'); expect(decodeFully('100%')).toBe('100%'); diff --git a/libs/shared/logging/src/lib/redact-sensitive-data.ts b/libs/shared/logging/src/lib/redact-sensitive-data.ts index ec6726002..46b1927f9 100644 --- a/libs/shared/logging/src/lib/redact-sensitive-data.ts +++ b/libs/shared/logging/src/lib/redact-sensitive-data.ts @@ -1,3 +1,5 @@ +import { decodeFully, findXtreamAccount } from './url-credential-parts'; + export const REDACTED_VALUE = '[Redacted]'; const CIRCULAR_VALUE = '[Circular]'; @@ -54,50 +56,6 @@ const SENSITIVE_KEY_SUFFIXES = [ 'username', ]; -const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet = new Set([ - 'live', - 'movie', - 'series', - 'timeshift', -]); -/** Percent-decoding rounds before a name is compared. */ -const MAX_DECODE_ROUNDS = 4; - -/** - * A name as it reads once every percent-encoding layer is undone, within a - * bound: `%2570assword` is `password`, and `%256Cive` is `live`. Every - * check that compares a key or marker by name must see this form, or one - * more encoding layer slips a secret past it. A malformed escape (`100%`) - * ends the decoding as written. - */ -export function decodeFully(value: string): string { - let current = value; - for ( - let round = 0; - round < MAX_DECODE_ROUNDS && current.includes('%'); - round += 1 - ) { - let next: string; - try { - next = decodeURIComponent(current); - } catch { - break; - } - if (next === current) { - break; - } - current = next; - } - return current; -} - -/** A path segment that precedes Xtream account segments, in any spelling. */ -export function isXtreamCredentialMarker(segment: string): boolean { - return XTREAM_CREDENTIAL_PATH_SEGMENTS.has( - decodeFully(segment).toLowerCase() - ); -} - export interface RedactionOptions { maxDepth?: number; maxArrayItems?: number; @@ -183,11 +141,8 @@ function redactUrl( } const pathSegments = redacted.pathname.split('/'); - for (let index = 0; index < pathSegments.length - 2; index += 1) { - if (isXtreamCredentialMarker(pathSegments[index])) { - pathSegments[index + 1] = REDACTED_VALUE; - pathSegments[index + 2] = REDACTED_VALUE; - } + for (const index of findXtreamAccount(pathSegments).segments) { + pathSegments[index] = REDACTED_VALUE; } redacted.pathname = pathSegments.join('/'); diff --git a/libs/shared/logging/src/lib/url-credential-parts.ts b/libs/shared/logging/src/lib/url-credential-parts.ts new file mode 100644 index 000000000..d7ca5ca57 --- /dev/null +++ b/libs/shared/logging/src/lib/url-credential-parts.ts @@ -0,0 +1,82 @@ +/** + * How a URL's parts read once decoded, for every check that compares them + * by name: the log redactor and the on-screen URL cleanup share these, so a + * spelling one of them understands the other does too. + */ + +const XTREAM_CREDENTIAL_PATH_SEGMENTS: ReadonlySet = new Set([ + 'live', + 'movie', + 'series', + 'timeshift', +]); +/** Percent-decoding rounds before a name is compared. */ +const MAX_DECODE_ROUNDS = 4; + +/** + * A name as it reads once every percent-encoding layer is undone, within a + * bound: `%2570assword` is `password`, and `%256Cive` is `live`. Every + * check that compares a key or marker by name must see this form, or one + * more encoding layer slips a secret past it. A malformed escape (`100%`) + * ends the decoding as written. + */ +export function decodeFully(value: string): string { + let current = value; + for ( + let round = 0; + round < MAX_DECODE_ROUNDS && current.includes('%'); + round += 1 + ) { + let next: string; + try { + next = decodeURIComponent(current); + } catch { + break; + } + if (next === current) { + break; + } + current = next; + } + return current; +} + +export interface XtreamAccountScan { + /** Indexes of the raw path segments that hold an account part. */ + readonly segments: ReadonlySet; + /** Each account found, as `[user, password]`. */ + readonly accounts: readonly (readonly [string, string])[]; +} + +/** + * Finds the Xtream account in a URL path (`/live///…`, + * markers in any case or encoding). The path is read decoded: separators + * encoded into one segment (`live%2Fjoe%2Fhunter2%2F1.ts`) still separate + * its parts, so an account cannot hide inside one segment. With + * `streamFollows`, the two parts after a marker count only when another + * part follows them, as a stream does; a log line redacts without it. + */ +export function findXtreamAccount( + rawSegments: readonly string[], + options: { streamFollows?: boolean } = {} +): XtreamAccountScan { + const parts: string[] = []; + const owners: number[] = []; + rawSegments.forEach((raw, owner) => { + for (const part of decodeFully(raw).split('/')) { + parts.push(part); + owners.push(owner); + } + }); + const segments = new Set(); + const accounts: [string, string][] = []; + const after = options.streamFollows ? 3 : 2; + for (let index = 0; index + after < parts.length; index += 1) { + if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(parts[index].toLowerCase())) { + segments.add(owners[index + 1]); + segments.add(owners[index + 2]); + accounts.push([parts[index + 1], parts[index + 2]]); + } + } + return { segments, accounts }; +}