fix(playlist): mask every password parameter on cards, skip host labels inside URLs

- Card display now hides the value of EVERY password query parameter and
  recognizes percent-encoded names ('pass%77ord'), which URLSearchParams —
  and therefore the importer — reads as 'password'. The URL stays otherwise
  byte-identical so the user still recognizes their link.
- The host-label matcher keeps scanning past host-shaped query keys inside
  unrelated links ('/setup?url=guide') instead of discarding the field, so a
  real 'Server:' line further down still wins the credentials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-16 06:14:37 +00:00
1 parent 422459e706
commit 34a5aa01d5
4 files changed
+114 -8

No files matched your search

@@ -94,6 +94,24 @@ describe('AutoImportComponent', () => {
expect(JSON.stringify(rows)).not.toContain('s3cret');
});
it('masks percent-encoded and repeated password parameters', () => {
fixture.detectChanges();
const rows = component.summaryRows({
kind: 'm3u-url',
confidence: 'low',
// `pass%77ord` is what URLSearchParams — and the importer — read
// as `password`, so the card must hide it too.
url: 'http://tv.example.com/get.php?pass%77ord=first&username=alice&password=second',
});
expect(rows[0].value).toContain('username=alice');
expect(rows[0].value).not.toContain('first');
expect(rows[0].value).not.toContain('second');
expect(rows[0].value).toContain('pass%77ord=••••••');
expect(rows[0].value).toContain('password=••••••');
});
it('masks the password in the summary rows', () => {
fixture.detectChanges();
@@ -38,6 +38,52 @@ const CONFIDENCE_LABEL_KEYS: Record<ProviderImportConfidence, string> = {
low: 'HOME.AUTO_DETECT.CONFIDENCE_LOW',
};
/**
* Hides the value of every `password` query parameter for display, keeping
* the rest of the URL byte-identical so the user still recognizes their link.
*
* Works pair by pair on the raw query rather than through `URLSearchParams`,
* for two reasons: re-serializing would re-encode the whole URL (the mask
* itself included) into something unreadable, and every occurrence must be
* covered, not just the first. The parameter NAME is decoded before the
* comparison because `pass%77ord=` is what `URLSearchParams` — and therefore
* the importer — reads as `password`.
*/
function maskUrlPasswords(url: string): string {
const queryStart = url.indexOf('?');
if (queryStart === -1) {
return url;
}
const head = url.slice(0, queryStart + 1);
const rest = url.slice(queryStart + 1);
const hashStart = rest.indexOf('#');
const query = hashStart === -1 ? rest : rest.slice(0, hashStart);
const hash = hashStart === -1 ? '' : rest.slice(hashStart);
const masked = query
.split('&')
.map((pair) => {
const separator = pair.indexOf('=');
if (separator === -1) {
return pair;
}
const rawName = pair.slice(0, separator);
let name = rawName;
try {
name = decodeURIComponent(rawName);
} catch {
// A malformed escape stays as written; it cannot be the
// decoded `password` the importer would read either.
}
return name.toLowerCase() === 'password'
? `${rawName}=••••••`
: pair;
})
.join('&');
return `${head}${masked}${hash}`;
}
/**
* "Paste anything from your provider" surface: a free-text area run through
* the deterministic `detectProviderImportCandidates` on every edit. Each
@@ -135,10 +181,7 @@ export class AutoImportComponent implements OnInit {
// The candidate keeps the untouched URL for prefilling.
push(
'HOME.URL_UPLOAD.PLAYLIST_URL',
candidate.url?.replace(
/([?&]password=)[^&#\s]*/i,
'$1••••••'
)
candidate.url && maskUrlPasswords(candidate.url)
);
break;
case 'xtream':
@@ -315,6 +315,23 @@ describe('detectProviderImportCandidates', () => {
expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080');
});
it('ignores host-shaped query keys inside unrelated links', () => {
// `?url=guide` is a query key, not a label — the real "Server:"
// line below it must still be the one the credentials attach to.
const candidates = detectProviderImportCandidates(
[
'Guide: https://reseller.example/setup?url=guide',
'Server: http://panel.example.org',
'User: alice',
'Pass: s3cret',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0].serverUrl).toBe('http://panel.example.org');
});
it('strips sentence punctuation from a labeled server URL', () => {
const candidates = detectProviderImportCandidates(
[
@@ -363,13 +363,18 @@ export function extractLabeledFields(text: string): LabeledFields {
if (fields[field] !== undefined) {
continue;
}
const match = pattern.exec(field === 'host' ? text : masked);
// The host label is the one matcher that reads the RAW text, because
// it legitimately points at a URL ("Portal: http://…"). That means it
// also meets host-shaped query keys inside unrelated links
// (`…/setup?url=guide`), so it keeps scanning past those rather than
// giving up — a real `Server:` line further down must still win.
const match =
field === 'host'
? firstMatchOutsideSpans(pattern, text, spans)
: pattern.exec(masked);
if (!match) {
continue;
}
if (field === 'host' && isInsideSpan(spans, match.index)) {
continue;
}
const value = stripWrapping(match[1] ?? '');
if (!value || (validate && !validate(value))) {
continue;
@@ -480,6 +485,29 @@ function isInsideSpan(
return spans.some(([start, end]) => index > start && index < end);
}
/** First match of `pattern` whose label does not start inside a URL span. */
function firstMatchOutsideSpans(
pattern: RegExp,
text: string,
spans: Array<[number, number]>
): RegExpExecArray | null {
const matcher = freshMatcher(
pattern.flags.includes('g')
? pattern
: new RegExp(pattern.source, `${pattern.flags}g`)
);
let match: RegExpExecArray | null;
while ((match = matcher.exec(text)) !== null) {
if (!isInsideSpan(spans, match.index)) {
return match;
}
if (match[0] === '') {
matcher.lastIndex += 1;
}
}
return null;
}
function stripWrapping(value: string): string {
return value.trim().replace(WRAPPING_CHARS, '').trim();
}