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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 00:31:37 +02:00
1 parent 3e4cc0185d
commit 64271ba50f
8 files changed
+167 -91

No files matched your search

@@ -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`;
@@ -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/<user>/<password>/<stream>`.
// 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;
@@ -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(
@@ -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/<user>/<password>/<id>.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);
+2 -2
View File
@@ -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,
@@ -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%');
@@ -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<string> = 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('/');
@@ -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<string> = 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<number>;
/** Each account found, as `[user, password]`. */
readonly accounts: readonly (readonly [string, string])[];
}
/**
* Finds the Xtream account in a URL path (`/live/<user>/<password>/…`,
* 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<number>();
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 };
}