mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(stalker): validate portal transport inputs
This commit is contained in:
1 parent
d0cf96caf8
commit
13f05d0a7e
5 files changed
+927
No files matched your search
+229
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
STALKER_RUNTIME_LIMITS,
|
||||
STALKER_TRANSPORT_DEFAULTS,
|
||||
} from './stalker-session.types';
|
||||
import {
|
||||
StalkerRuntimeValidationError,
|
||||
validateStalkerRuntimeInput,
|
||||
} from './stalker-runtime-validation';
|
||||
|
||||
const BASE_DESCRIPTOR = {
|
||||
connectionMode: 'persisted-open',
|
||||
macAddress: '00-1a-79-00-00-01',
|
||||
playlistRef: 'playlist-1',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
sourceUrl: 'https://portal.example/c/#discarded-fragment',
|
||||
} as const;
|
||||
|
||||
function validationError(
|
||||
descriptor: unknown,
|
||||
transport: unknown = STALKER_TRANSPORT_DEFAULTS
|
||||
): StalkerRuntimeValidationError {
|
||||
try {
|
||||
validateStalkerRuntimeInput(descriptor, transport);
|
||||
} catch (error) {
|
||||
if (error instanceof StalkerRuntimeValidationError) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Expected runtime validation to fail.');
|
||||
}
|
||||
|
||||
describe('validateStalkerRuntimeInput', () => {
|
||||
it('normalizes URLs and MAC while preserving bounded explicit profile values', () => {
|
||||
const result = validateStalkerRuntimeInput(
|
||||
{
|
||||
...BASE_DESCRIPTOR,
|
||||
identityOverrides: {
|
||||
deviceId1: 'Device-One',
|
||||
serialNumber: 'Serial-AbC',
|
||||
},
|
||||
learnedEndpointHint:
|
||||
'https://portal.example/server/load.php#old',
|
||||
transportConfiguration: {
|
||||
locale: 'de-DE',
|
||||
timezone: 'Europe/Berlin',
|
||||
userAgent: 'Synthetic Client/1.0',
|
||||
},
|
||||
},
|
||||
{
|
||||
maxResponseBytes: 1_048_576,
|
||||
timeoutMs: 12_000,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
descriptor: {
|
||||
...BASE_DESCRIPTOR,
|
||||
identityOverrides: {
|
||||
deviceId1: 'Device-One',
|
||||
serialNumber: 'Serial-AbC',
|
||||
},
|
||||
learnedEndpointHint:
|
||||
'https://portal.example/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
sourceUrl: 'https://portal.example/c/',
|
||||
transportConfiguration: {
|
||||
locale: 'de-DE',
|
||||
timezone: 'Europe/Berlin',
|
||||
userAgent: 'Synthetic Client/1.0',
|
||||
},
|
||||
},
|
||||
transport: {
|
||||
maxResponseBytes: 1_048_576,
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses finite safe transport defaults and omits blank optional strings', () => {
|
||||
const result = validateStalkerRuntimeInput({
|
||||
...BASE_DESCRIPTOR,
|
||||
identityOverrides: {
|
||||
deviceId1: ' ',
|
||||
serialNumber: '',
|
||||
},
|
||||
transportConfiguration: {
|
||||
locale: '',
|
||||
timezone: ' ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.transport).toEqual(STALKER_TRANSPORT_DEFAULTS);
|
||||
expect(result.descriptor).not.toHaveProperty('identityOverrides');
|
||||
expect(result.descriptor).not.toHaveProperty(
|
||||
'transportConfiguration'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://user:private-password@portal.example/c/',
|
||||
'https://portal.example/c/?username=private-account',
|
||||
'https://portal.example/c/?token=private-token',
|
||||
'https://portal.example/c/?mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
'ftp://portal.example/c/',
|
||||
])('rejects unsafe source URLs without echoing their value', (sourceUrl) => {
|
||||
const error = validationError({ ...BASE_DESCRIPTOR, sourceUrl });
|
||||
|
||||
expect(error).toMatchObject({
|
||||
message: 'invalid-url',
|
||||
reason: 'invalid-url',
|
||||
});
|
||||
expect(JSON.stringify(error)).not.toContain(sourceUrl);
|
||||
expect(error.message).not.toContain(sourceUrl);
|
||||
});
|
||||
|
||||
it('applies the same URL rules to a learned endpoint hint', () => {
|
||||
const learnedEndpointHint =
|
||||
'https://portal.example/server/load.php?password=private-password';
|
||||
const error = validationError({
|
||||
...BASE_DESCRIPTOR,
|
||||
learnedEndpointHint,
|
||||
});
|
||||
|
||||
expect(error.reason).toBe('invalid-url');
|
||||
expect(error.message).toBe('invalid-url');
|
||||
expect(JSON.stringify(error)).not.toContain('private-password');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'001A79000001',
|
||||
'00:1A:79:00:00',
|
||||
'GG:1A:79:00:00:01',
|
||||
])('rejects invalid MAC input without echoing it', (macAddress) => {
|
||||
const error = validationError({ ...BASE_DESCRIPTOR, macAddress });
|
||||
|
||||
expect(error).toMatchObject({
|
||||
message: 'invalid-identity-input',
|
||||
reason: 'invalid-identity-input',
|
||||
});
|
||||
expect(error.message).not.toContain(macAddress);
|
||||
expect(JSON.stringify(error)).not.toContain(macAddress);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['playlistRef', 'x'.repeat(STALKER_RUNTIME_LIMITS.playlistRefBytes + 1)],
|
||||
[
|
||||
'sourceUrl',
|
||||
`https://portal.example/${'x'.repeat(
|
||||
STALKER_RUNTIME_LIMITS.urlBytes
|
||||
)}`,
|
||||
],
|
||||
[
|
||||
'learnedEndpointHint',
|
||||
`https://portal.example/${'x'.repeat(
|
||||
STALKER_RUNTIME_LIMITS.urlBytes
|
||||
)}`,
|
||||
],
|
||||
])('rejects an oversized %s', (field, value) => {
|
||||
const error = validationError({
|
||||
...BASE_DESCRIPTOR,
|
||||
[field]: value,
|
||||
});
|
||||
|
||||
expect(error.reason).toBe(
|
||||
field === 'sourceUrl' || field === 'learnedEndpointHint'
|
||||
? 'invalid-url'
|
||||
: 'invalid-identity-input'
|
||||
);
|
||||
expect(error.message).not.toContain(value);
|
||||
});
|
||||
|
||||
it('bounds profile and transport strings and rejects unknown secret fields', () => {
|
||||
const profileValue = 'p'.repeat(
|
||||
STALKER_RUNTIME_LIMITS.profileStringBytes + 1
|
||||
);
|
||||
const transportValue = 't'.repeat(
|
||||
STALKER_RUNTIME_LIMITS.transportStringBytes + 1
|
||||
);
|
||||
|
||||
expect(
|
||||
validationError({
|
||||
...BASE_DESCRIPTOR,
|
||||
identityOverrides: { serialNumber: profileValue },
|
||||
}).reason
|
||||
).toBe('invalid-identity-input');
|
||||
expect(
|
||||
validationError({
|
||||
...BASE_DESCRIPTOR,
|
||||
transportConfiguration: { userAgent: transportValue },
|
||||
}).reason
|
||||
).toBe('invalid-identity-input');
|
||||
|
||||
const secret = 'private-password';
|
||||
const unknownFieldError = validationError({
|
||||
...BASE_DESCRIPTOR,
|
||||
password: secret,
|
||||
});
|
||||
expect(unknownFieldError.reason).toBe('invalid-identity-input');
|
||||
expect(JSON.stringify(unknownFieldError)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['timeoutMs', 0],
|
||||
['timeoutMs', -1],
|
||||
['timeoutMs', Number.NaN],
|
||||
['timeoutMs', Number.POSITIVE_INFINITY],
|
||||
['timeoutMs', STALKER_RUNTIME_LIMITS.timeoutMs + 1],
|
||||
['maxResponseBytes', 0],
|
||||
['maxResponseBytes', -1],
|
||||
['maxResponseBytes', Number.NaN],
|
||||
['maxResponseBytes', Number.POSITIVE_INFINITY],
|
||||
[
|
||||
'maxResponseBytes',
|
||||
STALKER_RUNTIME_LIMITS.maxResponseBytes + 1,
|
||||
],
|
||||
])('rejects invalid transport setting %s=%s', (field, value) => {
|
||||
const error = validationError(BASE_DESCRIPTOR, {
|
||||
...STALKER_TRANSPORT_DEFAULTS,
|
||||
[field]: value,
|
||||
});
|
||||
|
||||
expect(error.reason).toBe('invalid-identity-input');
|
||||
expect(error.message).toBe('invalid-identity-input');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
STALKER_FAILURE_REASONS,
|
||||
normalizeStalkerMacAddress,
|
||||
normalizeStalkerSourceUrl,
|
||||
} from '@iptvnator/portal/stalker/protocol';
|
||||
import {
|
||||
STALKER_SESSION_CONNECTION_MODES,
|
||||
STALKER_SESSION_PROVISIONAL_REASONS,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
StalkerSessionConnectionDescriptor,
|
||||
StalkerSessionIdentityOverrides,
|
||||
StalkerSessionProfilePreset,
|
||||
StalkerSessionProvisionalReason,
|
||||
StalkerSessionTransportConfiguration,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_RUNTIME_LIMITS,
|
||||
STALKER_TRANSPORT_DEFAULTS,
|
||||
} from './stalker-session.types';
|
||||
import type {
|
||||
StalkerTransportConfig,
|
||||
StalkerValidatedRuntimeInput,
|
||||
} from './stalker-session.types';
|
||||
|
||||
const DESCRIPTOR_KEYS = new Set([
|
||||
'connectionMode',
|
||||
'identityOverrides',
|
||||
'learnedEndpointHint',
|
||||
'macAddress',
|
||||
'playlistRef',
|
||||
'profilePreset',
|
||||
'provisionalReason',
|
||||
'sourceUrl',
|
||||
'transportConfiguration',
|
||||
]);
|
||||
const PROFILE_PRESET_KEYS = new Set(['id', 'version']);
|
||||
const IDENTITY_OVERRIDE_KEYS = [
|
||||
'apiSignature',
|
||||
'deviceId1',
|
||||
'deviceId2',
|
||||
'firmwareVersion',
|
||||
'hardwareVersion',
|
||||
'hardwareVersion2',
|
||||
'imageVersion',
|
||||
'numberOfBanks',
|
||||
'prehash',
|
||||
'serialNumber',
|
||||
'signature1',
|
||||
'signature2',
|
||||
'videoOutput',
|
||||
] as const satisfies readonly (keyof StalkerSessionIdentityOverrides)[];
|
||||
const TRANSPORT_CONFIGURATION_KEYS = [
|
||||
'language',
|
||||
'locale',
|
||||
'origin',
|
||||
'originPolicy',
|
||||
'referer',
|
||||
'refererPolicy',
|
||||
'timezone',
|
||||
'userAgent',
|
||||
'xUserAgent',
|
||||
] as const satisfies readonly (keyof StalkerSessionTransportConfiguration)[];
|
||||
const TRANSPORT_KEYS = new Set(['maxResponseBytes', 'timeoutMs']);
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export type StalkerRuntimeValidationFailureReason =
|
||||
| typeof STALKER_FAILURE_REASONS.InvalidIdentityInput
|
||||
| typeof STALKER_FAILURE_REASONS.InvalidUrl;
|
||||
|
||||
export class StalkerRuntimeValidationError extends Error {
|
||||
constructor(readonly reason: StalkerRuntimeValidationFailureReason) {
|
||||
super(reason);
|
||||
this.name = 'StalkerRuntimeValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(reason: StalkerRuntimeValidationFailureReason): never {
|
||||
throw new StalkerRuntimeValidationError(reason);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyKeys(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
allowedKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
return Object.keys(value).every((key) => allowedKeys.has(key));
|
||||
}
|
||||
|
||||
function isBoundedString(value: string, maxBytes: number): boolean {
|
||||
return (
|
||||
Buffer.byteLength(value, 'utf8') <= maxBytes &&
|
||||
!CONTROL_CHARACTERS.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, maxBytes: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.trim().length === 0 ||
|
||||
!isBoundedString(value, maxBytes)
|
||||
) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUrl(value: unknown, optional: boolean): string | undefined {
|
||||
if (optional && (value === undefined || value === null || value === '')) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.trim().length === 0 ||
|
||||
!isBoundedString(value, STALKER_RUNTIME_LIMITS.urlBytes)
|
||||
) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = normalizeStalkerSourceUrl(value);
|
||||
if (!isBoundedString(normalized, STALKER_RUNTIME_LIMITS.urlBytes)) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidUrl);
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMacAddress(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length > 32) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
try {
|
||||
return normalizeStalkerMacAddress(value.trim());
|
||||
} catch {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProfilePreset(value: unknown): StalkerSessionProfilePreset {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, PROFILE_PRESET_KEYS) ||
|
||||
value['id'] !== 'mag250-public-5_1-minimal-v1' ||
|
||||
value['version'] !== 1
|
||||
) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
return {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptionalStringRecord<Key extends string>(
|
||||
value: unknown,
|
||||
keys: readonly Key[],
|
||||
maxBytes: number
|
||||
): Partial<Record<Key, string>> | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, new Set(keys))) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
|
||||
const normalized: Partial<Record<Key, string>> = {};
|
||||
for (const key of keys) {
|
||||
const field = value[key];
|
||||
if (field === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof field !== 'string') {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
if (field.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isBoundedString(field, maxBytes)) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
normalized[key] = field;
|
||||
}
|
||||
return Object.keys(normalized).length === 0 ? undefined : normalized;
|
||||
}
|
||||
|
||||
function normalizeIdentityOverrides(
|
||||
value: unknown
|
||||
): StalkerSessionIdentityOverrides | undefined {
|
||||
return normalizeOptionalStringRecord(
|
||||
value,
|
||||
IDENTITY_OVERRIDE_KEYS,
|
||||
STALKER_RUNTIME_LIMITS.profileStringBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTransportConfiguration(
|
||||
value: unknown
|
||||
): StalkerSessionTransportConfiguration | undefined {
|
||||
return normalizeOptionalStringRecord(
|
||||
value,
|
||||
TRANSPORT_CONFIGURATION_KEYS,
|
||||
STALKER_RUNTIME_LIMITS.transportStringBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeProvisionalReason(
|
||||
connectionMode: unknown,
|
||||
provisionalReason: unknown
|
||||
): StalkerSessionProvisionalReason | undefined {
|
||||
if (connectionMode === STALKER_SESSION_CONNECTION_MODES.PersistedOpen) {
|
||||
if (provisionalReason !== undefined) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (connectionMode !== STALKER_SESSION_CONNECTION_MODES.Provisional) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
|
||||
const allowedReasons = Object.values(
|
||||
STALKER_SESSION_PROVISIONAL_REASONS
|
||||
) as readonly unknown[];
|
||||
if (!allowedReasons.includes(provisionalReason)) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
return provisionalReason as StalkerSessionProvisionalReason;
|
||||
}
|
||||
|
||||
function normalizeDescriptor(
|
||||
value: unknown
|
||||
): StalkerSessionConnectionDescriptor {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyKeys(value, DESCRIPTOR_KEYS)
|
||||
) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
|
||||
const provisionalReason = normalizeProvisionalReason(
|
||||
value['connectionMode'],
|
||||
value['provisionalReason']
|
||||
);
|
||||
const identityOverrides = normalizeIdentityOverrides(
|
||||
value['identityOverrides']
|
||||
);
|
||||
const transportConfiguration = normalizeTransportConfiguration(
|
||||
value['transportConfiguration']
|
||||
);
|
||||
const learnedEndpointHint = normalizeUrl(
|
||||
value['learnedEndpointHint'],
|
||||
true
|
||||
);
|
||||
const base = {
|
||||
connectionMode: value['connectionMode'],
|
||||
macAddress: normalizeMacAddress(value['macAddress']),
|
||||
playlistRef: requiredString(
|
||||
value['playlistRef'],
|
||||
STALKER_RUNTIME_LIMITS.playlistRefBytes
|
||||
).trim(),
|
||||
profilePreset: normalizeProfilePreset(value['profilePreset']),
|
||||
sourceUrl: normalizeUrl(value['sourceUrl'], false) as string,
|
||||
...(learnedEndpointHint === undefined
|
||||
? {}
|
||||
: { learnedEndpointHint }),
|
||||
...(identityOverrides === undefined ? {} : { identityOverrides }),
|
||||
...(transportConfiguration === undefined
|
||||
? {}
|
||||
: { transportConfiguration }),
|
||||
};
|
||||
|
||||
return provisionalReason === undefined
|
||||
? {
|
||||
...base,
|
||||
connectionMode: STALKER_SESSION_CONNECTION_MODES.PersistedOpen,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
connectionMode: STALKER_SESSION_CONNECTION_MODES.Provisional,
|
||||
provisionalReason,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransport(value: unknown): StalkerTransportConfig {
|
||||
const source = value ?? STALKER_TRANSPORT_DEFAULTS;
|
||||
if (!isRecord(source) || !hasOnlyKeys(source, TRANSPORT_KEYS)) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
|
||||
const timeoutMs = source['timeoutMs'];
|
||||
const maxResponseBytes = source['maxResponseBytes'];
|
||||
if (
|
||||
typeof timeoutMs !== 'number' ||
|
||||
!Number.isFinite(timeoutMs) ||
|
||||
timeoutMs <= 0 ||
|
||||
timeoutMs > STALKER_RUNTIME_LIMITS.timeoutMs ||
|
||||
typeof maxResponseBytes !== 'number' ||
|
||||
!Number.isFinite(maxResponseBytes) ||
|
||||
maxResponseBytes <= 0 ||
|
||||
maxResponseBytes > STALKER_RUNTIME_LIMITS.maxResponseBytes
|
||||
) {
|
||||
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
|
||||
}
|
||||
|
||||
return { maxResponseBytes, timeoutMs };
|
||||
}
|
||||
|
||||
export function validateStalkerRuntimeInput(
|
||||
descriptor: unknown,
|
||||
transport?: unknown
|
||||
): StalkerValidatedRuntimeInput {
|
||||
return {
|
||||
descriptor: normalizeDescriptor(descriptor),
|
||||
transport: normalizeTransport(transport),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { STALKER_FAILURE_REASONS } from '@iptvnator/portal/stalker/protocol';
|
||||
import type {
|
||||
StalkerSessionConnectionDescriptor,
|
||||
StalkerSessionFailureReason,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const STALKER_RUNTIME_LIMITS = {
|
||||
maxResponseBytes: 16 * 1024 * 1024,
|
||||
playlistRefBytes: 256,
|
||||
profileStringBytes: 2048,
|
||||
timeoutMs: 120_000,
|
||||
transportStringBytes: 4096,
|
||||
urlBytes: 4096,
|
||||
} as const;
|
||||
|
||||
export interface StalkerTransportConfig {
|
||||
readonly maxResponseBytes: number;
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
export const STALKER_TRANSPORT_DEFAULTS = {
|
||||
maxResponseBytes: 4 * 1024 * 1024,
|
||||
timeoutMs: 15_000,
|
||||
} as const satisfies StalkerTransportConfig;
|
||||
|
||||
export interface StalkerValidatedRuntimeInput {
|
||||
readonly descriptor: StalkerSessionConnectionDescriptor;
|
||||
readonly transport: StalkerTransportConfig;
|
||||
}
|
||||
|
||||
export interface StalkerTransportResult<T> {
|
||||
readonly body: T;
|
||||
readonly finalUrl: string;
|
||||
readonly status: number;
|
||||
}
|
||||
|
||||
export const STALKER_TRANSPORT_FAILURE_REASONS = {
|
||||
DnsFailure: STALKER_FAILURE_REASONS.DnsFailure,
|
||||
InvalidIdentityInput: STALKER_FAILURE_REASONS.InvalidIdentityInput,
|
||||
InvalidUrl: STALKER_FAILURE_REASONS.InvalidUrl,
|
||||
NetworkUnreachable: STALKER_FAILURE_REASONS.NetworkUnreachable,
|
||||
RedirectLoop: STALKER_FAILURE_REASONS.RedirectLoop,
|
||||
RequestTimeout: STALKER_FAILURE_REASONS.RequestTimeout,
|
||||
ResponseTooLarge: STALKER_FAILURE_REASONS.ResponseTooLarge,
|
||||
TlsFailure: STALKER_FAILURE_REASONS.TlsFailure,
|
||||
} as const satisfies Readonly<Record<string, StalkerSessionFailureReason>>;
|
||||
|
||||
export type StalkerTransportFailureReason =
|
||||
(typeof STALKER_TRANSPORT_FAILURE_REASONS)[keyof typeof STALKER_TRANSPORT_FAILURE_REASONS];
|
||||
|
||||
export interface StalkerTransportError {
|
||||
readonly reason: StalkerTransportFailureReason;
|
||||
readonly retryable: boolean;
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { StalkerProtocolInputError } from '@iptvnator/portal/stalker/protocol';
|
||||
import { UnsafeUrlError } from '../../events/url-safety';
|
||||
import { StalkerRuntimeValidationError } from './stalker-runtime-validation';
|
||||
import {
|
||||
STALKER_TRANSPORT_CAUSE_LIMIT,
|
||||
mapStalkerTransportError,
|
||||
} from './stalker-transport-errors';
|
||||
|
||||
function errorWithCode(code: string, message = 'synthetic failure'): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function axiosError(input: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
response?: { status: number };
|
||||
}): Error {
|
||||
return Object.assign(new Error(input.message ?? 'synthetic Axios failure'), {
|
||||
code: input.code,
|
||||
config: {
|
||||
headers: { authorization: 'private-bearer' },
|
||||
url: 'https://portal.example/c/?token=private-token',
|
||||
},
|
||||
isAxiosError: true,
|
||||
response: input.response,
|
||||
});
|
||||
}
|
||||
|
||||
describe('mapStalkerTransportError', () => {
|
||||
it.each([
|
||||
['ENOTFOUND', 'dns-failure', true],
|
||||
['EAI_AGAIN', 'dns-failure', true],
|
||||
['ENODATA', 'dns-failure', true],
|
||||
['ECONNABORTED', 'request-timeout', true],
|
||||
['ETIMEDOUT', 'request-timeout', true],
|
||||
['UND_ERR_CONNECT_TIMEOUT', 'request-timeout', true],
|
||||
['ECONNREFUSED', 'network-unreachable', true],
|
||||
['ECONNRESET', 'network-unreachable', true],
|
||||
['ENETUNREACH', 'network-unreachable', true],
|
||||
['EHOSTUNREACH', 'network-unreachable', true],
|
||||
['EPIPE', 'network-unreachable', true],
|
||||
['EADDRNOTAVAIL', 'network-unreachable', true],
|
||||
])('maps %s to %s', (code, reason, retryable) => {
|
||||
expect(mapStalkerTransportError(errorWithCode(code))).toEqual({
|
||||
reason,
|
||||
retryable,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
errorWithCode('CERT_HAS_EXPIRED'),
|
||||
errorWithCode('ERR_TLS_CERT_ALTNAME_INVALID'),
|
||||
errorWithCode('ERR_TLS_HANDSHAKE_TIMEOUT'),
|
||||
errorWithCode('ERR_SSL_PROTOCOL_ERROR'),
|
||||
new Error('self-signed certificate in certificate chain'),
|
||||
])('maps TLS evidence without returning its raw details', (error) => {
|
||||
expect(mapStalkerTransportError(error)).toEqual({
|
||||
reason: 'tls-failure',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps an Axios maxContentLength failure before generic no-response handling', () => {
|
||||
expect(
|
||||
mapStalkerTransportError(
|
||||
axiosError({
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
message:
|
||||
'maxContentLength size of private-limit exceeded',
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
reason: 'response-too-large',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps an Axios network error only when no HTTP response exists', () => {
|
||||
expect(
|
||||
mapStalkerTransportError(axiosError({ code: 'ERR_NETWORK' }))
|
||||
).toEqual({
|
||||
reason: 'network-unreachable',
|
||||
retryable: true,
|
||||
});
|
||||
expect(
|
||||
mapStalkerTransportError(
|
||||
axiosError({
|
||||
code: 'ERR_BAD_RESPONSE',
|
||||
response: { status: 503 },
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
new UnsafeUrlError('Redirect loop detected', 502),
|
||||
new UnsafeUrlError('Too many redirects', 502),
|
||||
])('maps redirect loop and ceiling failures', (error) => {
|
||||
expect(mapStalkerTransportError(error)).toEqual({
|
||||
reason: 'redirect-loop',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps other URL/input failures without echoing input evidence', () => {
|
||||
const secret = 'private-password';
|
||||
const errors = [
|
||||
new UnsafeUrlError(`Invalid URL ${secret}`),
|
||||
new StalkerProtocolInputError('invalid-url'),
|
||||
new StalkerRuntimeValidationError('invalid-url'),
|
||||
new StalkerRuntimeValidationError('invalid-identity-input'),
|
||||
];
|
||||
|
||||
const mapped = errors.map((error) =>
|
||||
mapStalkerTransportError(error)
|
||||
);
|
||||
|
||||
expect(mapped).toEqual([
|
||||
{ reason: 'invalid-url', retryable: false },
|
||||
{ reason: 'invalid-url', retryable: false },
|
||||
{ reason: 'invalid-url', retryable: false },
|
||||
{ reason: 'invalid-identity-input', retryable: false },
|
||||
]);
|
||||
expect(JSON.stringify(mapped)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('walks a bounded cause chain and handles cycles', () => {
|
||||
expect(
|
||||
mapStalkerTransportError({
|
||||
cause: { cause: errorWithCode('ENOTFOUND') },
|
||||
})
|
||||
).toEqual({
|
||||
reason: 'dns-failure',
|
||||
retryable: true,
|
||||
});
|
||||
|
||||
let tooDeep: unknown = errorWithCode('ENOTFOUND');
|
||||
for (let index = 0; index < STALKER_TRANSPORT_CAUSE_LIMIT; index += 1) {
|
||||
tooDeep = { cause: tooDeep };
|
||||
}
|
||||
expect(mapStalkerTransportError(tooDeep)).toBeUndefined();
|
||||
|
||||
const cyclic: { cause?: unknown } = {};
|
||||
cyclic.cause = cyclic;
|
||||
expect(mapStalkerTransportError(cyclic)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns only stable reason/retryable fields and never Axios secrets', () => {
|
||||
const mapped = mapStalkerTransportError(
|
||||
axiosError({
|
||||
code: 'ENOTFOUND',
|
||||
message: 'private-password private-token',
|
||||
})
|
||||
);
|
||||
|
||||
expect(Object.keys(mapped ?? {}).sort()).toEqual([
|
||||
'reason',
|
||||
'retryable',
|
||||
]);
|
||||
expect(JSON.stringify(mapped)).not.toMatch(
|
||||
/private-password|private-token|authorization|headers|config|url/i
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
STALKER_FAILURE_REASONS,
|
||||
StalkerProtocolInputError,
|
||||
} from '@iptvnator/portal/stalker/protocol';
|
||||
import { UnsafeUrlError } from '../../events/url-safety';
|
||||
import { isInvalidTlsCertificateError } from '../../util/security-errors';
|
||||
import { StalkerRuntimeValidationError } from './stalker-runtime-validation';
|
||||
import type { StalkerTransportError } from './stalker-session.types';
|
||||
|
||||
export const STALKER_TRANSPORT_CAUSE_LIMIT = 5;
|
||||
|
||||
const DNS_ERROR_CODES = new Set(['EAI_AGAIN', 'ENODATA', 'ENOTFOUND']);
|
||||
const TIMEOUT_ERROR_CODES = new Set([
|
||||
'ECONNABORTED',
|
||||
'ETIMEDOUT',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
]);
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
'EADDRNOTAVAIL',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EPIPE',
|
||||
'HOSTUNREACH',
|
||||
'NETUNREACH',
|
||||
]);
|
||||
const RESPONSE_TOO_LARGE =
|
||||
/maxcontentlength|content length[^]*exceed|size[^]*exceed/i;
|
||||
const REDIRECT_LOOP = /redirect loop|too many redirects/i;
|
||||
|
||||
function readProperty(value: unknown, property: string): unknown {
|
||||
return typeof value === 'object' && value !== null
|
||||
? Reflect.get(value, property)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function causeChain(error: unknown): readonly unknown[] {
|
||||
const chain: unknown[] = [];
|
||||
const seen = new Set<object>();
|
||||
let current: unknown = error;
|
||||
|
||||
while (chain.length < STALKER_TRANSPORT_CAUSE_LIMIT) {
|
||||
chain.push(current);
|
||||
if (typeof current !== 'object' || current === null) {
|
||||
break;
|
||||
}
|
||||
if (seen.has(current)) {
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
const cause = readProperty(current, 'cause');
|
||||
if (cause === undefined) {
|
||||
break;
|
||||
}
|
||||
current = cause;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function normalizedCode(value: unknown): string | undefined {
|
||||
const code = readProperty(value, 'code');
|
||||
return typeof code === 'string' ? code.toUpperCase() : undefined;
|
||||
}
|
||||
|
||||
function isTlsCode(code: string | undefined): boolean {
|
||||
return (
|
||||
code !== undefined &&
|
||||
(code.includes('CERT') ||
|
||||
code.startsWith('ERR_SSL_') ||
|
||||
code.startsWith('ERR_TLS_'))
|
||||
);
|
||||
}
|
||||
|
||||
function transportError(
|
||||
reason: StalkerTransportError['reason'],
|
||||
retryable: boolean
|
||||
): StalkerTransportError {
|
||||
return { reason, retryable };
|
||||
}
|
||||
|
||||
export function mapStalkerTransportError(
|
||||
error: unknown
|
||||
): StalkerTransportError | undefined {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response !== undefined &&
|
||||
!(
|
||||
error.code === 'ERR_BAD_RESPONSE' &&
|
||||
RESPONSE_TOO_LARGE.test(error.message)
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chain = causeChain(error);
|
||||
for (const current of chain) {
|
||||
if (current instanceof StalkerRuntimeValidationError) {
|
||||
return transportError(current.reason, false);
|
||||
}
|
||||
if (current instanceof StalkerProtocolInputError) {
|
||||
return transportError(STALKER_FAILURE_REASONS.InvalidUrl, false);
|
||||
}
|
||||
if (current instanceof UnsafeUrlError) {
|
||||
return transportError(
|
||||
REDIRECT_LOOP.test(current.message)
|
||||
? STALKER_FAILURE_REASONS.RedirectLoop
|
||||
: STALKER_FAILURE_REASONS.InvalidUrl,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.code === 'ERR_BAD_RESPONSE' &&
|
||||
RESPONSE_TOO_LARGE.test(error.message)
|
||||
) {
|
||||
return transportError(
|
||||
STALKER_FAILURE_REASONS.ResponseTooLarge,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
for (const current of chain) {
|
||||
const code = normalizedCode(current);
|
||||
if (DNS_ERROR_CODES.has(code ?? '')) {
|
||||
return transportError(STALKER_FAILURE_REASONS.DnsFailure, true);
|
||||
}
|
||||
if (TIMEOUT_ERROR_CODES.has(code ?? '')) {
|
||||
return transportError(
|
||||
STALKER_FAILURE_REASONS.RequestTimeout,
|
||||
true
|
||||
);
|
||||
}
|
||||
if (
|
||||
isTlsCode(code) ||
|
||||
(current instanceof Error &&
|
||||
isInvalidTlsCertificateError(current))
|
||||
) {
|
||||
return transportError(STALKER_FAILURE_REASONS.TlsFailure, false);
|
||||
}
|
||||
if (NETWORK_ERROR_CODES.has(code ?? '')) {
|
||||
return transportError(
|
||||
STALKER_FAILURE_REASONS.NetworkUnreachable,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (axios.isAxiosError(error) && error.response === undefined) {
|
||||
return transportError(
|
||||
STALKER_FAILURE_REASONS.NetworkUnreachable,
|
||||
true
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in new issue
Block a user