fix(stalker): harden session compatibility

This commit is contained in:
4gray committed 2026-07-27 14:00:30 +02:00
1 parent 63ab2837a7
commit bdf353a3b8
31 files changed
+1067 -82

No files matched your search

@@ -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'];
@@ -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,
}
@@ -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,
@@ -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) {
@@ -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 {
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "إعادة المحاولة",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "حاول مرة أخرى",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Паўтарыць",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -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"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "Επανάληψη",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "再試行",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "다시 시도",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Переопределение браузерного User-Agent (необязательно)",
"VALIDATE_CREDENTIALS": "Проверить данные",
"SAVE_AGAIN": "Сохранить снова",
"RETRY": "Повторить",
"USERNAME": "Имя пользователя",
"PASSWORD": "Пароль"
},
+1
View File
@@ -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"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "重试",
"USERNAME": "Username",
"PASSWORD": "Password"
},
+1
View File
@@ -223,6 +223,7 @@
"USER_AGENT": "Browser User-Agent override (optional)",
"VALIDATE_CREDENTIALS": "Validate credentials",
"SAVE_AGAIN": "Save again",
"RETRY": "重試",
"USERNAME": "Username",
"PASSWORD": "Password"
},