diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts index cd2d0bbbf..90e3c723d 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.ts index 66b1e43ae..30f3fb168 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.ts @@ -93,7 +93,13 @@ export class StalkerCookieJar { } async clone(): Promise { - const clone = new StalkerCookieJar(this.#managedCookies, { + return this.cloneWithManagedCookies(this.#managedCookies); + } + + async cloneWithManagedCookies( + managedCookies: StalkerManagedCookies + ): Promise { + const clone = new StalkerCookieJar(managedCookies, { now: this.#now, }); clone.#jar = await this.#jar.clone(); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts new file mode 100644 index 000000000..fdcbda736 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.spec.ts @@ -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 { + 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 +) { + 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 }).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', + ]); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts new file mode 100644 index 000000000..cdf991a44 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-endpoint-resolver.ts @@ -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; +} + +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; + 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; + 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 { + 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 { + 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 { + const normalized = new Set(); + 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 +): 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 +): 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> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : undefined; +} + +function failure( + reason: StalkerSessionFailureReason, + retryable: boolean, + retryAfterSeconds?: number +): StalkerEndpointFailureOutcome { + return { + kind: 'failure', + reason, + retryable, + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), + stage: 'resolving', + }; +} diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts index 9541adde2..d47f8750d 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts index 282f98b5c..68eb3a89c 100644 --- a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts @@ -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, '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 ); } } 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 index 1c0ba47b4..87db5db2b 100644 --- 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 @@ -30,7 +30,9 @@ export interface StalkerValidatedRuntimeInput { export interface StalkerTransportResult { readonly body: T; + readonly contentType?: string; readonly finalUrl: string; + readonly retryAfterSeconds?: number; readonly status: number; }