feat(stalker): resolve portal endpoints safely

This commit is contained in:
4gray committed 2026-07-27 10:17:21 +02:00
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();
@@ -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;
}