# SECURITY — mandatory rules and open findings

Applies to every change. A change whose fragment says `security_impact: none` must still have
answered the checklist in its `## Security review` section. "Not affected" is an answer; an empty
section is a rejected review.

References: OWASP Top 10 (2021), OWASP ASVS 4.0 (Level 2 is the target), OWASP API Security Top 10
(2023), Flask security docs.

This project was built **after** the sibling managers' security reviews, so their lessons were
applied from day one (see §5). What is still open is in §4.

---

## 1. Principles

1. **Fail closed.** Missing config, bad token, unknown account, invalid input: refuse, do not guess.
2. **Least privilege.** Smallest scope set (`user.info.basic`, `user.info.stats`, `video.list`,
   `video.publish`), non-root containers, one API key per client with only the accounts it needs.
3. **Defence in depth.** A guard at the route, validation in the service, safe messages at the edge.
4. **Secrets never leave their lane.** Not in code, not in git, not in logs, not in error pages,
   not in API responses, not in URLs.
5. **Every state change is authenticated, authorised, CSRF-protected and logged.**
6. **Security is reviewed per change, not per release.** The fragment carries the review.

---

## 2. Mandatory rules

### 2.1 Secrets and configuration
- Secrets come from environment only. `.env` is git-ignored; `.env.example` holds placeholders.
- **No working default for a secret.** `SECRET_KEY`, `API_KEY`, `TIKTOK_CLIENT_SECRET` and
  `TOKEN_ENCRYPTION_KEY` must be present and non-placeholder or `create_app()` raises at startup
  when `FLASK_ENV=production`. Outside production a random `SECRET_KEY` is minted when missing.
- Rotate a secret by: add new → deploy → revoke old → record the rotation as a `security` change.

### 2.2 Authentication
- API keys are compared with `hmac.compare_digest`, never `==` (satisfied at birth).
- Keys are accepted only in headers (`X-API-Key` or `Authorization: Bearer`), never in the query
  string.
- OAuth: `state` is generated per flow, stored in the session and verified on callback. TikTok
  **rotates the refresh token on every refresh** — `ensure_fresh_token()` persists the new one in
  the same transaction; losing it means reconnecting the account.
- Failed authentications are logged, never with the key or token.

### 2.3 Sessions and cookies
- `SESSION_COOKIE_HTTPONLY=True`, `SESSION_COOKIE_SAMESITE="Lax"`, `SESSION_COOKIE_SECURE=True`
  whenever the app is served over HTTPS (production), `PERMANENT_SESSION_LIFETIME` ≤ 12 h.
  *(partly open — see TT-S-06.)*
- Logging out invalidates the session (`session.clear()`) and is a `POST` (satisfied at birth).

### 2.4 CSRF and safe methods
- **GET never changes state.** Activate, refresh, delete, logout, reschedule, retry: `POST`
  (satisfied at birth).
- Every HTML form that changes state carries a CSRF token (Flask-WTF `CSRFProtect`). The API
  blueprint is exempt because it is authenticated by a header the browser cannot add cross-site;
  it must stay exempt only as long as it never accepts session cookies as authentication.
- Destructive actions (disconnect account, delete scheduled post) require an explicit
  confirmation step in the GUI and an explicit account selector in the API (see 2.5).

### 2.5 Authorisation and account resolution
- Mutating API endpoints (`POST`/`PATCH`/`PUT`/`DELETE`) **require** `account_id` or
  `X-Account-Id` (satisfied at birth — `resolve_account(require_explicit=True)` returns
  `400 EXPLICIT_ACCOUNT_REQUIRED` otherwise). Read endpoints may fall back to the first account.
- The GUI is session-only: `@login_required` passes only with an `active_account_id` in the
  session; **no first-account fallback**.
- GUI mutations on scheduled posts check `post.account_id == active account` before acting.
- Object identifiers from the client (`publish_id`, `video_id`) should be validated by shape
  before use (`^[A-Za-z0-9_]+$`). *(open — see TT-S-07.)*

### 2.6 Data at rest
- The access token and the rotated refresh token are encrypted in the database with Fernet
  (`cryptography`), key from `TOKEN_ENCRYPTION_KEY`. Encrypt/decrypt lives in the model
  (`access_token_plain()` / `refresh_token_plain()`), so no other layer sees ciphertext.
- `TIKTOK_CLIENT_SECRET` is **not** stored per row; it is app config.
- `data/`, `uploads/` and `media/` volumes are owned by the container user and not world-readable.
- Deleting an account cascade-deletes its scheduled posts and their stored media files.
- **PostgreSQL (ADR-004):** the `db` container is reachable only on the compose network; its
  port is not published in production. `POSTGRES_PASSWORD` is a secret under 2.1. The `pgdata`
  volume and `pg_dump` backups contain encrypted tokens only because of the Fernet rule above;
  the backup location is treated as sensitive anyway.

### 2.7 Input validation and files
- Validate at the service boundary: caption ≤ 2,200 chars, `privacy_level` from the allow-list
  (`PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `SELF_ONLY`), `mode` ∈ {`direct`, `draft`},
  video xor video_url (never both), http(s) URL shape, `scheduled_at` in the future.
- Uploaded file names are never used as paths. `store_upload()` saves under a server-generated
  `uuid4().hex + ext` name with an extension allow-list (mp4/mov/webm) and a realpath
  confinement check. Immediate posts use the temp `UPLOAD_FOLDER` and delete the file in
  `finally`; scheduled posts use the persistent `MEDIA_FOLDER` and delete the file on publish
  success or post deletion.
- The API never accepts a server-side path from the client.
- Reject request bodies over `MAX_CONTENT_LENGTH` (Flask does this).

### 2.8 Errors and logging
- Clients receive an error **code** and a safe message. `TikTokAPIError` carries fixed,
  client-safe strings; the TikTok payload and stack go to the log with `exc_info=True`.
- Never `flash(f"... {e}")` or `api_error(str(e))`. *(satisfied at birth.)* The scheduler records
  only the safe message in `scheduled_posts.error` — the same string the UI shows.
- Use the `logging` module (never `print`). Never log tokens, keys, secrets, full request
  bodies or file contents.
- Mutating calls write an info log line (account id, action, result). A structured audit record
  per mutation is still open — see TT-S-04.

### 2.9 HTTP hardening
- Response headers on every HTML response: `Content-Security-Policy` (self + the two CDNs, no
  `unsafe-inline` scripts), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
  `Referrer-Policy: strict-origin-when-cross-origin`, `Strict-Transport-Security` in production.
  *(open — see TT-S-01.)*
- CDN `<script>`/`<link>` tags carry `integrity=` (SRI). *(open — TT-S-01.)*
- No CORS headers unless a specific origin is configured; never `*` with credentials.
- Rate limiting (Flask-Limiter or the reverse proxy): API key auth failures, `/auth/*`, uploads.
  *(open — TT-S-02.)*

### 2.10 Dependencies and runtime
- `pip-audit` runs when `requirements.txt` changes and in CI. A known vulnerability blocks the
  change unless the fragment documents the accepted risk and an expiry date. (The initial build
  pinned `requests==2.33.0`, `python-dotenv==1.2.2`, `Flask==3.1.3` until the audit was clean.)
- Exact pins only; upgrade in a dedicated `infra` change with the audit output in `## Verification`.
- Docker: non-root users (the scheduler container runs as `1000:1000`), no `latest` base tags,
  `HEALTHCHECK` on the web container, secrets only through `env_file`, ports bound to
  `127.0.0.1` on the host when a reverse proxy fronts the app.
- Gunicorn behind a TLS-terminating reverse proxy in production; `ProxyFix` configured so
  `request.is_secure` and client IPs are right.

### 2.11 Third-party quota and abuse
- TikTok enforces API rate limits and **daily posting caps**; a 429 surfaces as `RATE_LIMITED`
  and the scheduler records it as a failed post (retryable later) — never retried in a hot loop.
- The scheduler publishes sequentially, one post at a time, every `SCHEDULER_INTERVAL_SECONDS`.

---

## 3. Security review checklist (copy into `## Security review` of every fragment)

```
- Inputs validated (type/length/allow-list/path)?           yes / not applicable / see below
- Every new route has the right guard?                       yes / n/a
- Any GET that changes state?                                no
- Secrets, tokens, personal data touched/logged/returned?    no / see below
- New dependency? pip-audit clean?                           n/a / yes (output in Verification)
- Mutating API endpoint requires explicit account?           yes / n/a
- Error messages safe (code + message, no exception text)?   yes
- Audit log written for mutations?                           yes / n/a
```

---

## 4. Open findings register

This project starts with the sibling projects' lessons already applied (§5). What is still open:

| ID | Sev | Where | Finding | Required fix (rule) | Status |
|----|-----|-------|---------|---------------------|--------|
| TT-S-01 | M | `templates/base.html`, `app/__init__.py` | No security headers (CSP, nosniff, X-Frame-Options, HSTS); CDN scripts without SRI. | Talisman or manual headers + SRI (2.9) | open |
| TT-S-02 | M | `app/routes/*` | No rate limiting on API-key checks, OAuth start, media uploads. | Flask-Limiter or reverse proxy (2.9) | open |
| TT-S-03 | M | `app/routes/api_routes.py`, `app/models.py` | One static shared API key, plaintext in env, no per-client identity, no account scoping, no audit trail. Whoever holds it controls every connected account. | Hashed multi-key table with account allow-list + audit log (2.2, 2.5, 2.8) | open |
| TT-S-04 | L | `app/routes/*`, `app/services/scheduled.py` | Mutations write info log lines but no structured audit record (who, what, when, result). | 2.8 | open |
| TT-S-05 | L | `app/routes/auth_routes.py` | The OAuth callback reflects TikTok's `error_description` query parameter handling into a flash; the current code flashes a fixed message and logs the parameter presence only — keep it that way when touching this route. | 2.8 | open (verification note) |
| TT-S-06 | L | `app/config.py` | No `SESSION_COOKIE_SECURE` / `SAMESITE` / lifetime configuration. | 2.3 | open |
| TT-S-07 | L | `app/routes/api_routes.py`, `post_routes.py` | Client-supplied TikTok object ids (`publish_id`, `video_id`) are not shape-validated before use. | `^[A-Za-z0-9_]+$` check (2.5) | open |
| TT-S-08 | L | `app/services/uploads.py` | Uploaded videos are validated by extension only; the file content is not sniffed. | Content-type sniffing or magic-byte check (2.7) | open |
| TT-S-09 | L | `docker-compose.yml` | The scheduler container shares the SQLite `data` bind mount with the web container; concurrent writes rely on SQLite file locking (fine at this scale, a real constraint at larger scale). | Move to `DB_ENGINE=postgres` for multi-writer deployments (ADR-004) | open (documented) |

Suggested closing order: TT-S-01 + TT-S-06 together (one headers/session change), then TT-S-07
(small), then TT-S-02, then TT-S-03 + TT-S-04 together (the multi-key + audit refactor).

---

## 5. Satisfied at birth (for the record)

Lessons from the sibling projects' reviews that this codebase implements from day one:

- API key compared with `hmac.compare_digest`.
- CSRF protection on every GUI form and mutation; API exempt as a header-authenticated blueprint.
- Uploads stored under server-generated names with an allow-list and `finally` cleanup; no
  server-side paths accepted from clients.
- Production startup guard for secrets and the encryption key.
- Tokens Fernet-encrypted at rest; app secret is config, not a column; **rotated refresh tokens
  persisted on every refresh**.
- No state-changing GETs.
- No raw exception text in client messages — including the scheduler's recorded error strings.
- Non-root containers (web drops privileges via gosu; scheduler runs as `1000:1000`).
- Mutating API endpoints require an explicit account.
- Session-only GUI login with **no first-account fallback**.

---

## 6. Incident response (minimum)

1. Revoke: rotate `API_KEY` (all clients) and `SECRET_KEY` (all sessions); for a token leak,
   disconnect the affected accounts (deletes the encrypted tokens and scheduled posts) and
   remove the app's access from TikTok's settings, then reconnect.
2. Record: a `security` change describing what leaked, when, and the containment. No secrets in it.
3. Review: add the root cause to §4 if it is a rule gap, and to §2 if it is a missing rule.
