mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(stalker): add isolated RFC cookie jar
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user