fix(electron): strip credentials on same-host https-to-http downgrades

Review follow-up (Greptile P1 + Codex on #1322): the host-only check
kept Authorization/Cookie/basic auth/params/body when an https request
was redirected to http on the same host, replaying a TLS-obtained
session in cleartext. Treat that downgrade like a host change: strip
credentials and refuse to replay request bodies. Scheme upgrades and
port moves on the same host keep headers — the actual #1158 scenarios.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-01 13:27:47 +02:00
1 parent 87058c2f6d
commit 552923fd55
4 files changed
+78 -18

No files matched your search

+5 -5
View File
@@ -4,8 +4,8 @@ area: stalker
issues: [1158]
---
Stalker portals whose server redirects between http/https or between ports no
longer lose their session mid-request. Since 0.22 such redirects silently
dropped the portal's MAC cookie and auth token, so categories failed to load
and streams never reached any player; the session now survives every redirect
that stays on the portal's own host.
Stalker portals whose server redirects to https or to another port no longer
lose their session mid-request. Since 0.22 such redirects silently dropped the
portal's MAC cookie and auth token, so categories failed to load and streams
never reached any player. Downgrade redirects from https to plain http still
strip credentials, so a secure session is never sent in cleartext.
@@ -403,7 +403,7 @@ describe('requestWithValidatedRedirects', () => {
});
});
it('replays the request body on a same-host scheme-change redirect', async () => {
it('replays the request body on a same-host scheme-upgrade redirect', async () => {
axiosMock
.mockResolvedValueOnce({
status: 307,
@@ -425,6 +425,58 @@ describe('requestWithValidatedRedirects', () => {
expect(axiosMock.mock.calls[1][0].data).toEqual({ payload: true });
});
it('strips sensitive headers when a same-host redirect downgrades https to http', async () => {
axiosMock
.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://portal.example/portal.php' },
})
.mockResolvedValueOnce({
status: 200,
headers: {},
data: { js: {} },
});
await requestWithValidatedRedirects(
'https://portal.example/portal.php',
{
auth: { password: 'secret', username: 'provider' },
headers: {
Authorization: 'Bearer secret',
Cookie: 'mac=00:1A:79:AA:BB:CC',
Accept: '*/*',
},
method: 'GET',
params: { token: 'secret' },
},
{ resolveHostname: publicResolver }
);
// A session obtained over TLS must never be replayed in cleartext.
const redirectedConfig = axiosMock.mock.calls[1][0];
expect(redirectedConfig.headers).toMatchObject({ Accept: '*/*' });
expect(redirectedConfig.headers).not.toHaveProperty('Authorization');
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
expect(redirectedConfig.auth).toBeUndefined();
expect(redirectedConfig.params).toBeUndefined();
});
it('rejects a same-host https-to-http redirect that would replay a request body', async () => {
axiosMock.mockResolvedValueOnce({
status: 307,
headers: { location: 'http://portal.example/submit' },
});
await expect(
requestWithValidatedRedirects(
'https://portal.example/submit',
{ data: { secret: true }, method: 'POST' },
{ resolveHostname: publicResolver }
)
).rejects.toThrow(/request bodies/i);
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('does not forward axios params to a cross-host redirect', async () => {
axiosMock
.mockResolvedValueOnce({
@@ -240,13 +240,19 @@ export async function requestWithValidatedRedirects<T = unknown>(
// Credentials are scoped to the host, not the origin: IPTV portals
// routinely redirect between schemes and ports of the same host
// (http -> https upgrades, port moves), and stripping the session
// cookie/token there breaks the portal outright (#1158). A redirect to
// a *different* host still loses Authorization/Cookie/basic
// auth/params so provider credentials never leak to third parties.
if (nextUrl.hostname !== validatedUrl.hostname) {
// cookie/token there breaks the portal outright (#1158). Two hops do
// lose Authorization/Cookie/basic auth/params: a *different* host
// (credentials never leak to third parties) and a same-host
// https -> http downgrade (a session obtained over TLS is never
// replayed in cleartext).
const isCredentialUnsafeRedirect =
nextUrl.hostname !== validatedUrl.hostname ||
(validatedUrl.protocol === 'https:' &&
nextUrl.protocol === 'http:');
if (isCredentialUnsafeRedirect) {
if (requestConfig.data !== undefined) {
throw new UnsafeUrlError(
'Cross-host redirects with request bodies are not supported',
'Redirects that change the host or downgrade to HTTP cannot replay request bodies',
502
);
}
+9 -7
View File
@@ -164,13 +164,15 @@ validation while retaining the original hostname for TLS SNI, certificate
validation, and virtual hosting. This prevents DNS rebinding between validation
and connection. Callers with custom TLS policy provide a typed agent factory;
the validated request layer supplies the pinned lookup instead of copying
private Node `Agent.options` state. Cross-host redirects must not forward `Authorization`,
`Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies. Credential
stripping is scoped to the host rather than the origin: redirects that stay on
the same hostname but change scheme or port keep their headers, because IPTV
portals routinely answer with http→https upgrades or port moves and losing the
session cookie/token there breaks the portal outright (#1158) while disclosing
nothing to a third party.
private Node `Agent.options` state. Credential stripping is scoped to the host
rather than the origin, with one transport-security carve-out: same-hostname
redirects that upgrade the scheme (http→https) or move ports keep
`Authorization`, `Cookie`, basic auth, `params`, and request bodies, because
IPTV portals routinely answer with such redirects and losing the session
cookie/token there breaks the portal outright (#1158) while disclosing nothing
to a third party. Redirects that change the host **or downgrade https→http**
must not forward any of those — the former would hand provider credentials to a
third party, the latter would replay a TLS-obtained session in cleartext.
EPG URLs are strict by default because an M3U playlist can supply them through
`url-tvg`. Operators who intentionally use a LAN-hosted EPG source should prefer