mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user