mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(sources): drop JSON configs from shown URLs; read their secrets
A nested value such as `?config={"password":"…"}` was neither a URL nor
a parameter list, and the quoted key slipped past the free-text check.
- A nested value that parses as a JSON object or array is dropped from
the shown URL (JSON can spell a key so no pattern reads it, such as
`w`); brackets that are not JSON, like `[1080p]`, stay.
- The free-text check accepts a quoted key (`"token": …`).
- The name check parses JSON values and collects what sits under
sensitive keys, and follows URLs in its other strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc879801a1
commit
bb854a9621
4 files changed
+101
-2
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -22,7 +22,10 @@ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]{0,31}:\/\//iu;
|
||||
const EMBEDDED_URL = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s"'<>]+/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 = /(?<![a-z0-9_.-])([a-z][a-z0-9_.-]{0,63})\s{0,8}[=:]/giu;
|
||||
// A key may be quoted, as in JSON or code: `"token": …`.
|
||||
const KEYED_PAIR =
|
||||
/(?<![a-z0-9_.-])([a-z][a-z0-9_.-]{0,63})["']?\s{0,8}[=:]/giu;
|
||||
const JSON_SHAPED = /^[{[]/;
|
||||
|
||||
/**
|
||||
* A nested value as it must be classified: percent-decoded one round at a
|
||||
@@ -56,6 +59,18 @@ export function unwrapNestedValue(value: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** A JSON object or array, such as a config in a parameter; `[1080p]` is not. */
|
||||
function isJsonLiteral(value: string): boolean {
|
||||
if (!JSON_SHAPED.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return typeof JSON.parse(value) === 'object';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A parameter whose value must not be on screen: every key the log redactor
|
||||
* treats as sensitive, plus the short account keys (`pass`, `usr`, …).
|
||||
@@ -96,7 +111,13 @@ function sanitizeNested(value: string, depth: number): string | null {
|
||||
if (looksLikeSearchParams(trimmed)) {
|
||||
return withoutSensitiveParams(new URLSearchParams(trimmed), depth + 1);
|
||||
}
|
||||
if (BARE_USERINFO.test(decoded) || hasSensitivePair(decoded)) {
|
||||
// JSON can spell a key so no pattern reads it (`"pass\u0077ord"`): a
|
||||
// list has no need to show a config object, so it goes whole.
|
||||
if (
|
||||
isJsonLiteral(trimmed) ||
|
||||
BARE_USERINFO.test(decoded) ||
|
||||
hasSensitivePair(decoded)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const cleaned = decoded.replace(
|
||||
|
||||
Reference in new issue
Block a user