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
12 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-07-02 | 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)
-
Request body buffered before the size cap —
apps/vision/vision_service/app.py:69.image = await request.body()concatenates the whole stream into memory beforelen(image) > MAX_IMAGE_BYTES(12 MB) runs. A chunked POST with noContent-Lengthstreams 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 onContent-Lengthup front and cap while drainingrequest.stream(); pass--limit-max-request-body-sizeto uvicorn. -
Pixel-bomb:
cv2.imdecodehas 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: checkframe.shapeagainst a max pixel budget immediately after decode; reject oversize frames with a 422.
Priority 2 — Bottleneck (also a DoS amplifier)
- CPU-bound inference runs synchronously on the asyncio event loop —
apps/vision/vision_service/app.py:84(callsrec.analyze, which doescv2.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 — includingGET /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 msAbortControllergives 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 theRecognizerprotocol async), plus anasyncio.Semaphore(1–2)to bound concurrent inferences. This is a property theRecognizermechanism should own, not a per-call patch.
Priority 2 — Security: network placement & weight integrity
-
Model weights are unauthenticated and operator-writable → persistent recognition-poisoning.
fast-alpr'sALPR()downloads ONNX weights + config over plainurllibwith 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). Thevisionuser (uid 999) owns both the~/.cacheweights 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.onnxis 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). -
Service binds
0.0.0.0by 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.exampledocuments127.0.0.1-only intent, but loopback-only exposure then depends entirely on the composeports:prefix being right every time. The app provides zero defense-in-depth:/analyzeand/healthhave no auth, so reachability is the only control. Fix: make127.0.0.1the default at all three layers; require explicit opt-in to widen. Relates to network-isolation, trust-boundary. -
Dev compose publishes vision on all interfaces —
docker-compose.dev.yml:29maps"8089:8089"(binds 0.0.0.0), unlikedocker-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: bind127.0.0.1:8089:8089to match prod. See container-deployment. -
No container resource limits or hardening. The
visionservice has nomem_limit/cpus/pids_limitand nosecurity_opt: [no-new-privileges:true]/cap_drop: [ALL]/read_onlyin 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: addmem_limit,pids_limit,cap_drop: [ALL],no-new-privileges. (Already good: runs non-root as uid 999;.envis not baked into any image layer —COPYs are explicit, root.dockerignore/.gitignorecover.env.) See disk-os-hardening, container-deployment.
Priority 3 — Correctness, robustness, hygiene
-
env_file=".env"resolves against the process CWD —settings.py:16. Launched from anywhere butapps/vision/(a systemd unit, or a run from the monorepo root), the.envisn't found and pydantic-settings raises no error — the service silently bootsrecognizer="stub",/healthreportsready: true, and every scan returnsplate: null. Production ANPR silently does nothing while looking healthy. Fix: anchor the path, e.g.env_file=Path(__file__).parent.parent / ".env". -
/healthreturns HTTP 200 even whenreadyis false, and the Docker HEALTHCHECK (Dockerfile:55) only checksstatus==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/healthwhen not ready, or make the HEALTHCHECK parse thereadyfield. -
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. -
Raw exception strings reflected into HTTP responses —
app.py:88(analysis failed: {exc}) and the 503 loader path atapp.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.) -
min_confidencehas no bounds validation —settings.py:33, unlikePlateResult.confidence(ge=0, le=1).VISION_MIN_CONFIDENCE=50(someone thinking in percent) silently flags every read aslow_confidence, with no startup error or health symptom. Fix:float = Field(0.5, ge=0.0, le=1.0). -
Node client discards the structured error detail —
apps/server/src/vision-client.ts:187.#postthrowsvision /analyze → HTTP 422without reading the body, so"could not decode image bytes"(corrupt camera frame — actionable) is indistinguishable in the logs from a deploy problem. Fix: readdetailfrom the JSON body before throwing. (Server-side file, just outsideapps/vision/, but it's the consumer of this contract.) -
build_recognizerdocstring 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-readyFastAlprRecognizer(better behaviour —/analyze503s instead of silently returning no-plate). Fix: correct the docstring so nobody "fixes" the code to match it. -
Model-name env vars are undefended in-repo (latent) —
settings.py:28–29.detector_model/ocr_modelare plainstr; 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 aLiteral/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-getwithout package pinning (Dockerfile:16–18).uv sync --frozenlocks 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.tsauto-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.