Files
oko_public/TODO.md
T
2026-09-09 21:44:05 -05:00

281 lines
16 KiB
Markdown
Raw 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.
# TODO — Security audit & performance review (2026-08-23)
Findings ordered by severity/impact. Line references verified against working tree.
Context: pipeline is runCam → mofin → terp → coordinator (+ livestream-cache side channel),
all communicating via plaintext gob over TCP. Ports assume LAN/Tailscale isolation today;
every finding marked [NET] matters the moment any port is reachable beyond localhost.
---
## SECURITY
### Critical
**S1. Path traversal → arbitrary file write in coordinator** [NET]
`coordinator/main.go` `handleClip`: `user, camera := splitSource(clp.SourceData)` takes
attacker-controlled strings straight into
`filepath.Join(storageDir, user, camera, dateShard, dirName)` then `MkdirAll` +
writes `clip.mp4`/`clip.json`.
A crafted `SourceData` such as `"../../../tmp--evil"` escapes the storage root and writes
an attacker-controlled MP4 (raw pixel bytes) outside it. Even benign-looking names with
`/` create unintended nested dirs (the test suite itself uses camera name containing `/`).
Fix: validate both fields against `^[A-Za-z0-9._-]+$` (reject otherwise), reject empty,
and add a final `strings.HasPrefix(finalPath, storageRoot+sep)` assertion.
**S2. Remote process-crash DoS across the whole pipeline** [NET]
Ingest paths never validate clip internal consistency (`CheckLenCorrelations()` exists in
frame/frame.go but is called nowhere except tests):
- `coordinator/main.go insertClip/dirName`: indexes `clp.Guids[0]`, `clp.Timestamps[0]`,
`clp.PixelMats[0]` unchecked → panic.
- `mofin/main.go``frame.CountChangedPixels`: loops `i < len(PixelMats)-1` indexing
`c.Guids[i]` → short `Guids` slice panics.
Any malformed gob Clip crashes the entire receiving process (panic in handler goroutine =
process exit). One bad client kills surveillance for all cameras.
Fix: call `clip.CheckLenCorrelations()` immediately after every decode in mofin ingest,
terp ingest, coordinator handleClip, livestream-cache handleTCPConnection; drop + log on
failure. Also wrap handlers with `recover()` as defense-in-depth.
**S3. TLS private key committed to git**
`livestream-cache/key.pem` (+ `cert.pem`) tracked in history. Regenerate, move out of repo,
gitignore, and treat the old key as burned (anyone with repo history can MITM the :8443
viewer traffic).
### High
**S4. No transport encryption or authentication on any pipeline hop** [NET]
mofin (:8083), terp (:8081), coordinator (:8082), livestream-cache ingest (:8085) accept
plaintext gob from anyone who can reach the port, binding all interfaces (`":8083"`-style
addresses, not `127.0.0.1`). Consequences: forged footage injected into any stage, live
view poisoning, clip replay/spoofing, free reconnaissance of camera names via logged
SourceData. Only livestream-cache's *viewer* port has TLS — and no auth either (see S5).
Fix options (pick one):
a. Default binds to `127.0.0.1` + explicit opt-in interface config (cheapest);
b. Shared-token handshake as first gob message on each conn (cheap, works over Tailscale);
c. Full mTLS mirroring the existing cert setup (strongest).
**S5. Unauthenticated MJPEG viewing**
`handleMJPEGStream` checks only that `sourceData` exists in `cameraUsers`. Anyone reaching
:8443 watches every camera feed. Add HTTP BasicAuth (bcrypt-hashed creds in YAML) or a
bearer token per viewer; return 401 before touching the stream.
**S6. Unbounded gob decode → memory-exhaustion DoS** [NET]
Every listener decodes attacker-sized `[][]byte` payloads with no cap (a single Clip can
declare gigabytes of pixel data → OOM). Combined with S7 this is trivially scriptable.
Fix: enforce a max-clip-bytes config; simplest robust route is `io.LimitReader`-wrapped
conn + length-prefixed framing, or decode then reject if `len(PixelMats) > MaxFrames` /
`len(frame) > MaxFrameBytes` *before* any processing/allocation beyond the decode itself.
**S7. No connection deadlines anywhere (slowloris / goroutine exhaustion)** [NET]
Zero `SetDeadline` calls repo-wide (verified). Servers hold one goroutine + buffers per
client indefinitely; a handful of idle sockets starves the process. Also client-side:
`Clip.Send` has DialTimeout but no write deadline, and `flush()` in runCam calls it inline
— a stuck receiver freezes that camera's clip building.
Fix: `conn.SetDeadline(time.Now().Add(N))` after accept and refresh per message; write
deadline around every send including `Clip.Send`.
### Medium
**S8. Passphrases exposed via CLI flags and plaintext configs**
`-passphrase` flags appear in `ps`/`/proc/*/cmdline` for every local user and persist in
shell history; YAML configs store them in cleartext. Fix: support env var + `-passfile`
(or keyring prompt), never echo back; document migration.
Related landmine: legacy `frame.ConsumeMat`/`preframeWrapper.ToFrame` concatenate
`CfgData.PassHash` INTO `SourceData` — if ever rewired, password hashes leak into clip.json
metadata and DB rows. Delete the hash-from-metadata behavior now while the code is dead.
**S9. Metadata travels in cleartext even when frames are encrypted**
AES-GCM covers `PixelBytes` only; SourceData (username/camera), GUIDs, timestamps,
detections, and Comparisons ride plaintext gob. Network observer learns which cameras,
when, and how active they are. Fix: extend encryption to the serialized metadata block
(e.g., encrypt a marshalled header alongside pixels) or tunnel the whole gob stream in TLS
(S4c solves this wholesale).
**S10. Livestream-cache trusts whatever arrives for a registered camera** [NET]
If a camera is registered without a passphrase, any host can inject frames into the live
view (no origin concept exists). Subsumed by S4b/S4c, but worth an explicit note: the cache
should reject conns lacking the shared secret even when passphrases are unused.
**S11. Hardened servers missing timeouts / limits (HTTPS viewer)**
`http.Server` created without `ReadHeaderTimeout`, `IdleTimeout`, `MaxHeaderBytes`;
unlimited concurrent streams. Slowloris applies here too. Add sane timeouts + a
semaphore-capped stream count.
**S12. Footage written world-readable**
Storage dirs/files default 0755/0644 (`MkdirAll`, VideoWriter output). On multi-user hosts
any local account can copy surveillance footage. Write with 0750/0640 (umask or explicit
Chmod after MkdirAll).
**S13. MySQL DSN built via fmt.Sprintf**
Special characters in db-user/password silently corrupt the DSN (and error messages may
echo it). Use `mysql.Config{...}.FormatDSN()`; load creds from env/file rather than flags.
### Low
- **S14 Log injection**: raw request paths/hosts flow into log lines (livestream-cache,
coordinator); sanitize/strip control chars.
- **S15 Git remote over plain HTTP** (`http://gitea:3000/...`) — switch to SSH or HTTPS
for push traffic on shared LANs.
- **S16 oko-run flag interpolation** builds child-process args from unquoted map values —
harmless today (config is trusted, exec doesn't use a shell) but easy to misuse; quote
values explicitly.
- **S17 `Frame.Errors` never populated** — no audit trail for dropped/failed frames;
populate at capture time (decode failures, drops-on-full-channel) so downstream can
report data-quality issues.
---
## PERFORMANCE
Ordered by expected impact on the target hardware (Pi Zero W-class).
**P1. PBKDF2 with 600k iterations executed PER FRAME**`frame.Encrypt`
(frame/frame.go). A 10 s clip at ~50 fps = ~500 KDF derivations per clip, each ~100 ms+
on a Pi → encryption dominates total CPU and burns through most of the clip interval.
Fix: derive ONE key per clip — move salt to Clip level (add `Salt []byte` field, derive
once in mofin, encrypt every frame with distinct random nonces). Backward-compatible
enough since all stages deploy together. Expected: ~99% reduction in KDF cost.
**P2. Haar cascade XML re-loaded for every frame, for every location**
`frame.Detect` (frame/detect.go:40-43) constructs `gocv.NewCascadeClassifier` + `.Load()`
per frame per model path. XML parsing dwarfs actual detection cost.
Fix: load all classifiers once at terp startup, share the objects across worker
goroutines (`detectMultiScale` is thread-safe for reads). Expected: order-of-magnitude
speedup of the classification stage.
**P3. Raw, uncompressed pixels end-to-end** — 320×240×3 B ≈ 230 KB/frame; observed clip =
~500 frames ⇒ ~115 MB per clip held in RAM (builder buffer + gob encode buffer + decode
side), plus 115 MB bursts on the wire every 10 s per camera. On a Zero W this is the
memory ceiling. Options in ascending effort:
a. Cap clip duration/fps for the motion stage (e.g. analyze 5 fps subsample);
b. Per-frame JPEG (or MJPEG stream) on the wire — ~1020× size reduction, decode cost
paid once in mofin;
c. H.264 chunk streaming (matches what coordinator re-encodes anyway).
At minimum document the footprint math near the clip builder.
**P4. Motion analysis done TWICE** — mofin computes `CountChangedPixels` and stores it in
`clp.Comparisons`; terp then recomputes identical values (terp/main.go classify loop)
instead of trusting `clp.Comparisons` when present. Free 2× saving on the AbsDiff stage:
`if len(clp.Comparisons) > 0 { skip }`.
**P5. mofin decrypts then re-encrypts every frame** (fresh salt each time, defeating any
KDF caching) purely to run motion detection on plaintext. With P1 fixed the re-encrypt is
cheap, but better: move motion gating upstream into runCam (pre-send) and ship encrypted
clips untouched through mofin, or have mofin operate on a small thumbnail sub-stream
(P3a) so bulk pixels never decrypt/re-encrypt.
**P6. Livestream JPEG re-encoded per viewer per tick**`handleMJPEGStream` encodes a
fresh JPEG from the cached RGBA image for EVERY connected client EVERY 100 ms. N viewers
= N× encode cost. Fix: cache `[]byte` JPEG per camera; re-encode only when a new frame
lands (janitor/ingest hook), serve the same bytes to all viewers.
**P7. Pure-Go BGR→RGBA pixel loop in frameToImage** — per-pixel Go loop with bounds
checks per frame. Since gocv is already in the module graph, replace the whole helper
with `gocv.Mat` construction from bytes + `gocv.IMencode(".jpg", mat, &buf)` — moves
color conversion AND compression into optimized OpenCV code in one step (subsumes P6's
encoder too).
**P8. Motion comparison allocates heavily per frame-pair**`compareTo` creates 3 Mats
(diff/gray/thresh) and converts overlapping frames to Mat twice (frame i used as pair i-1
tail and pair i head). Fix inside `CountChangedPixels`: keep previous frame's gray Mat
alive between iterations, reuse scratch Mats with `Mat.CopyTo`/in-place ops, and downscale
to e.g. 96×72 grayscale before diffing (motion gating needs neither resolution nor color).
Expected: 1030× less work in the hottest OpenCV section.
**P9. Unbounded goroutine-per-connection/per-clip** in mofin, terp, coordinator handlers.
One burst of clips = unbounded concurrent 100 MB-scale allocations (compounds S6/P3).
Cap with a buffered-channel semaphore (configurable workers, default 24) per service.
**P10. DB writes row-at-a-time**`insertClip` Execs one INSERT per detection. Prepare
once per connection + single transaction per clip; enable go-sql-driver interpolateParams
for small batches. Matters when detections-per-clip grows (YOLO wiring will multiply it).
**P11. VideoWriter codec fallback ends at MJPG** — MJPG fallback produces files ~510×
larger than mp4v/H264. Prefer trying `avc1 → mp4v → MJPG`, and make codec/fourcc
configurable per deployment. Also `computeFPS` uses only the first frame delta — average
over several deltas to avoid wildly wrong timestamps after capture hiccups.
**P12. Dev-loop: oko-run rebuild check stats binary existence only** — stale binaries are
silently reused after source edits. Compare mtimes (newest .go vs binary) or add a
`-force-rebuild` flag.
---
## COMPUTER-VISION PIPELINE ENHANCEMENTS
Grouped: correctness first, then accuracy, then capability.
### Correctness (do these before trusting any detections)
**C1. YOLO head parsed wrong — class information discarded.**
`DetectYolo` reads only the box/conf channels; ONNX YOLOv8/v11 export layout is
[1, 84, N] = cx,cy,w,h + 80 class scores, so every detection currently shares one title
and confidence source. Fix: transpose to [N,84], take argmax class + its score as the
detection, map through a COCO names table, filter by per-class thresholds.
**C2. NMS never applied to YOLO output.** `filterNMS` exists but is unwired — expect
dozens of duplicate boxes per object. Apply class-aware NMS (IoU ~0.45) after C1.
**C3. Model loaded per call in DetectYolo path** (`loadModel` per invocation). Same fix
as P2: load Net once at startup; `net.Empty()`/forward is the only per-frame op.
### Accuracy
**C4. Replace Haar fullbody primary detection.** Haar at 320×240 yields heavy false
positives/negatives. Better ladder: (a) HOG+SVM person detector as cheap upgrade;
(b) a lightweight DNN detector (MobileNet-SSD / nano-YOLO) reusing the existing ONNX
plumbing — the infrastructure lands with C1C3 anyway.
**C5. Background subtraction instead of consecutive-frame diff.**
`gocv.BackgroundSubtractorMOG2` gives: robustness to gradual lighting changes, built-in
shadow suppression, contour masks for area/centroid features (replacing raw changed-pixel
counts), and long-term stationary-object suppression. Keep frame-diff as fallback for the
first seconds after startup (model warm-up).
**C6. Clean up the motion signal.** Before/after diffing: Gaussian blur (σ≈3) to kill
sensor noise; morphological open+close on the threshold mask; minimum-contour-area gate;
auto-calibrated threshold per camera from rolling idle-period noise statistics (mean+3σ of
changed pixels) instead of magic constant 30.
**C7. ROI masking per camera.** Configurable polygon per view (sky/hedges/road excluded)
applied to the motion mask — eliminates the classic swaying-trees/headlights false alarms.
### Capability
**C8. Event-shaped recording.** Today fixed 10 s ticker slices cut events mid-action and
record dead air. Switch clip builder to: rolling ring buffer (~5 s pre-roll) + trigger on
motion + record until quiet for N s (+ hysteresis), emitting variable-length event clips.
Biggest UX win per line of code.
**C9. Intra-clip tracking.** IoU-match detections across frames within a clip → stable
object IDs, direction, dwell time; persist track summaries to MySQL next to detections.
Enables "person loitered 4 min" queries and dedupes alert storms.
**C10. Quality gates.** Variance-of-Laplacian blur check per captured frame; discard
garbage (exposure glitches) before they poison motion stats; populate `Frame.Errors`
(S17) with reasons for observability.
**C11. Review affordances at coordinator:** save first-detection-frame JPEG thumbnails +
detection crops beside clip.mp4; add a tiny JSON index endpoint for a future web UI.
Cheap, transforms triage from "watch every mp4" to scanning a contact sheet.
**C12. Retention/GC job.** Storage grows unbounded (only janitor is the live-view cache).
Age-based pruning + total-quota enforcement in coordinator, config-driven.
**C13. Notification hook.** After terp classification, POST event summaries (camera, class,
track info, thumbnail ref) to a webhook/Ntfy/MQTT — turns the pipeline from recorder into
alarm system.
**C14. Cross-camera correlation (later).** Time-windowed matching of tracks across cameras
(same user namespace) for entry/exit reasoning.
---
## Suggested execution order
Quick wins first (hours): S2 validation+recover, S1 traversal guard, S5 basic auth,
P4 (one-liner), P2 (classifier reuse), P6/P7 (jpeg cache + IMencode).
Then structural (days): P1 KDF-per-clip, S4b token auth, S6/S7 deadlines+caps,
P8 motion-buffer reuse, C1+C2+C3 YOLO fix.
Then feature work: C8 event recording, C5 background subtraction, C9 tracking,
P3 compression decision (needs a bandwidth/storage budget discussion).