From 604d2aca3eecd8024c301f04f1c3da14b0337b1d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 03:52:20 +0200 Subject: [PATCH] fix(stalker): reject ambiguous response failures --- .../lib/stalker-response-classifier.spec.ts | 55 ++++++++++++++++++ .../src/lib/stalker-response-classifier.ts | 57 ++++++++++++++----- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts index 21f7e9755..44d1f5d0b 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.spec.ts @@ -42,6 +42,36 @@ describe('Stalker response classifier', () => { } ); + it.each([ + { error: { message: 'Access denied' }, js: { id: 7 } }, + { js: { error: { message: 'Access denied' }, id: 7 } }, + ])('rejects structured profile failure indicators', (value) => { + expect(classifyStalkerProfile(value)).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + expect( + classifyStalkerResponseFailure({ + httpStatus: 200, + value, + }) + ).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + }); + + it('rejects a failure indicator before accepting profile status 0', () => { + expect( + classifyStalkerProfile({ + js: { error: 'Access denied', id: 7, status: 0 }, + }) + ).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + }); + it.each([ { js: { id: 7, status: 3 } }, { js: { status: 'unknown' } }, @@ -126,6 +156,31 @@ describe('Stalker response classifier', () => { }); }); + it('ignores a normal raw JSON body when a parsed response value exists', () => { + expect( + classifyStalkerResponseFailure({ + httpStatus: 200, + rawBody: '{"js":{"status":0}}', + value: { js: { status: 0 } }, + }) + ).toEqual({ kind: 'none' }); + }); + + it.each([ + ['Authorization failed', { kind: 'token-rejected' }], + [ + 'Access denied', + { kind: 'failure', reason: 'account-access-denied' }, + ], + ])('recognizes the exact plain error body %s', (rawBody, expected) => { + expect( + classifyStalkerResponseFailure({ + httpStatus: 200, + rawBody, + }) + ).toEqual(expected); + }); + it.each(['Authorization failed.', 'Access denied.'])( 'rejects the noncanonical error variant %s', (error) => { diff --git a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts index 13125693a..bfc617687 100644 --- a/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts +++ b/libs/portal/stalker/protocol/src/lib/stalker-response-classifier.ts @@ -36,6 +36,13 @@ interface StalkerResponseFailureInput { value?: unknown; } +const STALKER_ERROR_FIELDS = [ + 'error', + 'errors', + 'error_message', + 'error_msg', +] as const; + export function parseStalkerResponseEnvelope( input: StalkerResponseEnvelopeInput ): StalkerParsedEnvelope | StalkerClassifierFailure { @@ -76,8 +83,11 @@ export function classifyStalkerProfile( value: unknown ): StalkerNormalizedProfileResult { const envelope = asRecord(value); - const profile = asRecord(envelope?.['js']); - if (!profile) { + if (!envelope) { + return incompatibleResponse(); + } + const profile = asRecord(envelope['js']); + if (!profile || hasProfileFailureIndicator(envelope, profile)) { return incompatibleResponse(); } @@ -105,8 +115,7 @@ export function classifyStalkerProfile( } if ( profile['status'] === undefined && - hasRecognizedStatuslessProfileField(profile) && - !hasProfileFailureIndicator(envelope ?? {}, profile) + hasRecognizedStatuslessProfileField(profile) ) { return { kind: STALKER_PROFILE_RESULT_KINDS.Ready, @@ -141,6 +150,7 @@ export function classifyStalkerResponseFailure( | { kind: 'token-rejected' } | StalkerClassifierFailure { const bodyError = extractBodyError(input.value, input.rawBody); + const bodyHasErrorIndicator = hasBodyErrorIndicator(input.value); if (input.httpStatus === 403 && looksLikePortalProtection(input.rawBody)) { return { kind: 'failure', @@ -156,7 +166,11 @@ export function classifyStalkerResponseFailure( reason: STALKER_FAILURE_REASONS.AccountAccessDenied, }; } - if (bodyError !== undefined || input.httpStatus === 403) { + if ( + bodyError !== undefined || + bodyHasErrorIndicator || + input.httpStatus === 403 + ) { return incompatibleResponse(); } if (input.httpStatus === 429) { @@ -200,7 +214,6 @@ function hasProfileFailureIndicator( envelope: Readonly>, profile: Readonly> ): boolean { - const errorFields = ['error', 'errors', 'error_message', 'error_msg']; const blockFields = [ 'blocked', 'is_blocked', @@ -217,7 +230,7 @@ function hasProfileFailureIndicator( 'need_auth', 'requires_auth', ]; - return [...errorFields, ...blockFields, ...credentialFields].some( + return [...STALKER_ERROR_FIELDS, ...blockFields, ...credentialFields].some( (field) => isFailureIndicator(envelope[field]) || isFailureIndicator(profile[field]) @@ -227,6 +240,7 @@ function hasProfileFailureIndicator( function isFailureIndicator(value: unknown): boolean { if (value === true) return true; if (typeof value === 'number') return value !== 0; + if (value !== null && typeof value === 'object') return true; if (typeof value !== 'string') return false; const normalized = value.trim().toLowerCase(); return ( @@ -301,13 +315,28 @@ function extractBodyError( ): string | undefined { const envelope = asRecord(value); const js = asRecord(envelope?.['js']); - const candidate = - typeof js?.['error'] === 'string' - ? js['error'] - : typeof envelope?.['error'] === 'string' - ? envelope['error'] - : rawBody; - return typeof candidate === 'string' ? candidate : undefined; + if (typeof js?.['error'] === 'string') { + return js['error']; + } + if (typeof envelope?.['error'] === 'string') { + return envelope['error']; + } + if (value !== undefined) { + return undefined; + } + return rawBody === 'Authorization failed' || rawBody === 'Access denied' + ? rawBody + : undefined; +} + +function hasBodyErrorIndicator(value: unknown): boolean { + const envelope = asRecord(value); + const js = asRecord(envelope?.['js']); + return STALKER_ERROR_FIELDS.some( + (field) => + isFailureIndicator(envelope?.[field]) || + isFailureIndicator(js?.[field]) + ); } function looksLikePortalProtection(body: string | undefined): boolean {