From d0cf96caf8ca2ca36b2337c40751150f50bc58b9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 09:47:32 +0200 Subject: [PATCH] feat(stalker): add isolated RFC cookie jar --- .../stalker-cookie-jar.spec.ts | 170 ++++++++++++++++++ .../stalker-session/stalker-cookie-jar.ts | 144 +++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.ts 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 new file mode 100644 index 000000000..cd2d0bbbf --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts @@ -0,0 +1,170 @@ +import { StalkerCookieJar } from './stalker-cookie-jar'; + +describe('StalkerCookieJar', () => { + const managedCookies = { + mac: '00:1A:79:00:00:01', + stb_lang: 'en', + timezone: 'Europe/Berlin', + }; + + it('applies RFC domain, path, secure, HttpOnly, and duplicate-name rules', async () => { + const jar = new StalkerCookieJar(); + + await jar.collectResponseCookies('https://portal.example.com/c/', [ + 'session=root; Domain=example.com; Path=/; HttpOnly', + 'session=portal; Path=/c; Secure', + 'plain=available; Path=/', + 'secure=only-https; Path=/; Secure', + ]); + + expect( + await jar.getCookieHeader('https://portal.example.com/c/page') + ).toBe( + 'session=portal; session=root; plain=available; secure=only-https' + ); + expect( + await jar.getCookieHeader('http://portal.example.com/c/page') + ).toBe('session=root; plain=available'); + expect( + await jar.getCookieHeader('https://other.example.com/c/page') + ).toBe('session=root'); + }); + + it('uses the response-hop URL for redirect cookie mutations', async () => { + const jar = new StalkerCookieJar(); + + await jar.collectResponseCookies('https://one.example.test/start', [ + 'first=one; Path=/', + ]); + await jar.collectResponseCookies('https://two.example.test/landing', [ + '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'); + }); + + it('honors expiry against an injected clock', async () => { + let now = new Date('2026-07-27T10:00:00.000Z'); + const jar = new StalkerCookieJar({}, { now: () => now }); + + await jar.collectResponseCookies('https://portal.example.com/', [ + 'short=lived; Max-Age=60; Path=/', + 'absolute=alive; Expires=Mon, 27 Jul 2026 10:02:00 GMT; Path=/', + ]); + + now = new Date('2026-07-27T10:01:01.000Z'); + expect( + await jar.getCookieHeader('https://portal.example.com/') + ).toBe('absolute=alive'); + + now = new Date('2026-07-27T10:02:01.000Z'); + expect( + await jar.getCookieHeader('https://portal.example.com/') + ).toBeUndefined(); + }); + + it('rejects public-suffix cookies without retaining them', async () => { + const jar = new StalkerCookieJar(); + + await jar.collectResponseCookies('https://portal.example.com/', [ + 'bad=value; Domain=com; Path=/', + 'good=value; Path=/', + ]); + + expect( + await jar.getCookieHeader('https://portal.example.com/') + ).toBe('good=value'); + }); + + it.each([ + 'mac=server; Path=/', + 'MAC=server; Domain=example.com; Path=/c', + 'stb_lang=server; Path=/', + 'StB_LaNg=server; Domain=example.com; Path=/c', + 'timezone=server; Path=/', + 'TIMEZONE=server; Domain=example.com; Path=/c', + ])( + 'discards every managed-cookie shadow attempt: %s', + async (setCookie) => { + const jar = new StalkerCookieJar(managedCookies); + + await jar.collectResponseCookies( + 'https://portal.example.com/c/', + [setCookie, 'server=retained; Path=/'] + ); + + expect( + await jar.getCookieHeader( + 'https://portal.example.com/c/request' + ) + ).toBe( + 'server=retained; mac=00:1A:79:00:00:01; stb_lang=en; timezone=Europe/Berlin' + ); + } + ); + + it('filters managed names again at request materialization', async () => { + const jar = new StalkerCookieJar(managedCookies); + + await jar.collectResponseCookies('https://portal.example.com/', [ + 'session=server; Path=/', + ]); + + const internalJar = Reflect.get(jar, '__testJar'); + expect(internalJar).toBeUndefined(); + 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=/'] + ); + + const candidate = await original.clone(); + await candidate.collectResponseCookies( + 'https://portal.example.com/', + ['candidate=isolated; Path=/'] + ); + + expect( + await original.getCookieHeader('https://portal.example.com/') + ).toBe( + 'landing=kept; mac=00:1A:79:00:00:01; stb_lang=en; timezone=Europe/Berlin' + ); + expect( + await candidate.getCookieHeader('https://portal.example.com/') + ).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); + + expect('serialize' in jar).toBe(false); + expect('toJSON' in jar).toBe(false); + expect(JSON.stringify(jar)).toBe('{}'); + expect(Object.keys(jar)).toEqual([]); + }); + + it('rejects unsafe managed-cookie values', () => { + expect( + () => + new StalkerCookieJar({ + ...managedCookies, + timezone: 'UTC; injected=value', + }) + ).toThrow('invalid-identity-input'); + }); +}); 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 new file mode 100644 index 000000000..66b1e43ae --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.ts @@ -0,0 +1,144 @@ +import { Cookie, CookieJar } from 'tough-cookie'; + +const MANAGED_COOKIE_NAMES = ['mac', 'stb_lang', 'timezone'] as const; +const MANAGED_COOKIE_NAME_SET = new Set(MANAGED_COOKIE_NAMES); +const COOKIE_VALUE_PATTERN = /^[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]+$/; + +type StalkerManagedCookieName = (typeof MANAGED_COOKIE_NAMES)[number]; + +export type StalkerManagedCookies = Readonly< + Partial> +>; + +export interface StalkerCookieJarOptions { + now?: () => Date; +} + +/** + * Main-process-only Stalker cookie state. + * + * The native jar and managed identity values are private fields on purpose: + * this wrapper has no serialization API and cannot accidentally become an IPC + * DTO. Candidate isolation uses {@link clone} instead. + */ +export class StalkerCookieJar { + #jar: CookieJar; + readonly #managedCookies: StalkerManagedCookies; + readonly #now: () => Date; + + constructor( + managedCookies: StalkerManagedCookies = {}, + options: StalkerCookieJarOptions = {} + ) { + this.#managedCookies = copyAndValidateManagedCookies(managedCookies); + this.#now = options.now ?? (() => new Date()); + this.#jar = createNativeCookieJar(); + } + + async collectResponseCookies( + responseUrl: string, + setCookieHeaders: string | readonly string[] | undefined + ): Promise { + if (setCookieHeaders === undefined) { + return; + } + + const headers = + typeof setCookieHeaders === 'string' + ? [setCookieHeaders] + : setCookieHeaders; + for (const setCookie of headers) { + const parsed = Cookie.parse(setCookie); + if ( + !parsed || + MANAGED_COOKIE_NAME_SET.has(parsed.key.toLowerCase()) + ) { + continue; + } + + const receivedAt = this.#now(); + normalizeMaxAgeToAbsoluteExpiry(parsed, receivedAt); + await this.#jar.setCookie(parsed, responseUrl, { + http: true, + ignoreError: true, + loose: false, + now: receivedAt, + }); + } + } + + async getCookieHeader(requestUrl: string): Promise { + const now = this.#now(); + const serverCookies = await this.#jar.getCookies(requestUrl, { + expire: false, + http: true, + sort: true, + }); + const cookieParts = serverCookies + .filter( + (cookie) => + !MANAGED_COOKIE_NAME_SET.has(cookie.key.toLowerCase()) && + isUnexpired(cookie, now) + ) + .map((cookie) => cookie.cookieString()); + + for (const name of MANAGED_COOKIE_NAMES) { + const value = this.#managedCookies[name]; + if (value !== undefined) { + cookieParts.push(`${name}=${value}`); + } + } + + return cookieParts.length > 0 ? cookieParts.join('; ') : undefined; + } + + async clone(): Promise { + const clone = new StalkerCookieJar(this.#managedCookies, { + now: this.#now, + }); + clone.#jar = await this.#jar.clone(); + return clone; + } +} + +function createNativeCookieJar(): CookieJar { + return new CookieJar(undefined, { + allowSpecialUseDomain: true, + looseMode: false, + prefixSecurity: 'strict', + rejectPublicSuffixes: true, + }); +} + +function copyAndValidateManagedCookies( + managedCookies: StalkerManagedCookies +): StalkerManagedCookies { + const result: Partial> = {}; + for (const name of MANAGED_COOKIE_NAMES) { + const value = managedCookies[name]; + if (value === undefined) { + continue; + } + if (!COOKIE_VALUE_PATTERN.test(value)) { + throw new Error('invalid-identity-input'); + } + result[name] = value; + } + return Object.freeze(result); +} + +function isUnexpired(cookie: Cookie, now: Date): boolean { + const expiry = cookie.expiryTime(); + return expiry === undefined || expiry > now.getTime(); +} + +function normalizeMaxAgeToAbsoluteExpiry( + cookie: Cookie, + receivedAt: Date +): void { + if (typeof cookie.maxAge !== 'number' || cookie.maxAge <= 0) { + return; + } + cookie.expires = new Date(receivedAt.getTime() + cookie.maxAge * 1000); + cookie.maxAge = null; +}