feat(stalker): add cookie-aware portal HTTP session

This commit is contained in:
4gray committed 2026-07-27 10:01:55 +02:00
1 parent 2646c0c492
commit c4974e9b93
2 files changed
+811

No files matched your search

@@ -0,0 +1,511 @@
import axios, { AxiosError } from 'axios';
import { StalkerCookieJar } from './stalker-cookie-jar';
import {
STALKER_HTTP_REQUEST_MODES,
StalkerHttpSession,
type StalkerHttpRequestOutcome,
} from './stalker-http-session';
jest.mock('axios', () => {
const actual = jest.requireActual<typeof import('axios')>('axios');
return {
...actual,
__esModule: true,
default: Object.assign(jest.fn(), actual.default),
};
});
const axiosMock = axios as unknown as jest.Mock;
const DEFAULT_TRANSPORT = {
maxResponseBytes: 1024,
timeoutMs: 2500,
};
function expectNoSecretEcho(
outcome: StalkerHttpRequestOutcome,
secrets: readonly string[]
): void {
const serialized = JSON.stringify(outcome);
for (const secret of secrets) {
expect(serialized).not.toContain(secret);
}
}
describe('StalkerHttpSession', () => {
beforeEach(() => {
axiosMock.mockReset();
});
it('uses bounded arraybuffer transport, Axios params, pinned LAN policy, and preserves HTTP statuses', async () => {
axiosMock.mockImplementationOnce(async (config) => ({
config,
data: Buffer.from('denied', 'utf8'),
headers: {},
status: 403,
}));
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
params: {
action: 'handshake',
type: 'stb',
},
url: 'http://192.168.1.20/server/load.php#ignored',
});
expect(outcome.kind).toBe('success');
if (outcome.kind !== 'success') {
throw new Error('Expected transport success');
}
expect(outcome.result).toMatchObject({
finalUrl: 'http://192.168.1.20/server/load.php',
status: 403,
});
expect(Buffer.from(outcome.result.body).toString('utf8')).toBe(
'denied'
);
expect(axiosMock).toHaveBeenCalledTimes(1);
const config = axiosMock.mock.calls[0][0];
expect(config).toMatchObject({
maxBodyLength: DEFAULT_TRANSPORT.maxResponseBytes,
maxContentLength: DEFAULT_TRANSPORT.maxResponseBytes,
maxRedirects: 0,
method: 'GET',
params: {
action: 'handshake',
type: 'stb',
},
proxy: false,
responseType: 'arraybuffer',
timeout: DEFAULT_TRANSPORT.timeoutMs,
url: 'http://192.168.1.20/server/load.php',
});
expect(config.validateStatus(403)).toBe(true);
expect(config.validateStatus(500)).toBe(true);
expect(config.httpAgent).toBeDefined();
expect(config.headers).not.toHaveProperty('Authorization');
expect(config.headers).not.toHaveProperty('Cookie');
expect(config.headers).not.toHaveProperty('Proxy-Authorization');
});
it.each([200, 401, 404, 429, 500, 503])(
'returns HTTP %i as a successful transport result',
async (status) => {
axiosMock.mockResolvedValueOnce({
data: Buffer.from('{}'),
headers: {},
status,
});
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://10.0.0.20/server/load.php',
});
expect(outcome).toMatchObject({
kind: 'success',
result: { status },
});
}
);
it('prepares and collects the cookie jar on every same-origin hop without flattening Set-Cookie arrays', async () => {
const jar = new StalkerCookieJar({
mac: '00:1A:79:00:00:01',
stb_lang: 'en',
timezone: 'UTC',
});
await jar.collectResponseCookies(
'http://192.168.1.20/server/load.php',
['seed=present; Path=/']
);
const getCookieHeader = jest.spyOn(jar, 'getCookieHeader');
const collectResponseCookies = jest.spyOn(
jar,
'collectResponseCookies'
);
axiosMock
.mockResolvedValueOnce({
data: Buffer.alloc(0),
headers: {
location: '/server/next.php',
'set-cookie': ['first=one; Path=/', 'second=two; Path=/'],
},
status: 302,
})
.mockResolvedValueOnce({
data: Buffer.from('{"js":{}}'),
headers: {
'set-cookie': ['final=three; Path=/'],
},
status: 200,
});
const session = new StalkerHttpSession(jar, DEFAULT_TRANSPORT);
const outcome = await session.request({
headers: {
Authorization: 'Bearer same-origin-token',
Cookie: 'caller-cookie=must-not-win',
'Proxy-Authorization': 'Basic must-not-be-sent',
'X-User-Agent': 'Model: MAG250',
},
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
params: { action: 'get_profile', type: 'stb' },
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome.kind).toBe('success');
expect(getCookieHeader).toHaveBeenNthCalledWith(
1,
'http://192.168.1.20/server/load.php'
);
expect(getCookieHeader).toHaveBeenNthCalledWith(
2,
'http://192.168.1.20/server/next.php'
);
expect(collectResponseCookies).toHaveBeenNthCalledWith(
1,
'http://192.168.1.20/server/load.php',
['first=one; Path=/', 'second=two; Path=/']
);
expect(collectResponseCookies).toHaveBeenNthCalledWith(
2,
'http://192.168.1.20/server/next.php',
['final=three; Path=/']
);
const firstHeaders = axiosMock.mock.calls[0][0].headers;
const secondHeaders = axiosMock.mock.calls[1][0].headers;
expect(firstHeaders.Authorization).toBe('Bearer same-origin-token');
expect(firstHeaders.Cookie).toContain('seed=present');
expect(firstHeaders.Cookie).not.toContain('caller-cookie');
expect(firstHeaders).not.toHaveProperty('Proxy-Authorization');
expect(secondHeaders.Authorization).toBe('Bearer same-origin-token');
expect(secondHeaders.Cookie).toContain('first=one');
expect(secondHeaders.Cookie).toContain('second=two');
expect(secondHeaders.Cookie).toContain('mac=00:1A:79:00:00:01');
});
it('keeps anonymous requests free of identity headers and managed credentials', async () => {
axiosMock.mockResolvedValueOnce({
data: Buffer.from('{}'),
headers: {},
status: 200,
});
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
params: { probe: 'landing' },
url: 'http://172.16.0.10/c/',
});
const config = axiosMock.mock.calls[0][0];
expect(config.params).toEqual({ probe: 'landing' });
expect(
Object.keys(config.headers).map((name) => name.toLowerCase())
).not.toEqual(
expect.arrayContaining([
'authorization',
'cookie',
'proxy-authorization',
'sn',
'x-user-agent',
])
);
});
it('pauses an identity-bearing cross-origin redirect before target preparation or contact', async () => {
const secrets = [
'Bearer source-secret',
'caller-cookie-secret',
'proxy-secret',
'provider-user',
'provider-password',
'00:1A:79:00:00:09',
];
const jar = new StalkerCookieJar({
mac: '00:1A:79:00:00:09',
stb_lang: 'en',
timezone: 'UTC',
});
const getCookieHeader = jest.spyOn(jar, 'getCookieHeader');
const collectResponseCookies = jest.spyOn(
jar,
'collectResponseCookies'
);
axiosMock.mockResolvedValueOnce({
data: Buffer.alloc(0),
headers: {
location: 'http://192.168.2.30/landing?display=portal',
'set-cookie': [
'landing=retained-before-pause; Path=/',
'route=blue; Path=/',
],
},
status: 302,
});
const session = new StalkerHttpSession(jar, DEFAULT_TRANSPORT);
const outcome = await session.request({
headers: {
Authorization: 'Bearer source-secret',
Cookie: 'caller-cookie-secret',
'Proxy-Authorization': 'Basic proxy-secret',
'X-User-Agent': 'Model: MAG250',
},
mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing,
params: {
login: 'provider-user',
mac: '00:1A:79:00:00:09',
password: 'provider-password',
},
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome).toEqual({
finalOrigin: 'http://192.168.2.30',
kind: 'origin-approval-required',
sourceOrigin: 'http://192.168.1.20',
});
expectNoSecretEcho(outcome, secrets);
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(getCookieHeader).toHaveBeenCalledTimes(1);
expect(collectResponseCookies).toHaveBeenCalledWith(
'http://192.168.1.20/server/load.php',
['landing=retained-before-pause; Path=/', 'route=blue; Path=/']
);
const sourceConfig = axiosMock.mock.calls[0][0];
expect(sourceConfig.headers.Authorization).toBe('Bearer source-secret');
expect(sourceConfig.headers.Cookie).toContain('mac=00:1A:79:00:00:09');
expect(sourceConfig.headers.Cookie).not.toContain(
'caller-cookie-secret'
);
expect(sourceConfig.headers).not.toHaveProperty('Proxy-Authorization');
});
it.each([
'http://portal-user:portal-password@192.168.1.20/server/load.php',
'http://192.168.1.20/server/load.php?token=query-secret',
'http://192.168.1.20/server/load.php?USERNAME=query-secret',
'http://192.168.1.20/server/load.php?mac=00:1A:79:00:00:07',
])(
'rejects an unsafe initial target without contact or secret echo',
async (url) => {
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url,
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'invalid-url',
retryable: false,
});
expectNoSecretEcho(outcome, [
'portal-user',
'portal-password',
'query-secret',
'00:1A:79:00:00:07',
]);
expect(axiosMock).not.toHaveBeenCalled();
}
);
it.each([
'http://redirect-user:redirect-password@192.168.2.30/landing',
'http://192.168.2.30/landing?authorization=redirect-secret',
'http://192.168.2.30/landing?device_id=redirect-secret',
])(
'rejects an unsafe redirect before target contact or secret echo',
async (location) => {
axiosMock.mockResolvedValueOnce({
data: Buffer.alloc(0),
headers: { location },
status: 302,
});
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'invalid-url',
retryable: false,
});
expectNoSecretEcho(outcome, [
'redirect-user',
'redirect-password',
'redirect-secret',
]);
expect(axiosMock).toHaveBeenCalledTimes(1);
}
);
it('checks the actual response byte length even when an adapter ignores maxContentLength', async () => {
const responseSecret = '12345';
axiosMock.mockResolvedValueOnce({
data: Buffer.from(responseSecret),
headers: {},
status: 200,
});
const session = new StalkerHttpSession(new StalkerCookieJar(), {
maxResponseBytes: 4,
timeoutMs: 1000,
});
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'response-too-large',
retryable: false,
});
expectNoSecretEcho(outcome, [responseSecret]);
expect(axiosMock.mock.calls[0][0]).toMatchObject({
maxBodyLength: 4,
maxContentLength: 4,
});
});
it.each([
{
transport: {
maxResponseBytes: 1024,
timeoutMs: Number.POSITIVE_INFINITY,
},
},
{
transport: {
maxResponseBytes: Number.NaN,
timeoutMs: 1000,
},
},
{
transport: {
maxResponseBytes: 0,
timeoutMs: 1000,
},
},
])(
'rejects non-finite or non-positive transport bounds before contact',
async ({ transport }) => {
const session = new StalkerHttpSession(
new StalkerCookieJar(),
transport
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'invalid-identity-input',
retryable: false,
});
expect(axiosMock).not.toHaveBeenCalled();
}
);
it.each([
{
error: Object.assign(
new Error(
'request to http://portal/?token=transport-secret timed out'
),
{ code: 'ETIMEDOUT' }
),
expected: {
kind: 'failure',
reason: 'request-timeout',
retryable: true,
},
},
{
error: new AxiosError(
'maxContentLength size of transport-secret exceeded',
'ERR_BAD_RESPONSE'
),
expected: {
kind: 'failure',
reason: 'response-too-large',
retryable: false,
},
},
])(
'maps transport failures to stable redacted outcomes',
async ({ error, expected }) => {
axiosMock.mockRejectedValueOnce(error);
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome).toEqual(expected);
expectNoSecretEcho(outcome, ['transport-secret']);
}
);
it('returns undecoded bytes so strict UTF-8 and JSON consumers run only after the cap', async () => {
const invalidUtf8 = Buffer.from([0xc3, 0x28]);
axiosMock.mockResolvedValueOnce({
data: invalidUtf8,
headers: {},
status: 200,
});
const session = new StalkerHttpSession(
new StalkerCookieJar(),
DEFAULT_TRANSPORT
);
const outcome = await session.request({
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
url: 'http://192.168.1.20/server/load.php',
});
expect(outcome.kind).toBe('success');
if (outcome.kind !== 'success') {
throw new Error('Expected transport success');
}
expect(Array.from(outcome.result.body)).toEqual([0xc3, 0x28]);
expect(() =>
new TextDecoder('utf-8', { fatal: true }).decode(
outcome.result.body
)
).toThrow();
});
});
@@ -0,0 +1,300 @@
import type { RawAxiosRequestHeaders, RawAxiosResponseHeaders } from 'axios';
import {
STALKER_FAILURE_REASONS,
normalizeStalkerSourceUrl,
} from '@iptvnator/portal/stalker/protocol';
import type { StalkerSessionFailureReason } from '@iptvnator/shared/interfaces';
import { requestWithValidatedRedirects } from '../../util/validated-axios';
import type { ValidatedAxiosRedirectContext } from '../../util/validated-axios';
import { StalkerCookieJar } from './stalker-cookie-jar';
import { StalkerRuntimeValidationError } from './stalker-runtime-validation';
import {
STALKER_RUNTIME_LIMITS,
type StalkerTransportConfig,
type StalkerTransportResult,
} from './stalker-session.types';
import { mapStalkerTransportError } from './stalker-transport-errors';
export const STALKER_HTTP_REQUEST_MODES = {
Anonymous: 'anonymous',
IdentityBearing: 'identity-bearing',
} as const;
export type StalkerHttpRequestMode =
(typeof STALKER_HTTP_REQUEST_MODES)[keyof typeof STALKER_HTTP_REQUEST_MODES];
export const STALKER_HTTP_OUTCOME_KINDS = {
Failure: 'failure',
OriginApprovalRequired: 'origin-approval-required',
Success: 'success',
} as const;
export type StalkerHttpParameterValue = string | number;
export type StalkerHttpParameters = Readonly<
Record<string, StalkerHttpParameterValue>
>;
export interface StalkerAnonymousHttpRequest {
readonly mode: typeof STALKER_HTTP_REQUEST_MODES.Anonymous;
readonly params?: StalkerHttpParameters;
readonly url: string;
}
export interface StalkerIdentityBearingHttpRequest {
readonly headers: Readonly<Record<string, string>>;
readonly mode: typeof STALKER_HTTP_REQUEST_MODES.IdentityBearing;
readonly params?: StalkerHttpParameters;
readonly url: string;
}
export type StalkerHttpRequest =
| StalkerAnonymousHttpRequest
| StalkerIdentityBearingHttpRequest;
export interface StalkerHttpSuccessOutcome {
readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.Success;
readonly result: StalkerTransportResult<Uint8Array>;
}
export interface StalkerHttpOriginApprovalRequiredOutcome {
readonly finalOrigin: string;
readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired;
readonly sourceOrigin: string;
}
export interface StalkerHttpFailureOutcome {
readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.Failure;
readonly reason: StalkerSessionFailureReason;
readonly retryable: boolean;
}
export type StalkerHttpRequestOutcome =
| StalkerHttpSuccessOutcome
| StalkerHttpOriginApprovalRequiredOutcome
| StalkerHttpFailureOutcome;
const CALLER_MANAGED_HEADERS = new Set(['cookie', 'proxy-authorization']);
class StalkerOriginApprovalPause extends Error {
constructor(
readonly sourceOrigin: string,
readonly finalOrigin: string
) {
super(STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired);
this.name = 'StalkerOriginApprovalPause';
}
}
export class StalkerHttpSession {
constructor(
private readonly cookieJar: StalkerCookieJar,
private readonly transport: StalkerTransportConfig
) {}
async request(
request: StalkerHttpRequest
): Promise<StalkerHttpRequestOutcome> {
try {
assertTransportConfig(this.transport);
const sourceUrl = normalizeStalkerSourceUrl(request.url);
const sourceOrigin = new URL(sourceUrl).origin;
const identityBearing =
request.mode === STALKER_HTTP_REQUEST_MODES.IdentityBearing;
const baseHeaders = identityBearing
? copyIdentityHeaders(request.headers)
: undefined;
let finalUrl = sourceUrl;
const response = await requestWithValidatedRedirects<unknown>(
sourceUrl,
{
headers: baseHeaders,
maxBodyLength: this.transport.maxResponseBytes,
maxContentLength: this.transport.maxResponseBytes,
method: 'GET',
params:
request.params === undefined
? undefined
: { ...request.params },
redirectHooks: {
beforeValidatedRedirect: (redirect) => {
validateRedirectTarget(
redirect,
identityBearing,
sourceOrigin
);
},
collectResponse: async (hop) => {
finalUrl = normalizeStalkerSourceUrl(hop.url);
await this.cookieJar.collectResponseCookies(
hop.url,
readSetCookieHeaders(hop.headers)
);
},
prepareHeaders: async (hop) => {
const cookieHeader =
await this.cookieJar.getCookieHeader(hop.url);
return prepareHopHeaders(hop.headers, cookieHeader);
},
},
responseType: 'arraybuffer',
timeout: this.transport.timeoutMs,
validateStatus: () => true,
},
{
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
const byteLength = responseByteLength(response.data);
if (byteLength === undefined) {
return failure(
STALKER_FAILURE_REASONS.IncompatibleResponse,
false
);
}
if (byteLength > this.transport.maxResponseBytes) {
return failure(STALKER_FAILURE_REASONS.ResponseTooLarge, false);
}
if (!isHttpStatus(response.status)) {
return failure(
STALKER_FAILURE_REASONS.IncompatibleResponse,
false
);
}
return {
kind: STALKER_HTTP_OUTCOME_KINDS.Success,
result: {
body: copyResponseBytes(response.data),
finalUrl,
status: response.status,
},
};
} catch (error) {
if (error instanceof StalkerOriginApprovalPause) {
return {
finalOrigin: error.finalOrigin,
kind: STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired,
sourceOrigin: error.sourceOrigin,
};
}
const mapped = mapStalkerTransportError(error);
return mapped
? failure(mapped.reason, mapped.retryable)
: failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
}
}
}
function assertTransportConfig(config: StalkerTransportConfig): void {
if (
!isPositiveFiniteBound(
config.timeoutMs,
STALKER_RUNTIME_LIMITS.timeoutMs
) ||
!isPositiveFiniteBound(
config.maxResponseBytes,
STALKER_RUNTIME_LIMITS.maxResponseBytes
)
) {
throw new StalkerRuntimeValidationError(
STALKER_FAILURE_REASONS.InvalidIdentityInput
);
}
}
function isPositiveFiniteBound(value: number, maximum: number): boolean {
return Number.isFinite(value) && value > 0 && value <= maximum;
}
function copyIdentityHeaders(
headers: Readonly<Record<string, string>>
): RawAxiosRequestHeaders {
return Object.fromEntries(
Object.entries(headers).filter(
([name]) => !CALLER_MANAGED_HEADERS.has(name.toLowerCase())
)
);
}
function prepareHopHeaders(
headers: RawAxiosRequestHeaders,
cookieHeader: string | undefined
): RawAxiosRequestHeaders {
const prepared = Object.fromEntries(
Object.entries(headers).filter(
([name]) => !CALLER_MANAGED_HEADERS.has(name.toLowerCase())
)
) as RawAxiosRequestHeaders;
if (cookieHeader !== undefined) {
prepared['Cookie'] = cookieHeader;
}
return prepared;
}
function readSetCookieHeaders(
headers: RawAxiosResponseHeaders
): string | readonly string[] | undefined {
const value = headers['set-cookie'];
if (typeof value === 'string') {
return value;
}
if (
Array.isArray(value) &&
value.every((entry): entry is string => typeof entry === 'string')
) {
return value;
}
return undefined;
}
function validateRedirectTarget(
redirect: ValidatedAxiosRedirectContext,
identityBearing: boolean,
sourceOrigin: string
): void {
const normalizedTarget = normalizeStalkerSourceUrl(redirect.toUrl);
if (identityBearing && redirect.crossOrigin) {
throw new StalkerOriginApprovalPause(
sourceOrigin,
new URL(normalizedTarget).origin
);
}
}
function responseByteLength(value: unknown): number | undefined {
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return value.byteLength;
}
return undefined;
}
function copyResponseBytes(value: unknown): Uint8Array {
if (value instanceof ArrayBuffer) {
return new Uint8Array(value.slice(0));
}
if (ArrayBuffer.isView(value)) {
return Uint8Array.from(
new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
);
}
throw new Error(STALKER_FAILURE_REASONS.IncompatibleResponse);
}
function isHttpStatus(status: number): boolean {
return Number.isInteger(status) && status >= 100 && status <= 599;
}
function failure(
reason: StalkerSessionFailureReason,
retryable: boolean
): StalkerHttpFailureOutcome {
return {
kind: STALKER_HTTP_OUTCOME_KINDS.Failure,
reason,
retryable,
};
}