From 34a5aa01d5f5e794e01013492739b4a3d837e6dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 06:14:37 +0000 Subject: [PATCH] fix(playlist): mask every password parameter on cards, skip host labels inside URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4 --- .../auto-import/auto-import.component.spec.ts | 18 +++++++ .../lib/auto-import/auto-import.component.ts | 51 +++++++++++++++++-- .../provider-import-detection.util.spec.ts | 17 +++++++ .../src/lib/provider-import-scan.util.ts | 36 +++++++++++-- 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts index 0859f3d75..09bf57a1b 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts @@ -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(); diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index af6c3bf5b..ffb72b9fd 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -38,6 +38,52 @@ const CONFIDENCE_LABEL_KEYS: Record = { 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': diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts index 93fa486ee..e02278eeb 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -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( [ diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts index fb35cf204..2f7938cff 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -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(); }