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;