From 6ebf5fd55af401ce9a14e33368ea4693a288bf84 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:18:46 +0200 Subject: [PATCH] test(stalker): reject noncanonical do auth replay --- .../noncanonical-do-auth-string.json | 323 ++++++++++++++++++ .../authentication/noncanonical-do-auth.json | 2 +- .../app/replay/replay-fixture-corpus.spec.ts | 68 ++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth-string.json diff --git a/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth-string.json b/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth-string.json new file mode 100644 index 000000000..378621ce2 --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth-string.json @@ -0,0 +1,323 @@ +{ + "description": "Synthetic string do auth near-miss is incompatible and forbids a second profile.", + "entry": { + "origin": "portal", + "path": "/c/" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "anonymous-probe", + "method": "GET", + "operation": "anonymous-probe", + "origin": "portal", + "path": "/c/", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "mac", + "session" + ], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [ + "authorization", + "cookie" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "device_id", + "device_id2", + "mac", + "password", + "signature", + "signature2", + "sn", + "token", + "username" + ], + "exact": {}, + "present": [] + } + }, + "response": { + "body": { + "kind": "empty" + }, + "headers": {}, + "status": 204 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "handshake", + "method": "GET", + "operation": "handshake", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [ + "authorization" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "action": "handshake", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "token": { + "kind": "generate", + "symbol": "session-token", + "valueKind": "token" + } + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "profile-first", + "method": "GET", + "operation": "profile-first", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "session-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "password", + "username" + ], + "exact": { + "action": "get_profile", + "auth_second_step": "0", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "status": 2 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "do-auth-noncanonical-string", + "method": "GET", + "operation": "do-auth-noncanonical-string", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "session-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "do_auth", + "login": { + "kind": "ref", + "symbol": "username" + }, + "password": { + "kind": "ref", + "symbol": "password" + }, + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": "true" + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "noncanonical-do-auth-string", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "authentication-noncanonical-do-auth-string", + "schemaVersion": 1, + "symbols": [ + { + "kind": "generate", + "symbol": "mac", + "valueKind": "mac" + }, + { + "kind": "generate", + "symbol": "username", + "valueKind": "credential" + }, + { + "kind": "generate", + "symbol": "password", + "valueKind": "credential" + } + ], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth.json b/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth.json index 2cb4318a4..049497497 100644 --- a/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth.json +++ b/apps/stalker-mock-server/fixtures/replay/authentication/noncanonical-do-auth.json @@ -1,5 +1,5 @@ { - "description": "Synthetic noncanonical do auth success still permits the second profile.", + "description": "Synthetic numeric do auth near-miss is incompatible and forbids a second profile.", "entry": { "origin": "portal", "path": "/c/" diff --git a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts index 23e98e27b..7fd8c07ca 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- The committed corpus matrix and its typed deterministic driver form one auditable contract. */ import { readdirSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { classifyStalkerDoAuth } from '@iptvnator/portal/stalker/protocol'; import { createReplayRun, type ReplayRun } from './replay-run.js'; import { parseReplayFixtureText } from './replay-schema.js'; import type { @@ -26,6 +27,7 @@ const FIXTURE_ROOT = resolve( const EXPECTED_SCENARIOS = [ 'authentication-blocked-profile', 'authentication-noncanonical-do-auth', + 'authentication-noncanonical-do-auth-string', 'authentication-saved-rejected-fresh', 'authentication-status2-second-step', 'authentication-three-attempt-limit', @@ -641,6 +643,72 @@ describe('committed replay fixture corpus', () => { } } }); + + it('terminates both noncanonical do_auth near misses before a second profile', () => { + const cases = [ + { + expectedJs: 1, + fileName: 'noncanonical-do-auth.json', + scenarioId: 'authentication-noncanonical-do-auth', + }, + { + expectedJs: 'true', + fileName: 'noncanonical-do-auth-string.json', + scenarioId: 'authentication-noncanonical-do-auth-string', + }, + ] as const; + + for (const current of cases) { + const fixture = parseReplayFixtureText( + readFileSync( + join(FIXTURE_ROOT, 'authentication', current.fileName), + 'utf8' + ) + ); + const expectations = fixture.phases.flatMap( + (phase) => phase.expectations + ); + const profileSteps = expectations + .filter( + (expectation) => + expectation.request.query.exact['action'] === + 'get_profile' + ) + .map( + (expectation) => + expectation.request.query.exact['auth_second_step'] + ); + const doAuth = expectations.find( + (expectation) => + expectation.request.query.exact['action'] === 'do_auth' + ); + if (doAuth?.response.body.kind !== 'json') { + throw new Error( + `Noncanonical fixture lacks do_auth JSON: ${fixture.scenarioId}.` + ); + } + const doAuthValue = doAuth.response.body.value as { + readonly js?: unknown; + }; + const terminalPhase = fixture.phases.find((phase) => + phase.expectations.includes(doAuth) + ); + + expect(fixture).toMatchObject({ + failOnUnexpectedRequest: true, + scenarioId: current.scenarioId, + }); + expect(doAuthValue.js).toBe(current.expectedJs); + expect( + classifyStalkerDoAuth({ js: doAuthValue.js }) + ).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + expect(profileSteps).toEqual(['0']); + expect(terminalPhase?.nextState).toBe(fixture.terminalState); + } + }); }); function compareCodeUnits(left: string, right: string): number {