Files

112 lines
5.3 KiB
Markdown
Raw Permalink Normal View History

# Security Audit — Movie Night
**Date:** 2026-03-15
**Auditor:** Claude (AI-assisted review)
**Scope:** Full codebase review (24 source files)
**Context:** Currently exposed only on Tailscale tailnet. Planning to expose via Pangolin to the internet — these findings should be resolved before that transition.
---
## Critical / High
### 1. Shared search links bypass authentication entirely
- **Location:** `app/routers/mood.py:187-207`
- **Issue:** `GET /api/history/shared/{entry_id}` is fully public. Sequential integer IDs make enumeration trivial. Anyone can view mood text, movie recommendations, and viewing patterns.
- **Fix:** Add a cryptographic share token (e.g. `secrets.token_urlsafe(16)`) stored alongside the history entry. Require the token as a query parameter to access the shared endpoint.
### 2. Prompt injection via mood text
- **Location:** `app/services/llm/base.py:31-48`
- **Issue:** User-supplied `mood` is interpolated directly into the LLM prompt with no sanitization or length limit. A crafted input could manipulate rankings or attempt to extract the system prompt.
- **Mitigating factor:** The title/ID cross-validation in `app/routers/mood.py:87-94` is good defense-in-depth and limits the blast radius.
- **Fix:** (a) Add a max length on mood input (~500 chars). (b) Strip or escape prompt-like patterns. (c) Add an explicit instruction in the system prompt to ignore meta-instructions in user text.
---
## Medium
### 3. Container runs as root
- **Location:** `Dockerfile`
- **Issue:** No `USER` directive. The app runs as root inside the container.
- **Fix:** Add `RUN useradd -m -u 1000 appuser` and `USER appuser`.
### 4. Insecure default session secret
- **Location:** `app/config.py`
- **Issue:** `session_secret` defaults to `"change-me-in-production"`. If `.env` is misconfigured, sessions are trivially forgeable.
- **Fix:** Remove the default and make it a required field (pydantic will error on startup if missing).
### 5. No rate limiting on login
- **Location:** `app/routers/auth.py`
- **Issue:** `/api/auth/login` has no rate limiting, enabling brute-force attacks against Jellyfin credentials.
- **Fix:** Add a simple in-memory rate limiter (e.g. `slowapi` or a manual counter per IP with a 5-attempt/minute window).
### 6. No security headers
- **Issue:** No `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, or `Strict-Transport-Security` headers are set by the application or documented for the reverse proxy.
- **Fix:** Add a FastAPI middleware or document the required headers for the nginx/reverse proxy config.
### 7. Poster proxy has no input validation or auth
- **Location:** `app/main.py:43-52`
- **Issue:** `item_id` is passed directly to Jellyfin with no format validation and no authentication required. This leaks library contents and is a mild SSRF vector.
- **Fix:** Validate `item_id` matches `^[a-f0-9]+$` (Jellyfin ID format) and require authentication.
### 8. Arbitrary `additional_user_ids` accepted
- **Location:** `app/routers/mood.py:31`
- **Issue:** The client can pass any user IDs to filter watch state. No server-side validation that the IDs belong to the household.
- **Fix:** Maintain an allowlist of valid Jellyfin user IDs (or fetch them server-side) instead of trusting client input.
### 9. Database directory permissions not set
- **Location:** `app/database.py:8-9`
- **Issue:** `os.makedirs` uses default umask. Other container processes could read/write the DB.
- **Fix:** `os.makedirs(..., mode=0o700, exist_ok=True)`
### 10. Sync endpoint has no rate limit
- **Location:** `app/routers/library.py:58-62`
- **Issue:** Any authenticated user can spam `POST /api/library/sync`, spawning unbounded background tasks.
- **Fix:** Track last sync time and reject requests within a cooldown window (e.g. 10 minutes).
---
## Low (easy wins)
| # | Finding | Location | Fix |
|---|---------|----------|-----|
| 11 | No mood text length limit | `app/routers/mood.py:25` | Add `max_length=500` to Pydantic model |
| 12 | No auth event logging | `app/routers/auth.py` | Log failed/successful logins with IP + timestamp |
| 13 | No search history retention policy | `app/routers/mood.py` | Add periodic cleanup (e.g. 90-day TTL) |
| 14 | Tailwind loaded from CDN | `app/static/index.html:7` | Consider Subresource Integrity (`integrity=` attribute) |
| 15 | No `.dockerignore` for `.env` | `.dockerignore` | Ensure `.env` is listed |
---
## What's done well
- **Parameterized SQL everywhere** — no injection vectors found
- **Secure session cookies** — `HttpOnly`, `SameSite=Lax`, `Secure` flag, 32-byte random IDs
- **LLM output validation** — title/ID cross-check prevents hallucinated recommendations
- **Dependencies are current** — no known CVEs in pinned versions
- **No secrets in source** — `.env` is gitignored, `.env.example` is clean
---
## Resolution priority
**Must fix before internet exposure (via Pangolin):**
1. Shared search link auth (#1)
2. Container runs as root (#3)
3. Session secret default (#4)
4. Rate limiting on login (#5)
5. Security headers (#6)
6. Poster proxy validation (#7)
7. Mood text length limit (#11)
**Should fix soon after:**
- Prompt injection hardening (#2)
- User ID validation (#8)
- DB permissions (#9)
- Sync rate limit (#10)
- Auth logging (#12)
**Nice to have:**
- History retention (#13)
- Tailwind SRI (#14)
- Dockerignore check (#15)