Skip to content

Security: kleinpanic/Ai-Pi-Cam

Security

SECURITY.md

SECURITY — Ai-Pi-Cam Pre-Public-Release Audit

Audit date: 2026-06-17 Scope: Phase 23 public GitHub release gate ASVS level: L1 (LAN appliance, single-user, no authentication)


Summary

Severity Finding Status
BLOCKER Identifiable face photo committed to git history OPEN
BLOCKER .claude/scheduled_tasks.lock committed — internal session state tracked in public repo OPEN
HIGH web_host defaults to 0.0.0.0 with no auth; unauthenticated control plane if Pi is internet-facing ACCEPTED (LAN-only; see mitigations)
HIGH Google Fonts loaded from CDN in control panel HTML — browser makes external request on every page load OPEN
INFO No CSRF protection on POST API routes ACCEPTED (LAN-only; same reasoning as WR-06)
CLOSED Feature flags (notifications/cloud/GPIO) OFF by default CLOSED
CLOSED No hardcoded secrets in src/, config/, scripts/ CLOSED
CLOSED .env gitignored; .env.example has no real values CLOSED
CLOSED output/ gitignored; event recordings not tracked CLOSED
CLOSED .venv/ gitignored CLOSED
CLOSED models/*.tflite gitignored CLOSED
CLOSED .claude/worktrees/ gitignored CLOSED
CLOSED Input validation on all POST API routes (allowlist via VALID_MODES/VALID_PREPROCESS_MODES/VALID_HUD_THEMES; threshold clamped 0.05–0.95) CLOSED
CLOSED No shell injection sinks (subprocess call in preview/sink.py uses list argv, no shell=True) CLOSED
CLOSED Flask debug mode not enabled (no debug=True in app.run call) CLOSED
CLOSED No eval/exec on user-controlled input CLOSED
CLOSED output/ write path uses pathlib.Path.resolve() — no path traversal via config CLOSED

OPEN / BLOCKER

B-01 — Identifiable face photo in git history

File: .planning/phases/19-hand-fidelity/evidence-live-skeleton-offset.jpg Evidence: 640×480 JPEG, tracked in git (confirmed via git ls-files), contains a clear full-face photograph of a real person (presumably the owner) with interior room background visible.

Why this is a blocker for public release: The image is in git history and the working tree. A git push to a public repo will publish it. There is no .gitattributes export-ignore rule and no .gitignore entry for .planning/. The photo is unambiguously identifiable and includes home-interior context.

Required action before public push:

  1. Remove the file from the working tree and all git history using git filter-repo (or git filter-branch):
    git filter-repo --path .planning/phases/19-hand-fidelity/evidence-live-skeleton-offset.jpg --invert-paths
    
  2. Force-push the rewritten history to the remote (required after filter-repo; confirm before doing).
  3. Add .planning/ to .gitignore if planning artifacts should not be public, or add *.jpg / *.jpeg / *.png under .planning/ specifically.

Alternatively: exclude .planning/ from the public repo entirely by adding it to .gitignore before the first push, then running filter-repo to remove all currently tracked .planning/ content from history.


B-02 — .claude/scheduled_tasks.lock committed

File: .claude/scheduled_tasks.lock Content: {"sessionId":"b49ec618-...","pid":301444,"procStart":"540287","acquiredAt":1781686608166} Evidence: Confirmed tracked via git ls-files.

Why this matters: This is a runtime lock file generated by the Claude Code agent runtime. It contains a session UUID, a PID, and a process start timestamp. It has no business being in a public repo. It is not covered by .gitignore (.claude/worktrees/ is excluded, but not .claude/scheduled_tasks.lock).

Required action before public push:

  1. Add .claude/scheduled_tasks.lock to .gitignore.
  2. Remove it from git tracking: git rm --cached .claude/scheduled_tasks.lock
  3. Commit that change.

The .claude/agents/ and .claude/skills/ files are intentional project artifacts and are fine to publish.


HIGH — Accepted with required documentation

H-01 — Unauthenticated control plane on 0.0.0.0

Files: src/ai_pi_cam/web/server.py (routes), src/ai_pi_cam/config/settings.py:104 (web_host default), config/config.yaml:19, config/examples/config.example.yaml:61–65

Detail: Five POST routes (/api/mode, /api/threshold, /api/preprocess, /api/theme, /api/state) accept unauthenticated requests. web_host defaults to 0.0.0.0, binding to all interfaces. Any host that can reach the Pi on port 8080 can change detection mode, thresholds, and HUD settings without authentication.

Threat model disposition: ACCEPTED for a LAN-only single-user appliance. The documented project constraint is "LAN-only web view." This is consistent with the design intent.

Condition for acceptance (must be in README / DEPLOYMENT.md before publish):

  • A prominent security note warning that the web port must not be exposed to the internet.
  • Explicit firewall guidance: sudo ufw allow from 192.168.0.0/16 to any port 8080 or equivalent.
  • Guidance to set web_host: 127.0.0.1 or a specific LAN IP in config/config.yaml if additional hardening is desired (this does not break LAN access — clients connect to the Pi's LAN IP, not 127.0.0.1 from outside).

Current state of documentation: config/examples/config.example.yaml:62 has a one-line comment ("Never expose to a public/untrusted IP without additional security") and docs/DEPLOYMENT.md mentions the feature-flags are safe but contains no firewall guidance and no explicit warning about the unauthenticated control plane. This gap must be filled before public release.

Input validation (confirmed CLOSED): All POST routes validate inputs against allowlists in AppState (VALID_MODES, VALID_PREPROCESS_MODES, VALID_HUD_THEMES) and return 400 on invalid input. Threshold is clamped to [0.05, 0.95] with type validation. No injection sinks.


H-02 — Google Fonts CDN load in control panel HTML

File: src/ai_pi_cam/web/control_panel.html:7–9

Detail: The control panel loads two font families from fonts.googleapis.com and fonts.gstatic.com on every page open. This means every browser that views the panel makes outbound requests to Google's servers, which can log the client IP. For a hobbyist LAN appliance this is a minor operational concern, but for a public repo it establishes a privacy expectation mismatch: users who believe they are running a local-only appliance are inadvertently pinging Google on every session.

Severity: HIGH for a project whose stated constraint is "no cloud" and "LAN-only."

Required action before public push: Either:

  • Self-host the fonts (download and serve as static files from Flask, or inline as base64 in the HTML), or
  • Add a note to the README and config.example.yaml explaining the fonts CDN load and how to disable it (e.g. set web.host: 127.0.0.1 and use a browser with font-blocking, or replace the <link> tags with a local font stack).

The fallback font stacks already in the CSS (ui-monospace,monospace and sans-serif) are functional — removing the Google Fonts <link> tags degrades aesthetics only.


Closed Findings — Verification Evidence

Finding Evidence Location
Feature flags OFF by default settings.py:45–47 (features_notifications_enabled=False, features_cloud_enabled=False, features_gpio_enabled=False); all integration stubs guard with if not settings.features_*_enabled: return
notify.py raises NotImplementedError, no SMTP creds read src/ai_pi_cam/integrations/notify.py:3–9
cloud.py raises NotImplementedError, no token read src/ai_pi_cam/integrations/cloud.py:3–9
gpio.py raises NotImplementedError, no pin read src/ai_pi_cam/integrations/gpio.py:3–9
No hardcoded credentials Full grep of src/, config/, scripts/ for password, token, api_key, secret, smtp, credential — no matches outside comments/stubs
.env gitignored .gitignore:2
.env.example has no real values All credential lines are commented-out placeholders
output/ gitignored .gitignore:19; confirmed empty in git ls-files output/
models/*.tflite gitignored .gitignore:22
.venv/ gitignored .gitignore:16
.claude/worktrees/ gitignored .gitignore:35
API allowlist validation state.py:10–11,65–67,75–77,82–84,93–104
No shell=True subprocess preview/sink.py:10–13subprocess.run(["xrandr"], ...) with list argv
Flask debug=False server.py:109–114app.run(..., use_reloader=False) with no debug= arg; Flask defaults to debug=False
No eval/exec on user input Full grep of src/ — zero matches
Output path resolved events/recorder.py:14pathlib.Path(settings.output_dir).resolve()

Pre-Push Checklist

Before git push to a public remote:

  • B-01 MUST: Remove .planning/phases/19-hand-fidelity/evidence-live-skeleton-offset.jpg from git history and working tree (git filter-repo --invert-paths --path ...). Consider excluding all of .planning/ from the public repo.
  • B-02 MUST: Add .claude/scheduled_tasks.lock to .gitignore, run git rm --cached, commit.
  • H-01 SHOULD: Add firewall/LAN-only warning with ufw example to docs/DEPLOYMENT.md and README.md.
  • H-02 SHOULD: Self-host fonts or document the CDN load as a known privacy trade-off in README.md.
  • Verify git ls-files | grep -E "\.(jpg|jpeg|png|mp4|h264)$" returns only intentional images (currently: one — the face photo, which must be removed).
  • Run git ls-files | grep "\.env$" — must return empty.

There aren't any published security advisories