mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
fix(stalker): harden session compatibility
This commit is contained in:
1 parent
63ab2837a7
commit
bdf353a3b8
31 files changed
+1067
-82
No files matched your search
+74
-1
@@ -395,7 +395,12 @@ describe('StalkerEndpointResolver', () => {
|
||||
action === 'handshake'
|
||||
) {
|
||||
return rawSuccess(
|
||||
'<!doctype html><html><body>Portal landing</body></html>',
|
||||
[
|
||||
'<!doctype html><html><head>',
|
||||
'<title>stalker_portal</title>',
|
||||
'<script src="../server/api/load_js.php"></script>',
|
||||
'</head><body></body></html>',
|
||||
].join(''),
|
||||
{
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
finalUrl: request.url,
|
||||
@@ -438,6 +443,74 @@ describe('StalkerEndpointResolver', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unknown HTML account denial',
|
||||
'<!doctype html><html><body>Account suspended</body></html>',
|
||||
'text/html',
|
||||
],
|
||||
[
|
||||
'an unknown XHTML device denial',
|
||||
'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>Device blocked</body></html>',
|
||||
'application/xhtml+xml',
|
||||
],
|
||||
[
|
||||
'an HTML denial with only a portal title marker',
|
||||
'<!doctype html><html><head><title>stalker_portal</title></head><body>Account suspended</body></html>',
|
||||
'text/html',
|
||||
],
|
||||
])(
|
||||
'treats %s as terminal after earlier stateless evidence',
|
||||
async (_, denialBody, contentType) => {
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
if (request.mode === STALKER_HTTP_REQUEST_MODES.Anonymous) {
|
||||
return success({}, { finalUrl: request.url });
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
return success(
|
||||
{},
|
||||
{ finalUrl: request.url, status: 404 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/server/load.php') &&
|
||||
action === 'get_genres'
|
||||
) {
|
||||
return success(
|
||||
{ js: [{ id: '*', title: 'All' }] },
|
||||
{ finalUrl: request.url }
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.url.endsWith('/portal.php') &&
|
||||
action === 'handshake'
|
||||
) {
|
||||
return rawSuccess(denialBody, {
|
||||
contentType,
|
||||
finalUrl: request.url,
|
||||
});
|
||||
}
|
||||
throw new Error('Unexpected request');
|
||||
});
|
||||
|
||||
const outcome = await harness.resolver.resolve({
|
||||
descriptor: descriptor(),
|
||||
transport,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
retryable: false,
|
||||
stage: 'resolving',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('continues after early stateless evidence so a later full endpoint wins', async () => {
|
||||
const harness = createHarness((request) => {
|
||||
const action = request.params?.['action'];
|
||||
|
||||
+11
-7
@@ -563,15 +563,19 @@ function isBenignHtmlEndpointMiss(
|
||||
?.split(';', 1)[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (mediaType !== 'text/html' && mediaType !== 'application/xhtml+xml') {
|
||||
if (mediaType !== 'text/html' || rawBody === undefined) {
|
||||
return false;
|
||||
}
|
||||
const prefix = rawBody?.trimStart().slice(0, 64).toLowerCase() ?? '';
|
||||
return (
|
||||
prefix.startsWith('<!doctype html') ||
|
||||
prefix.startsWith('<html') ||
|
||||
prefix.startsWith('<?xml')
|
||||
);
|
||||
// The public Stalker /c/ shell combines these bootstrap markers. Requiring
|
||||
// two keeps branded denial pages from becoming a downgrade signal.
|
||||
const normalized = rawBody.toLowerCase();
|
||||
const markerCount = [
|
||||
/<title[^>]*>\s*stalker_portal\s*<\/title>/i.test(rawBody),
|
||||
normalized.includes('server/api/load_js.php'),
|
||||
normalized.includes('window.loadrequiredfiles('),
|
||||
normalized.includes('stb.init('),
|
||||
].filter(Boolean).length;
|
||||
return markerCount >= 2;
|
||||
}
|
||||
|
||||
function isApiEnvelopeRejectedOnlyByMediaType(
|
||||
|
||||
@@ -98,6 +98,38 @@ describe('StalkerHttpSession', () => {
|
||||
expect(config.headers).not.toHaveProperty('Proxy-Authorization');
|
||||
});
|
||||
|
||||
it('blocks an anonymous public-to-private redirect before target contact', async () => {
|
||||
axiosMock
|
||||
.mockResolvedValueOnce({
|
||||
data: Buffer.alloc(0),
|
||||
headers: {
|
||||
location: 'http://127.0.0.1/private-admin',
|
||||
},
|
||||
status: 302,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: Buffer.from('private response'),
|
||||
headers: {},
|
||||
status: 200,
|
||||
});
|
||||
const session = new StalkerHttpSession(
|
||||
new StalkerCookieJar(),
|
||||
DEFAULT_TRANSPORT
|
||||
);
|
||||
|
||||
const outcome = await session.request({
|
||||
mode: STALKER_HTTP_REQUEST_MODES.Anonymous,
|
||||
url: 'http://93.184.216.34/landing',
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'failure',
|
||||
reason: 'invalid-url',
|
||||
retryable: false,
|
||||
});
|
||||
expect(axiosMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([200, 401, 404, 429, 500, 503])(
|
||||
'returns HTTP %i as a successful transport result',
|
||||
async (status) => {
|
||||
|
||||
@@ -144,6 +144,7 @@ export class StalkerHttpSession {
|
||||
validateStatus: () => true,
|
||||
},
|
||||
{
|
||||
allowPrivateNetworkRedirects: identityBearing,
|
||||
allowPrivateNetworks: true,
|
||||
pinAllowedPrivateNetworkHosts: true,
|
||||
}
|
||||
|
||||
+212
@@ -16,6 +16,63 @@ function mapRemote<Operation extends StalkerSessionApplicationOperation>(
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const listResultMappers = [
|
||||
{
|
||||
name: 'categories',
|
||||
emptyResult: { items: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
|
||||
{ contentType: 'vod' }
|
||||
).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'catalog',
|
||||
emptyResult: { items: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, {
|
||||
contentType: 'vod',
|
||||
}).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'seasons',
|
||||
emptyResult: { seasons: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesSeasons, {
|
||||
seriesId: '42',
|
||||
}).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'episodes',
|
||||
emptyResult: { episodes: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.SeriesEpisodes, {
|
||||
seasonId: '42:1',
|
||||
seriesId: '42',
|
||||
}).mapResult(value),
|
||||
},
|
||||
{
|
||||
name: 'short EPG',
|
||||
emptyResult: { programs: [] },
|
||||
mapResult: (value: unknown) =>
|
||||
mapRemote(STALKER_SESSION_APPLICATION_OPERATIONS.ShortEpg, {
|
||||
channelId: '11',
|
||||
}).mapResult(value),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const malformedListEnvelopes = [
|
||||
{ name: 'object-without-data', value: { js: {} } },
|
||||
{ name: 'direct-null', value: { js: null } },
|
||||
{ name: 'direct-scalar', value: { js: 7 } },
|
||||
{ name: 'direct-string', value: { js: 'not-a-list' } },
|
||||
{ name: 'undefined', value: { js: { data: undefined } } },
|
||||
{ name: 'null', value: { js: { data: null } } },
|
||||
{ name: 'scalar', value: { js: { data: 7 } } },
|
||||
{ name: 'object', value: { js: { data: {} } } },
|
||||
{ name: 'string', value: { js: { data: 'not-a-list' } } },
|
||||
] as const;
|
||||
|
||||
describe('stalker-operation-adapter', () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -302,6 +359,161 @@ describe('stalker-operation-adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(listResultMappers)(
|
||||
'keeps real empty arrays valid for $name direct and data-envelope shapes',
|
||||
({ emptyResult, mapResult }) => {
|
||||
expect(mapResult({ js: [] })).toEqual(emptyResult);
|
||||
expect(mapResult({ js: { data: [] } })).toEqual(emptyResult);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(
|
||||
listResultMappers.flatMap((operation) =>
|
||||
malformedListEnvelopes.map((envelope) => ({
|
||||
envelope: envelope.name,
|
||||
mapResult: operation.mapResult,
|
||||
operation: operation.name,
|
||||
value: envelope.value,
|
||||
}))
|
||||
)
|
||||
)(
|
||||
'rejects $envelope data for $operation',
|
||||
({ mapResult, value }) => {
|
||||
expect(() => mapResult(value)).toThrow(
|
||||
'stalker-operation-invalid-response'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts the real data-wrapped channel map and empty bulk EPG shapes', () => {
|
||||
const mapped = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
|
||||
{}
|
||||
);
|
||||
const program = {
|
||||
ch_id: '11',
|
||||
name: 'News',
|
||||
start_timestamp: 100,
|
||||
stop_timestamp: 200,
|
||||
};
|
||||
|
||||
expect(
|
||||
mapped.mapResult({
|
||||
js: {
|
||||
data: {
|
||||
'11': [program],
|
||||
'12': [],
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
programs: [
|
||||
{
|
||||
channelId: '11',
|
||||
endsAt: 200,
|
||||
startsAt: 100,
|
||||
title: 'News',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mapped.mapResult({ js: { data: {} } })).toEqual({
|
||||
programs: [],
|
||||
});
|
||||
expect(mapped.mapResult({ js: [] })).toEqual({ programs: [] });
|
||||
expect(mapped.mapResult({ js: { data: [] } })).toEqual({
|
||||
programs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves direct and nested channel-keyed bulk EPG variants', () => {
|
||||
const mapped = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
|
||||
{}
|
||||
);
|
||||
const program = {
|
||||
ch_id: '11',
|
||||
name: 'News',
|
||||
start_timestamp: 100,
|
||||
stop_timestamp: 200,
|
||||
};
|
||||
|
||||
expect(
|
||||
mapped.mapResult({
|
||||
js: {
|
||||
'11': [program],
|
||||
'12': { data: [] },
|
||||
'13': { epg: [] },
|
||||
'14': { items: [] },
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
programs: [
|
||||
{
|
||||
channelId: '11',
|
||||
endsAt: 200,
|
||||
startsAt: 100,
|
||||
title: 'News',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
mapped.mapResult({
|
||||
js: {
|
||||
data: {
|
||||
'11': { data: [program] },
|
||||
'12': { epg: [] },
|
||||
'13': { items: [] },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
programs: [
|
||||
{
|
||||
channelId: '11',
|
||||
endsAt: 200,
|
||||
startsAt: 100,
|
||||
title: 'News',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'object-without-data', value: { js: {} } },
|
||||
{ name: 'direct-null', value: { js: null } },
|
||||
{ name: 'direct-scalar', value: { js: 7 } },
|
||||
{ name: 'direct-string', value: { js: 'not-an-epg-map' } },
|
||||
{ name: 'undefined-data', value: { js: { data: undefined } } },
|
||||
{ name: 'null-data', value: { js: { data: null } } },
|
||||
{ name: 'scalar-data', value: { js: { data: 7 } } },
|
||||
{ name: 'string-data', value: { js: { data: 'not-an-epg-map' } } },
|
||||
{
|
||||
name: 'scalar-channel-entry',
|
||||
value: { js: { data: { '11': 7 } } },
|
||||
},
|
||||
{
|
||||
name: 'unknown-channel-entry',
|
||||
value: { js: { data: { '11': { unknown: [] } } } },
|
||||
},
|
||||
{
|
||||
name: 'direct-scalar-channel-entry',
|
||||
value: { js: { '11': 7 } },
|
||||
},
|
||||
{
|
||||
name: 'direct-unknown-channel-entry',
|
||||
value: { js: { '11': { unknown: [] } } },
|
||||
},
|
||||
] as const)('rejects malformed bulk EPG $name', ({ value }) => {
|
||||
const mapped = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.EpgInfo,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(() => mapped.mapResult(value)).toThrow(
|
||||
'stalker-operation-invalid-response'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes categories, seasons, episodes, EPG, links, and account summaries', () => {
|
||||
const categories = mapRemote(
|
||||
STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories,
|
||||
|
||||
+86
-27
@@ -212,7 +212,9 @@ function mapShortEpg<Operation extends StalkerSessionApplicationOperation>(
|
||||
type: 'itv',
|
||||
};
|
||||
copyPositiveInteger(record, 'limit', wire, 'size');
|
||||
return remote(wire, (value) => mapEpg(value, channelId));
|
||||
return remote(wire, (value) =>
|
||||
mapEpg(value, { fallbackChannelId: channelId })
|
||||
);
|
||||
}
|
||||
|
||||
function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
|
||||
@@ -227,7 +229,9 @@ function mapEpgInfo<Operation extends StalkerSessionApplicationOperation>(
|
||||
copyNonNegativeInteger(record, 'fromTimestamp', wire, 'from_timestamp');
|
||||
copyPositiveInteger(record, 'periodHours', wire, 'period');
|
||||
copyNonNegativeInteger(record, 'toTimestamp', wire, 'to_timestamp');
|
||||
return remote(wire, (value) => mapEpg(value));
|
||||
return remote(wire, (value) =>
|
||||
mapEpg(value, { allowChannelMap: true })
|
||||
);
|
||||
}
|
||||
|
||||
function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
|
||||
@@ -257,7 +261,7 @@ function mapCreateLink<Operation extends StalkerSessionApplicationOperation>(
|
||||
}
|
||||
|
||||
function mapCategories(value: unknown): StalkerSessionCategoriesResult {
|
||||
const items = readArray(unwrapJs(value)).flatMap((value) => {
|
||||
const items = readDataArray(value).flatMap((value) => {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
@@ -292,7 +296,7 @@ function mapCatalog(
|
||||
): StalkerSessionCatalogResult {
|
||||
const js = unwrapJs(value);
|
||||
const envelope = asRecord(js);
|
||||
const rawItems = envelope ? readArray(envelope['data']) : readArray(js);
|
||||
const rawItems = readListPayload(js);
|
||||
const items = rawItems.flatMap((value) => {
|
||||
const mapped = mapCatalogItem(value, contentType);
|
||||
return mapped ? [mapped] : [];
|
||||
@@ -470,9 +474,18 @@ function mapEpisodeResult(
|
||||
|
||||
function mapEpg(
|
||||
value: unknown,
|
||||
fallbackChannelId?: StalkerSessionEntityId
|
||||
{
|
||||
allowChannelMap = false,
|
||||
fallbackChannelId,
|
||||
}: {
|
||||
readonly allowChannelMap?: boolean;
|
||||
readonly fallbackChannelId?: StalkerSessionEntityId;
|
||||
} = {}
|
||||
): StalkerSessionEpgResult {
|
||||
const programs = flattenEpgEntries(unwrapJs(value)).flatMap((value) => {
|
||||
const programs = flattenEpgEntries(
|
||||
unwrapJs(value),
|
||||
allowChannelMap
|
||||
).flatMap((value) => {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
@@ -561,43 +574,93 @@ function unwrapJs(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function readDataArray(value: unknown): readonly unknown[] {
|
||||
const js = unwrapJs(value);
|
||||
const record = asRecord(js);
|
||||
return record ? readArray(record['data']) : readArray(js);
|
||||
return readListPayload(unwrapJs(value));
|
||||
}
|
||||
|
||||
function flattenEpgEntries(value: unknown): readonly unknown[] {
|
||||
function readListPayload(value: unknown): readonly unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const envelope = asRecord(value);
|
||||
if (
|
||||
!envelope ||
|
||||
!Object.prototype.hasOwnProperty.call(envelope, 'data') ||
|
||||
!Array.isArray(envelope['data'])
|
||||
) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return envelope['data'];
|
||||
}
|
||||
|
||||
function flattenEpgEntries(
|
||||
value: unknown,
|
||||
allowChannelMap: boolean
|
||||
): readonly unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
throw invalidResponse();
|
||||
}
|
||||
const direct = readArray(record['data']);
|
||||
if (direct.length > 0) {
|
||||
return direct;
|
||||
if (Object.prototype.hasOwnProperty.call(record, 'data')) {
|
||||
if (Array.isArray(record['data'])) {
|
||||
return record['data'];
|
||||
}
|
||||
if (!allowChannelMap) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
const channelMap = asRecord(record['data']);
|
||||
if (!channelMap) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return flattenEpgChannelMap(channelMap, true);
|
||||
}
|
||||
if (!allowChannelMap) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return flattenEpgChannelMap(record, false);
|
||||
}
|
||||
|
||||
function flattenEpgChannelMap(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
allowEmpty: boolean
|
||||
): readonly unknown[] {
|
||||
const nestedValues = Object.values(value);
|
||||
if (!allowEmpty && nestedValues.length === 0) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
|
||||
const result: unknown[] = [];
|
||||
for (const nested of Object.values(record)) {
|
||||
for (const nested of nestedValues) {
|
||||
if (Array.isArray(nested)) {
|
||||
result.push(...nested);
|
||||
continue;
|
||||
}
|
||||
const nestedRecord = asRecord(nested);
|
||||
if (nestedRecord) {
|
||||
result.push(
|
||||
...readArray(
|
||||
nestedRecord['data'] ??
|
||||
nestedRecord['epg'] ??
|
||||
nestedRecord['items']
|
||||
)
|
||||
);
|
||||
if (!nestedRecord) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
result.push(...readNestedEpgArray(nestedRecord));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readNestedEpgArray(
|
||||
value: Readonly<Record<string, unknown>>
|
||||
): readonly unknown[] {
|
||||
for (const key of ['data', 'epg', 'items'] as const) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(value[key])) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return value[key];
|
||||
}
|
||||
throw invalidResponse();
|
||||
}
|
||||
|
||||
function normalizePlaybackCommand(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const separator = trimmed.indexOf(' ');
|
||||
@@ -631,10 +694,6 @@ function asRecord(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readArray(value: unknown): readonly unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown): string {
|
||||
const result = optionalString(value);
|
||||
if (result === undefined) {
|
||||
|
||||
+81
@@ -834,6 +834,87 @@ describe('StalkerSessionManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('caps one attempt at three credential submissions across auth replacements caused by alternating principals', async () => {
|
||||
const targetAuths = [
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 1, kind: 'credentials-required' },
|
||||
]),
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 2, kind: 'credentials-required' },
|
||||
]),
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 2, kind: 'credentials-required' },
|
||||
]),
|
||||
new FakeAuth('target-user', false, [
|
||||
{ attemptNumber: 2, kind: 'credentials-required' },
|
||||
]),
|
||||
];
|
||||
const { manager } = harness({
|
||||
auths: [
|
||||
targetAuths[0],
|
||||
new FakeAuth('principal-a'),
|
||||
targetAuths[1],
|
||||
new FakeAuth('principal-b'),
|
||||
targetAuths[2],
|
||||
new FakeAuth('principal-a'),
|
||||
targetAuths[3],
|
||||
],
|
||||
resolverOutcomes: Array.from({ length: 7 }, () => full()),
|
||||
});
|
||||
|
||||
let challenge = await manager.open(1, {
|
||||
descriptor: descriptor('credential-budget-target'),
|
||||
});
|
||||
if (challenge.kind !== 'credentials-required') {
|
||||
throw new Error('expected-credentials-challenge');
|
||||
}
|
||||
|
||||
for (const [index, principal] of [
|
||||
[0, 'principal-a'],
|
||||
[1, 'principal-b'],
|
||||
[2, 'principal-a'],
|
||||
] as const) {
|
||||
const interferer = await manager.open(index + 2, {
|
||||
descriptor: descriptor(
|
||||
`credential-budget-${principal}-${index}`
|
||||
),
|
||||
});
|
||||
expectFullReady(interferer);
|
||||
|
||||
const outcome = await manager.continue(1, {
|
||||
challengeRef: challenge.challengeRef,
|
||||
response: {
|
||||
kind: 'credentials',
|
||||
password: `bad-password-${index}`,
|
||||
username: 'target-user',
|
||||
},
|
||||
});
|
||||
if (index === 2) {
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'failure',
|
||||
reason: STALKER_SESSION_FAILURE_REASONS
|
||||
.CredentialsAttemptLimit,
|
||||
stage: 'do-auth',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
expect(outcome).toMatchObject({
|
||||
attemptNumber: index + 2,
|
||||
kind: 'credentials-required',
|
||||
});
|
||||
if (outcome.kind !== 'credentials-required') {
|
||||
throw new Error('expected-credentials-challenge');
|
||||
}
|
||||
challenge = outcome;
|
||||
}
|
||||
|
||||
expect(
|
||||
targetAuths.flatMap((auth) => auth.startedCredentials).filter(
|
||||
(credentials) => credentials !== undefined
|
||||
)
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('resets the two-minute lifetime when a slow attempt becomes ready or issues a challenge', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
} from './stalker-watchdog';
|
||||
|
||||
const ATTEMPT_TTL_MS = 120_000;
|
||||
const MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT = 3;
|
||||
const REFERENCE_BYTES = 32;
|
||||
const MAX_REFERENCE_ATTEMPTS = 4;
|
||||
const RECIPE_CLASSIFIER_VERSION = 1;
|
||||
@@ -214,6 +215,7 @@ interface AttemptRecord {
|
||||
challengeRef?: string;
|
||||
coordinator: StalkerBaseIdentityCoordinator;
|
||||
credentialCandidate?: StalkerAuthCredentials;
|
||||
credentialSubmissions: number;
|
||||
descriptor: StalkerSessionConnectionDescriptor;
|
||||
expiresAt: number;
|
||||
lastEndpoint?: string;
|
||||
@@ -916,6 +918,7 @@ export class StalkerSessionManager {
|
||||
validated.descriptor.sourceUrl,
|
||||
validated.descriptor.macAddress
|
||||
),
|
||||
credentialSubmissions: 0,
|
||||
descriptor: validated.descriptor,
|
||||
expiresAt: this.#now() + ATTEMPT_TTL_MS,
|
||||
previousSessions:
|
||||
@@ -970,7 +973,16 @@ export class StalkerSessionManager {
|
||||
attempt.descriptor
|
||||
);
|
||||
attempt.lastLandingUrl = resolved.landingUrl;
|
||||
const authOutcome = await auth.start(credentials);
|
||||
const authOutcome =
|
||||
credentials === undefined
|
||||
? this.#normalizeCredentialChallenge(
|
||||
attempt,
|
||||
await auth.start()
|
||||
)
|
||||
: await this.#runCredentialSubmission(
|
||||
attempt,
|
||||
() => auth.start(credentials)
|
||||
);
|
||||
if (authOutcome.kind !== 'ready') {
|
||||
throw new ProgressSignal(
|
||||
authProgress(authOutcome, auth, resolved, epoch)
|
||||
@@ -1044,9 +1056,9 @@ export class StalkerSessionManager {
|
||||
attempt.coordinator,
|
||||
epoch
|
||||
);
|
||||
const outcome = await auth.submitCredentials(
|
||||
credentials,
|
||||
false
|
||||
const outcome = await this.#runCredentialSubmission(
|
||||
attempt,
|
||||
() => auth.submitCredentials(credentials, false)
|
||||
);
|
||||
if (outcome.kind !== 'ready') {
|
||||
throw new ProgressSignal(
|
||||
@@ -1096,6 +1108,42 @@ export class StalkerSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
async #runCredentialSubmission(
|
||||
attempt: AttemptRecord,
|
||||
operation: () => Promise<StalkerAuthOutcome>
|
||||
): Promise<StalkerAuthOutcome> {
|
||||
if (
|
||||
attempt.credentialSubmissions >=
|
||||
MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT
|
||||
) {
|
||||
return credentialAttemptLimit();
|
||||
}
|
||||
attempt.credentialSubmissions += 1;
|
||||
return this.#normalizeCredentialChallenge(
|
||||
attempt,
|
||||
await operation()
|
||||
);
|
||||
}
|
||||
|
||||
#normalizeCredentialChallenge(
|
||||
attempt: AttemptRecord,
|
||||
outcome: StalkerAuthOutcome
|
||||
): StalkerAuthOutcome {
|
||||
if (outcome.kind !== 'credentials-required') {
|
||||
return outcome;
|
||||
}
|
||||
if (
|
||||
attempt.credentialSubmissions >=
|
||||
MAX_CREDENTIAL_SUBMISSIONS_PER_ATTEMPT
|
||||
) {
|
||||
return credentialAttemptLimit();
|
||||
}
|
||||
return {
|
||||
...outcome,
|
||||
attemptNumber: attempt.credentialSubmissions + 1,
|
||||
};
|
||||
}
|
||||
|
||||
async #publishProgress(
|
||||
attempt: AttemptRecord,
|
||||
progress: AuthenticationProgress,
|
||||
@@ -1702,6 +1750,7 @@ export class StalkerSessionManager {
|
||||
approvedOrigins: new Set(session.approvedOrigins),
|
||||
coordinator: session.coordinator,
|
||||
credentialCandidate: session.credentials,
|
||||
credentialSubmissions: 0,
|
||||
descriptor: session.descriptor,
|
||||
expiresAt: this.#now() + ATTEMPT_TTL_MS,
|
||||
lastIdentityRevision: session.identityRevision,
|
||||
@@ -2273,6 +2322,18 @@ export class StalkerSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
function credentialAttemptLimit(): Extract<
|
||||
StalkerAuthOutcome,
|
||||
{ kind: 'failure' }
|
||||
> {
|
||||
return {
|
||||
kind: 'failure',
|
||||
reason: STALKER_SESSION_FAILURE_REASONS.CredentialsAttemptLimit,
|
||||
retryable: false,
|
||||
stage: 'do-auth',
|
||||
};
|
||||
}
|
||||
|
||||
function endpointProgress(
|
||||
outcome: Exclude<StalkerEndpointResolverOutcome, { kind: 'full-session' }>
|
||||
): AuthenticationProgress {
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "حاول مرة أخرى",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Паўтарыць",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser-User-Agent überschreiben (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Anmeldedaten prüfen",
|
||||
"SAVE_AGAIN": "Erneut speichern",
|
||||
"RETRY": "Erneut versuchen",
|
||||
"USERNAME": "Benutzername",
|
||||
"PASSWORD": "Passwort"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Επανάληψη",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Retry",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Reintentar",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Réessayer",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Újrapróbálkozás",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Riprova",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "再試行",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "다시 시도",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Opnieuw proberen",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Spróbuj ponownie",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Tentar novamente",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
|
||||
"VALIDATE_CREDENTIALS": "Проверить данные",
|
||||
"SAVE_AGAIN": "Сохранить снова",
|
||||
"RETRY": "Повторить",
|
||||
"USERNAME": "Имя пользователя",
|
||||
"PASSWORD": "Пароль"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "Tekrar dene",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "重试",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
"USER_AGENT": "Browser User-Agent override (optional)",
|
||||
"VALIDATE_CREDENTIALS": "Validate credentials",
|
||||
"SAVE_AGAIN": "Save again",
|
||||
"RETRY": "重試",
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user