fix(stalker): normalize DNS root dots in the shared credential classifier

Codex P2 on #1364, extending the `localhost.` fix into
`isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a
portal on `portal.example` serving `https://portal.example./movie.mkv`
classified its own stream as third-party.

Wider than the download guard it was reported against: this predicate is the
single rule BOTH the renderer playback-header builder and the Electron
main-process fallback use to decide whether a stream may carry the mac cookie
and Bearer token. A portal-owned stream spelled with the root dot was getting
the credential-free profile and would 401 — pre-existing, and exactly the
"only VLC works" class this contract exists to prevent. My PR added two new
dependencies on the same predicate (the download static guard and the
portal-owned fallback), which is how it surfaced.

Both sides are normalized, so it stays symmetric, and it can only widen toward
"same host" — never toward handing credentials to a different one. A test pins
that `evil.portal.example.` is still rejected.

Also carries the authority guard found by probing the same class myself rather
than waiting for it to be reported: `http:///ch/1` has no authority and `URL`
quietly reinterprets the first path segment as the host, so a malformed
command reached the player as a nonsense address instead of going to the
portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other
exotic spellings I probed were already covered — `URL` canonicalizes `127.1`,
`2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6
normalize too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-03 11:10:25 +02:00
1 parent 109320a22e
commit bdf096887b
7 files changed
+91 -5

No files matched your search

@@ -65,6 +65,33 @@ describe('isStalkerStreamCredentialSafe', () => {
).toBe(false);
});
it('treats a terminal DNS root dot as the same host', () => {
// `portal.example.` and `portal.example` resolve identically, but
// `URL` preserves the dot — comparing literally classified a
// portal-owned stream as third-party and stripped its credentials.
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://portal.example./live/ch1.ts'
)
).toBe(true);
expect(
isStalkerStreamCredentialSafe(
'http://portal.example./portal.php',
'http://portal.example/live/ch1.ts'
)
).toBe(true);
});
it('still rejects a different host that merely shares a suffix', () => {
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://evil.portal.example./live/ch1.ts'
)
).toBe(false);
});
it('fails closed on missing or unparseable URLs', () => {
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
@@ -15,6 +15,16 @@
* credentials to a third party, and an https→http downgrade would replay a
* TLS-obtained session in cleartext — both stay credential-free.
*/
/**
* A terminal dot is the DNS root: `portal.example.` and `portal.example`
* resolve to the same host, but `URL` preserves the dot, so a literal
* comparison would call them different origins — classifying a portal-owned
* stream as third-party and stripping the credentials it needs.
*/
function normalizeHostname(hostname: string): string {
return hostname.toLowerCase().replace(/\.$/, '');
}
export function isStalkerStreamCredentialSafe(
portalUrl: string | undefined | null,
streamUrl: string | undefined | null
@@ -36,7 +46,10 @@ export function isStalkerStreamCredentialSafe(
return false;
}
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
if (
normalizeHostname(portal.hostname) !==
normalizeHostname(stream.hostname)
) {
return false;
}