fix(stalker): anchor auth-failure body detection and share it across transports (#1358)

* 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
`<html><body>Access denied</body></html>` (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 <noreply@anthropic.com>

* 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 <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-03 08:03:08 +02:00
1 parent c741815b97
commit d3cc18dc72
8 files changed
+347 -126

No files matched your search

@@ -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.
+11 -1
View File
@@ -71,7 +71,17 @@ Two portal modes exist, persisted per playlist as
except `handshake`, `get_profile`, `get_localization`, and `do_auth`
requires `Authorization: Bearer <token>`; 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
@@ -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<string, unknown>))
) {
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';
@@ -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.');
@@ -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<string, unknown>;
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;
}
/**
+1
View File
@@ -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';
@@ -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(
'<html><body>Access denied</body></html>'
)
).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(
`<html>${'x'.repeat(300)} access denied</html>`
)
).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);
});
});
@@ -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 `<html><body>Access denied</body></html>` 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<string, unknown>))
) {
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)
);
}