fix: BER-safe proactive TLV walks + hardened FETCH loop (v3.6.57)

The live 500 in the Explore ('OTA send error: index out of range') was a
parser bug, reproduced with the live FETCH shape: _parse_proactive_header
walked the command TLVs reading the length as a single byte, so the card's
SEND SHORT MESSAGE with its 150-byte SMS TPDU (8B 81 96) was mis-walked
into the TPDU and eventually indexed past the end (val[2] on a short
slice) - the FETCH was left unanswered and the request answered 500.

- _ber_tlv_list/_proactive_body helpers: BER lengths (_ber_len_at) plus a
  bounds check that stops at a truncated TLV instead of walking into the
  value; _parse_proactive_header, _parse_display_text, _parse_select_item,
  _parse_setup_menu_items, _tlv_map and _decode_poll_negotiation all use
  them now (the same latent bug for any long-form TLV);
- _run_proactive_chain guards every step (header parse, proactive log,
  on_fetch, BIP/timer handlers): an exception is logged with the offending
  FETCH bytes and the TERMINAL RESPONSE is still sent - a decoder bug can
  never leave a FETCH unanswered again;
- the expanded-registry report distinguishes a request error from a remote
  SW: 'ELFs: request error (index out of range) - compact listing used'.

Regression tests: the live long-form FETCH shape parses (header + TLV map +
TPDU), truncated TLVs stop the walk, and a raising parser still gets a TR.

sw cache simple-v330; tests: 553 Python / 711 Node.
This commit is contained in:
2026-09-30 10:27:50 +03:00
parent 61bb1ab621
commit 81a7aace15
6 changed files with 201 additions and 73 deletions
+19 -6
View File
@@ -1749,7 +1749,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.6.56';
const SIMPLE_VERSION = '3.6.57';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9170,6 +9170,7 @@ function ramExpandedGroups(report) {
groups.push(g);
}
g.compact = g.compact || !!r.compact;
g.transport = g.transport || !!r.transport;
if (r.form) {
const score = ((r.entries || 0) > 0 ? 100 : 0) +
(RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form));
@@ -9204,7 +9205,10 @@ function ramExpandedReport(report) {
if (g.form) return;
const why = g.sw ? ' (' + g.sw + ')' : '';
if (!g.compact) {
parts.push(g.label + ': ' + (g.sw || t('no data')));
parts.push(g.label + ': ' + (g.transport && g.sw
? t('request error') + ' (' + g.sw + ')' : (g.sw || t('no data'))));
} else if (g.transport) {
parts.push(g.label + ': ' + t('request error') + why + ' \u2014 ' + t('compact listing used'));
} else if (g.p1 === '80') {
// cards commonly report nothing for the ISD in the expanded form
// (GPPro treats its 6A88/6A86 as 'no data to report')
@@ -9409,10 +9413,12 @@ async function ramExplore(sp) {
: RAM_EXPANDED_FORMS.slice();
if (p1 === '80') forms = [expandedForm || 'tags'];
let lastSw = '';
let lastTransport = false;
for (const form of forms) {
let data = '';
let sw = '';
let accepted = true;
let transportError = false;
const sendOne = async (apdu, what) => {
ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...');
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
@@ -9421,6 +9427,8 @@ async function ramExplore(sp) {
else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
if (!res.success || !res.por || !spPorAccepted(res.por)) {
accepted = false;
// a request/transport failure is not a remote status word
transportError = !res.por;
sw = res.por ? res.por.response_status : (res.error || t('no data'));
return '';
}
@@ -9451,16 +9459,19 @@ async function ramExplore(sp) {
const parsed = data ? parser(data) : [];
if (parsed.length) {
expandedForm = form;
return { form: form, parsed: parsed, sw: sw, entries: parsed.length };
return { form: form, parsed: parsed, sw: sw, entries: parsed.length,
transport: transportError };
}
lastSw = sw;
lastTransport = transportError;
if (!accepted) break;
if (ramRemoteSwOk(sw)) {
// accepted but empty: nothing to gain from another form
return { form: form, parsed: [], sw: sw, entries: 0 };
return { form: form, parsed: [], sw: sw, entries: 0,
transport: transportError };
}
}
return { form: null, parsed: [], sw: lastSw, entries: 0 };
return { form: null, parsed: [], sw: lastSw, entries: 0, transport: lastTransport };
}
async function paginate(p1, collector, parser, label, quiet) {
@@ -9533,7 +9544,8 @@ async function ramExplore(sp) {
if (collector.length === before || wantDetails) {
const out = await expandedPass(p1, label, parser, spi2);
expandedReport.push({ p1: p1, label: label, compact: collector.length > before,
form: out.form, sw: out.sw, entries: out.entries });
form: out.form, sw: out.sw, entries: out.entries,
transport: out.transport });
if (collector.length === before) {
// Nothing from the compact pass: the expanded result is the listing
collector.push(...out.parsed);
@@ -18766,6 +18778,7 @@ const LANG_RU = {
'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.',
'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.',
'from the app instance': 'от экземпляра апплета',
'request error': 'ошибка запроса',
'STK menu entry:': 'Пункт меню STK:',
'position': 'позиция',
'Other registry data:': 'Прочие данные реестра:',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v329';
const CACHE = 'simple-v330';
const URLS = [
'index.html',
'help.html',
+10
View File
@@ -666,6 +666,14 @@ test('ramExpandedReport summarizes the query forms and the fallbacks', () => {
{ p1: '10', label: 'ELFs', compact: true, form: 'lean', sw: '9000', entries: 3 },
{ p1: '20', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 5 },
]), 'Expanded registry: lean tag list (ELFs)');
// a request/transport failure is not a remote status word
assert.strictEqual(ramExpandedReport([
{ p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 9 },
{ p1: '10', label: 'ELFs', compact: true, form: null, sw: 'index out of range', transport: true },
]), 'Expanded registry: tag list + EA (Apps) \u00b7 ELFs: request error (index out of range) \u2014 compact listing used');
assert.strictEqual(ramExpandedReport([
{ p1: '10', label: 'ELFs', compact: false, form: null, sw: 'index out of range', transport: true },
]), 'Expanded registry refused by the card \u2014 compact listing only (package versions unavailable) ELFs: request error (index out of range)');
assert.strictEqual(ramExpandedReport([]), '');
delete globalThis.t;
});
@@ -707,6 +715,8 @@ test('the Explore detects the expanded query form and reports it', () => {
assert.ok(src.includes('ramChainGetResponse(apdu)'), '61xx must be answered with a chained GET RESPONSE');
assert.ok(!src.includes('00C00000'), 'no standalone GET RESPONSE (it answers 6700)');
assert.ok(src.includes('ramExpandedQueryApdu(p1, form, page > 1)'), '6310 must page');
// a transport failure must not be reported as a remote status word
assert.ok(src.includes('transportError'), 'request errors are marked as such');
});
test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => {