Files
parking_solution/wiki/concepts/vision-service-hardening.md
julian f486dcbbfc
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m58s
CI / check (push) Successful in 41s
docs(wiki): vision-service hardening backlog + boot-migration data-seed note
Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
  2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
  operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
  opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
  (e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
  and that a built-in-role grant does not auto-apply to a custom role.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:41:32 +02:00

170 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, vision, anpr, security, hardening, tech-debt]
sources: []
updated: 2026-07-02
status: open
---
# Vision Service — Hardening & Fix Backlog
Tracked findings against the [[opencv-anpr-service]] (`apps/vision/`, the Python/FastAPI ANPR
microservice — see [[vision-service-packaging]]). Raised by two code reviews on 2026-07-02: a
general pass (bottlenecks / bugs / best-practice) and a security-focused pass. **Nothing here is
fixed yet** — this page is the to-do list; tick items off (and note the commit) as they land.
Framing that shapes the priorities below, both from [[threat-model]]:
- **A forged image cannot open a barrier by itself.** The Node server pushes snapshot bytes to
`/analyze`, then **re-gates** the result server-side (`VISION_ENTRY_MIN_CONFIDENCE` = 0.85,
stricter than the service's own advisory 0.5 floor), with debounce and a mid-poll
"already-transacted" abort (`apps/server/src/anpr-entry.ts`). So the real exposure is
**availability (DoS)** and **network/weight placement**, not decision forgery. See
[[lane-presence-and-anpr-entry]], [[fail-state-safety]] (recognition is advisory; the host falls
back to the ticket path when vision is down/unsure).
- **The primary adversary is the local booth operator**, with filesystem/USB access to the
appliance — which is what makes the model-weight and config-placement items real, not theoretical.
## Priority 1 — DoS (remotely triggerable, zero appliance access)
1. **Request body buffered *before* the size cap** — `apps/vision/vision_service/app.py:69`.
`image = await request.body()` concatenates the whole stream into memory before
`len(image) > MAX_IMAGE_BYTES` (12 MB) runs. A chunked POST with no `Content-Length` streams
unbounded bytes → RAM exhaustion → OOM-kill **before** the 413 is ever returned. Killing the
recognizer forces permanent ticket-fallback (which may be the operator's goal — suppress plate
evidence). **Fix:** reject on `Content-Length` up front and cap while draining
`request.stream()`; pass `--limit-max-request-body-size` to uvicorn.
2. **Pixel-bomb: `cv2.imdecode` has no decoded-dimension bound** —
`apps/vision/vision_service/recognizer.py:143`. A crafted JPEG *under* the 12 MB byte cap can
declare enormous dimensions (e.g. 30000×30000) and decode to a multi-GB BGR ndarray. Combined
with item 3 (inference on the event loop), one request both spikes memory and stalls the whole
service. **Fix:** check `frame.shape` against a max pixel budget immediately after decode; reject
oversize frames with a 422.
## Priority 2 — Bottleneck (also a DoS amplifier)
3. **CPU-bound inference runs synchronously on the asyncio event loop** —
`apps/vision/vision_service/app.py:84` (calls `rec.analyze`, which does `cv2.imdecode` + ONNX
YOLO+OCR). uvicorn runs a single event loop; while inference runs (hundreds of ms on the i5-8500,
CPU-only), **nothing else is served — including `GET /health`**. Back-to-back entry/exit
snapshots serialize, and health probes time out, making a live service look down. When the Node
client's 1500 ms `AbortController` gives up, Python keeps burning CPU on the abandoned request.
Flagged independently by ~half the review angles — the one real bottleneck. **Fix:**
`await run_in_threadpool(rec.analyze, image)` (or make the `Recognizer` protocol async), plus an
`asyncio.Semaphore(1–2)` to bound concurrent inferences. This is a property the `Recognizer`
mechanism should own, not a per-call patch.
## Priority 2 — Security: network placement & weight integrity
4. **Model weights are unauthenticated *and* operator-writable → persistent recognition-poisoning.**
`fast-alpr`'s `ALPR()` downloads ONNX weights + config over plain `urllib` with **no
checksum/signature** (verified in `.venv`: `open_image_models/detection/core/hub.py`,
`fast_plate_ocr/inference/hub.py`), and a cache-hit is treated as trust (present → skip, never
re-verify). The `vision` user (uid 999) owns **both** the `~/.cache` weights and the runtime
process, so any same-user write primitive overwrites weights in place — no privesc — surviving
container **restarts** (cache is in the writable layer, not re-verified at startup). Under
[[threat-model]], a local operator overwriting a cached `.onnx` is a persistent, targeted fraud
primitive: a detector tuned to never see a specific plate, or an OCR model that reproducibly
substitutes a character. **Fix:** pin + verify a weights hash, mount the weights **read-only**,
and assert a known hash at startup. Relates to [[disk-os-hardening]] (physical-tamper chain),
[[reconciliation]] (the real backstop).
5. **Service binds `0.0.0.0` by default at every layer** — `settings.py:18`, `Dockerfile:51`
(`ENV VISION_HOST=0.0.0.0`), `Dockerfile:57` (uvicorn `--host 0.0.0.0`). The `.env.example`
documents `127.0.0.1`-only intent, but loopback-only exposure then depends entirely on the
compose `ports:` prefix being right every time. The app provides **zero** defense-in-depth:
`/analyze` and `/health` have no auth, so reachability is the only control. **Fix:** make
`127.0.0.1` the default at all three layers; require explicit opt-in to widen. Relates to
[[network-isolation]], [[trust-boundary]].
6. **Dev compose publishes vision on all interfaces** — `docker-compose.dev.yml:29` maps
`"8089:8089"` (binds 0.0.0.0), unlike `docker-compose.prod.yml:91`'s `"127.0.0.1:8089:8089"`.
Anyone on the same LAN as a dev/staging box — or a field deploy that reuses the dev file — can
hit the unauthenticated endpoint: fingerprint the model via `/health`, run the DoS above, or
probe confidence behaviour. **Fix:** bind `127.0.0.1:8089:8089` to match prod. See
[[container-deployment]].
7. **No container resource limits or hardening.** The `vision` service has no
`mem_limit`/`cpus`/`pids_limit` and no `security_opt: [no-new-privileges:true]` /
`cap_drop: [ALL]` / `read_only` in any compose file. Since prod runs the server on the **host
network** doing safety-critical device I/O, an unbounded vision container (via items 1–2) can
starve the host of RAM/CPU. **Fix:** add `mem_limit`, `pids_limit`, `cap_drop: [ALL]`,
`no-new-privileges`. (Already good: runs non-root as uid 999; `.env` is **not** baked into any
image layer — `COPY`s are explicit, root `.dockerignore`/`.gitignore` cover `.env`.) See
[[disk-os-hardening]], [[container-deployment]].
## Priority 3 — Correctness, robustness, hygiene
8. **`env_file=".env"` resolves against the process CWD** — `settings.py:16`. Launched from anywhere
but `apps/vision/` (a systemd unit, or a run from the monorepo root), the `.env` isn't found and
pydantic-settings raises **no error** — the service silently boots `recognizer="stub"`, `/health`
reports `ready: true`, and every scan returns `plate: null`. Production ANPR silently does
nothing while looking healthy. **Fix:** anchor the path, e.g.
`env_file=Path(__file__).parent.parent / ".env"`.
9. **`/health` returns HTTP 200 even when `ready` is false**, and the Docker HEALTHCHECK
(`Dockerfile:55`) only checks `status==200` — `app.py:54`. If the fast-alpr weights fail to load
(e.g. the best-effort build pre-warm was skipped and the appliance is air-gapped), the container
stays "healthy" to Docker forever: no restart, no infra alert; only the in-app device monitor
notices. **Fix:** return 503 from `/health` when not ready, or make the HEALTHCHECK parse the
`ready` field.
10. **Build pre-warm swallows *all* failures** — `Dockerfile:46`
(`... || echo "skipped (no network)"`). It can't distinguish "no network at build, expected"
from "download corrupted/tampered/interrupted". A skipped pre-warm silently converts the
air-gapped appliance into one that fetches weights from github.com on the **first real
`/analyze`** — an unreviewed runtime network dependency contradicting [[offline-first]] (and a
first-scan DoS if egress is truly blocked). **Fix:** fail the *prod* build on pre-warm failure
(or assert weights present at startup) rather than degrade to a lazy fetch.
11. **Raw exception strings reflected into HTTP responses** — `app.py:88` (`analysis failed: {exc}`)
and the 503 loader path at `app.py:81`. Echoes native cv2/onnxruntime error text (absolute
paths, library versions) to any caller. **Fix:** log detail server-side; return a generic
message to the client. (Modest severity — the primary adversary already has local access.)
12. **`min_confidence` has no bounds validation** — `settings.py:33`, unlike `PlateResult.confidence`
(`ge=0, le=1`). `VISION_MIN_CONFIDENCE=50` (someone thinking in percent) silently flags every
read as `low_confidence`, with no startup error or health symptom. **Fix:**
`float = Field(0.5, ge=0.0, le=1.0)`.
13. **Node client discards the structured error detail** — `apps/server/src/vision-client.ts:187`.
`#post` throws `vision /analyze → HTTP 422` without reading the body, so
`"could not decode image bytes"` (corrupt camera frame — actionable) is indistinguishable in the
logs from a deploy problem. **Fix:** read `detail` from the JSON body before throwing.
(Server-side file, just outside `apps/vision/`, but it's the consumer of this contract.)
14. **`build_recognizer` docstring is wrong** — `recognizer.py:169`. It says "falls back to the stub
if the real one can't load," but the function returns the **not-ready** `FastAlprRecognizer`
(better behaviour — `/analyze` 503s instead of silently returning no-plate). **Fix:** correct
the docstring so nobody "fixes" the code to match it.
15. **Model-name env vars are undefended in-repo (latent)** — `settings.py:28–29`.
`detector_model` / `ocr_model` are plain `str`; the allowlist that makes them safe (rejects
unknown names) lives **entirely** in the third-party libs, not here. **Not a bug today** (no
SSRF/traversal), but a future recognizer swap (the pluggable design invites one) could turn an
operator-settable env var into a URL/path primitive. **Fix:** constrain to a `Literal`/enum or
validate explicitly.
## Lower priority / noted (not scheduled)
- **Base image tag-, not digest-, pinned** — `Dockerfile:9`
(`ghcr.io/astral-sh/uv:python3.12-bookworm-slim`); `apt-get` without package pinning
(`Dockerfile:16–18`). `uv sync --frozen` locks the Python deps, so this is OS-layer drift only —
low priority hardening.
- **CLI reads image files unbounded** — `cli.py:55` (`read_bytes()`, no size cap). Dev-only tool,
local invocation; mirror the HTTP size discipline for consistency.
- **Architecture note (server, not vision):** `anpr-entry.ts` auto-opens the barrier on a
high-confidence plate match with **no second factor**, so a printed duplicate of a known
subscriber's plate is a physical-spoofing bypass (mitigated only by the signed event log, once a
hardware signer lands — see [[hardware-signer-options]], [[append-only-event-chain]]). A conscious
design sign-off, not a vision-service bug — flagged so it isn't an accident. See
[[lane-presence-and-anpr-entry]], [[plate-reconciliation]].
## Clean (checked, no action)
Content-type is **not** trusted for parsing (cv2 sniffs bytes, ignores the header). The service
already runs non-root (uid 999), loads models once at startup (not per request), and `.env` is not
committed or baked into an image layer. The `getattr`-based fast-alpr result mapping, the typed
`app.state` accessors, and the lifespan factory all have stated rationales and are fine as-is.