mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
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();
|
||||
}
|
||||
Reference in new issue
Block a user