Ctrl K
ComunidadGuías…

SECURITY.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Security

## Threat model

Curatarr is built for a **trusted home LAN**. It binds to `0.0.0.0` so
household members can reach it, authenticates users via Plex OAuth + JWT,
and keeps behavioural data local — watch history, ratings and taste vectors
never leave, though enrichment does send titles and artist names to public
metadata APIs. It is **not hardened for the open internet** —
do not port-forward it or put it on a public host without adding your
own reverse proxy, TLS and access control.

**Data at rest is not encrypted by the app.** An early design encrypted
taste vectors with a PIN-derived key; it was removed rather than shipped,
because the vector's consumers are background jobs that run when nobody is
present to type a PIN — the server would have to cache the key, which
unmakes the scheme — and because the source data (watch history) sits in
the same database regardless. Use OS disk encryption if the disk itself is
in your threat model. The leftover "encryption passphrase" (PIN) setting went
too (2026-10-10): it encrypted nothing, and a short PIN's hash and salt side
by side in the database are recoverable offline. Its endpoints and Settings
form are gone, and the next start drops the stored hashes.

Defaults that matter:

- `/api/docs` (Swagger) **and** the OpenAPI spec are **off** by default
  (`ENABLE_DOCS=false`) — either would map every endpoint for anyone who
  finds the port. (Until 2026-09 only the UI was gated; the spec stayed
  public at `/openapi.json`.)
- Secrets (`JWT_SECRET`, API keys, Plex token) live only in `.env`,
  which is gitignored. Never commit it. The wizard writes it through a
  temp file that is owner-only from the moment it exists (0600 on POSIX;
  on Windows the inherited ACL is stripped before any content is written)
  and renames it into place atomically. `.env` is the secret store by
  design: the process needs the plaintext at boot with nobody present to
  unlock a key — the same reason data at rest is not encrypted.
- Every response carries a Content-Security-Policy: the UI
  loads nothing from other origins, so `connect-src 'self'` means even a
  script injection that survived DOMPurify cannot phone a token home.
  Scripts are the module tree only (`script-src 'self'`; no inline handlers
  or inline scripts since 2026-09-13), so injected markup cannot run code
  either. `style-src 'self'` too: no inline styles since 2026-09-25.
- Deletion actions require an authenticated session; proposals are never
  executed without an explicit user approval in the UI. The arr id or Plex
  rating key must be a positive integer before a DELETE is built; anything
  else (a missing id, a path fragment) marks the proposal as an error and
  sends nothing. Without Lidarr,
  music deletions go through Plex's own API (the server's 'Allow media
  deletion' setting) on the same approval path, and the item is re-read
  afterwards: a file Plex kept is reported as a failure, never as deleted.

## Who can log in

Authentication is Plex OAuth, but a plex.tv account is free to create in a
minute — so holding one is not enough. A new account may log in only if
the configured Plex server itself knows it (owner, Plex Home users, shared
friends — the same `/accounts` list watch history is attributed with), and
the **first** account, which becomes the admin, must be the account that
owns the server token. Both checks fail closed for *new* accounts when
Plex is unreachable; existing users are unaffected. `PLEX_LOGIN_REQUIRE_
MEMBERSHIP=false` disables the gate for the rare setup where `/accounts`
does not list a legitimate member.

The Plex PIN itself is bound to the browser that requested it: `/plex/pin`
returns a nonce that `/plex/poll` must echo in a header, so another LAN client that
learns a pending PIN id cannot collect the session it approves, and polls
are budgeted per client address as well as per PIN (2026-09-25).

**On a Jellyfin install** (`MEDIA_SERVER_TYPE=jellyfin`, 2026-10-26) the Plex
routes answer 404 and people sign in against the one configured Jellyfin
server, so a successful sign-in is itself the membership proof: there are no
outside accounts to keep out. The **first** account must be a Jellyfin
administrator (`Policy.IsAdministrator`), the counterpart of the Plex server
owner. Quick Connect is the preferred way in: no password passes through
Curatarr, and the Quick Connect secret never leaves the server: the browser
holds an opaque login id plus a nonce that the poll must echo in a header,
like the Plex PIN. The password fallback sends the name and password to the
Jellyfin server once; nothing is stored or logged, and wrong passwords are
budgeted per client address and across all clients (ten and a hundred per 15
minutes; a locked client stays locked even with the right password). Every
session Jellyfin opens for a sign-in is closed right away (`/Sessions/Logout`):
Curatarr keeps no Jellyfin user token. The Jellyfin API key in `.env` is an
admin credential for the media server and is handled like the Plex token.

The Jellyfin Webhook plugin posts to `POST /api/jellyfin/webhook`, the one
route without a user: it needs a shared secret in `X-Curatarr-Secret`
(constant-time compare; the admin creates and rotates it in Settings →
Integrations; without one the route refuses everything), takes JSON objects
of at most 64 KB, is budgeted per client, and treats a post only as a hint:
a play is written when the Jellyfin server itself, re-read with the API key,
says the item is played and was last played within half an hour. A forged
or replayed post costs one read (2026-10-26).

## Sessions

JWTs live 7 days and are silently re-issued once a day old — but always
from the live account row, never from the token's own claims. Every
request re-checks `is_active` and a per-user token version: **Sign out**
bumps the version, so the token dies on every device at once, and an
admin deactivating an account ends its sessions immediately instead of at
expiry. (Before 2026-09 both were cosmetic — a deactivated member kept
working, and only rotating `JWT_SECRET` could revoke anything.)

## First-run setup

Until the first admin exists the wizard endpoints have to be open —
nobody can authenticate yet. Because the server binds the whole LAN, a
browser on the machine itself may drive setup freely, while any other
device must present the one-time **setup code** the server prints to its
console (and log) at startup. "The machine itself" means a loopback
connection that addresses the server as `localhost` / `127.0.0.1` / `[::1]`,
carries no proxy forwarding headers and no foreign `Origin` (2026-10-10):
a web page that rebinds its own name to 127.0.0.1 and a reverse proxy on
the same host both connect from loopback too, and both now need the code. That closes the window in which a LAN
neighbour could have pointed a fresh install at their own Plex. Wrong codes
are budgeted — ten per address and a hundred in all per fifteen minutes,
then the gate answers 429 for the rest of the window — and a browser on the
machine itself is never locked out (2026-09-25).

`ALLOWED_HOSTS` in `.env` (comma-separated names, `*.example.com` wildcards)
adds a second, opt-in line against DNS rebinding: a rebound page still sends
its own name as `Host`, so every request whose Host is not listed is answered
400 before auth or the UI see it. `localhost`, `127.0.0.1` and `::1` always
pass, so a typo cannot lock the owner out locally. It is off by default
because no default list can know the IP, hostname or proxy name an install
is reached by — turning it on with the wrong list would cut off every LAN
browser on upgrade.

Publishing Curatarr under a public name through Cloudflare Access and a
reverse proxy leaves one gap: the proxy answers anyone who connects to the
home IP directly and sends the public name, and Access never sees them.
`CF_ACCESS_HOSTS` closes it. A request under a listed name must carry the
token Access stamps on what it let through (`Cf-Access-Jwt-Assertion`). The
token is RS256, signed by the team's published keys, for this application's
audience, and unexpired. Anything else gets 403 before auth or the UI. The
signature check needs no extra package: it is one modular exponentiation,
compared byte for byte with the only valid PKCS#1 v1.5 encoding. A listed
name without `CF_ACCESS_TEAM_DOMAIN` and `CF_ACCESS_AUD` refuses everything.
LAN names are never checked, so local use keeps working when Cloudflare or
the internet is down (2026-10-22).

## Connection tests and service addresses

Plex, Jellyfin, arr, Ollama, SoulSync and Shoko addresses must be plain `http(s)` base URLs:
no credentials, query string or fragment (a `#` would cut off the API path
the client appends), and no link-local or cloud-metadata address. A host
name is resolved before a test or setup step connects to it, and is refused
when it resolves to such an address, or when the lookup times out (3 s)
or errors (a name that does not exist simply fails to connect). LAN and loopback
addresses are of course fine. "Test connection" with a blank key
field re-uses the saved key only against the saved address — a new address
needs its key typed in, so a saved token never travels to a host it was not
saved for. A failed test says what kind of failure it was (key rejected,
unexpected answer, could not connect) without echoing the remote body or
telling a refused port from a filtered one; the detail goes to the log
(2026-10-10).

## What a member can make the server do

Every household member may chat, search and refresh their recommendations
— that is the product — but each of those turns one request into curator
time on the single GPU or into calls against metadata APIs with quotas.
Since 2026-09 the endpoints that do so carry **per-user budgets** (sliding
windows with a `Retry-After` on refusal) and, where the work is long, a
**one-at-a-time guard**: one chat reply in flight per user, one
recommendation generation per user (the plain `GET` lane included), one
Plex sync server-wide, music pipeline starts and sync triggers a few per
ten minutes, and (2026-10-19) one taste-profile rebuild per user at a time,
three per ten minutes, plus small budgets on forced live arr refreshes and
on "re-fetch metadata" in chat, which drops a shared cache row. Budgets are generous for a human and tight for a script; the
guards expire on their own, so a dropped stream cannot lock anyone out.
Administrative work (enrichment runs, backfills, migrations, process
classification, deletions, arr writes) is admin-only at the router mount.
What a member *sees* is scoped the same way (2026-09-25): the Activity
view lists only the tasks that carry their user id (their own analyses,
recommendation refreshes, memory extractions), never the server's, and the
library status tells a member which arr services exist, not their LAN
addresses, root folders or test results (the arr profile lists, which carry
root folders, are admin-only since 2026-10-10; since 2026-10-19 the saved
arr defaults, the Plex section paths, each arr item's root folder, the "open
in arr" link and raw connector errors are the admin's too). The OMDb key has a daily budget
(`OMDB_DAILY_LIMIT`, the free tier's 1,000): once spent, lookups wait for
tomorrow instead of hammering a refusing API.

## Third-party text in prompts

Overviews, reviews, encyclopedia extracts and bios come from sources
anyone can edit or post to. Before they reach a model they are **fenced**
(`<<<UNTRUSTED_SOURCE:…>>>` … `<<<END_UNTRUSTED_SOURCE>>>`) and scrubbed of
markup, chat special tokens and role markers, and every system prompt that
receives fenced text states that such text is data, never an instruction.
The shared verified-data block — the one renderer behind chat answers,
deletion verdicts and pitches — is fenced as a whole. Model output is
stripped of tag-like markup before it is stored, the chat sink renders
through DOMPurify, and names pushed into Plex are plain printable text.
The Content-Security-Policy allows no inline script and, since
2026-09-25, no inline style either: every static style is a class, a
computed size travels as a data attribute applied from script, and the
sanitizer drops data attributes from anything untrusted — markup that
survives it can neither run nor style.
Studio and director notes distilled from Wikipedia cache for a year, not a
decade, so an upstream correction reaches the evidence.

## Bodies from the outside

Subtitle downloads, proxied poster images and uploaded Spotify archives are
read through bounds enforced *while* streaming (4 MB, 5 MB, 50 MB per zip
member / 400 MB per archive), never buffered first and measured after; a
Spotify upload takes at most 20 files of at most 200 MB each; the
CPU-bound subtitle metrics run off the event loop so one oversized file
cannot stall every other user's request. The image proxy accepts a name
only from its whitelist, serves raster images only (no SVG; every response
carries `Content-Security-Policy: sandbox`, 2026-10-10) and, since
2026-09-25, fetches only when that name resolves to a public address —
checked before the first connection and before every redirect hop, so a
rebinding CDN name cannot turn the proxy into a LAN fetcher.

## The container and a second machine

**The container** runs as a non-root user (uid 568) on a digest-pinned
Python base and installs only the hash-checked lock. Everything it writes,
including the `.env` with every token, lives in the one `/app/data` volume,
so protect that dataset as you would the `.env` itself. With the usual port
mapping a browser reaches the container through Docker's forwarding, never
over loopback, so first-run setup always needs the setup code.

**The PC agent** is a second listener, on the gaming PC (port 11435, plain
HTTP). Every request must carry `PC_AGENT_TOKEN` (16+ characters, compared
in constant time); without a token the agent does not start. It reports
process names and GPU memory (no paths, command lines or user data), and it
takes one action: `POST /update` fast-forwards the PC's own checkout to its
own origin's `main` and restarts the tray. The caller chooses nothing. On the
server, the agent's shutdown notice (`POST /api/hosts/pc/goodbye`) takes the
same token instead of a login, and every other node route is admin-only. The
token crosses the LAN in clear text: allow the agent's port in the PC's
firewall for the server's address only, and use a long random token
([docs/NAS_MOVE.md](docs/NAS_MOVE.md) shows how to make one).

**A remote Ollama** has no login of its own: anyone who reaches its port can
run, pull or delete models. When `OLLAMA_ENDPOINT` or `OLLAMA_PC_ENDPOINT`
points at another machine, allow port 11434 there for Curatarr's address
only.

## Known dependency advisories

The pinned `chromadb` release carries four open advisories
(GHSA-f4j7-r4q5-qw2c, GHSA-36p7-vc44-83pf, GHSA-2wm9-hf6c-p5cr,
GHSA-xph7-9rjv-w5fr — pre-auth code injection and tenant-RBAC bypasses).
**All four target the ChromaDB HTTP server** (`/api/v2/...` endpoints and
its server-side authorization providers). Curatarr embeds
`chromadb.PersistentClient` in-process and never starts the Chroma
server, so that API surface does not exist in any Curatarr deployment —
the vulnerable code path is unused. No patched ChromaDB release exists
yet; the pin will move once one does. The same four IDs are excepted in
`osv-scanner.toml` so the OSV workflow no longer fails on them; the
reasons are repeated there and `tests/test_osv_config.py` keeps the two
files in step. That scan covers the pinned direct dependencies only
(`--no-resolve`): `requirements.txt` does not lock transitive packages,
and the resolver would otherwise report floor versions no pip install
produces. Transitive packages are scanned through `lock/requirements.txt`
instead: the closure of the pins as installed on the tested machine,
written by `src/deps_lock.py` and kept current by the launchers, so an
advisory such as anyio's of 2026-09-18 shows up against the version that
actually runs. The lock is hash-pinned (`uv pip compile --universal
--generate-hashes`): CI installs the app and its scanning tools with
`pip install --require-hashes`, so a package file that does not match a
sha256 written in this repository is never installed on a runner, and the
launchers use the same check when they raise a package to the lock.

`oauthlib` went to 4.0.0 on 2026-10-10 for two advisories on the OAuth
**provider** side (a timing attack on the PKCE verifier comparison, a JSONP
injection in the RevocationEndpoint). It reaches the lock only through
`kubernetes` → `requests-oauthlib`, which chromadb imports for its
distributed-segment mode alone, so it was never loaded; a fresh uv
resolution confirmed the bump (requests-oauthlib asks for oauthlib >= 3.0.0).
PyJWT went to 2.15.0 for GHSA-42vr-xj54-vc7v (a RecursionError on a deeply
nested token in the pre-verification paths); Curatarr only ever calls
`jwt.decode` with its key and HS256, so it was not reachable either.

## Reporting a vulnerability

Please use GitHub's **[private vulnerability reporting](https://github.com/Randomname653/Curatarr/security/advisories/new)**
on this repository rather than a public issue. Include reproduction
steps; you'll get a response as time permits — this is a spare-time
project.