From d3cc18dc720c49956e9a37652dc70a670e053c41 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:03:08 +0200 Subject: [PATCH] fix(stalker): anchor auth-failure body detection and share it across transports (#1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stalker): anchor auth-failure body detection and share it across transports The middleware's auth failures are bare plain-text bodies, but they were matched by substring under a 200-character cap. A short page from something in FRONT of the portal — a proxy or WAF answering `Access denied` (38 characters) — therefore read as the portal refusing authorization, which drives probe classification and the lazy repair trigger: a portal that never answered at all could be re-probed and reclassified. The body match is now anchored to the whole reply, with the stock server's optional trailing counter still accepted. The structured `js.error`/`js.msg` fields keep the wider phrase set, since a panel fills those in deliberately. The detection also moves to `@iptvnator/shared/interfaces`. It had to live somewhere both transports can reach: the Electron main process is where these bodies actually arrive and cannot import a renderer library, which is the same reason the identity and URL builders were centralised there. `stalker-portal-discovery.utils.ts` re-exports it, so no call site changes. Co-Authored-By: Claude Fable 5 * fix(stalker): drop the unanchored auth-failure sweep in the session service Anchoring the body detector closed one door and left another open. The session service still stringified the whole response and matched an UNANCHORED `authorization failed`, so a short page from something in FRONT of the portal retired the token, retried, and threw `Authorization failed after retry` — whose own message then matched the repair trigger's wide phrase set and re-probed a portal that had refused nothing. The shared detector already covers every real shape, including the `js.error`/`js.msg` envelopes the sweep was also catching, so removing it costs no coverage. Regression goes through `makeAuthenticatedRequest` rather than the primitive, since that is where the chain actually ran. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/stalker-auth-failure-detection.md | 8 + docs/architecture/stalker-portal.md | 12 +- .../src/lib/stalker-portal-discovery.utils.ts | 112 ++---------- .../src/lib/stalker-session.service.spec.ts | 22 +++ .../src/lib/stalker-session.service.ts | 35 ++-- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/stalker-auth-failure.util.spec.ts | 117 ++++++++++++ .../src/lib/stalker-auth-failure.util.ts | 166 ++++++++++++++++++ 8 files changed, 347 insertions(+), 126 deletions(-) create mode 100644 .changes/stalker-auth-failure-detection.md create mode 100644 libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts diff --git a/.changes/stalker-auth-failure-detection.md b/.changes/stalker-auth-failure-detection.md new file mode 100644 index 000000000..84097a3d7 --- /dev/null +++ b/.changes/stalker-auth-failure-detection.md @@ -0,0 +1,8 @@ +--- +type: fix +area: stalker +--- + +A Stalker portal is no longer re-probed and reclassified because something +in front of it — a proxy or firewall, not the portal — answered with a short +"Access denied" page. Only the portal's own authorization replies count now. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 0ddde12fc..4b5b9d0a9 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -71,7 +71,17 @@ Two portal modes exist, persisted per playlist as except `handshake`, `get_profile`, `get_localization`, and `do_auth` requires `Authorization: Bearer `; auth failures are HTTP 200 with a plain-text body (`Authorization failed.`, `Access denied.`, - `Unauthorized request.`), never a 401/403. While a full portal is the + `Unauthorized request.`), never a 401/403. Detection of those bodies — and + of the JSON envelope (`{js: {error|msg}}`) some panels answer instead — + lives in `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`, so + the Electron main process (where the bodies actually arrive) and the + renderer classify identically; `stalker-portal-discovery.utils.ts` + re-exports it. The body match is anchored to the whole reply: these are + bare phrases, and a substring rule also accepted a short proxy or WAF page + containing one, which then triggered portal reclassification against a + portal that never answered. The structured `js.error`/`js.msg` fields keep + the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`), + since a panel fills those in deliberately. While a full portal is the active playlist, `StalkerSessionService` keeps a **watchdog** running — periodic authenticated `watchdog/get_events` pings (currently every 25 s; the protocol default expects 120 s, tracked for a later PR) whose failures diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts index 40a5ff0f0..c7797dd4f 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.ts @@ -9,6 +9,8 @@ * it responds, instead of guessing from the URL shape (#850, #686, #755). */ +import { isStalkerAuthFailureResponse } from '@iptvnator/shared/interfaces'; + /** * Candidate API endpoints for a pasted portal URL, in probe order. * @@ -99,108 +101,16 @@ export function buildStalkerEndpointCandidates(rawUrl: string): string[] { } /** - * The stock Stalker middleware answers auth failures with HTTP 200 and a - * bare plain-text body — never a 401/403. These are the three exact strings - * it emits (sometimes with a trailing numeric counter). + * Auth-failure detection lives in `@iptvnator/shared/interfaces` so the + * Electron main process — where these bodies actually arrive — and the + * renderer classify identically. Re-exported here because discovery, + * repair and the session service already import it from this module. */ -const STALKER_AUTH_FAILURE_PATTERNS = [ - /authorization\s+failed/i, - /access\s+denied/i, - /unauthorized\s+request/i, -]; - -/** - * Whether a portal response body is one of the middleware's plain-text auth - * failures. The length cap keeps an arbitrary HTML error page that merely - * mentions "access denied" from being mistaken for the middleware's bare - * phrase. - */ -export function isStalkerAuthFailureBody(response: unknown): boolean { - if (typeof response !== 'string') { - return false; - } - - const body = response.trim(); - if (body.length === 0 || body.length > 200) { - return false; - } - - return STALKER_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(body)); -} - -/** - * Whether a portal response is an authorization failure in EITHER wire - * shape: the middleware's plain-text body, or the JSON envelope some panels - * answer instead (`{ js: { error: "Authorization failed" } }` / - * `{ js: { msg: … } }` — the same forms - * `StalkerSessionService.isAuthorizationError()` recognizes). Classification - * and the lazy-repair trigger must use this, not the string-only primitive: - * a JSON-failing panel would otherwise be persisted as token-free and never - * repaired. - */ -export function isStalkerAuthFailureResponse(response: unknown): boolean { - if (isStalkerAuthFailureBody(response)) { - return true; - } - - if ( - response === null || - typeof response !== 'object' || - !('js' in (response as Record)) - ) { - return false; - } - - const js = (response as { js?: unknown }).js; - if (js === null || typeof js !== 'object') { - return false; - } - - const { error, msg } = js as { error?: unknown; msg?: unknown }; - return [error, msg].some( - (value) => - typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value) - ); -} - -/** - * Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg` - * fields. Deliberately wider than the plain-text body patterns (which stay - * narrow to avoid matching arbitrary HTML pages): these are the same forms - * `StalkerSessionService.isAuthorizationError()` recognizes — panels answer - * "Invalid token", "Auth failed" or bare "unauthorized" here. - */ -const STALKER_JSON_AUTH_FAILURE_PATTERNS = [ - ...STALKER_AUTH_FAILURE_PATTERNS, - /auth\s+failed/i, - /invalid\s+token/i, - /\bunauthorized\b/i, - /authorization/i, -]; - -/** - * Whether an ERROR MESSAGE reports an authorization failure. Uses the wide - * phrase set (including `Invalid token` / `Auth failed`) because the input - * is a controlled string produced by our own auth layer — e.g. - * `Error('Profile error: Invalid token')` — not an arbitrary portal body, - * where the same breadth would false-positive on HTML pages. - */ -export function isStalkerAuthFailureMessage(message: unknown): boolean { - return ( - typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message) - ); -} - -function isStalkerJsonAuthFailurePhrase(value: string): boolean { - const phrase = value.trim(); - if (phrase.length === 0 || phrase.length > 200) { - return false; - } - - return STALKER_JSON_AUTH_FAILURE_PATTERNS.some((pattern) => - pattern.test(phrase) - ); -} +export { + isStalkerAuthFailureBody, + isStalkerAuthFailureMessage, + isStalkerAuthFailureResponse, +} from '@iptvnator/shared/interfaces'; export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index ee2e776b7..bb4540b2a 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -259,6 +259,28 @@ describe('StalkerSessionService identity-tagged token cache', () => { } ); + it('does not treat a proxy page mentioning the phrase as an auth failure', async () => { + // End to end through makeAuthenticatedRequest, not just the + // primitive: this is the path that used to stringify the whole + // response and match an unanchored `authorization failed`. A short + // page from something in FRONT of the portal would retire the token, + // retry, and throw a message that itself matches the repair trigger — + // re-probing a portal that never refused anything. + service.setCachedToken('portal-1', 'LIVE', playlistA); + const body = 'The request Authorization failed. Try again later.'; + sendIpcEvent.mockResolvedValue(body); + + await expect( + service.makeAuthenticatedRequest(playlistA, { + action: 'get_genres', + }) + ).resolves.toBe(body); + + // No retry, no retirement, nothing for the repair layer to act on. + expect(sendIpcEvent).toHaveBeenCalledTimes(1); + expect(service.getCachedToken('portal-1')).toBe('LIVE'); + }); + it('retires a failed token even on the no-retry path (watchdog pings)', async () => { service.setCachedToken('portal-1', 'DEAD', playlistA); sendIpcEvent.mockResolvedValue('Authorization failed.'); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 910036dec..746e95a6c 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -801,30 +801,17 @@ export class StalkerSessionService { return true; } - // Convert response to string for pattern matching - const responseStr = JSON.stringify(responseOrError).toLowerCase(); - - // Check for "Authorization failed. XX" pattern (like "Authorization failed. 75") - if (/authorization\s*failed\.?\s*\d*/i.test(responseStr)) { - return true; - } - - // Check for common auth failure indicators - const jsData = response?.['js'] as Record; - const errorMessage = - (response?.['message'] as string)?.toLowerCase?.() || - jsData?.['error']?.toString?.().toLowerCase?.() || - jsData?.['msg']?.toString?.().toLowerCase?.() || - ''; - - return ( - errorMessage.includes('authorization') || - errorMessage.includes('unauthorized') || - errorMessage.includes('auth failed') || - errorMessage.includes('invalid token') || - response?.['status'] === 401 || - jsData?.['error'] === 'Authorization failed' - ); + // Everything above is structural. What used to follow was a + // `JSON.stringify(responseOrError)` sweep with an UNANCHORED + // `authorization failed` pattern — the same false positive the body + // detector was just anchored against, reachable through a different + // door: a short proxy/WAF page containing the phrase retired the + // token, retried, and threw `Authorization failed after retry`, + // whose own message then matched the repair trigger and re-probed a + // portal that never refused anything. The shared detector already + // covers every real shape, including the `js.error`/`js.msg` + // envelopes, so the sweep only added the false positive. + return response?.['status'] === 401 || response?.['status'] === 403; } /** diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 3417f954b..411041f00 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -38,6 +38,7 @@ export * from './lib/portal-playback.interface'; export * from './lib/random-id.util'; export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; +export * from './lib/stalker-auth-failure.util'; export * from './lib/stalker-cmd-encoding.util'; export * from './lib/stalker-portal-actions.enum'; export * from './lib/stalker-portal-mode.util'; diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts new file mode 100644 index 000000000..4332d46e7 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts @@ -0,0 +1,117 @@ +import { + classifyStalkerAuthFailureBody, + isStalkerAuthFailureBody, + isStalkerAuthFailureMessage, + isStalkerAuthFailureResponse, +} from './stalker-auth-failure.util'; + +describe('classifyStalkerAuthFailureBody', () => { + it.each([ + ['Authorization failed.', 'Authorization failed.'], + // The stock server appends a numeric debug counter to this body only. + ['Authorization failed. 75', 'Authorization failed.'], + ['authorization failed', 'Authorization failed.'], + ['Access denied.', 'Access denied.'], + ['access denied', 'Access denied.'], + ['Unauthorized request.', 'Unauthorized request.'], + [' Unauthorized request.\n', 'Unauthorized request.'], + ])('classifies %j as %j', (body, expected) => { + expect(classifyStalkerAuthFailureBody(body)).toBe(expected); + }); + + it('rejects a short proxy/WAF page that merely contains the phrase', () => { + // 38 characters — well under any length cap, so only anchoring keeps + // it out. Read as the middleware speaking it would trigger portal + // reclassification against a portal that never answered at all. + expect( + classifyStalkerAuthFailureBody( + 'Access denied' + ) + ).toBeNull(); + expect( + classifyStalkerAuthFailureBody('Error: access denied by policy') + ).toBeNull(); + expect( + classifyStalkerAuthFailureBody( + 'The request Authorization failed. Try again later.' + ) + ).toBeNull(); + }); + + it('rejects long pages, non-strings and empty bodies', () => { + expect( + classifyStalkerAuthFailureBody( + `${'x'.repeat(300)} access denied` + ) + ).toBeNull(); + expect(classifyStalkerAuthFailureBody({ js: [] })).toBeNull(); + expect(classifyStalkerAuthFailureBody(undefined)).toBeNull(); + expect(classifyStalkerAuthFailureBody(null)).toBeNull(); + expect(classifyStalkerAuthFailureBody('')).toBeNull(); + expect(classifyStalkerAuthFailureBody('OK')).toBeNull(); + }); + + it('exposes the same verdict through the boolean primitive', () => { + expect(isStalkerAuthFailureBody('Access denied.')).toBe(true); + expect(isStalkerAuthFailureBody('Access denied by policy')).toBe(false); + }); +}); + +describe('isStalkerAuthFailureResponse', () => { + it('recognizes the plain-text body and the JSON envelope forms', () => { + expect(isStalkerAuthFailureResponse('Authorization failed.')).toBe( + true + ); + expect( + isStalkerAuthFailureResponse({ + js: { error: 'Authorization failed' }, + }) + ).toBe(true); + expect( + isStalkerAuthFailureResponse({ js: { msg: 'Access denied.' } }) + ).toBe(true); + }); + + it('recognizes the wider structured-field phrases', () => { + expect( + isStalkerAuthFailureResponse({ js: { error: 'Invalid token' } }) + ).toBe(true); + expect( + isStalkerAuthFailureResponse({ js: { msg: 'Auth failed' } }) + ).toBe(true); + expect( + isStalkerAuthFailureResponse({ js: { error: 'unauthorized' } }) + ).toBe(true); + }); + + it('recognizes a phrase placed directly in js', () => { + expect( + isStalkerAuthFailureResponse({ js: 'Authorization failed. 75' }) + ).toBe(true); + }); + + it('leaves ordinary responses alone', () => { + expect(isStalkerAuthFailureResponse({ js: { data: [] } })).toBe(false); + expect(isStalkerAuthFailureResponse({ js: [] })).toBe(false); + expect(isStalkerAuthFailureResponse({ js: false })).toBe(false); + expect(isStalkerAuthFailureResponse(undefined)).toBe(false); + }); +}); + +describe('isStalkerAuthFailureMessage', () => { + it('accepts messages our own auth layer produces', () => { + expect( + isStalkerAuthFailureMessage('Profile error: Invalid token') + ).toBe(true); + expect(isStalkerAuthFailureMessage('Authorization failed. 75')).toBe( + true + ); + }); + + it('rejects unrelated messages', () => { + expect(isStalkerAuthFailureMessage('HTTP Error 404: Not Found')).toBe( + false + ); + expect(isStalkerAuthFailureMessage(undefined)).toBe(false); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts new file mode 100644 index 000000000..eb2d44edc --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts @@ -0,0 +1,166 @@ +/** + * Stalker/Ministra authorization-failure detection, shared by both + * transports. + * + * The stock middleware answers an unauthenticated or unauthorized request + * with **HTTP 200 and a bare plain-text body** — never a 401/403 — because it + * exits before the JSON envelope is built. The three exact bodies + * (Stalker 4.9.35), optionally followed by a numeric debug counter: + * + * - `Authorization failed.` — token missing, stale, or replaced by another + * device's session + * - `Access denied.` — the account is blocked or disabled + * - `Unauthorized request.` — the `mac` cookie is missing entirely + * + * Some reseller panels answer a JSON envelope (`{js: {error: …}}` / + * `{js: {msg: …}}`) instead, with a wider vocabulary. + * + * This lives in `shared/interfaces` rather than in the Stalker renderer lib + * because the Electron main process is where these bodies actually arrive, + * and it cannot import renderer libraries — the same reason the identity and + * URL builders were centralised here. Two copies would drift. + */ + +/** The exact plain-text bodies the stock middleware emits. */ +export const STALKER_AUTH_FAILURE_BODIES = [ + 'Authorization failed.', + 'Access denied.', + 'Unauthorized request.', +] as const; + +export type StalkerAuthFailureBody = + (typeof STALKER_AUTH_FAILURE_BODIES)[number]; + +/** + * Body patterns, anchored to the WHOLE trimmed body. + * + * Anchoring matters: these bodies are bare phrases, so a substring match + * would also accept any short page that merely contains one — a reverse + * proxy or WAF answering `Access denied` is well + * under any sane length cap and would otherwise be read as the middleware + * speaking, triggering portal reclassification against a portal that never + * answered at all. Only the `Authorization failed.` body carries the stock + * server's optional trailing counter. + */ +const AUTH_FAILURE_PATTERNS: ReadonlyArray<{ + body: StalkerAuthFailureBody; + pattern: RegExp; +}> = [ + { + body: 'Authorization failed.', + pattern: /^authorization\s+failed[.!]*(?:\s+\d+)?$/i, + }, + { body: 'Access denied.', pattern: /^access\s+denied[.!]*$/i }, + { body: 'Unauthorized request.', pattern: /^unauthorized\s+request[.!]*$/i }, +]; + +/** Bodies longer than this are never the middleware's bare phrase. */ +const MAX_BODY_LENGTH = 64; + +/** + * Classifies a raw portal response body, returning the canonical failure + * body when the value is one of the three plain-text auth failures. + */ +export function classifyStalkerAuthFailureBody( + value: unknown +): StalkerAuthFailureBody | null { + if (typeof value !== 'string') { + return null; + } + + const body = value.trim(); + if (body.length === 0 || body.length > MAX_BODY_LENGTH) { + return null; + } + + return ( + AUTH_FAILURE_PATTERNS.find(({ pattern }) => pattern.test(body))?.body ?? + null + ); +} + +/** + * Whether a portal response body is one of the middleware's plain-text auth + * failures. + */ +export function isStalkerAuthFailureBody(value: unknown): boolean { + return classifyStalkerAuthFailureBody(value) !== null; +} + +/** + * Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg` + * fields, and in error messages our own auth layer produces. Deliberately + * wider than the body patterns above — the input is a field a panel filled + * in deliberately, not an arbitrary document, so `Invalid token`, + * `Auth failed` and a bare `unauthorized` are all meaningful there. + */ +const JSON_AUTH_FAILURE_PATTERNS = [ + /authorization\s+failed/i, + /access\s+denied/i, + /unauthorized\s+request/i, + /auth\s+failed/i, + /invalid\s+token/i, + /\bunauthorized\b/i, + /authorization/i, +]; + +const MAX_PHRASE_LENGTH = 200; + +function isStalkerJsonAuthFailurePhrase(value: string): boolean { + const phrase = value.trim(); + if (phrase.length === 0 || phrase.length > MAX_PHRASE_LENGTH) { + return false; + } + + return JSON_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(phrase)); +} + +/** + * Whether an ERROR MESSAGE reports an authorization failure. Uses the wide + * phrase set because the input is a controlled string produced by our own + * auth layer — e.g. `Error('Profile error: Invalid token')` — not an + * arbitrary portal body, where the same breadth would false-positive. + */ +export function isStalkerAuthFailureMessage(message: unknown): boolean { + return ( + typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message) + ); +} + +/** + * Whether a portal response is an authorization failure in EITHER wire + * shape: the middleware's plain-text body, or the JSON envelope some panels + * answer instead. Classification and the lazy-repair trigger must use this, + * not the string-only primitive: a JSON-failing panel would otherwise be + * persisted as token-free and never repaired. + */ +export function isStalkerAuthFailureResponse(response: unknown): boolean { + if (isStalkerAuthFailureBody(response)) { + return true; + } + + if ( + response === null || + typeof response !== 'object' || + !('js' in (response as Record)) + ) { + return false; + } + + const js = (response as { js?: unknown }).js; + + // Some panels put the phrase directly in `js` instead of an object. + if (typeof js === 'string') { + return isStalkerJsonAuthFailurePhrase(js); + } + + if (js === null || typeof js !== 'object') { + return false; + } + + const { error, msg } = js as { error?: unknown; msg?: unknown }; + return [error, msg].some( + (value) => + typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value) + ); +}