fix(stalker): fail closed on incompatible responses

This commit is contained in:
4gray committed 2026-07-27 12:01:23 +02:00
1 parent 3ebe31f041
commit 91d116a572
4 files changed
+333 -101

No files matched your search

@@ -51,6 +51,25 @@ function success(
};
}
function rawSuccess(
body: string,
options: {
contentType?: string;
finalUrl?: string;
status?: number;
} = {}
): StalkerHttpRequestOutcome {
return {
kind: STALKER_HTTP_OUTCOME_KINDS.Success,
result: {
body: new TextEncoder().encode(body),
contentType: options.contentType ?? 'application/json',
finalUrl: options.finalUrl ?? 'https://portal.test/c/',
status: options.status ?? 200,
},
};
}
function createHarness(
responder: (
request: StalkerHttpRequest,
@@ -176,6 +195,146 @@ describe('StalkerEndpointResolver', () => {
).toBe('Bearer token-one');
});
it('stops at the first profile with an unknown status', async () => {
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') {
return success(
{ js: { token: 'candidate-token' } },
{ finalUrl: request.url }
);
}
if (action === 'get_profile') {
return success(
{ js: { id: 'profile-one', status: 3 } },
{ finalUrl: request.url }
);
}
throw new Error(`Unexpected action: ${String(action)}`);
});
const outcome = await harness.resolver.resolve({
descriptor: descriptor(),
transport,
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'incompatible-response',
retryable: false,
stage: 'resolving',
});
expect(
harness.calls.filter(
(request) => request.params?.['action'] === 'handshake'
)
).toHaveLength(1);
expect(
harness.calls.filter(
(request) => request.params?.['action'] === 'get_profile'
)
).toHaveLength(1);
expect(
harness.calls.some(
(request) => request.params?.['action'] === 'get_genres'
)
).toBe(false);
});
it.each([
['a handshake token near miss', () => success({ js: { token: 1 } })],
[
'malformed JSONP',
() =>
rawSuccess('callback({"js":{"token":"candidate-token"}}', {
contentType: 'application/javascript',
}),
],
[
'JSON with the wrong content type',
() =>
rawSuccess('{"js":{"token":"candidate-token"}}', {
contentType: 'text/html',
}),
],
])(
'treats %s as terminal and never advances candidates',
async (_, reply) => {
const harness = createHarness((request) => {
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
return success({}, { finalUrl: request.url });
}
return reply();
});
const outcome = await harness.resolver.resolve({
descriptor: descriptor(),
transport,
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'incompatible-response',
retryable: false,
stage: 'resolving',
});
expect(
harness.calls.filter(
(request) => request.params?.['action'] === 'handshake'
)
).toHaveLength(1);
expect(
harness.calls.some(
(request) => request.params?.['action'] === 'get_genres'
)
).toBe(false);
}
);
it('rejects a stateless catalog near miss without advancing candidates', async () => {
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') {
return success({}, { finalUrl: request.url, status: 404 });
}
if (action === 'get_genres') {
return success(
{ js: { id: 'not-a-catalog' } },
{ finalUrl: request.url }
);
}
throw new Error(`Unexpected action: ${String(action)}`);
});
const outcome = await harness.resolver.resolve({
descriptor: descriptor(),
transport,
});
expect(outcome).toEqual({
kind: 'failure',
reason: 'incompatible-response',
retryable: false,
stage: 'resolving',
});
expect(
harness.calls.filter(
(request) => request.params?.['action'] === 'handshake'
)
).toHaveLength(1);
expect(
harness.calls.filter(
(request) => request.params?.['action'] === 'get_genres'
)
).toHaveLength(1);
});
it('continues after early stateless evidence so a later full endpoint wins', async () => {
const harness = createHarness((request) => {
const action = request.params?.['action'];
@@ -279,7 +438,7 @@ describe('StalkerEndpointResolver', () => {
await jar.collectResponseCookies(request.url, [
'poison=first; Path=/',
]);
return success({}, { finalUrl: request.url });
return success({}, { finalUrl: request.url, status: 404 });
}
if (request.url.endsWith('/portal.php') && action === 'handshake') {
secondCandidateCookie =
@@ -307,48 +466,54 @@ describe('StalkerEndpointResolver', () => {
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') {
it.each([404, 405, 501])(
'keeps explicit unsupported HTTP %i eligible for candidate rediscovery',
async (unsupportedStatus) => {
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/')) {
return success(
{},
{
finalUrl: request.url,
status: unsupportedStatus,
}
);
}
if (action === 'handshake') {
return success(
{ js: { token: 'rediscovered-token' } },
{ finalUrl: request.url }
);
}
return success(
{ unexpected: true },
{
contentType: 'text/html',
finalUrl: request.url,
}
);
}
if (action === 'handshake') {
return success(
{ js: { token: 'rediscovered-token' } },
{ js: { status: 0 } },
{ 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,
});
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',
]);
});
expect(outcome.kind).toBe('full-session');
expect(handshakeUrls).toEqual([
'https://portal.test/learned/server/load.php',
'https://portal.test/server/load.php',
]);
}
);
it('ignores an unapproved cross-origin learned endpoint before sending identity', async () => {
const harness = createHarness((request) => {
@@ -90,9 +90,15 @@ export type StalkerEndpointResolverOutcome =
| StalkerEndpointFailureOutcome;
interface ParsedTransportEnvelope {
kind: 'parsed';
rawBody: string;
result: StalkerTransportResult<Uint8Array>;
value?: unknown;
value: unknown;
}
interface UnparsedTransportEnvelope {
kind: 'unparsed';
rawBody?: string;
reason: StalkerSessionFailureReason;
}
interface CandidateProbeUnsupported {
@@ -259,7 +265,7 @@ export class StalkerEndpointResolver {
const handshakeValue = asRecord(handshakeEnvelope?.['js']);
const token = boundedNonEmptyString(handshakeValue?.['token']);
if (token === undefined) {
return { kind: 'miss' };
return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
}
const handshakeRandom = boundedOptionalString(
handshakeValue?.['random']
@@ -294,7 +300,7 @@ export class StalkerEndpointResolver {
normalizedProfile.value
);
if (classifiedProfile.kind === 'failure') {
return { kind: 'miss' };
return failure(classifiedProfile.reason, false);
}
return {
@@ -343,7 +349,7 @@ export class StalkerEndpointResolver {
kind: 'stateless-mac',
landingUrl: endpoint,
}
: { kind: 'miss' };
: failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
}
}
@@ -439,12 +445,15 @@ function normalizeProtocolOutcome(
sourceOrigin: outcome.sourceOrigin,
};
}
if (EXPLICITLY_UNSUPPORTED_STATUSES.has(outcome.result.status)) {
return { kind: 'unsupported' };
}
const parsed = parseTransportEnvelope(outcome.result);
const classifiedFailure = classifyStalkerResponseFailure({
httpStatus: outcome.result.status,
rawBody: parsed?.rawBody,
value: parsed?.value,
rawBody: parsed.rawBody,
value: parsed.kind === 'parsed' ? parsed.value : undefined,
});
if (classifiedFailure.kind === 'failure') {
return failure(
@@ -457,11 +466,14 @@ function normalizeProtocolOutcome(
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) {
return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
}
if (outcome.result.status !== 200 || parsed === null) {
return { kind: 'miss' };
if (parsed.kind === 'unparsed') {
return failure(
parsed.reason,
parsed.reason !== STALKER_FAILURE_REASONS.IncompatibleResponse
);
}
return { kind: 'parsed', value: parsed.value };
}
@@ -472,8 +484,8 @@ function inspectResponseFailure(
const parsed = parseTransportEnvelope(result);
const classified = classifyStalkerResponseFailure({
httpStatus: result.status,
rawBody: parsed?.rawBody,
value: parsed?.value,
rawBody: parsed.rawBody,
value: parsed.kind === 'parsed' ? parsed.value : undefined,
});
if (classified.kind === 'failure') {
return failure(
@@ -490,12 +502,15 @@ function inspectResponseFailure(
function parseTransportEnvelope(
result: StalkerTransportResult<Uint8Array>
): ParsedTransportEnvelope | null {
): ParsedTransportEnvelope | UnparsedTransportEnvelope {
let rawBody: string;
try {
rawBody = new TextDecoder('utf-8', { fatal: true }).decode(result.body);
} catch {
return null;
return {
kind: 'unparsed',
reason: STALKER_FAILURE_REASONS.IncompatibleResponse,
};
}
const parsed = parseStalkerResponseEnvelope({
body: rawBody,
@@ -503,8 +518,8 @@ function parseTransportEnvelope(
maxBodyBytes: result.body.byteLength,
});
return parsed.kind === 'parsed'
? { rawBody, result, value: parsed.value }
: { rawBody, result };
? { kind: 'parsed', rawBody, value: parsed.value }
: { kind: 'unparsed', rawBody, reason: parsed.reason };
}
function boundedNonEmptyString(value: unknown): string | undefined {
@@ -14,8 +14,47 @@ describe('Stalker response classifier', () => {
[2, 'credentials-required'],
['2', 'credentials-required'],
])('normalizes profile status %p', (status, expectedKind) => {
expect(classifyStalkerProfile({ js: { id: 7, status } })).toMatchObject({
kind: expectedKind,
expect(classifyStalkerProfile({ js: { id: 7, status } })).toMatchObject(
{
kind: expectedKind,
}
);
});
it.each([
['profile block flag', { js: { blocked: true, status: 1 } }, 'blocked'],
[
'envelope block flag',
{ account_blocked: '1', js: { status: '1' } },
'blocked',
],
[
'profile credential flag',
{ js: { credentials_required: true, status: 2 } },
'credentials-required',
],
[
'envelope credential flag',
{ auth_required: '1', js: { status: '2' } },
'credentials-required',
],
])(
'accepts a status confirmed by a matching %s',
(_, value, expectedKind) => {
expect(classifyStalkerProfile(value)).toMatchObject({
kind: expectedKind,
});
}
);
it.each([
{ js: { credentials_required: true, status: 1 } },
{ js: { blocked: true, status: 2 } },
{ js: { blocked: true, status: 0 } },
])('rejects a profile status with a conflicting indicator', (value) => {
expect(classifyStalkerProfile(value)).toEqual({
kind: 'failure',
reason: 'incompatible-response',
});
});
@@ -32,15 +71,12 @@ describe('Stalker response classifier', () => {
{ js: { blocked: true, id: 7 } },
{ js: { credentials_required: true, id: 7 } },
{ error: 'Authorization failed', js: { id: 7 } },
])(
'rejects status-less profiles carrying failure indicators',
(value) => {
expect(classifyStalkerProfile(value)).toEqual({
kind: 'failure',
reason: 'incompatible-response',
});
}
);
])('rejects status-less profiles carrying failure indicators', (value) => {
expect(classifyStalkerProfile(value)).toEqual({
kind: 'failure',
reason: 'incompatible-response',
});
});
it.each([
{ error: { message: 'Access denied' }, js: { id: 7 } },
@@ -186,10 +222,7 @@ describe('Stalker response classifier', () => {
it.each([
['Authorization failed', { kind: 'token-rejected' }],
[
'Access denied',
{ kind: 'failure', reason: 'account-access-denied' },
],
['Access denied', { kind: 'failure', reason: 'account-access-denied' }],
])('classifies the canonical error %s on HTTP 403', (error, expected) => {
expect(
classifyStalkerResponseFailure({
@@ -211,10 +244,7 @@ describe('Stalker response classifier', () => {
it.each([
['Authorization failed', { kind: 'token-rejected' }],
[
'Access denied',
{ kind: 'failure', reason: 'account-access-denied' },
],
['Access denied', { kind: 'failure', reason: 'account-access-denied' }],
])('recognizes the exact plain error body %s', (rawBody, expected) => {
expect(
classifyStalkerResponseFailure({
@@ -42,6 +42,22 @@ const STALKER_ERROR_FIELDS = [
'error_message',
'error_msg',
] as const;
const STALKER_BLOCK_FIELDS = [
'blocked',
'is_blocked',
'account_blocked',
'disabled',
'banned',
] as const;
const STALKER_CREDENTIAL_FIELDS = [
'credentials_required',
'credential_required',
'login_required',
'auth_required',
'authentication_required',
'need_auth',
'requires_auth',
] as const;
export function parseStalkerResponseEnvelope(
input: StalkerResponseEnvelopeInput
@@ -87,12 +103,25 @@ export function classifyStalkerProfile(
return incompatibleResponse();
}
const profile = asRecord(envelope['js']);
if (!profile || hasProfileFailureIndicator(envelope, profile)) {
if (!profile || hasAnyIndicator(envelope, profile, STALKER_ERROR_FIELDS)) {
return incompatibleResponse();
}
const status = normalizeProfileStatus(profile['status']);
const hasBlockIndicator = hasAnyIndicator(
envelope,
profile,
STALKER_BLOCK_FIELDS
);
const hasCredentialIndicator = hasAnyIndicator(
envelope,
profile,
STALKER_CREDENTIAL_FIELDS
);
if (status === 0) {
if (hasBlockIndicator || hasCredentialIndicator) {
return incompatibleResponse();
}
return {
kind: STALKER_PROFILE_RESULT_KINDS.Ready,
profile,
@@ -100,6 +129,9 @@ export function classifyStalkerProfile(
};
}
if (status === 1) {
if (hasCredentialIndicator) {
return incompatibleResponse();
}
return {
kind: STALKER_PROFILE_RESULT_KINDS.Blocked,
profile,
@@ -107,6 +139,9 @@ export function classifyStalkerProfile(
};
}
if (status === 2) {
if (hasBlockIndicator) {
return incompatibleResponse();
}
return {
kind: STALKER_PROFILE_RESULT_KINDS.CredentialsRequired,
profile,
@@ -115,7 +150,9 @@ export function classifyStalkerProfile(
}
if (
profile['status'] === undefined &&
hasRecognizedStatuslessProfileField(profile)
hasRecognizedStatuslessProfileField(profile) &&
!hasBlockIndicator &&
!hasCredentialIndicator
) {
return {
kind: STALKER_PROFILE_RESULT_KINDS.Ready,
@@ -145,10 +182,7 @@ export function classifyStalkerDoAuth(
export function classifyStalkerResponseFailure(
input: StalkerResponseFailureInput
):
| { kind: 'none' }
| { kind: 'token-rejected' }
| StalkerClassifierFailure {
): { kind: 'none' } | { kind: 'token-rejected' } | StalkerClassifierFailure {
if (input.httpStatus === 403 && looksLikePortalProtection(input.rawBody)) {
return {
kind: 'failure',
@@ -217,27 +251,12 @@ function hasRecognizedStatuslessProfileField(
].some((field) => profile[field] !== undefined);
}
function hasProfileFailureIndicator(
function hasAnyIndicator(
envelope: Readonly<Record<string, unknown>>,
profile: Readonly<Record<string, unknown>>
profile: Readonly<Record<string, unknown>>,
fields: readonly string[]
): boolean {
const blockFields = [
'blocked',
'is_blocked',
'account_blocked',
'disabled',
'banned',
];
const credentialFields = [
'credentials_required',
'credential_required',
'login_required',
'auth_required',
'authentication_required',
'need_auth',
'requires_auth',
];
return [...STALKER_ERROR_FIELDS, ...blockFields, ...credentialFields].some(
return fields.some(
(field) =>
isFailureIndicator(envelope[field]) ||
isFailureIndicator(profile[field])
@@ -262,7 +281,8 @@ function isFailureIndicator(value: unknown): boolean {
function normalizeMediaType(
contentType: string | undefined
): string | undefined | 'invalid' {
if (contentType === undefined || contentType.trim() === '') return undefined;
if (contentType === undefined || contentType.trim() === '')
return undefined;
const [rawType, ...parameters] = contentType.split(';');
if (
!rawType ||
@@ -310,7 +330,9 @@ function parseAllowlistedJsonp(
return match?.[1] === undefined ? { parsed: false } : parseJson(match[1]);
}
function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
function asRecord(
value: unknown
): Readonly<Record<string, unknown>> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: undefined;