fix(sources): compare passwords in their own case; only a MAC caselessly

Matching every secret lower-cased hid any name holding the password word
in another case: the Electron E2E source "Stress Xtream Portal" (password
`stress`) showed its host, and the deletion test could not find its row.
A password is case-sensitive, so it is compared as written; the text is
still read fully decoded, so `%3a` escapes count as `:`. A MAC is the
same address in either letter case and is compared lower-cased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 02:51:31 +02:00
1 parent c4da37ebb0
commit c6d7b241d3
2 files changed
+42 -17

No files matched your search

@@ -148,9 +148,11 @@ describe('sourceDisplayName', () => {
expect(sourceDisplayName({ ...xtream, title: 'pw' })).toBe(
'xtream.tv:8080'
);
expect(sourceDisplayName({ ...xtream, title: ' PW ' })).toBe(
expect(sourceDisplayName({ ...xtream, title: ' pw ' })).toBe(
'xtream.tv:8080'
);
// A password has its own case: `PW` is not `pw`.
expect(sourceDisplayName({ ...xtream, title: 'PW' })).toBe('PW');
// Inside a longer name a short password is just letters.
expect(sourceDisplayName({ ...xtream, title: 'Pwnova' })).toBe(
'Pwnova'
@@ -160,6 +162,17 @@ describe('sourceDisplayName', () => {
);
});
it('keeps a name holding the password word in another case', () => {
expect(
sourceDisplayName({
title: 'Stress Portal',
serverUrl: 'http://xtream.tv',
username: 'stress',
password: 'stress',
})
).toBe('Stress Portal');
});
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';
@@ -199,28 +199,40 @@ function sourceSecrets(playlist: SourceDisplayFields): string[] {
}
/**
* Whether the text shows any of the source's secrets. Escapes may be
* written in either case (`%3a` for `%3A`) and a MAC in either letter case,
* so text and secrets are compared lower-cased, the text as written and
* fully decoded.
* Whether one of the texts shows the secret: as the whole text, or inside
* it when the secret is long enough. Each form on its own: `%2542` decodes
* to `B`, which may only hide a text that is nothing but `B`.
*/
function shows(texts: readonly string[], secret: string): boolean {
return texts.some(
(text) =>
text === secret ||
(secret.length >= MIN_SECRET_LENGTH && text.includes(secret))
);
}
/**
* Whether the text shows any of the source's secrets, read as written and
* fully decoded (so `%3a` escapes count as `:`). A password is compared in
* its own case: `Stress` in a name is not the password `stress`. A MAC is
* the same address in either letter case, so it is compared lower-cased.
*/
export function containsSourceSecret(
text: string,
playlist: SourceDisplayFields
): boolean {
const forms = [text, decodeFully(text)].map((form) =>
form.trim().toLowerCase()
const texts = [text, decodeFully(text)].map((form) => form.trim());
if (sourceSecrets(playlist).some((secret) => shows(texts, secret))) {
return true;
}
const mac = playlist.macAddress?.toLowerCase();
const lowerTexts = texts.map((form) => form.toLowerCase());
return (
!!mac &&
[mac, encodeURIComponent(mac).toLowerCase()].some((form) =>
shows(lowerTexts, form)
)
);
return sourceSecrets(playlist).some((secret) => {
const needle = secret.toLowerCase();
// Each form on its own: `%2542` decodes to `B`, which may only hide
// a text that is nothing but `b`.
return forms.some(
(form) =>
form === needle ||
(needle.length >= MIN_SECRET_LENGTH && form.includes(needle))
);
});
}
/**