mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(stalker): keep session headers on same-host redirects (#1322)
* fix(stalker): keep session headers on same-host redirects Since 0.22 requestWithValidatedRedirects stripped Cookie/Authorization whenever a redirect changed the *origin*, so a portal answering with an http->https upgrade or a port move lost the MAC cookie and Bearer token mid-session. Real Stalker/Ministra servers then reply with a plain-text "Authorization failed." body: categories fail to load, create_link never resolves, and no player receives a stream URL (#1158 regression window). Scope credential stripping to the host instead: same-host scheme/port redirects keep headers, basic auth, params, and request bodies; a redirect to a different host still drops all of them, preserving the original hardening intent (no credential leaks to third-party hosts). Also adds the Stalker API compatibility roadmap produced by the 2026-08-01 protocol audit (.plans/, force-added like earlier plans). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c59aace71
commit
46c58f4f57
5 files changed
+272
-8
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
type: fix
|
||||||
|
area: stalker
|
||||||
|
issues: [1158]
|
||||||
|
---
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Stalker/Ministra API Compatibility — Roadmap
|
||||||
|
|
||||||
|
Date: 2026-08-01. Source: full audit (codebase map + reference implementations + GitHub issues).
|
||||||
|
Status legend: [ ] planned · [~] in progress · [x] merged.
|
||||||
|
|
||||||
|
## Reference facts (from Stalker 4.9.35 plaintext source `zfix/stalker-portal-4.9.x`, 5.1.1 client JS, Kodi pvr.stalker, stalkerhek)
|
||||||
|
|
||||||
|
These are the ground-truth rules a compatible client must follow. Re-verified 2026-08-01; do not re-research unless contradicted.
|
||||||
|
|
||||||
|
- Auth failure is **HTTP 200 + text/html body** — exact strings: `Authorization failed.`, `Access denied.`, `Unauthorized request.` Never a 401/403.
|
||||||
|
- `Cookie: mac=<URL-encoded, UPPERCASE>` required on every request; `Authorization: Bearer <token>` required for everything except `handshake`, `get_profile`, `get_localization`, `do_auth`.
|
||||||
|
- The **only** identity params the stock server enforces are `device_id`/`device_id2`: first non-empty value is pinned to the MAC forever; mismatch → `{status:1, msg:"device conflict …", block_msg:"Your STB is damaged…"}`. Empty on a fresh MAC is fine; empty after a value was pinned = permanent lockout.
|
||||||
|
- `signature` is read but never verified (only passed to optional operator `access_filter.php`). `metrics`, `prehash`, `timestamp`, `api_signature` are **never read** by the server.
|
||||||
|
- `signature` on a real box = `gSTB.GetUID(nonce)` firmware call; 4.9.x signs `access_token`, 5.x signs handshake `random`. Not reproducible client-side; `stalker-to-m3u` fabricates `SHA256(mac)`-based values and works.
|
||||||
|
- MAC format validation is ON by default: `/^00:1A:79:[0-9A-F]{2}:…$/` (Infomir OUI, uppercase). Failure → bare `{status:1}`.
|
||||||
|
- `get_profile` `status` decoded: full profile = OK; `1` = blocked (see `msg`/`block_msg`); `2` = login/password required → `do_auth` (params `login,password,device_id,device_id2`; response `{js:true|false}`) then `get_profile` with `auth_second_step=1`.
|
||||||
|
- Handshake token = random 32 uppercase hex; **idempotent** (re-presenting a valid token returns it) → tokens can be persisted across restarts. Tokens have **no TTL**; they are only invalidated when another device does `get_profile` on the same MAC.
|
||||||
|
- Watchdog `get_events` expected every **120 s** (`watchdog_timeout` echoed in profile + per-user `timeslot` jitter). Not calling it does NOT invalidate auth (only admin-panel "online" status); calling every 25 s is ~5× too often.
|
||||||
|
- `create_link` should be called only when the channel row sets `use_http_tmp_link`/`use_load_balancing`; otherwise the static `cmd` plays directly. Temp links live **5 s** (`tv_tmp_link_ttl`), not single-use → resolve immediately before playback, never cache.
|
||||||
|
- `cmd` format: `<solution> <url>` where solution ∈ {ffrt, ffrt2, ffrt3, ffmpeg, auto…} — strip by splitting on the FIRST space if present; bare URL is legal. Real MAG sends `cmd` **unencoded**.
|
||||||
|
- `portal.php` does not exist in official Stalker/Ministra — it's a reseller-panel alias. Canonical endpoint derives from `/c/` → `<base>/server/load.php`. Robust discovery: probe `portal.php` → `server/load.php` → `stalker_portal/server/load.php` (→ optionally parse `ajax_loader` out of `/c/xpcom.common.js`).
|
||||||
|
- Timezone cookie must be a valid PHP timezone or omitted (server does `new DateTimeZone()` on it).
|
||||||
|
- Response envelope `{js, text}`: ignore `text` (carries `var_dump` noise).
|
||||||
|
|
||||||
|
## Audit findings (what's wrong in our code)
|
||||||
|
|
||||||
|
1. **0.22 regression (issue #1158):** `requestWithValidatedRedirects` strips `Cookie`/`Authorization` when `nextUrl.origin !== validatedUrl.origin` — scheme/port changes (http→https, :80→:8080) on the SAME host count as cross-origin, so redirected portals lose mac-cookie + token → `Authorization failed.` → no categories, no `create_link`, MPV "won't even launch". Introduced in 2c032cd3c (0.22). File: `apps/electron-backend/src/app/util/validated-axios.ts` (~line 240).
|
||||||
|
2. Same commit made `cmd` percent-encoded (`encodeURIComponent` + slash restore); real MAG sends it raw → possible double-encoding when portal cmd already contains `%` and strict panels compare strings. Needs verification.
|
||||||
|
3. Portal mode (`isFullStalkerPortal`) is guessed from URL shape, with TWO diverging predicates (`/stalker_portal/` w/ slash in session svc vs w/o slash at import); simple portals get NO handshake/token/watchdog; `…/c` is rewritten to `portal.php` which official portals don't have; no endpoint probing. Issues #850, #686, #755, #910.
|
||||||
|
4. Built-in web players receive only User-Agent/Referer/Origin — `request-header-overrides.service.ts` cannot set Cookie/Authorization; token/mac-gated streams can never play in HTML5/Video.js/ArtPlayer/Shaka → the perennial "works only in VLC" cluster (#849, #910, #732).
|
||||||
|
5. Stalker playback headers built ONLY for ITV (`with-stalker-player.feature.ts:244`); VOD/series/radio go to players with none. Cross-origin streams get `User-Agent: KSPlayer` profile with no cookie/token and it overrides caller headers. Same-origin sets `X-User-Agent` but not `User-Agent`.
|
||||||
|
6. No `js.status` handling (status 2 → do_auth is dead code, import form login/password commented out), `msg`/`block_msg` never surfaced, only `Authorization failed` string caught (not `Access denied.` / `Unauthorized request.`), `not_valid`/`keep_alive` ignored, persisted `stalkerToken` never reused despite idempotent handshake.
|
||||||
|
7. No MAC normalization/validation at import; raw string goes into cookie and `sha1(mac.toUpperCase())` prehash.
|
||||||
|
8. Watchdog interval hardcoded 25 s (should be `watchdog_timeout` from profile, default 120).
|
||||||
|
9. `create_link` called unconditionally; `use_http_tmp_link`/`use_load_balancing` never checked.
|
||||||
|
10. PWA path much weaker than Electron: no MAG UA/X-User-Agent, cookie only `mac=`, no `JsHttpRequest=1-xml` injection, `sn` not stripped for non-get_profile, mac+token leak into portal query string, `cmd` slashes become `%2F`.
|
||||||
|
11. Never-sent profile params: `ver`, `hw_version`, `image_version`, `client_type`; `stb_type` always `''`. Free to send, some `access_filter.php` deployments inspect them.
|
||||||
|
12. Mock server implements neither `get_profile` nor `get_events`, validates no auth at all; e2e uses `portal.php` → full-portal auth surface has ZERO coverage.
|
||||||
|
13. Two divergent `cmd` normalizers (`stalker-player-request.utils.ts` strong vs `stream-resolver.service.ts` weak, no base-path resolution).
|
||||||
|
14. `.claude/skills/stalker-portal/SKILL.md` + `.codex` twin point at deleted `apps/web/src/app/stalker/*` paths.
|
||||||
|
|
||||||
|
## PR series (ordered for safety)
|
||||||
|
|
||||||
|
### PR 1 — fix(electron): keep auth headers on same-host redirects [PR #1322 open]
|
||||||
|
Fixes the #1158 regression. In `validated-axios.ts`, strip `Cookie`/`Authorization`/`Proxy-Authorization` only when the redirect changes **host**, not on scheme/port change of the same host (curl semantics). Regression tests: http→https same host keeps headers; host change strips. Release note required. Branch: `claude/stalker-api-compatibility-7a1ecb` → https://github.com/4gray/iptvnator/pull/1322
|
||||||
|
|
||||||
|
### PR 2 — test(stalker): mock-server auth enforcement + e2e for the full-portal surface [~ in progress, branch claude/stalker-mock-auth-e2e]
|
||||||
|
Pull coverage FORWARD before risky refactors: mock `get_profile` (validates Bearer, returns profile w/ configurable `status` 0/1/2, `msg`/`block_msg`), `get_events`, token enforcement returning literal `Authorization failed.` (200 text/html!) for missing/invalid Bearer, scenario MACs for device-conflict and status-2. E2E: full-portal import → handshake → get_profile → content → create_link → re-auth after token invalidation. This is the regression safety net for PRs 3–7.
|
||||||
|
|
||||||
|
### PR 3 — fix(stalker): verify/fix cmd encoding against reference behavior
|
||||||
|
Reproduce with a `%`-containing cmd; decide raw-with-safe-charset vs decode-before-encode. Keep the injection protection from 2c032cd3c but avoid double-encoding. Unit tests with real-world cmd corpus (`ffrt3 http://…`, `/media/123.mpg`, tokens with `%3A`).
|
||||||
|
|
||||||
|
### PR 4 — feat(stalker): endpoint probing + behavior-based portal mode
|
||||||
|
Replace URL-shape guessing: probe `portal.php` → `server/load.php` → `stalker_portal/server/load.php` at import (and once at runtime for legacy records); classify full/simple by observed handshake success, persist the resolved endpoint + mode; unify the two predicates. Migration for existing playlists. Fixes #850/#686/#755 class.
|
||||||
|
|
||||||
|
### PR 5 — feat(playback): forward Cookie/Authorization to built-in players + headers for VOD/series/radio
|
||||||
|
Extend `request-header-overrides.service.ts` (scoped per stream origin/URL, cleared on playback end) to set Cookie + Authorization; extract headers in `web-player-view.component.ts`; build Stalker header set for VOD/series/radio, not just ITV; fix same-origin `User-Agent`; revisit KSPlayer cross-origin profile override. Fixes the "only VLC works" cluster (#849/#910/#732 for Stalker).
|
||||||
|
|
||||||
|
### PR 6 — feat(stalker): protocol-correct auth lifecycle
|
||||||
|
- Parse `js.status`: 2 → revive `do_auth` (uncomment import form login/password), then `get_profile` with `auth_second_step=1`.
|
||||||
|
- Surface `msg`/`block_msg` to the user (import dialog + portal error state).
|
||||||
|
- Detect all three plain-text bodies (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) at the transport level instead of JSON.stringify regex.
|
||||||
|
- Reuse persisted `stalkerToken` (handshake is idempotent), propagate `not_valid_token`.
|
||||||
|
- Watchdog interval from profile `watchdog_timeout` (+`timeslot`), default 120 s instead of 25 s.
|
||||||
|
|
||||||
|
### PR 7 — feat(stalker): identity hardening
|
||||||
|
- MAC normalization (uppercase, colon format) + validation with OUI `00:1A:79` hint in the import UI.
|
||||||
|
- Optional deterministic device_id derivation (SHA256(mac), opt-in checkbox like StbEmu) with an explanation of the pinning/lockout semantics; never auto-change once set.
|
||||||
|
- Send the free plausible params: `ver`, `stb_type` (real value), `hw_version`, `image_version`, `client_type`, `num_banks`, `video_out`, `hd`.
|
||||||
|
- Device-conflict `msg` mapped to a human-readable error.
|
||||||
|
|
||||||
|
### PR 8 — fix(stalker): playback link semantics
|
||||||
|
Respect `use_http_tmp_link`/`use_load_balancing` (static cmd when unset); never cache resolved links (5 s TTL); unify the two cmd normalizers into one shared util.
|
||||||
|
|
||||||
|
### PR 9 — fix(pwa): Stalker parity with Electron transport
|
||||||
|
MAG UA/X-User-Agent, full cookie, `JsHttpRequest=1-xml` injection, strip `sn` for non-get_profile, stop leaking mac/token into the portal query string, slash-preserving cmd encoding in web-backend proxy.
|
||||||
|
|
||||||
|
### PR 10 — chore(docs/skills): sync
|
||||||
|
Update `docs/architecture/stalker-portal.md` (watchdog, endpoint probing, auth lifecycle), fix stale `.claude/skills/stalker-portal/SKILL.md` + `.codex` twin paths, document protocol reference facts.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
- One PR per numbered item; each in its own thread/worktree with this file as the handoff. Update the status legend here as PRs land (and renumber if scope shifts).
|
||||||
|
- Before opening each PR from a worktree: check `git log origin/master..HEAD` for inherited local commits (known trap).
|
||||||
|
- Every behavior PR: `.changes/` release note; targeted tests per Regression Prevention policy.
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
#1158 (0.22 regression, PR 1), #910/#849/#732 (VLC-only cluster, PR 5), #850/#686/#755 (URL/portal-mode, PR 4), #927/#860/#345/#448/#453 (identity fields, PR 7), #1146 (search, mostly shipped via #1209).
|
||||||
@@ -297,7 +297,7 @@ describe('requestWithValidatedRedirects', () => {
|
|||||||
await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110');
|
await expect(resolvePinnedAddress(1)).resolves.toBe('142.250.191.110');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes sensitive headers when a redirect changes origin', async () => {
|
it('removes sensitive headers when a redirect changes host', async () => {
|
||||||
axiosMock
|
axiosMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -330,7 +330,154 @@ describe('requestWithValidatedRedirects', () => {
|
|||||||
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
|
expect(redirectedConfig.headers).not.toHaveProperty('Cookie');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not forward axios params to a cross-origin redirect', async () => {
|
it('keeps sensitive headers when a redirect upgrades the scheme on the same host', async () => {
|
||||||
|
axiosMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 302,
|
||||||
|
headers: { location: 'https://portal.example/portal.php' },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
data: { js: { token: 'abc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestWithValidatedRedirects(
|
||||||
|
'http://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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectedConfig = axiosMock.mock.calls[1][0];
|
||||||
|
expect(redirectedConfig.headers).toMatchObject({
|
||||||
|
Accept: '*/*',
|
||||||
|
Authorization: 'Bearer secret',
|
||||||
|
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||||
|
});
|
||||||
|
expect(redirectedConfig.auth).toEqual({
|
||||||
|
password: 'secret',
|
||||||
|
username: 'provider',
|
||||||
|
});
|
||||||
|
expect(redirectedConfig.params).toEqual({ token: 'secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sensitive headers when a redirect changes the port on the same host', async () => {
|
||||||
|
axiosMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
location: 'http://portal.example:8080/server/load.php',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
data: { js: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestWithValidatedRedirects(
|
||||||
|
'http://portal.example/server/load.php',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer secret',
|
||||||
|
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
{ resolveHostname: publicResolver }
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectedConfig = axiosMock.mock.calls[1][0];
|
||||||
|
expect(redirectedConfig.headers).toMatchObject({
|
||||||
|
Authorization: 'Bearer secret',
|
||||||
|
Cookie: 'mac=00:1A:79:AA:BB:CC',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays the request body on a same-host scheme-upgrade redirect', async () => {
|
||||||
|
axiosMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 307,
|
||||||
|
headers: { location: 'https://portal.example/submit' },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
data: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestWithValidatedRedirects(
|
||||||
|
'http://portal.example/submit',
|
||||||
|
{ data: { payload: true }, method: 'POST' },
|
||||||
|
{ resolveHostname: publicResolver }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(axiosMock).toHaveBeenCalledTimes(2);
|
||||||
|
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
|
axiosMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -354,7 +501,7 @@ describe('requestWithValidatedRedirects', () => {
|
|||||||
expect(axiosMock.mock.calls[1][0].params).toBeUndefined();
|
expect(axiosMock.mock.calls[1][0].params).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not forward axios basic auth to a cross-origin redirect', async () => {
|
it('does not forward axios basic auth to a cross-host redirect', async () => {
|
||||||
axiosMock
|
axiosMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -378,7 +525,7 @@ describe('requestWithValidatedRedirects', () => {
|
|||||||
expect(axiosMock.mock.calls[1][0].auth).toBeUndefined();
|
expect(axiosMock.mock.calls[1][0].auth).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a cross-origin redirect that would replay a request body', async () => {
|
it('rejects a cross-host redirect that would replay a request body', async () => {
|
||||||
axiosMock.mockResolvedValueOnce({
|
axiosMock.mockResolvedValueOnce({
|
||||||
status: 307,
|
status: 307,
|
||||||
headers: { location: 'https://other.example/submit' },
|
headers: { location: 'https://other.example/submit' },
|
||||||
|
|||||||
@@ -237,10 +237,22 @@ export async function requestWithValidatedRedirects<T = unknown>(
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (nextUrl.origin !== validatedUrl.origin) {
|
// 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). 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) {
|
if (requestConfig.data !== undefined) {
|
||||||
throw new UnsafeUrlError(
|
throw new UnsafeUrlError(
|
||||||
'Cross-origin redirects with request bodies are not supported',
|
'Redirects that change the host or downgrade to HTTP cannot replay request bodies',
|
||||||
502
|
502
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,8 +164,15 @@ validation while retaining the original hostname for TLS SNI, certificate
|
|||||||
validation, and virtual hosting. This prevents DNS rebinding between validation
|
validation, and virtual hosting. This prevents DNS rebinding between validation
|
||||||
and connection. Callers with custom TLS policy provide a typed agent factory;
|
and connection. Callers with custom TLS policy provide a typed agent factory;
|
||||||
the validated request layer supplies the pinned lookup instead of copying
|
the validated request layer supplies the pinned lookup instead of copying
|
||||||
private Node `Agent.options` state. Cross-origin redirects must not forward `Authorization`,
|
private Node `Agent.options` state. Credential stripping is scoped to the host
|
||||||
`Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies.
|
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
|
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
|
`url-tvg`. Operators who intentionally use a LAN-hosted EPG source should prefer
|
||||||
|
|||||||
Reference in new issue
Block a user