mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
import { isHostConnectivityFastFailMessage } from './host-connectivity.util';
|
|
|
|
/** 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 {
|
|
if (typeof message !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
// The connectivity guard's refusal names the portal endpoint, and a
|
|
// hostname is user data: `https://authorization.example` would otherwise
|
|
// match the broad phrase set below and send an unreachable host into lazy
|
|
// portal repair. An explicit marker outranks a heuristic.
|
|
if (isHostConnectivityFastFailMessage(message)) {
|
|
return false;
|
|
}
|
|
|
|
return isStalkerJsonAuthFailurePhrase(message);
|
|
}
|
|
|
|
/**
|
|
* Structured shape a transport returns in place of the raw plain-text body.
|
|
*
|
|
* Returned, never thrown: `ipcRenderer.invoke` strips custom properties from
|
|
* rejected values, so a classified body would not survive the trip to the
|
|
* renderer as an error. The Electron main process mints this because it is
|
|
* where the body arrives; the PWA proxy still delivers the raw string, and
|
|
* every consumer goes through the predicates below rather than caring which.
|
|
*/
|
|
export interface StalkerAuthFailureMarker {
|
|
stalkerAuthFailure: StalkerAuthFailureBody;
|
|
}
|
|
|
|
export function createStalkerAuthFailureMarker(
|
|
body: StalkerAuthFailureBody
|
|
): StalkerAuthFailureMarker {
|
|
return { stalkerAuthFailure: body };
|
|
}
|
|
|
|
function readAuthFailureMarker(
|
|
value: unknown
|
|
): StalkerAuthFailureBody | null {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return null;
|
|
}
|
|
|
|
return classifyStalkerAuthFailureBody(
|
|
(value as StalkerAuthFailureMarker).stalkerAuthFailure
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extracts the canonical failure body from any shape an auth failure reaches
|
|
* a consumer in: the transport marker (Electron), the raw plain-text payload
|
|
* (PWA proxy), or a `{js: '<body>'}` envelope. Returns null for everything
|
|
* else, including the structured `{js: {error|msg}}` form — that one is a
|
|
* panel's own wording, not one of the middleware's three bodies, so it has
|
|
* no canonical body to report. Use `isStalkerAuthFailureResponse` when the
|
|
* question is merely "did authorization fail?".
|
|
*/
|
|
export function extractStalkerAuthFailureBody(
|
|
value: unknown
|
|
): StalkerAuthFailureBody | null {
|
|
return (
|
|
readAuthFailureMarker(value) ??
|
|
classifyStalkerAuthFailureBody(value) ??
|
|
classifyStalkerAuthFailureBody(
|
|
typeof value === 'object' && value !== null && 'js' in value
|
|
? (value as { js?: unknown }).js
|
|
: undefined
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a portal response is an authorization failure in ANY wire shape:
|
|
* the middleware's plain-text body, the transport marker minted from it, 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, and an Electron-classified body would stop triggering repair at
|
|
* all.
|
|
*/
|
|
export function isStalkerAuthFailureResponse(response: unknown): boolean {
|
|
if (isStalkerAuthFailureBody(response)) {
|
|
return true;
|
|
}
|
|
|
|
if (readAuthFailureMarker(response) !== null) {
|
|
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)
|
|
);
|
|
}
|