From d0d3e0b899bf859971064d6a231965ad1cce4694 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 10:10:14 +0000 Subject: [PATCH] fix(playlist): mask HTTP Basic passwords on auto-detect cards An M3U link can carry credentials as userinfo ('https://alice:secret@host/list.m3u'); the card masked query passwords only, so that one showed in clear despite the card's masking guarantee. The username stays visible and the candidate keeps the untouched URL for prefilling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4 --- .../auto-import/auto-import.component.spec.ts | 15 +++++++ .../lib/auto-import/auto-import.component.ts | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+) 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 09bf57a1b..e6d7822aa 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,21 @@ describe('AutoImportComponent', () => { expect(JSON.stringify(rows)).not.toContain('s3cret'); }); + it('masks an HTTP Basic password embedded in the URL', () => { + fixture.detectChanges(); + + const rows = component.summaryRows({ + kind: 'm3u-url', + confidence: 'high', + url: 'https://alice:s3cret@lists.example.com/list.m3u', + }); + + expect(rows[0].value).toBe( + 'https://alice:••••••@lists.example.com/list.m3u' + ); + expect(rows[0].value).not.toContain('s3cret'); + }); + it('masks percent-encoded and repeated password parameters', () => { 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 ffb72b9fd..59641610e 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 @@ -50,6 +50,49 @@ const CONFIDENCE_LABEL_KEYS: Record = { * the importer — reads as `password`. */ function maskUrlPasswords(url: string): string { + return maskUrlQueryPasswords(maskUrlUserinfoPassword(url)); +} + +/** + * Hides the password half of HTTP Basic userinfo + * (`https://alice:secret@host/…`), which is a password on the card exactly + * like a query one. The username stays visible, as it does everywhere else. + */ +function maskUrlUserinfoPassword(url: string): string { + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) { + return url; + } + const authorityStart = schemeEnd + 3; + const authorityEnd = (() => { + const rest = url.slice(authorityStart); + const cut = rest.search(/[/?#]/); + return cut === -1 ? url.length : authorityStart + cut; + })(); + + const authority = url.slice(authorityStart, authorityEnd); + // Last `@`: one inside the password itself has to be percent-encoded, so + // whatever follows the final `@` is the host. + const userinfoEnd = authority.lastIndexOf('@'); + if (userinfoEnd === -1) { + return url; + } + const userinfo = authority.slice(0, userinfoEnd); + const separator = userinfo.indexOf(':'); + if (separator === -1) { + return url; + } + + const masked = `${userinfo.slice(0, separator)}:••••••`; + return ( + url.slice(0, authorityStart) + + masked + + authority.slice(userinfoEnd) + + url.slice(authorityEnd) + ); +} + +function maskUrlQueryPasswords(url: string): string { const queryStart = url.indexOf('?'); if (queryStart === -1) { return url;