Files
oko_public/TODO.md
T

281 lines
16 KiB
Markdown
Raw Normal View History

2026-09-09 21:44:05 -05:00
# 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).