mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:16:43 -08:00
fix(stalker): fail closed on incompatible responses
This commit is contained in:
1 parent
3ebe31f041
commit
91d116a572
4 files changed
+333
-101
No files matched your search
+203
-38
@@ -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) => {
|
||||
|
||||
+32
-17
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user