mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(stalker): resolve portal endpoints safely
This commit is contained in:
1 parent
6726863081
commit
c12ad52019
7 files changed
+984
-31
No files matched your search
@@ -40,12 +40,12 @@ describe('StalkerCookieJar', () => {
|
||||
'second=two; Path=/',
|
||||
]);
|
||||
|
||||
expect(
|
||||
await jar.getCookieHeader('https://one.example.test/next')
|
||||
).toBe('first=one');
|
||||
expect(
|
||||
await jar.getCookieHeader('https://two.example.test/next')
|
||||
).toBe('second=two');
|
||||
expect(await jar.getCookieHeader('https://one.example.test/next')).toBe(
|
||||
'first=one'
|
||||
);
|
||||
expect(await jar.getCookieHeader('https://two.example.test/next')).toBe(
|
||||
'second=two'
|
||||
);
|
||||
});
|
||||
|
||||
it('honors expiry against an injected clock', async () => {
|
||||
@@ -58,9 +58,9 @@ describe('StalkerCookieJar', () => {
|
||||
]);
|
||||
|
||||
now = new Date('2026-07-27T10:01:01.000Z');
|
||||
expect(
|
||||
await jar.getCookieHeader('https://portal.example.com/')
|
||||
).toBe('absolute=alive');
|
||||
expect(await jar.getCookieHeader('https://portal.example.com/')).toBe(
|
||||
'absolute=alive'
|
||||
);
|
||||
|
||||
now = new Date('2026-07-27T10:02:01.000Z');
|
||||
expect(
|
||||
@@ -76,9 +76,9 @@ describe('StalkerCookieJar', () => {
|
||||
'good=value; Path=/',
|
||||
]);
|
||||
|
||||
expect(
|
||||
await jar.getCookieHeader('https://portal.example.com/')
|
||||
).toBe('good=value');
|
||||
expect(await jar.getCookieHeader('https://portal.example.com/')).toBe(
|
||||
'good=value'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -93,10 +93,10 @@ describe('StalkerCookieJar', () => {
|
||||
async (setCookie) => {
|
||||
const jar = new StalkerCookieJar(managedCookies);
|
||||
|
||||
await jar.collectResponseCookies(
|
||||
'https://portal.example.com/c/',
|
||||
[setCookie, 'server=retained; Path=/']
|
||||
);
|
||||
await jar.collectResponseCookies('https://portal.example.com/c/', [
|
||||
setCookie,
|
||||
'server=retained; Path=/',
|
||||
]);
|
||||
|
||||
expect(
|
||||
await jar.getCookieHeader(
|
||||
@@ -117,25 +117,21 @@ describe('StalkerCookieJar', () => {
|
||||
|
||||
const internalJar = Reflect.get(jar, '__testJar');
|
||||
expect(internalJar).toBeUndefined();
|
||||
expect(
|
||||
await jar.getCookieHeader('https://portal.example.com/')
|
||||
).toBe(
|
||||
expect(await jar.getCookieHeader('https://portal.example.com/')).toBe(
|
||||
'session=server; mac=00:1A:79:00:00:01; stb_lang=en; timezone=Europe/Berlin'
|
||||
);
|
||||
});
|
||||
|
||||
it('clones cookie state without sharing later mutations', async () => {
|
||||
const original = new StalkerCookieJar(managedCookies);
|
||||
await original.collectResponseCookies(
|
||||
'https://portal.example.com/',
|
||||
['landing=kept; Path=/']
|
||||
);
|
||||
await original.collectResponseCookies('https://portal.example.com/', [
|
||||
'landing=kept; Path=/',
|
||||
]);
|
||||
|
||||
const candidate = await original.clone();
|
||||
await candidate.collectResponseCookies(
|
||||
'https://portal.example.com/',
|
||||
['candidate=isolated; Path=/']
|
||||
);
|
||||
await candidate.collectResponseCookies('https://portal.example.com/', [
|
||||
'candidate=isolated; Path=/',
|
||||
]);
|
||||
|
||||
expect(
|
||||
await original.getCookieHeader('https://portal.example.com/')
|
||||
@@ -149,6 +145,28 @@ describe('StalkerCookieJar', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('promotes anonymous landing cookies into an isolated managed candidate jar', async () => {
|
||||
const landing = new StalkerCookieJar();
|
||||
await landing.collectResponseCookies('https://portal.example.com/c/', [
|
||||
'landing=kept; Path=/',
|
||||
]);
|
||||
|
||||
const candidate = await landing.cloneWithManagedCookies(managedCookies);
|
||||
await candidate.collectResponseCookies(
|
||||
'https://portal.example.com/c/',
|
||||
['candidate=isolated; Path=/']
|
||||
);
|
||||
|
||||
expect(
|
||||
await landing.getCookieHeader('https://portal.example.com/c/')
|
||||
).toBe('landing=kept');
|
||||
expect(
|
||||
await candidate.getCookieHeader('https://portal.example.com/c/')
|
||||
).toBe(
|
||||
'landing=kept; candidate=isolated; mac=00:1A:79:00:00:01; stb_lang=en; timezone=Europe/Berlin'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a serialization surface or enumerable cookie state', () => {
|
||||
const jar = new StalkerCookieJar(managedCookies);
|
||||
|
||||
|
||||
@@ -93,7 +93,13 @@ export class StalkerCookieJar {
|
||||
}
|
||||
|
||||
async clone(): Promise<StalkerCookieJar> {
|
||||
const clone = new StalkerCookieJar(this.#managedCookies, {
|
||||
return this.cloneWithManagedCookies(this.#managedCookies);
|
||||
}
|
||||
|
||||
async cloneWithManagedCookies(
|
||||
managedCookies: StalkerManagedCookies
|
||||
): Promise<StalkerCookieJar> {
|
||||
const clone = new StalkerCookieJar(managedCookies, {
|
||||
now: this.#now,
|
||||
});
|
||||
clone.#jar = await this.#jar.clone();
|
||||
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
STALKER_HTTP_OUTCOME_KINDS,
|
||||
STALKER_HTTP_REQUEST_MODES,
|
||||
type StalkerHttpRequest,
|
||||
type StalkerHttpRequestOutcome,
|
||||
} from './stalker-http-session';
|
||||
import { StalkerCookieJar } from './stalker-cookie-jar';
|
||||
import {
|
||||
StalkerEndpointResolver,
|
||||
type StalkerEndpointResolverDependencies,
|
||||
} from './stalker-endpoint-resolver';
|
||||
import type { StalkerSessionConnectionDescriptor } from '@iptvnator/shared/interfaces';
|
||||
|
||||
const transport = {
|
||||
maxResponseBytes: 1024 * 1024,
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
function descriptor(
|
||||
overrides: Partial<StalkerSessionConnectionDescriptor> = {}
|
||||
): StalkerSessionConnectionDescriptor {
|
||||
return {
|
||||
connectionMode: 'persisted-open',
|
||||
playlistRef: 'playlist-1',
|
||||
sourceUrl: 'https://portal.test/c/',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
...overrides,
|
||||
} as StalkerSessionConnectionDescriptor;
|
||||
}
|
||||
|
||||
function success(
|
||||
value: unknown,
|
||||
options: {
|
||||
contentType?: string;
|
||||
finalUrl?: string;
|
||||
status?: number;
|
||||
} = {}
|
||||
): StalkerHttpRequestOutcome {
|
||||
return {
|
||||
kind: STALKER_HTTP_OUTCOME_KINDS.Success,
|
||||
result: {
|
||||
body: new TextEncoder().encode(JSON.stringify(value)),
|
||||
contentType: options.contentType ?? 'application/json',
|
||||
finalUrl: options.finalUrl ?? 'https://portal.test/c/',
|
||||
status: options.status ?? 200,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
responder: (
|
||||
request: StalkerHttpRequest,
|
||||
jar: StalkerCookieJar
|
||||
) => Promise<StalkerHttpRequestOutcome> | StalkerHttpRequestOutcome
|
||||
) {
|
||||
const calls: StalkerHttpRequest[] = [];
|
||||
const jars: StalkerCookieJar[] = [];
|
||||
const dependencies: StalkerEndpointResolverDependencies = {
|
||||
createHttpSession: (jar) => {
|
||||
jars.push(jar);
|
||||
return {
|
||||
request: async (request) => {
|
||||
calls.push(request);
|
||||
return responder(request, jar);
|
||||
},
|
||||
};
|
||||
},
|
||||
createCookieJar: () => new StalkerCookieJar(),
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
jars,
|
||||
resolver: new StalkerEndpointResolver(dependencies),
|
||||
};
|
||||
}
|
||||
|
||||
describe('StalkerEndpointResolver', () => {
|
||||
it('keeps landing anonymous and requires approval before cross-origin identity', async () => {
|
||||
const harness = createHarness((request) => {
|
||||
expect(request.mode).toBe(STALKER_HTTP_REQUEST_MODES.Anonymous);
|
||||
return success(
|
||||
{},
|
||||
{
|
||||
finalUrl: 'https://edge.portal.test/customer/c/',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
finalOrigin: 'https://edge.portal.test',
|
||||
kind: 'origin-approval-required',
|
||||
landingUrl: 'https://edge.portal.test/customer/c/',
|
||||
sourceOrigin: 'https://portal.test',
|
||||
});
|
||||
expect(harness.calls).toHaveLength(1);
|
||||
expect(harness.calls[0]).toEqual({
|
||||
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
|
||||
url: 'https://portal.test/c/',
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes the winning handshake, first profile, and candidate jar without repeating requests', async () => {
|
||||
const harness = createHarness(async (request, jar) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
await jar.collectResponseCookies(request.url, [
|
||||
'landing=retained; Path=/',
|
||||
]);
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (action === 'handshake') {
|
||||
return success(
|
||||
{ js: { token: 'token-one', random: 'random-one' } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
if (action === 'get_profile') {
|
||||
return success(
|
||||
{ js: { id: 'profile-one', status: 0 } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected action: ${String(action)}`);
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe('full-session');
|
||||
if (outcome.kind !== 'full-session') {
|
||||
throw new Error('Expected full session');
|
||||
}
|
||||
expect(outcome).toMatchObject({
|
||||
endpoint: 'https://portal.test/server/load.php',
|
||||
handshakeRandom: 'random-one',
|
||||
landingUrl: 'https://portal.test/c/',
|
||||
profile: {
|
||||
kind: 'ready',
|
||||
status: 0,
|
||||
},
|
||||
token: 'token-one',
|
||||
});
|
||||
expect(
|
||||
await outcome.cookieJar.getCookieHeader(outcome.endpoint)
|
||||
).toContain('landing=retained');
|
||||
expect(
|
||||
await outcome.cookieJar.getCookieHeader(outcome.endpoint)
|
||||
).toContain('mac=00:1A:79:AA:BB:CC');
|
||||
expect(
|
||||
harness.calls.filter(
|
||||
(request) => request.params?.['action'] === 'handshake'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
const profileRequest = harness.calls.find(
|
||||
(request) => request.params?.['action'] === 'get_profile'
|
||||
);
|
||||
expect(profileRequest?.params?.['auth_second_step']).toBe(0);
|
||||
expect(
|
||||
(profileRequest as { headers?: Record<string, string> }).headers
|
||||
?.Authorization
|
||||
).toBe('Bearer token-one');
|
||||
});
|
||||
|
||||
it('continues after early stateless evidence so a later full endpoint wins', async () => {
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
return success({}, { finalUrl: request.url, status: 404 });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'get_genres'
|
||||
) {
|
||||
return success(
|
||||
{ js: [{ id: '*', title: 'All' }] },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
if (action === 'handshake') {
|
||||
return success(
|
||||
{ js: { token: 'later-token' } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
if (action === 'get_profile') {
|
||||
return success(
|
||||
{ js: { status: 2 } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
throw new Error('Unexpected request');
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
endpoint: 'https://portal.test/portal.php',
|
||||
kind: 'full-session',
|
||||
profile: { kind: 'credentials-required', status: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('forbids stateless downgrade after protection, rate-limit, or transport failure', async () => {
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
return success({}, { finalUrl: request.url, status: 404 });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'get_genres'
|
||||
) {
|
||||
return success(
|
||||
{ js: [{ id: '1' }] },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: STALKER_HTTP_OUTCOME_KINDS.Failure,
|
||||
reason: 'rate-limited',
|
||||
retryable: true,
|
||||
};
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'rate-limited',
|
||||
retryable: true,
|
||||
stage: 'resolving',
|
||||
});
|
||||
});
|
||||
|
||||
it('isolates failed candidate cookie mutations from later candidates', async () => {
|
||||
let secondCandidateCookie = '';
|
||||
const harness = createHarness(async (request, jar) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
await jar.collectResponseCookies(request.url, [
|
||||
'poison=first; Path=/',
|
||||
]);
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (request.url.endsWith('/portal.php') && action === 'handshake') {
|
||||
secondCandidateCookie =
|
||||
(await jar.getCookieHeader(request.url)) ?? '';
|
||||
return success(
|
||||
{ js: { token: 'clean-token' } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
if (action === 'get_profile') {
|
||||
return success(
|
||||
{ js: { status: 0 } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
return success({}, { finalUrl: request.url, status: 404 });
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe('full-session');
|
||||
expect(secondCandidateCookie).not.toContain('poison=first');
|
||||
});
|
||||
|
||||
it('tries a learned endpoint once, then rediscovers from the landing shape', async () => {
|
||||
const handshakeUrls: string[] = [];
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (action === 'handshake') {
|
||||
handshakeUrls.push(request.url);
|
||||
}
|
||||
if (request.url.includes('/learned/') && action === 'handshake') {
|
||||
return success(
|
||||
{ unexpected: true },
|
||||
{
|
||||
contentType: 'text/html',
|
||||
finalUrl: request.url,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (action === 'handshake') {
|
||||
return success(
|
||||
{ js: { token: 'rediscovered-token' } },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
return success({ js: { status: 0 } }, { finalUrl: request.url });
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor({
|
||||
learnedEndpointHint:
|
||||
'https://portal.test/learned/server/load.php',
|
||||
}),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe('full-session');
|
||||
expect(handshakeUrls).toEqual([
|
||||
'https://portal.test/learned/server/load.php',
|
||||
'https://portal.test/server/load.php',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
import {
|
||||
STALKER_ENDPOINT_CANDIDATE_LIMIT,
|
||||
STALKER_FAILURE_REASONS,
|
||||
classifyStalkerProfile,
|
||||
classifyStalkerResponseFailure,
|
||||
createStalkerIdentityProfile,
|
||||
deduplicateAndLimitStalkerEndpointCandidates,
|
||||
deriveStalkerEndpointCandidates,
|
||||
normalizeStalkerSourceUrl,
|
||||
parseStalkerResponseEnvelope,
|
||||
type StalkerIdentityProfile,
|
||||
type StalkerNormalizedProfileResult,
|
||||
} from '@iptvnator/portal/stalker/protocol';
|
||||
import type {
|
||||
StalkerSessionConnectionDescriptor,
|
||||
StalkerSessionFailureReason,
|
||||
StalkerSessionStage,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerCookieJar } from './stalker-cookie-jar';
|
||||
import {
|
||||
STALKER_HTTP_OUTCOME_KINDS,
|
||||
STALKER_HTTP_REQUEST_MODES,
|
||||
StalkerHttpSession,
|
||||
type StalkerHttpRequest,
|
||||
type StalkerHttpRequestOutcome,
|
||||
} from './stalker-http-session';
|
||||
import type {
|
||||
StalkerTransportConfig,
|
||||
StalkerTransportResult,
|
||||
} from './stalker-session.types';
|
||||
|
||||
const EXPLICITLY_UNSUPPORTED_STATUSES = new Set([404, 405, 501]);
|
||||
const TOKEN_BYTES_LIMIT = 4096;
|
||||
|
||||
interface StalkerHttpSessionLike {
|
||||
request(request: StalkerHttpRequest): Promise<StalkerHttpRequestOutcome>;
|
||||
}
|
||||
|
||||
export interface StalkerEndpointResolverDependencies {
|
||||
createCookieJar?: () => StalkerCookieJar;
|
||||
createHttpSession?: (
|
||||
jar: StalkerCookieJar,
|
||||
transport: StalkerTransportConfig
|
||||
) => StalkerHttpSessionLike;
|
||||
}
|
||||
|
||||
export interface StalkerEndpointResolverInput {
|
||||
approvedOrigins?: readonly string[];
|
||||
descriptor: StalkerSessionConnectionDescriptor;
|
||||
transport: StalkerTransportConfig;
|
||||
}
|
||||
|
||||
export interface StalkerEndpointFullSessionOutcome {
|
||||
cookieJar: StalkerCookieJar;
|
||||
endpoint: string;
|
||||
handshakeRandom?: string;
|
||||
identity: StalkerIdentityProfile;
|
||||
kind: 'full-session';
|
||||
landingUrl: string;
|
||||
profile: Exclude<StalkerNormalizedProfileResult, { kind: 'failure' }>;
|
||||
profileEnvelope: unknown;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface StalkerEndpointStatelessOutcome {
|
||||
endpoint: string;
|
||||
kind: 'stateless-mac';
|
||||
landingUrl: string;
|
||||
}
|
||||
|
||||
export interface StalkerEndpointOriginApprovalOutcome {
|
||||
finalOrigin: string;
|
||||
kind: 'origin-approval-required';
|
||||
landingUrl: string;
|
||||
sourceOrigin: string;
|
||||
}
|
||||
|
||||
export interface StalkerEndpointFailureOutcome {
|
||||
kind: 'failure';
|
||||
reason: StalkerSessionFailureReason;
|
||||
retryable: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
stage: StalkerSessionStage;
|
||||
}
|
||||
|
||||
export type StalkerEndpointResolverOutcome =
|
||||
| StalkerEndpointFullSessionOutcome
|
||||
| StalkerEndpointStatelessOutcome
|
||||
| StalkerEndpointOriginApprovalOutcome
|
||||
| StalkerEndpointFailureOutcome;
|
||||
|
||||
interface ParsedTransportEnvelope {
|
||||
rawBody: string;
|
||||
result: StalkerTransportResult<Uint8Array>;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
interface CandidateProbeUnsupported {
|
||||
kind: 'unsupported';
|
||||
}
|
||||
|
||||
interface CandidateProbeMiss {
|
||||
kind: 'miss';
|
||||
}
|
||||
|
||||
type CandidateProbeOutcome =
|
||||
| StalkerEndpointFullSessionOutcome
|
||||
| StalkerEndpointOriginApprovalOutcome
|
||||
| StalkerEndpointFailureOutcome
|
||||
| CandidateProbeUnsupported
|
||||
| CandidateProbeMiss;
|
||||
|
||||
export class StalkerEndpointResolver {
|
||||
private readonly createCookieJar: () => StalkerCookieJar;
|
||||
private readonly createHttpSession: (
|
||||
jar: StalkerCookieJar,
|
||||
transport: StalkerTransportConfig
|
||||
) => StalkerHttpSessionLike;
|
||||
|
||||
constructor(dependencies: StalkerEndpointResolverDependencies = {}) {
|
||||
this.createCookieJar =
|
||||
dependencies.createCookieJar ?? (() => new StalkerCookieJar());
|
||||
this.createHttpSession =
|
||||
dependencies.createHttpSession ??
|
||||
((jar, transport) => new StalkerHttpSession(jar, transport));
|
||||
}
|
||||
|
||||
async resolve(
|
||||
input: StalkerEndpointResolverInput
|
||||
): Promise<StalkerEndpointResolverOutcome> {
|
||||
const sourceUrl = normalizeStalkerSourceUrl(input.descriptor.sourceUrl);
|
||||
const sourceOrigin = new URL(sourceUrl).origin;
|
||||
const anonymousJar = this.createCookieJar();
|
||||
const landingSession = this.createHttpSession(
|
||||
anonymousJar,
|
||||
input.transport
|
||||
);
|
||||
const landingOutcome = await landingSession.request({
|
||||
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
|
||||
url: sourceUrl,
|
||||
});
|
||||
if (landingOutcome.kind === STALKER_HTTP_OUTCOME_KINDS.Failure) {
|
||||
return failure(landingOutcome.reason, landingOutcome.retryable);
|
||||
}
|
||||
if (
|
||||
landingOutcome.kind ===
|
||||
STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired
|
||||
) {
|
||||
return {
|
||||
finalOrigin: landingOutcome.finalOrigin,
|
||||
kind: landingOutcome.kind,
|
||||
landingUrl: landingOutcome.targetUrl,
|
||||
sourceOrigin: landingOutcome.sourceOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
const landingInspection = inspectResponseFailure(landingOutcome.result);
|
||||
if (landingInspection !== null) {
|
||||
return landingInspection;
|
||||
}
|
||||
|
||||
const landingUrl = normalizeStalkerSourceUrl(
|
||||
landingOutcome.result.finalUrl
|
||||
);
|
||||
const finalOrigin = new URL(landingUrl).origin;
|
||||
const approvedOrigins = normalizeApprovedOrigins(input.approvedOrigins);
|
||||
if (finalOrigin !== sourceOrigin && !approvedOrigins.has(finalOrigin)) {
|
||||
return {
|
||||
finalOrigin,
|
||||
kind: 'origin-approval-required',
|
||||
landingUrl,
|
||||
sourceOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
const identity = buildIdentity(input.descriptor, landingUrl);
|
||||
const candidates = buildCandidates(
|
||||
landingUrl,
|
||||
input.descriptor.learnedEndpointHint
|
||||
);
|
||||
let statelessFallback: string | undefined;
|
||||
|
||||
for (const endpoint of candidates) {
|
||||
const candidateJar = await anonymousJar.cloneWithManagedCookies(
|
||||
identity.managedCookies
|
||||
);
|
||||
const candidateSession = this.createHttpSession(
|
||||
candidateJar,
|
||||
input.transport
|
||||
);
|
||||
const probe = await this.probeCandidate({
|
||||
candidateJar,
|
||||
candidateSession,
|
||||
endpoint,
|
||||
identity,
|
||||
landingUrl,
|
||||
transport: input.transport,
|
||||
});
|
||||
|
||||
if (
|
||||
probe.kind === 'full-session' ||
|
||||
probe.kind === 'failure' ||
|
||||
probe.kind === 'origin-approval-required'
|
||||
) {
|
||||
return probe;
|
||||
}
|
||||
if (probe.kind === 'unsupported') {
|
||||
const simple = await this.probeStateless(
|
||||
candidateSession,
|
||||
endpoint,
|
||||
identity
|
||||
);
|
||||
if (
|
||||
simple.kind === 'failure' ||
|
||||
simple.kind === 'origin-approval-required'
|
||||
) {
|
||||
return simple;
|
||||
}
|
||||
if (simple.kind === 'stateless-mac') {
|
||||
statelessFallback ??= endpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statelessFallback === undefined
|
||||
? failure(STALKER_FAILURE_REASONS.EndpointNotFound, false)
|
||||
: {
|
||||
endpoint: statelessFallback,
|
||||
kind: 'stateless-mac',
|
||||
landingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private async probeCandidate(input: {
|
||||
candidateJar: StalkerCookieJar;
|
||||
candidateSession: StalkerHttpSessionLike;
|
||||
endpoint: string;
|
||||
identity: StalkerIdentityProfile;
|
||||
landingUrl: string;
|
||||
transport: StalkerTransportConfig;
|
||||
}): Promise<CandidateProbeOutcome> {
|
||||
const handshake = await input.candidateSession.request({
|
||||
headers: input.identity.headers,
|
||||
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
|
||||
params: {
|
||||
action: 'handshake',
|
||||
JsHttpRequest: '1-xml',
|
||||
token: '',
|
||||
type: 'stb',
|
||||
},
|
||||
url: input.endpoint,
|
||||
});
|
||||
const normalizedHandshake = normalizeProtocolOutcome(handshake);
|
||||
if (normalizedHandshake.kind !== 'parsed') {
|
||||
return normalizedHandshake;
|
||||
}
|
||||
|
||||
const handshakeEnvelope = asRecord(normalizedHandshake.value);
|
||||
const handshakeValue = asRecord(handshakeEnvelope?.['js']);
|
||||
const token = boundedNonEmptyString(handshakeValue?.['token']);
|
||||
if (token === undefined) {
|
||||
return { kind: 'miss' };
|
||||
}
|
||||
const handshakeRandom = boundedOptionalString(
|
||||
handshakeValue?.['random']
|
||||
);
|
||||
const profileIdentity = withHandshakeRandom(
|
||||
input.identity,
|
||||
handshakeRandom
|
||||
);
|
||||
const profile = await input.candidateSession.request({
|
||||
headers: {
|
||||
...profileIdentity.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
|
||||
params: {
|
||||
...profileIdentity.profileParameters,
|
||||
action: 'get_profile',
|
||||
auth_second_step: 0,
|
||||
JsHttpRequest: '1-xml',
|
||||
metrics: JSON.stringify(profileIdentity.metrics),
|
||||
not_valid_token: 0,
|
||||
type: 'stb',
|
||||
},
|
||||
url: input.endpoint,
|
||||
});
|
||||
const normalizedProfile = normalizeProtocolOutcome(profile);
|
||||
if (normalizedProfile.kind !== 'parsed') {
|
||||
return normalizedProfile;
|
||||
}
|
||||
|
||||
const classifiedProfile = classifyStalkerProfile(
|
||||
normalizedProfile.value
|
||||
);
|
||||
if (classifiedProfile.kind === 'failure') {
|
||||
return { kind: 'miss' };
|
||||
}
|
||||
|
||||
return {
|
||||
cookieJar: input.candidateJar,
|
||||
endpoint: input.endpoint,
|
||||
...(handshakeRandom === undefined ? {} : { handshakeRandom }),
|
||||
identity: profileIdentity,
|
||||
kind: 'full-session',
|
||||
landingUrl: input.landingUrl,
|
||||
profile: classifiedProfile,
|
||||
profileEnvelope: normalizedProfile.value,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
private async probeStateless(
|
||||
session: StalkerHttpSessionLike,
|
||||
endpoint: string,
|
||||
identity: StalkerIdentityProfile
|
||||
): Promise<
|
||||
| StalkerEndpointStatelessOutcome
|
||||
| StalkerEndpointOriginApprovalOutcome
|
||||
| StalkerEndpointFailureOutcome
|
||||
| CandidateProbeMiss
|
||||
> {
|
||||
const outcome = await session.request({
|
||||
headers: identity.headers,
|
||||
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
|
||||
params: {
|
||||
action: 'get_genres',
|
||||
JsHttpRequest: '1-xml',
|
||||
type: 'itv',
|
||||
},
|
||||
url: endpoint,
|
||||
});
|
||||
const normalized = normalizeProtocolOutcome(outcome);
|
||||
if (normalized.kind !== 'parsed') {
|
||||
return normalized.kind === 'unsupported'
|
||||
? { kind: 'miss' }
|
||||
: normalized;
|
||||
}
|
||||
const envelope = asRecord(normalized.value);
|
||||
return Array.isArray(envelope?.['js'])
|
||||
? {
|
||||
endpoint,
|
||||
kind: 'stateless-mac',
|
||||
landingUrl: endpoint,
|
||||
}
|
||||
: { kind: 'miss' };
|
||||
}
|
||||
}
|
||||
|
||||
function buildIdentity(
|
||||
descriptor: StalkerSessionConnectionDescriptor,
|
||||
landingUrl: string
|
||||
): StalkerIdentityProfile {
|
||||
return createStalkerIdentityProfile({
|
||||
macAddress: descriptor.macAddress,
|
||||
...descriptor.identityOverrides,
|
||||
...descriptor.transportConfiguration,
|
||||
referer: descriptor.transportConfiguration?.referer ?? landingUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function withHandshakeRandom(
|
||||
identity: StalkerIdentityProfile,
|
||||
handshakeRandom: string | undefined
|
||||
): StalkerIdentityProfile {
|
||||
if (handshakeRandom === undefined) {
|
||||
return identity;
|
||||
}
|
||||
return {
|
||||
...identity,
|
||||
metrics: {
|
||||
...identity.metrics,
|
||||
random: handshakeRandom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidates(
|
||||
landingUrl: string,
|
||||
learnedEndpointHint: string | undefined
|
||||
): readonly string[] {
|
||||
const candidates = [
|
||||
...(learnedEndpointHint === undefined
|
||||
? []
|
||||
: [normalizeStalkerSourceUrl(learnedEndpointHint)]),
|
||||
...deriveStalkerEndpointCandidates(landingUrl),
|
||||
];
|
||||
return deduplicateAndLimitStalkerEndpointCandidates(candidates).slice(
|
||||
0,
|
||||
STALKER_ENDPOINT_CANDIDATE_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeApprovedOrigins(
|
||||
origins: readonly string[] | undefined
|
||||
): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const origin of origins ?? []) {
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
if (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
parsed.origin === origin
|
||||
) {
|
||||
normalized.add(parsed.origin);
|
||||
}
|
||||
} catch {
|
||||
// Runtime input validation rejects malformed values at the IPC
|
||||
// boundary. Ignoring one here remains fail-closed.
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProtocolOutcome(
|
||||
outcome: StalkerHttpRequestOutcome
|
||||
):
|
||||
| { kind: 'parsed'; value: unknown }
|
||||
| CandidateProbeUnsupported
|
||||
| CandidateProbeMiss
|
||||
| StalkerEndpointOriginApprovalOutcome
|
||||
| StalkerEndpointFailureOutcome {
|
||||
if (outcome.kind === STALKER_HTTP_OUTCOME_KINDS.Failure) {
|
||||
return failure(outcome.reason, outcome.retryable);
|
||||
}
|
||||
if (outcome.kind === STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired) {
|
||||
return {
|
||||
finalOrigin: outcome.finalOrigin,
|
||||
kind: outcome.kind,
|
||||
landingUrl: outcome.targetUrl,
|
||||
sourceOrigin: outcome.sourceOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseTransportEnvelope(outcome.result);
|
||||
const classifiedFailure = classifyStalkerResponseFailure({
|
||||
httpStatus: outcome.result.status,
|
||||
rawBody: parsed?.rawBody,
|
||||
value: parsed?.value,
|
||||
});
|
||||
if (classifiedFailure.kind === 'failure') {
|
||||
return failure(
|
||||
classifiedFailure.reason,
|
||||
classifiedFailure.reason !==
|
||||
STALKER_FAILURE_REASONS.IncompatibleResponse,
|
||||
outcome.result.retryAfterSeconds
|
||||
);
|
||||
}
|
||||
if (classifiedFailure.kind === 'token-rejected') {
|
||||
return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
|
||||
}
|
||||
if (EXPLICITLY_UNSUPPORTED_STATUSES.has(outcome.result.status)) {
|
||||
return { kind: 'unsupported' };
|
||||
}
|
||||
if (outcome.result.status !== 200 || parsed === null) {
|
||||
return { kind: 'miss' };
|
||||
}
|
||||
return { kind: 'parsed', value: parsed.value };
|
||||
}
|
||||
|
||||
function inspectResponseFailure(
|
||||
result: StalkerTransportResult<Uint8Array>
|
||||
): StalkerEndpointFailureOutcome | null {
|
||||
const parsed = parseTransportEnvelope(result);
|
||||
const classified = classifyStalkerResponseFailure({
|
||||
httpStatus: result.status,
|
||||
rawBody: parsed?.rawBody,
|
||||
value: parsed?.value,
|
||||
});
|
||||
if (classified.kind === 'failure') {
|
||||
return failure(
|
||||
classified.reason,
|
||||
classified.reason !== STALKER_FAILURE_REASONS.IncompatibleResponse,
|
||||
result.retryAfterSeconds
|
||||
);
|
||||
}
|
||||
if (classified.kind === 'token-rejected') {
|
||||
return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTransportEnvelope(
|
||||
result: StalkerTransportResult<Uint8Array>
|
||||
): ParsedTransportEnvelope | null {
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = new TextDecoder('utf-8', { fatal: true }).decode(result.body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseStalkerResponseEnvelope({
|
||||
body: rawBody,
|
||||
contentType: result.contentType,
|
||||
maxBodyBytes: result.body.byteLength,
|
||||
});
|
||||
return parsed.kind === 'parsed'
|
||||
? { rawBody, result, value: parsed.value }
|
||||
: { rawBody, result };
|
||||
}
|
||||
|
||||
function boundedNonEmptyString(value: unknown): string | undefined {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.trim().length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > TOKEN_BYTES_LIMIT
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedOptionalString(value: unknown): string | undefined {
|
||||
return value === undefined ? undefined : boundedNonEmptyString(value);
|
||||
}
|
||||
|
||||
function asRecord(
|
||||
value: unknown
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Readonly<Record<string, unknown>>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function failure(
|
||||
reason: StalkerSessionFailureReason,
|
||||
retryable: boolean,
|
||||
retryAfterSeconds?: number
|
||||
): StalkerEndpointFailureOutcome {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason,
|
||||
retryable,
|
||||
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
||||
stage: 'resolving',
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,10 @@ describe('StalkerHttpSession', () => {
|
||||
axiosMock.mockImplementationOnce(async (config) => ({
|
||||
config,
|
||||
data: Buffer.from('denied', 'utf8'),
|
||||
headers: {},
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'retry-after': '12',
|
||||
},
|
||||
status: 403,
|
||||
}));
|
||||
const session = new StalkerHttpSession(
|
||||
@@ -62,7 +65,9 @@ describe('StalkerHttpSession', () => {
|
||||
throw new Error('Expected transport success');
|
||||
}
|
||||
expect(outcome.result).toMatchObject({
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
finalUrl: 'http://192.168.1.20/server/load.php',
|
||||
retryAfterSeconds: 12,
|
||||
status: 403,
|
||||
});
|
||||
expect(Buffer.from(outcome.result.body).toString('utf8')).toBe(
|
||||
@@ -279,6 +284,7 @@ describe('StalkerHttpSession', () => {
|
||||
finalOrigin: 'http://192.168.2.30',
|
||||
kind: 'origin-approval-required',
|
||||
sourceOrigin: 'http://192.168.1.20',
|
||||
targetUrl: 'http://192.168.2.30/landing?display=portal',
|
||||
});
|
||||
expectNoSecretEcho(outcome, secrets);
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface StalkerHttpOriginApprovalRequiredOutcome {
|
||||
readonly finalOrigin: string;
|
||||
readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired;
|
||||
readonly sourceOrigin: string;
|
||||
readonly targetUrl: string;
|
||||
}
|
||||
|
||||
export interface StalkerHttpFailureOutcome {
|
||||
@@ -78,7 +79,8 @@ const CALLER_MANAGED_HEADERS = new Set(['cookie', 'proxy-authorization']);
|
||||
class StalkerOriginApprovalPause extends Error {
|
||||
constructor(
|
||||
readonly sourceOrigin: string,
|
||||
readonly finalOrigin: string
|
||||
readonly finalOrigin: string,
|
||||
readonly targetUrl: string
|
||||
) {
|
||||
super(STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired);
|
||||
this.name = 'StalkerOriginApprovalPause';
|
||||
@@ -168,6 +170,7 @@ export class StalkerHttpSession {
|
||||
kind: STALKER_HTTP_OUTCOME_KINDS.Success,
|
||||
result: {
|
||||
body: copyResponseBytes(response.data),
|
||||
...readResponseMetadata(response.headers),
|
||||
finalUrl,
|
||||
status: response.status,
|
||||
},
|
||||
@@ -178,6 +181,7 @@ export class StalkerHttpSession {
|
||||
finalOrigin: error.finalOrigin,
|
||||
kind: STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired,
|
||||
sourceOrigin: error.sourceOrigin,
|
||||
targetUrl: error.targetUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +255,37 @@ function readSetCookieHeaders(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readResponseMetadata(
|
||||
headers: RawAxiosResponseHeaders
|
||||
): Pick<StalkerTransportResult<unknown>, 'contentType' | 'retryAfterSeconds'> {
|
||||
const contentType = readStringHeader(headers, 'content-type');
|
||||
const retryAfter = readStringHeader(headers, 'retry-after');
|
||||
const retryAfterSeconds =
|
||||
retryAfter === undefined ? undefined : Number(retryAfter.trim());
|
||||
return {
|
||||
...(contentType === undefined ? {} : { contentType }),
|
||||
...(retryAfterSeconds !== undefined &&
|
||||
Number.isFinite(retryAfterSeconds) &&
|
||||
retryAfterSeconds >= 0
|
||||
? { retryAfterSeconds }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readStringHeader(
|
||||
headers: RawAxiosResponseHeaders,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const value = headers[name];
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateRedirectTarget(
|
||||
redirect: ValidatedAxiosRedirectContext,
|
||||
identityBearing: boolean,
|
||||
@@ -260,7 +295,8 @@ function validateRedirectTarget(
|
||||
if (identityBearing && redirect.crossOrigin) {
|
||||
throw new StalkerOriginApprovalPause(
|
||||
sourceOrigin,
|
||||
new URL(normalizedTarget).origin
|
||||
new URL(normalizedTarget).origin,
|
||||
normalizedTarget
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ export interface StalkerValidatedRuntimeInput {
|
||||
|
||||
export interface StalkerTransportResult<T> {
|
||||
readonly body: T;
|
||||
readonly contentType?: string;
|
||||
readonly finalUrl: string;
|
||||
readonly retryAfterSeconds?: number;
|
||||
readonly status: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user