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:
@@ -41,6 +41,9 @@ from pysim_simple_server import presets
|
||||
# 3DES3. Anything else is refused with a report line.
|
||||
_KEY_ALGO = {8: 0x01, 24: 0x09}
|
||||
_KEY_ALGO_16 = {'3des2': 0x05, 'aes': 0x02}
|
||||
# The 16-byte algorithm choices as the API accepts them (derived from the map
|
||||
# above, so the endpoint's validation and the parser cannot drift apart).
|
||||
ALGORITHM16_CHOICES = tuple(_KEY_ALGO_16)
|
||||
KEY_SIZES = (8, 16, 24)
|
||||
PSK_KEY_HEX_LEN = 32 # the store's PSK contract: 32 hex chars (16 bytes)
|
||||
MAX_KEYSET = presets.KVN_MAX
|
||||
@@ -58,23 +61,42 @@ def _tokens(line):
|
||||
return [p for p in parts if p]
|
||||
|
||||
|
||||
# A column name: a short identifier (IMSI, ICCID, KIC1, PSK_ID, ...). The
|
||||
# A column name: an identifier (IMSI, ICCID, KIC1, PSK_ID, ...). The shape
|
||||
# check keeps a configuration line that names a value next to a column
|
||||
# (``ICCID 897...`` - the value token is not an identifier) from being read as
|
||||
# the column line.
|
||||
_COLUMN_NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,7}')
|
||||
# (``ICCID 897...`` - the value is not an identifier) from being read as the
|
||||
# column line.
|
||||
_COLUMN_NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_]*')
|
||||
|
||||
# The columns the presets use, beyond ICCID itself. A candidate header must
|
||||
# carry at least one of them: a line with ICCID and nothing usable cannot
|
||||
# yield a card (every keyset needs a KICn/KIDn pair), so it is not the header.
|
||||
_FIXED_COLUMNS = ('ADM', 'PIN1', 'PIN2', 'PUK1', 'PUK2', 'PSK', 'PSKID', 'PSK_ID')
|
||||
|
||||
|
||||
def _is_known_column(name):
|
||||
"""A column the import consumes: a fixed name or a numbered KIC/KID."""
|
||||
if name in _FIXED_COLUMNS:
|
||||
return True
|
||||
m = re.fullmatch(r'(?:KIC|KID)([0-9]{1,2})', name)
|
||||
return bool(m) and 1 <= int(m.group(1)) <= MAX_KEYSET
|
||||
|
||||
|
||||
def find_columns(text):
|
||||
"""The column-name line of an outfile: the first line whose tokens all
|
||||
look like column names and include ``ICCID`` (at least two tokens).
|
||||
Returns ``(columns, line_index)`` with the column names uppercased, or
|
||||
look like column names, include ``ICCID`` and carry at least one other
|
||||
column the import consumes (at least two tokens in total). Returns
|
||||
``(columns, line_index)`` with the column names uppercased, or
|
||||
``(None, -1)``."""
|
||||
for i, line in enumerate(str(text or '').splitlines()):
|
||||
toks = _tokens(line)
|
||||
if (len(toks) >= 2 and any(t.upper() == 'ICCID' for t in toks)
|
||||
and all(_COLUMN_NAME_RE.fullmatch(t) for t in toks)):
|
||||
return [t.upper() for t in toks], i
|
||||
if len(toks) < 2 or not all(_COLUMN_NAME_RE.fullmatch(t) for t in toks):
|
||||
continue
|
||||
names = [t.upper() for t in toks]
|
||||
if 'ICCID' not in names:
|
||||
continue
|
||||
if not any(n != 'ICCID' and _is_known_column(n) for n in names):
|
||||
continue
|
||||
return names, i
|
||||
return None, -1
|
||||
|
||||
|
||||
@@ -240,14 +262,15 @@ def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None
|
||||
for field, col in (('pin1', 'PIN1'), ('pin2', 'PIN2'),
|
||||
('puk1', 'PUK1'), ('puk2', 'PUK2')):
|
||||
i = idx[col]
|
||||
v = re.sub(r'\D', '', str(toks[i])) if 0 <= i < len(toks) else ''
|
||||
if not v:
|
||||
continue
|
||||
raw = str(toks[i]).strip() if 0 <= i < len(toks) else ''
|
||||
if not raw:
|
||||
continue # the column is simply absent from this row
|
||||
v = re.sub(r'\D', '', raw)
|
||||
if re.fullmatch(r'[0-9]{4,8}', v):
|
||||
entry[field] = v
|
||||
else:
|
||||
skip('line %d: %s %r is not 4-8 digits - dropped'
|
||||
% (row_no, col, v))
|
||||
% (row_no, col, raw))
|
||||
i = idx['ADM']
|
||||
if 0 <= i < len(toks) and toks[i]:
|
||||
adm = _ascii_from_hex(_hex_value(toks[i]))
|
||||
|
||||
@@ -10875,11 +10875,30 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(resp, 400)
|
||||
self._log_resp(resp)
|
||||
return
|
||||
# A misspelled algorithm must not silently pick one: the 16-byte
|
||||
# key's algorithm nibble decides whether the card accepts the
|
||||
# packet at all.
|
||||
algorithm16 = str(body.get('algorithm16') or '3des2').strip().lower()
|
||||
if algorithm16 not in outfile.ALGORITHM16_CHOICES:
|
||||
resp = {'error': 'algorithm16 must be one of: %s'
|
||||
% ', '.join(outfile.ALGORITHM16_CHOICES)}
|
||||
self._send_json(resp, 400)
|
||||
self._log_resp(resp)
|
||||
return
|
||||
mode = body.get('mode')
|
||||
if mode in (None, ''):
|
||||
mode = None
|
||||
elif mode not in ('merge', 'replace'):
|
||||
resp = {'error': "mode must be 'merge', 'replace' or omitted "
|
||||
'(a preview)'}
|
||||
self._send_json(resp, 400)
|
||||
self._log_resp(resp)
|
||||
return
|
||||
try:
|
||||
parsed = outfile.parse_outfile(
|
||||
text,
|
||||
name=str(body.get('name') or ''),
|
||||
algorithm16=str(body.get('algorithm16') or '3des2'),
|
||||
algorithm16=algorithm16,
|
||||
tars=body.get('tars'),
|
||||
tar_keyset=tar_keyset)
|
||||
except ValueError as e:
|
||||
@@ -10887,8 +10906,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(resp, 400)
|
||||
self._log_resp(resp)
|
||||
return
|
||||
mode = body.get('mode')
|
||||
if mode not in ('merge', 'replace'):
|
||||
if mode is None:
|
||||
resp = {'ok': True, 'preview': True,
|
||||
'presets': parsed['presets'], 'report': parsed['report']}
|
||||
self._send_json(resp)
|
||||
|
||||
Reference in New Issue
Block a user