feat(stalker): add isolated RFC cookie jar

This commit is contained in:
4gray committed 2026-07-27 09:47:32 +02:00
1 parent 9166d0f12c
commit d0cf96caf8
2 files changed
+314

No files matched your search

@@ -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');
});
});
@@ -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<string>(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<Record<StalkerManagedCookieName, string>>
>;
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<void> {
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<string | undefined> {
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<StalkerCookieJar> {
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<Record<StalkerManagedCookieName, string>> = {};
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;
}