# Security model

prn.tf talks to a desktop client on the public internet and to browsers with cookies. Those are different threat models. The code splits them.

## The problem

A simple `POST /upload` with `{ user_id: 1 }` (the old client shape) lets anyone attach shots to anyone. Cookies on a screenshot site are a CSRF and session-fixation target. Short codes are enumerable if they are short and you do not limit misses.

## The approach

### Web (browser)

1. Cookie `FRAMESHOT_SESSION`, HttpOnly, SameSite=Lax, Secure on HTTPS, `session.use_strict_mode=1`.
2. CSRF token in session (`CsrfMiddleware::init()` at boot). State-changing form POSTs must call `$csrfMiddleware->handle()`. Failure: redirect back, flash `csrf_error`.
3. Login lockout via `login_attempts`: 5 failures, 15 minute lock (`BruteForceProtection` + `AppConfig`).
4. Password rules in `PasswordValidator` (length 8–128, mixed case, number, special, common-password list, strength score ≥ 3).
5. Optional TOTP 2FA and backup codes (`TwoFactorService`). Attempts stored in `two_factor_attempts`.
6. Password reset and email verify tokens on `frameshot_users`. Mail is PHP `mail()`.

Register is not a separate URL. `/login` POST with `form_type=register` (or `reg_username`) creates the account.

### API (desktop client)

`SecurityMiddleware::handle($endpoint, $method, $config)` runs in this order:

1. CORS (`CorsHandler`, `CORS_ALLOWED_ORIGINS`, wildcard subdomains). OPTIONS exits 200.
2. Security headers (API responses): `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy: default-src 'none'`, HSTS on HTTPS, strip `X-Powered-By`. These headers are for the JSON API, not the HTML app (the app needs scripts and CDN CSS).
3. API key (`X-API-Key`) if `requires_api_key`.
4. Device session (`X-Session-Token`) if `requires_session`. Revoked devices fail with `DEVICE_REVOKED`.
5. JWT (`Authorization: Bearer`) always attempted; success sets `user_id` and `is_authenticated`.
6. HMAC signature if `requires_signature`. **Live register/login/upload set this false.**
7. Rate limit (`RateLimiter`, table `rate_limits`).
8. Audit log (`api_logs`) on success or failure.

Device register: middleware auth is off, but `RegisterController` still demands a valid API key from env `API_KEY_*`.

API login does **not** check 2FA or email verification. Those gates exist only on the web `/login` flow. CORS with an empty `CORS_ALLOWED_ORIGINS` allows **no** origins (not "allow all").

### Short codes

Invalid or missing codes increment `short_code_miss` per IP (60 / 60s) then 404. That is an enumeration guard, not a privacy guarantee.

### Uploads

`ImageValidationService` checks MIME and size before disk write. Optimization and WebP are best-effort after save.

### HTML on profiles

Custom profile mode stores HTML/CSS. `HtmlSanitizerService` is the brake. Treat custom JS columns as hostile; do not widen that without a real sanitizer pass.

### IP logs

`GET /api/v1/share/logs/{code}` is not "owners can see who opened my link." It is hardcoded to session username `zelax`. Documenting it as a user-facing privacy feature would be wrong.

## Trade-offs

- **Signature off in production routes.** Replay protection for JSON bodies is weaker. Timestamp+HMAC exists but is unused. Turning it on will break any client that does not sign.
- **Anonymous upload is rate-limited as `upload_anon`**, which uses the anonymous upload window (default 100/min per IP). Public routes still skip API keys.
- **CSP `default-src 'none'` on API** is correct for JSON. Do not copy that header onto HTML layouts.
- **PHP `mail()`** for reset/verify. Deliverability depends on the host. No SMTP abstraction in code.

## Related

- Config and limits: [reference-config.md](reference-config.md)
- API headers: [reference-api.md](reference-api.md)
- Client flow: [howto-integrate-client.md](howto-integrate-client.md)
