v3.27.0 review: the outfile import hardening

- The endpoint validates algorithm16 (3des2/aes, case-insensitive) and mode
  (merge/replace or omitted): a misspelled value answers 400 instead of
  silently picking 3DES2 or turning an import into a dry run.  The choices
  come from outfile.ALGORITHM16_CHOICES, derived from the algorithm map, so
  the validation and the parser cannot drift apart.
- A present but unusable PIN/PUK value is reported with its raw text: a value
  that strips to nothing (e.g. 'abcd') used to vanish without a report line,
  contradicting the "nothing is silently dropped" contract.  A column absent
  from a short row stays silent - it is not present.
- The column-line detection drops the arbitrary 8-character column-name cap:
  a candidate header must carry ICCID plus at least one column the import
  consumes (a fixed name or a numbered KIC/KID), so a long unknown column
  name next to a real one parses while 'ICCID <value>' and
  'ICCID CUSTOMER_NAME' config lines are still rejected.  Both real samples
  are unaffected.
- PWA: a chosen default keyset survives an empty or failed preview (it used
  to reset to null, and the import silently fell back to the card's first
  keyset); the select renders the preview's keysets plus a remembered value.
- PWA: the destructive Replace mode asks for confirmation first, matching the
  other store-wiping actions.
- api.md documents both validations.  Tests: the endpoint's 400s and the
  replace import, the raw-value report, the header guards, the keyset
  preservation and the declined confirmation.  906 frontend + 904 Python;
  sw cache simple-v437.
This commit is contained in:
2026-10-14 13:50:30 +03:00
parent 212bfc24ac
commit 21d7f58f61
8 changed files with 163 additions and 23 deletions
+12 -3
View File
@@ -14731,8 +14731,14 @@ function cardsOutfileRender() {
const o = _cardsOutfile;
if (!panel || !o) return;
const keysets = cardsOutfileKeysets();
const selected = keysets.includes(o.tarKeyset) ? o.tarKeyset : (keysets[0] || null);
o.tarKeyset = selected;
// An empty or failed preview must not drop a chosen keyset: the value is
// only re-selected against a list that has keysets, and the option list is
// the preview's keysets plus a remembered value (so it stays visible).
if (keysets.length && !keysets.includes(o.tarKeyset)) o.tarKeyset = keysets[0];
const options = keysets.slice();
if (o.tarKeyset && !options.includes(o.tarKeyset)) options.push(o.tarKeyset);
options.sort((a, b) => a - b);
const selected = o.tarKeyset;
let html = '<div class="flex items-center justify-between mb-2">';
html += '<span class="text-sm font-semibold">' + esc(t('outfile import')) +
': <span class="font-mono text-xs">' + esc(o.file || '') + '</span></span>';
@@ -14747,7 +14753,7 @@ function cardsOutfileRender() {
'<option value="aes"' + (o.algorithm16 === 'aes' ? ' selected' : '') + '>AES</option></select></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Default keyset for the TARs')) +
'<select id="cards-of-keyset" onchange="cardsOutfilePreview()" class="block mt-0.5 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' +
(keysets.length ? keysets.map(v => '<option value="' + v + '"' + (v === selected ? ' selected' : '') + '>' + esc(t('Keyset') + ' ' + v) + '</option>').join('')
(options.length ? options.map(v => '<option value="' + v + '"' + (v === selected ? ' selected' : '') + '>' + esc(t('Keyset') + ' ' + v) + '</option>').join('')
: '<option value="">\u2014</option>') +
'</select></label>';
html += '<label class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Import mode')) +
@@ -14788,6 +14794,8 @@ async function cardsOutfileImport() {
const o = _cardsOutfile;
if (!o) return;
cardsOutfileControls();
// Replace wipes the whole store - the destructive choice asks first
if (o.mode === 'replace' && !confirm(t('Replace mode wipes the whole preset store. Continue?'))) return;
const btn = document.getElementById('cards-of-import');
if (btn) btn.disabled = true;
const body = {
@@ -23283,6 +23291,7 @@ const LANG_RU = {
'Import mode': 'Режим импорта',
'Merge': 'Дополнить',
'Replace': 'Заменить',
'Replace mode wipes the whole preset store. Continue?': 'Режим «Заменить» очищает всё хранилище пресетов. Продолжить?',
'TAR table': 'Таблица TAR',
'Refresh preview': 'Обновить предпросмотр',
'Name': 'Имя',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v436';
const CACHE = 'simple-v437';
const URLS = [
'index.html',
'help.html',
+45 -1
View File
@@ -45,10 +45,11 @@ code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_
// absent (the code guards every getElementById result).
const els = {};
const app = new Function('els', `
var sandbox = { calls: [], io: [], alerts: [], fetched: 0, reply: null, replyError: null };
var sandbox = { calls: [], io: [], alerts: [], fetched: 0, reply: null, replyError: null, confirmResult: true };
var document = { getElementById: id => els[id] || null };
var t = k => k;
var alert = m => { sandbox.alerts.push(String(m)); };
var confirm = m => { sandbox.confirmCalls = (sandbox.confirmCalls || []); sandbox.confirmCalls.push(String(m)); return sandbox.confirmResult; };
var ioStatus = (id, msg) => { sandbox.io.push([id, msg]); };
var pysimFetchOk = async (path, body) => {
sandbox.calls.push({ path: path, body: body });
@@ -252,6 +253,7 @@ test('the import posts the chosen mode and refreshes the list', async () => {
app.calls = [];
app.fetched = 0;
app.io = [];
app.confirmResult = true;
app.reply = { ok: true, preview: false, added: 2, skipped: 1, errors: ['dup'] };
setEl('cards-outfile-panel', '');
setEl('cards-of-mode', 'replace');
@@ -266,4 +268,46 @@ test('the import posts the chosen mode and refreshes the list', async () => {
assert.strictEqual(app.fetched, 1);
assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]);
assert.strictEqual(app.state, null); // the panel closed
// the replace mode asks first, and a declined answer imports nothing
assert.deepStrictEqual(app.confirmCalls, ['Replace mode wipes the whole preset store. Continue?']);
});
test('a declined replace confirmation aborts the import', async () => {
clearEls();
app.calls = [];
app.confirmResult = false;
setEl('cards-of-mode', 'replace');
app.state = { text: TEXT, file: 'batch.out', name: 'x', algorithm16: '3des2',
tarKeyset: null, mode: 'merge', preview: { presets: [], report: {} } };
await app.cardsOutfileImport();
app.confirmResult = true;
assert.strictEqual(app.calls.length, 0);
assert.ok(app.state); // the panel stays open
// merge never asks
app.confirmResult = false;
setEl('cards-of-mode', 'merge');
app.reply = { ok: true, preview: false, added: 1, skipped: 0, errors: [] };
await app.cardsOutfileImport();
app.confirmResult = true;
assert.strictEqual(app.calls.length, 1);
});
test('a chosen keyset survives an empty (failed) preview', () => {
clearEls();
setEl('cards-outfile-panel', '');
app.state = { text: TEXT, file: 'a.out', name: 'x', algorithm16: '3des2',
tarKeyset: 2, mode: 'merge',
preview: { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15' }, { kic: '25' }] }], report: {} } };
app.cardsOutfileRender();
assert.strictEqual(app.state.tarKeyset, 2);
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="2" selected>/);
// a failed re-preview has no keysets - the choice must not be dropped
app.state.preview = { report: { error: 'TAR must be three hex bytes: ZZ' } };
app.cardsOutfileRender();
assert.strictEqual(app.state.tarKeyset, 2);
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="2" selected>/);
// a keyset the new preview does not define falls back to its first one
app.state.preview = { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15' }] }], report: {} };
app.cardsOutfileRender();
assert.strictEqual(app.state.tarKeyset, 1);
});