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:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-01 17:36:44 +02:00
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');
});
it('removes sensitive headers when a redirect changes origin', async () => {
it('removes sensitive headers when a redirect changes host', async () => {
axiosMock
.mockResolvedValueOnce({
status: 302,
@@ -330,7 +330,154 @@ describe('requestWithValidatedRedirects', () => {
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
.mockResolvedValueOnce({
status: 302,
@@ -354,7 +501,7 @@ describe('requestWithValidatedRedirects', () => {
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
.mockResolvedValueOnce({
status: 302,
@@ -378,7 +525,7 @@ describe('requestWithValidatedRedirects', () => {
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({
status: 307,
headers: { location: 'https://other.example/submit' },
@@ -237,10 +237,22 @@ export async function requestWithValidatedRedirects<T = unknown>(
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) {
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
);
}
+9 -2
View File
@@ -164,8 +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-origin redirects must not forward `Authorization`,
`Cookie`, `Proxy-Authorization`, Axios `params`, or request bodies.
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