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

+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)
);
}