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:
4grayandClaude Opus 5.5 committed 2026-10-14 00:55:15 +02:00
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(