16 KiB
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: indexesclp.Guids[0],clp.Timestamps[0],clp.PixelMats[0]unchecked → panic.mofin/main.go→frame.CountChangedPixels: loopsi < len(PixelMats)-1indexingc.Guids[i]→ shortGuidsslice 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: callclip.CheckLenCorrelations()immediately after every decode in mofin ingest, terp ingest, coordinator handleClip, livestream-cache handleTCPConnection; drop + log on failure. Also wrap handlers withrecover()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.Errorsnever 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 — ~10–20× 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: 10–30× 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 2–4) 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 ~5–10×
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 C1–C3 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).