webapp: fix review findings (imaging types, NIC status, iPXE gates)

- imaging: coerce client payload and stored session fields to str/int,
  drop lists/dicts; tiles template guards pctype/current_stage; POST
  rejects non-object bodies and non-string serials.
- nic_status: normalise every check-file entry; load nic.conf as-is with
  a [nic] fallback; split interface= lists; keep the check's binding while
  fresh, resolve wildcard sockets from the dnsmasq pin (never True for an
  excluded NIC); mark vanished NICs down; include role bridges; stale or
  future-dated files downgrade to warnings and drop evidence.
- Overview renders the network pill in its own try and shows
  'Network: unknown' when status fails.
- ipxe_update: read every Authenticode signature (all WIN_CERTIFICATE
  entries plus nested SPC_NESTED_SIGNATURE) with openssl; shim swap and
  install need both Microsoft UEFI CA 2011 and 2023; install re-reads
  each file (snponly ipxe.efi only); staged.json schema version, older
  records shown stale and refused; uhci counts as native USB.
- backups describe their files; risky rollbacks need a confirm flag.
- mac_folders: guarded listdir, cached describe, configured TFTP root,
  warning for a shim without ipxe.efi.
- evidence pack: years 2000..2100, 400 days inclusive.
- a11y role=group on quick ranges; progress bar stays full during 'shim'.
- tests/test_review_fixes.py: 29 unittest cases incl. both real shims.
This commit is contained in:
cproudlock
2026-10-15 14:20:24 -04:00
parent 53d39bfc50
commit 606659bf07
11 changed files with 1185 additions and 166 deletions

View File

@@ -39,6 +39,110 @@ ACTIVE_WINDOW_HOURS = 6
_SAFE_SERIAL = re.compile(r"[^A-Za-z0-9._-]")
# Payload and stored-session field types. The pages compare and look these
# up (`pctype in {...}`, `'%]' in current_stage`), so a list or dict from a
# client or a hand-edited file must never reach them.
INT_FIELDS = ("stage_index", "stage_total")
# Written by this module only; a client cannot set them.
SERVER_FIELDS = ("log_tail", "stage_history", "started_at", "last_updated", "previous_run_at")
STR_MAX = 4096
LOG_LINE_MAX = 2000
_SAFE_KEY = re.compile(r"[A-Za-z0-9_]{1,64}")
def _scalar_str(v, limit=STR_MAX):
"""str for a scalar, None for anything else (dict, list, None)."""
if isinstance(v, str):
return v[:limit]
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
return None
def _as_int(v):
if isinstance(v, bool):
return None
try:
return int(v)
except (TypeError, ValueError, OverflowError):
return None
def _str_lines(v):
"""A str or a list of scalars as a list of str lines; [] otherwise."""
if isinstance(v, str):
v = [v]
if not isinstance(v, list):
return []
out = []
for x in v:
x = _scalar_str(x, LOG_LINE_MAX)
if x is not None:
out.append(x)
return out
def _clean_payload(payload: dict) -> dict:
"""A client payload with every value in the type the pages expect:
stage_index/stage_total int, log_lines a list of str, everything else a
str. Non-scalar values and server-owned fields are dropped."""
out = {}
for key, value in payload.items():
if not isinstance(key, str) or not _SAFE_KEY.fullmatch(key) or key in SERVER_FIELDS:
continue
if key in INT_FIELDS:
n = _as_int(value)
if n is not None:
out[key] = n
elif key == "log_lines":
lines = _str_lines(value)
if lines:
out[key] = lines
else:
v = _scalar_str(value)
if v is not None:
out[key] = v
return out
def sanitize_session(state) -> Optional[dict]:
"""A stored session made safe to render: same rules as a payload, plus
log_tail (list of str) and stage_history (list of dicts). None when it is
not a JSON object or has no usable serial."""
if not isinstance(state, dict):
return None
out = {}
for key, value in state.items():
if not isinstance(key, str):
continue
if key in INT_FIELDS:
n = _as_int(value)
if n is not None:
out[key] = n
elif key == "log_tail":
out[key] = _str_lines(value)[-LOG_TAIL_MAX:]
elif key == "stage_history":
hist = []
for h in value if isinstance(value, list) else []:
if isinstance(h, dict):
hist.append({"ts": _scalar_str(h.get("ts")) or "",
"stage_index": _as_int(h.get("stage_index")) or 0,
"current_stage": _scalar_str(h.get("current_stage")) or "",
"status": _scalar_str(h.get("status")) or ""})
out[key] = hist[-STAGE_HISTORY_MAX:]
elif key == "log_lines":
continue
else:
v = _scalar_str(value)
if v is not None:
out[key] = v
if not out.get("serial"):
return None
return out
def _ensure_dir():
os.makedirs(config.IMAGING_DIR, exist_ok=True)
@@ -83,9 +187,15 @@ def update_session(payload: dict) -> dict:
present overwrites the existing field. log_tail is appended to (capped).
Returns the resulting full state.
"""
if not isinstance(payload, dict):
raise ValueError("payload must be a JSON object")
if not isinstance(payload.get("serial") or "", str):
raise ValueError("serial must be a string")
payload = _clean_payload(payload)
serial = (payload.get("serial") or "").strip()
if not serial:
raise ValueError("payload missing 'serial'")
payload["serial"] = serial
# "inferred" is the server's own label for log-only bays; a client that
# sends it would hide its row from the floor's failure-first order.
if payload.get("source") not in (None, "client", "blancco"):
@@ -101,8 +211,8 @@ def update_session(payload: dict) -> dict:
if os.path.isfile(path):
try:
with open(path, "r") as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
state = sanitize_session(json.load(f)) or {}
except (ValueError, OSError):
state = {}
# Reimage detection: if the new payload's stage_index <= 2 (WinPE startnet
@@ -216,9 +326,11 @@ def list_sessions(include_inferred: bool = True) -> list[dict]:
path = os.path.join(config.IMAGING_DIR, name)
try:
with open(path, "r") as f:
out.append(json.load(f))
except (json.JSONDecodeError, OSError):
state = sanitize_session(json.load(f))
except (ValueError, OSError):
continue
if state is not None: # a broken or hand-edited file must not 500 the pages
out.append(state)
if include_inferred:
from services import imaging_log_tail
out = imaging_log_tail.merge_with_client_sessions(out)
@@ -292,8 +404,8 @@ def get_session(serial: str) -> Optional[dict]:
return None
try:
with open(path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return sanitize_session(json.load(f))
except (ValueError, OSError):
return None