fix(stalker): rediscover stale learned MIME routes

This commit is contained in:
4gray committed 2026-07-27 12:23:46 +02:00
1 parent 6ebf5fd55a
commit 9204f400bb
2 files changed
+102 -7

No files matched your search

@@ -294,6 +294,55 @@ describe('StalkerEndpointResolver', () => {
}
);
it('skips a learned endpoint whose valid API JSON has the wrong content type', 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/')) {
return rawSuccess(
'{"js":{"token":"stale-learned-token"}}',
{
contentType: 'text/html',
finalUrl: request.url,
}
);
}
if (action === 'handshake') {
return success(
{ js: { token: 'rediscovered-token' } },
{ 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,
});
expect(outcome).toMatchObject({
endpoint: 'https://portal.test/server/load.php',
kind: 'full-session',
});
expect(handshakeUrls).toEqual([
'https://portal.test/learned/server/load.php',
'https://portal.test/server/load.php',
]);
});
it('rejects a stateless catalog near miss without advancing candidates', async () => {
const harness = createHarness((request) => {
const action = request.params?.['action'];
@@ -180,14 +180,21 @@ export class StalkerEndpointResolver {
}
const identity = buildIdentity(input.descriptor, landingUrl);
const learnedEndpointHint =
input.descriptor.learnedEndpointHint === undefined
? undefined
: normalizeStalkerSourceUrl(
input.descriptor.learnedEndpointHint
);
const candidates = buildCandidates(
landingUrl,
input.descriptor.learnedEndpointHint,
learnedEndpointHint,
approvedOrigins
);
let statelessFallback: string | undefined;
for (const endpoint of candidates) {
const isLearnedEndpoint = endpoint === learnedEndpointHint;
const candidateJar = await anonymousJar.cloneWithManagedCookies(
identity.managedCookies
);
@@ -200,6 +207,7 @@ export class StalkerEndpointResolver {
candidateSession,
endpoint,
identity,
isLearnedEndpoint,
landingUrl,
transport: input.transport,
});
@@ -215,7 +223,8 @@ export class StalkerEndpointResolver {
const simple = await this.probeStateless(
candidateSession,
endpoint,
identity
identity,
isLearnedEndpoint
);
if (
simple.kind === 'failure' ||
@@ -243,6 +252,7 @@ export class StalkerEndpointResolver {
candidateSession: StalkerHttpSessionLike;
endpoint: string;
identity: StalkerIdentityProfile;
isLearnedEndpoint: boolean;
landingUrl: string;
transport: StalkerTransportConfig;
}): Promise<CandidateProbeOutcome> {
@@ -256,7 +266,10 @@ export class StalkerEndpointResolver {
},
url: input.endpoint,
});
const normalizedHandshake = normalizeProtocolOutcome(handshake);
const normalizedHandshake = normalizeProtocolOutcome(
handshake,
input.isLearnedEndpoint
);
if (normalizedHandshake.kind !== 'parsed') {
return normalizedHandshake;
}
@@ -291,7 +304,10 @@ export class StalkerEndpointResolver {
},
url: input.endpoint,
});
const normalizedProfile = normalizeProtocolOutcome(profile);
const normalizedProfile = normalizeProtocolOutcome(
profile,
input.isLearnedEndpoint
);
if (normalizedProfile.kind !== 'parsed') {
return normalizedProfile;
}
@@ -319,7 +335,8 @@ export class StalkerEndpointResolver {
private async probeStateless(
session: StalkerHttpSessionLike,
endpoint: string,
identity: StalkerIdentityProfile
identity: StalkerIdentityProfile,
isLearnedEndpoint: boolean
): Promise<
| StalkerEndpointStatelessOutcome
| StalkerEndpointOriginApprovalOutcome
@@ -336,7 +353,10 @@ export class StalkerEndpointResolver {
},
url: endpoint,
});
const normalized = normalizeProtocolOutcome(outcome);
const normalized = normalizeProtocolOutcome(
outcome,
isLearnedEndpoint
);
if (normalized.kind !== 'parsed') {
return normalized.kind === 'unsupported'
? { kind: 'miss' }
@@ -427,7 +447,8 @@ function normalizeApprovedOrigins(
}
function normalizeProtocolOutcome(
outcome: StalkerHttpRequestOutcome
outcome: StalkerHttpRequestOutcome,
allowLearnedEndpointMediaTypeMiss = false
):
| { kind: 'parsed'; value: unknown }
| CandidateProbeUnsupported
@@ -470,6 +491,15 @@ function normalizeProtocolOutcome(
return failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false);
}
if (parsed.kind === 'unparsed') {
if (
allowLearnedEndpointMediaTypeMiss &&
isApiEnvelopeRejectedOnlyByMediaType(
outcome.result,
parsed.rawBody
)
) {
return { kind: 'miss' };
}
if (isBenignHtmlEndpointMiss(outcome.result, parsed.rawBody)) {
return { kind: 'miss' };
}
@@ -544,6 +574,22 @@ function isBenignHtmlEndpointMiss(
);
}
function isApiEnvelopeRejectedOnlyByMediaType(
result: StalkerTransportResult<Uint8Array>,
rawBody: string | undefined
): boolean {
if (rawBody === undefined) {
return false;
}
return (
parseStalkerResponseEnvelope({
body: rawBody,
contentType: undefined,
maxBodyBytes: result.body.byteLength,
}).kind === 'parsed'
);
}
function boundedNonEmptyString(value: unknown): string | undefined {
if (
typeof value !== 'string' ||