From 552923fd55e613630bd9af444b46f7a79d605d05 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Aug 2026 13:27:47 +0200 Subject: [PATCH] fix(electron): strip credentials on same-host https-to-http downgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changes/stalker-same-host-redirect-auth.md | 10 ++-- .../src/app/util/validated-axios.spec.ts | 54 ++++++++++++++++++- .../src/app/util/validated-axios.ts | 16 ++++-- docs/architecture/electron-security.md | 16 +++--- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/.changes/stalker-same-host-redirect-auth.md b/.changes/stalker-same-host-redirect-auth.md index f9d5b89ef..f9a04444f 100644 --- a/.changes/stalker-same-host-redirect-auth.md +++ b/.changes/stalker-same-host-redirect-auth.md @@ -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. diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index 63d25b3af..5e6e0a674 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -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({ diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index c8efa4676..f0a482935 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -240,13 +240,19 @@ export async function requestWithValidatedRedirects( // 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 ); } diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 7c0e8e9ae..a8cd3e42f 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -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