From 13f05d0a7efa43c769155c0391d1206b29f96a55 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 09:48:19 +0200 Subject: [PATCH] feat(stalker): validate portal transport inputs --- .../stalker-runtime-validation.spec.ts | 229 +++++++++++++ .../stalker-runtime-validation.ts | 321 ++++++++++++++++++ .../stalker-session/stalker-session.types.ts | 54 +++ .../stalker-transport-errors.spec.ts | 164 +++++++++ .../stalker-transport-errors.ts | 159 +++++++++ 5 files changed, 927 insertions(+) create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.spec.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-session.types.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.spec.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.ts diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.spec.ts new file mode 100644 index 000000000..f38649bae --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.spec.ts @@ -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'); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.ts new file mode 100644 index 000000000..6856a4616 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-runtime-validation.ts @@ -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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys( + value: Readonly>, + allowedKeys: ReadonlySet +): 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( + value: unknown, + keys: readonly Key[], + maxBytes: number +): Partial> | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value) || !hasOnlyKeys(value, new Set(keys))) { + return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput); + } + + const normalized: Partial> = {}; + 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), + }; +} diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-session.types.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-session.types.ts new file mode 100644 index 000000000..1c0ba47b4 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-session.types.ts @@ -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 { + 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>; + +export type StalkerTransportFailureReason = + (typeof STALKER_TRANSPORT_FAILURE_REASONS)[keyof typeof STALKER_TRANSPORT_FAILURE_REASONS]; + +export interface StalkerTransportError { + readonly reason: StalkerTransportFailureReason; + readonly retryable: boolean; +} diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.spec.ts new file mode 100644 index 000000000..058965fab --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.spec.ts @@ -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 + ); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.ts new file mode 100644 index 000000000..fd4c8949a --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-transport-errors.ts @@ -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(); + 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; +}