mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(mock): tighten portal-auth fidelity per review
Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid: - adoptToken only accepts tokens the mock actually issued (or the already-bound one). The stock server pins any presented Bearer — handshake is stateless there — but a fixture that does the same cannot catch a client with a broken token pipeline; documented as a deliberate strictness divergence. - /invalidate-session clears tokens but keeps pinned device identity: losing a token never unpins device_id on a real portal, so changed identity after re-auth must still hit the device-conflict branch. - The login-required scenario gates on actual do_auth completion instead of auth_second_step: the app sends auth_second_step=1 on its very first get_profile, so the parameter check was trivially bypassed and the status-2 flow never exercised. do_auth is now the faithful boolean step (non-empty credentials -> {js:true}, recorded; empty -> {js:false}). - /server/load.php — the second URL shape isFullStalkerPortal recognizes — is now served and enforced, directly and through the /stalker proxy predicate, so full-portal tests cannot silently fall into the tolerant branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b30e8b9e9
commit
149df18c32
7 files changed
+155
-36
No files matched your search
@@ -41,6 +41,7 @@ imported as a **full portal** (handshake + token + watchdog), anything else as a
|
||||
|---|---|
|
||||
| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. |
|
||||
| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. |
|
||||
| `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. |
|
||||
|
||||
The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can
|
||||
actually get wrong:
|
||||
@@ -48,7 +49,9 @@ actually get wrong:
|
||||
- Every action except `handshake`, `get_profile`, `get_localization` and
|
||||
`do_auth` requires `Authorization: Bearer <token>`.
|
||||
- A token only counts once `get_profile` has adopted it — a handshake alone is
|
||||
not a session.
|
||||
not a session. Adoption is deliberately stricter than the stock server:
|
||||
only tokens the mock actually issued (or the already-bound one) are
|
||||
accepted, so a client with a broken token pipeline fails loudly.
|
||||
- Auth failures come back as **HTTP 200 with a plain-text body**
|
||||
(`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients
|
||||
that only check status codes will silently render nothing.
|
||||
@@ -74,7 +77,7 @@ actually get wrong:
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` and retries with `auth_second_step=1` |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
## Configuration
|
||||
@@ -90,7 +93,7 @@ actually get wrong:
|
||||
|---|---|---|
|
||||
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
|
||||
| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) |
|
||||
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's session so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries |
|
||||
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal |
|
||||
|
||||
## API Coverage
|
||||
|
||||
@@ -101,7 +104,7 @@ All endpoints are served at `GET /portal.php?action=<action>&...` matching the r
|
||||
| `handshake` | Issues the access token (idempotent) plus the 5.x `random` nonce and `not_valid` flag |
|
||||
| `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format |
|
||||
| `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) |
|
||||
| `do_auth` | Returns a mock user profile |
|
||||
| `do_auth` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise |
|
||||
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
|
||||
| `get_genres` | Genre list (mirrors categories) |
|
||||
| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons |
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Request } from 'express';
|
||||
import {
|
||||
adoptToken,
|
||||
checkRequestAuthorization,
|
||||
hasCompletedDoAuth,
|
||||
invalidateSession,
|
||||
issueHandshakeToken,
|
||||
markDoAuthCompleted,
|
||||
pinDeviceIdentity,
|
||||
readBearerToken,
|
||||
resetAuthState,
|
||||
@@ -89,6 +91,28 @@ describe('stalker mock auth store', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to adopt a token the handshake never issued', () => {
|
||||
issueHandshakeToken(MAC);
|
||||
|
||||
// Stricter than the stock server on purpose: a forged or stale token
|
||||
// must not become a session, or the mock cannot catch a client whose
|
||||
// token pipeline is broken.
|
||||
expect(adoptToken(MAC, 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12')).toBe(false);
|
||||
expect(
|
||||
checkRequestAuthorization(
|
||||
request({ token: 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12' }),
|
||||
true
|
||||
)
|
||||
).toBe('Authorization failed.');
|
||||
});
|
||||
|
||||
it('re-adopts the already-bound token', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
adoptToken(MAC, token);
|
||||
|
||||
expect(adoptToken(MAC, token)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails after the session is invalidated so clients must re-authenticate', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
adoptToken(MAC, token);
|
||||
@@ -99,6 +123,26 @@ describe('stalker mock auth store', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pinned device identity across session invalidation', () => {
|
||||
pinDeviceIdentity(MAC, 'dev-1', undefined);
|
||||
invalidateSession(MAC);
|
||||
|
||||
// Losing the token (another device logged in) never unpins device_id
|
||||
// on a real portal, so a changed identity must still conflict.
|
||||
expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe(
|
||||
'device conflict - device_id mismatch'
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks do_auth completion per MAC', () => {
|
||||
expect(hasCompletedDoAuth(MAC)).toBe(false);
|
||||
|
||||
markDoAuthCompleted(MAC);
|
||||
|
||||
expect(hasCompletedDoAuth(MAC)).toBe(true);
|
||||
expect(hasCompletedDoAuth('00:1A:79:00:00:99')).toBe(false);
|
||||
});
|
||||
|
||||
it('never enforces the token when enforcement is off', () => {
|
||||
expect(checkRequestAuthorization(request({}), false)).toBe(null);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ interface PortalSession {
|
||||
pendingToken?: string;
|
||||
/** Token stored for the MAC — what authorizes non-auth actions. */
|
||||
accessToken?: string;
|
||||
/** Whether do_auth completed with non-empty credentials for this MAC. */
|
||||
didAuth?: boolean;
|
||||
deviceId?: string;
|
||||
deviceId2?: string;
|
||||
}
|
||||
@@ -83,11 +85,32 @@ export function issueHandshakeToken(mac: string, presentedToken?: string): {
|
||||
return { token, notValid: Boolean(presentedToken) };
|
||||
}
|
||||
|
||||
/** Adopt the handshake token as the MAC's session token (what get_profile does). */
|
||||
export function adoptToken(mac: string, token: string): void {
|
||||
/**
|
||||
* Adopt the handshake token as the MAC's session token (what get_profile
|
||||
* does). Deliberately STRICTER than the stock server here: 4.9.35 issues
|
||||
* handshake tokens statelessly and pins whatever Bearer get_profile presents,
|
||||
* so a forged token would become a session on a real portal. The mock only
|
||||
* adopts tokens it actually issued (or the already-bound one), so a client
|
||||
* with a broken or substituted token pipeline fails loudly in tests instead
|
||||
* of passing by accident.
|
||||
*/
|
||||
export function adoptToken(mac: string, token: string): boolean {
|
||||
const session = getSession(mac);
|
||||
if (token !== session.pendingToken && token !== session.accessToken) {
|
||||
return false;
|
||||
}
|
||||
session.accessToken = token;
|
||||
session.pendingToken = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Record that do_auth completed with non-empty credentials for this MAC. */
|
||||
export function markDoAuthCompleted(mac: string): void {
|
||||
getSession(mac).didAuth = true;
|
||||
}
|
||||
|
||||
export function hasCompletedDoAuth(mac: string): boolean {
|
||||
return getSession(mac).didAuth === true;
|
||||
}
|
||||
|
||||
export function readBearerToken(req: Request): string | undefined {
|
||||
@@ -163,9 +186,19 @@ export function checkRequestAuthorization(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Drop the MAC's session so the next request must re-authenticate. */
|
||||
/**
|
||||
* Drop the MAC's tokens so the next request must re-authenticate. Pinned
|
||||
* device identity survives on purpose: on a real portal a lost token (another
|
||||
* device logged in) never unpins device_id, so re-authenticating with a
|
||||
* changed identity must still hit the device-conflict branch.
|
||||
*/
|
||||
export function invalidateSession(mac: string): void {
|
||||
sessions.delete(mac.toLowerCase());
|
||||
const session = sessions.get(mac.toLowerCase());
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
session.accessToken = undefined;
|
||||
session.pendingToken = undefined;
|
||||
}
|
||||
|
||||
export function resetAuthState(): void {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { markDoAuthCompleted } from '../auth-store.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker do_auth — returns user profile / account info.
|
||||
* Stalker do_auth — the login/password step behind `get_profile` status 2.
|
||||
*
|
||||
* The real portal never checks a password itself: it proxies the credentials
|
||||
* to the operator's billing script and answers a bare boolean. The mock
|
||||
* accepts any non-empty pair, records the completion so `get_profile` can
|
||||
* stop answering `status: 2` for the login-required scenario, and rejects
|
||||
* empty credentials the way a billing script would.
|
||||
*/
|
||||
export function handleDoAuth(req: Request, res: Response): void {
|
||||
res.json({
|
||||
js: {
|
||||
id: '1',
|
||||
name: 'Mock User',
|
||||
login: 'mockuser',
|
||||
password: '',
|
||||
status: 'active',
|
||||
tariff_expired_date: '2099-12-31',
|
||||
phone: '',
|
||||
ls: '0',
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
blocked: '0',
|
||||
acc_enabled: '1',
|
||||
max_connections: '1',
|
||||
active_connections: '0',
|
||||
},
|
||||
});
|
||||
const login = String(req.query['login'] ?? '');
|
||||
const password = String(req.query['password'] ?? '');
|
||||
|
||||
if (!login || !password) {
|
||||
res.json({ js: false });
|
||||
return;
|
||||
}
|
||||
|
||||
markDoAuthCompleted(extractMac(req));
|
||||
res.json({ js: true });
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { adoptToken, pinDeviceIdentity, readBearerToken } from '../auth-store.js';
|
||||
import {
|
||||
adoptToken,
|
||||
hasCompletedDoAuth,
|
||||
pinDeviceIdentity,
|
||||
readBearerToken,
|
||||
} from '../auth-store.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
@@ -28,7 +33,11 @@ export function handleGetProfile(
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario.requiresLogin && req.query['auth_second_step'] !== '1') {
|
||||
// Gate on the actual do_auth completion, not on auth_second_step: the app
|
||||
// sends auth_second_step=1 on its very first get_profile, so a parameter
|
||||
// check would let the login-required flow be bypassed without ever
|
||||
// exercising status 2 -> do_auth -> profile retry.
|
||||
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
|
||||
res.json({
|
||||
js: {
|
||||
status: 2,
|
||||
|
||||
@@ -81,9 +81,22 @@ app.use(
|
||||
// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check)
|
||||
app.use('/portal.php', portalRouter);
|
||||
|
||||
// Canonical Ministra endpoint — enforces the Bearer token and the MAC format
|
||||
// Canonical Ministra endpoints — enforce the Bearer token and the MAC format
|
||||
// exactly like the real middleware, so the full-portal auth flow is testable.
|
||||
// Both URL shapes the app classifies as "full" must land on the strict branch.
|
||||
app.use('/stalker_portal/server/load.php', createPortalRouter(true));
|
||||
app.use('/server/load.php', createPortalRouter(true));
|
||||
|
||||
/**
|
||||
* Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks
|
||||
* `/stalker_portal/` or `/server/load.php`; import-time normalization checks
|
||||
* `/stalker_portal`). Any URL shape the client would authenticate against must
|
||||
* be enforced by the proxy too, or tests would silently exercise the tolerant
|
||||
* branch.
|
||||
*/
|
||||
function isFullPortalUrlShape(url: string): boolean {
|
||||
return url.includes('/stalker_portal') || url.includes('/server/load.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
|
||||
@@ -140,9 +153,9 @@ app.get('/stalker', (req: Request, res: Response) => {
|
||||
} as unknown as Response & { send: (body: string) => void };
|
||||
|
||||
dispatchPortalAction(syntheticReq, syntheticRes, {
|
||||
// The proxied portal URL decides strictness, matching the two direct
|
||||
// endpoints: a canonical Ministra path enforces the token.
|
||||
enforceAuth: (asString(portalUrl) ?? '').includes('/stalker_portal/'),
|
||||
// The proxied portal URL decides strictness, matching the direct
|
||||
// endpoints: every canonical Ministra path shape enforces the token.
|
||||
enforceAuth: isFullPortalUrlShape(asString(portalUrl) ?? ''),
|
||||
});
|
||||
|
||||
// The portal answers auth failures with a plain-text body; the real backend
|
||||
|
||||
@@ -51,6 +51,11 @@ paths:
|
||||
|---|---|---|
|
||||
| `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels |
|
||||
| `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware |
|
||||
| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes |
|
||||
|
||||
The `/stalker` proxy route applies the same rule through
|
||||
`isFullPortalUrlShape()` — every URL the client would authenticate against is
|
||||
enforced, so tests cannot silently fall into the tolerant branch.
|
||||
|
||||
Keeping the tolerant path is what lets the pre-existing e2e suite (which imports
|
||||
`portal.php`) stay meaningful — it covers the simple-portal branch — while the
|
||||
@@ -66,7 +71,11 @@ The strict behaviours mirror the plaintext Stalker 4.9.35 middleware
|
||||
sees "success" and renders nothing. The `/stalker` proxy route still wraps the
|
||||
body in the `{ payload }` envelope, matching what `apps/web-backend` does.
|
||||
- **A handshake is not a session.** The token only authorizes requests once
|
||||
`get_profile` has adopted it for that MAC.
|
||||
`get_profile` has adopted it for that MAC. Adoption is deliberately
|
||||
*stricter* than the stock server: 4.9.35 issues handshake tokens statelessly
|
||||
and pins whatever Bearer `get_profile` presents, so a forged token would
|
||||
become a session on a real portal — the mock only adopts tokens it actually
|
||||
issued, so a client with a broken token pipeline fails loudly in tests.
|
||||
- **Idempotent handshake.** Presenting the MAC's current token returns that same
|
||||
token, which is what allows real clients to persist tokens across restarts.
|
||||
- **Device-id pinning.** `device_id`/`device_id2` are stored on first non-empty
|
||||
@@ -78,9 +87,17 @@ The strict behaviours mirror the plaintext Stalker 4.9.35 middleware
|
||||
- **MAC format validation.** Non-Infomir MACs (`00:1A:79:XX:XX:XX`) get a bare
|
||||
`{ status: 1 }` from `get_profile`.
|
||||
|
||||
- **`do_auth` is a boolean login step.** Non-empty credentials answer
|
||||
`{js:true}` and are recorded; the `login-required` scenario's `get_profile`
|
||||
keeps answering `status: 2` until that record exists, because the app sends
|
||||
`auth_second_step=1` on its very first profile request and a parameter check
|
||||
alone would be trivially bypassed.
|
||||
|
||||
Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`.
|
||||
`POST /invalidate-session?macAddress=<mac>` drops a single session so tests can
|
||||
assert the client re-handshakes and retries instead of surfacing an error.
|
||||
`POST /invalidate-session?macAddress=<mac>` drops a single MAC's tokens so
|
||||
tests can assert the client re-handshakes and retries instead of surfacing an
|
||||
error; pinned device identity survives invalidation, as it does on a real
|
||||
portal.
|
||||
|
||||
## Data Generation Pipeline
|
||||
|
||||
|
||||
Reference in new issue
Block a user