# X-Ray server: security design and how to check it Written for re-checking (by you, or by another model such as Fable). Every claim below names the file that implements it, and section 5 has commands that test it on the running server. ## 1. What's exposed, and what's worth stealing | Asset | Where | If stolen | |---|---|---| | Owner's Plex token | scanner container only: `config/plex_token` (0600, from plex.tv/link) or a root-only secret file | full control of the Plex account | | Users' Plex tokens | in transit to `POST /api/v1/auth/plex`, then forgotten | their Plex account | | X-Ray sessions | Liquiflux memory / browser cookie | read timelines for 2 h | | Timelines | appdata | who's on screen when in your movies (low value) | | Unraid itself | | everything | Internet path: Cloudflare edge (TLS) -> your cloudflared container -> `xray-api` port 8090. Only `xray-api` is reachable. `xray-scanner` has no ports. ## 2. Controls **Isolation (compose.yaml, unraid/*.xml, Dockerfiles)** - Two containers sharing only folders (on separate Docker networks with Compose; with the Unraid templates, see README "Separate networks"). The internet-facing one has no Plex token, no GPU, and mounts timelines and status read-only; it can write only the `import` folder (where packs wait for the scanner). - Both: non-root (99:100), read-only root filesystem, all capabilities dropped, `no-new-privileges`, pid/memory/CPU limits, log rotation. - `xray-api` is a static Go binary on `distroless/static` (no shell, no package manager, no libc), built from the Go standard library only: there are no third-party Go modules (`api/go.mod` has no requires). `go vet` and the tests run inside the image build. - `xray-scanner`: pip is removed from the final image; no compilers; `/tmp` is a noexec tmpfs. - `xray-scanner` sets `ORT_DISABLE_TELEMETRY=1` (image and the scan process). Newer onnxruntime Linux wheels (seen in 1.31.1) carry Microsoft's 1DS telemetry client, which posts to mobile.events.data.microsoft.com and is only stopped by that variable. The pinned 1.26.0 has no such client (no OneCollector / events.data.microsoft.com strings in its libraries, checked 2026-11-01); the variable is there in case the pin ever moves. - The scanner gets its Plex token through plex.tv/link (a PIN shown in its log): it accepts the token only if that account owns the server at `PLEX_URL` (plex.tv resources, `owned`, matching `/identity`), writes it 0600 to `config/` (made 0700), and never logs it. It's never in an environment variable or the container's settings. If Plex later refuses it (401), the file is deleted and a new code is shown. The setup script's secret file, if given, wins. - The scanner only needs your Plex server and plex.tv (and api.themoviedb.org if you give it a TMDB key). If your router or Unraid firewall can do per-host rules, block the scanner container's other internet egress. **Supply chain** - Base images pinned by digest (`golang:1.27.3-trixie`, `distroless/static-debian13:nonroot`, `python:3.13-slim-trixie`). Go 1.27.3 was the newest Go release on 2026-11-01. - Python packages: `scanner/requirements.lock`, every package (transitive ones included) pinned with sha256, installed with `--require-hashes --no-deps --only-binary=:all:` (no source builds, so no setup.py runs). PyPI listed no known vulnerabilities for any of them on 2026-11-01. - Two pins are deliberately not the newest: `onnxruntime-gpu 1.26.0` (1.27+ only ship for CUDA 13, which dropped Pascal GPUs like the GTX 1070) and `nvidia-cudnn-cu12 9.10.2.21` (newer cuDNN dropped Pascal). The scanner isn't reachable from outside and only runs fixed models, so this is the accepted trade-off. On a Turing or newer GPU you can move to the CUDA 13 builds. - Face models (InsightFace buffalo_l) are downloaded at build time and refused unless their sha256s match (`scanner/fetch_models.py`). **Sign-in (api/plex.go, api/server.go)** - Plex tokens are accepted only in a JSON body (`POST /api/v1/auth/plex`) or come from plex.tv's PIN flow on the server side; never from a URL, never logged, never stored. Only a SHA-256 of a token is kept, for 10 minutes, to avoid asking plex.tv again. - A token is checked with plex.tv (`/api/v2/user` and `/api/v2/resources`): the account must list this Plex server (by `machineIdentifier`). `owned` there decides "owner". - Liquiflux stores the server token Plex gave it, which for a shared user isn't a plex.tv account token. Those are checked with your own Plex server (`/library/sections`). Because Plex may wave a whole network through without a token ("allowed without auth"), the server asks Plex with a made-up token at start, every 10 minutes, and again at every sign-in that Plex accepted, before trusting it; if Plex says yes, this check is switched off (logged) and only plex.tv sign-ins work. It fails closed: until Plex has answered (say Plex was still starting after a reboot), no server token is trusted and the app is told to retry. - Set `XRAY_TRUSTED_PROXIES` to your cloudflared container's address. The default (every private address) lets anyone on your LAN who connects to 8090 directly claim any visitor address, which only gets around the per-address rate limits. - Liquiflux sends its Plex token to the X-Ray address set in its Settings. Only point it at a server whose operator you trust with that token (your own, or your Plex server owner's). - Calls to plex.tv: 10 s timeout, no redirects, responses capped at 1 MiB, token in a header. All outgoing Plex calls share one rate limit (5/s) so the server can't be used to flood plex.tv. - Sessions: `xr1..`, key made fresh at every start and never written down (a restart signs everyone out; Liquiflux signs in again by itself). 2 h by default, so un-sharing someone on Plex takes effect within 2 h. Sign-out revokes by session id. - Web page: the PIN id lives in a signed, HttpOnly, SameSite=Strict cookie, so another browser can't poll someone else's PIN. Session cookie: HttpOnly, SameSite=Strict, Secure when the request came over https. - Cookie-authenticated POSTs must be same-origin (`Sec-Fetch-Site` or `Origin`). Bearer tokens aren't sent by browsers on their own, so they need no CSRF check. **Abuse limits (api/ratelimit.go, api/server.go)** - Per visitor: 20 requests/s (burst 40) on the public port, 50/s on the LAN port; sign-in attempts and new PINs 10 per minute; PIN polls 1/s. - 10 failed sign-ins in 15 minutes locks that address out for 15 minutes. - At most 128 requests in flight per port; memory for per-visitor state is capped. - Visitor address: `Cf-Connecting-Ip` only when the connection comes from `XRAY_TRUSTED_PROXIES` (private ranges by default, i.e. cloudflared); otherwise the TCP peer. - Server timeouts: headers 5 s, body 30 s, response 60 s, idle 90 s, headers at most 16 KiB. JSON bodies at most 4 KiB, unknown fields refused. **Scan settings (api/settings.go, scanner/daemon.py)** - Owner-only, LAN-port-only `POST /api/v1/admin/settings`, strict JSON (known fields, ranges checked). The API writes it as `import/settings.request.json`; the scanner reads it (max 4 KiB, no symlinks), checks every value against the same limits again, and deletes it. The worst a compromised API could do through it is pause scanning or change pacing within those limits. **Two listeners** - Public port 8090 has no admin routes at all (they don't exist on that mux, so they 404). - LAN port 8089 refuses any request carrying Cloudflare headers (421) and any peer outside `XRAY_LAN_CIDRS` (403), so a tunnel mistakenly pointed at it doesn't expose admin. **What leaves the server (api/store.go)** - Timeline files are parsed and re-encoded with only what Liquiflux uses (people, spans, chapters' times and confidence, quality). Title, Plex guid, probe numbers and scan stats are dropped, so a ratingKey reveals the cast at most. - ratingKey must be 1-12 digits; files are opened through `os.Root`, so neither `..` nor a symlink can leave the timelines folder; a file whose `ratingKey` doesn't match its name is not served. - Headers on everything: CSP `default-src 'none'` (the page allows only its own script/style), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: no-referrer`, COOP/CORP same-origin, `Cache-Control: no-store` (timelines: private, 5 minutes), HSTS over https. No CORS headers, so other sites' scripts can't read anything. **Packs (scanner/importer.py, api/store.go)** - Uploads: owner only, LAN port only, `application/zip` with a ZIP signature, at most 1 GiB, at most 3 waiting; written with a random name via `os.Root` and renamed when complete. - The scanner imports: member names must match strict patterns (no paths, no `..`, no symlinks, no encryption), total unpacked at most 4 GiB, per-member ratio at most 100:1, every file's sha256 and size must match the manifest and nothing unlisted may be present, the pack must name this Plex server, JSON shapes checked, `.npy` loaded with `allow_pickle=False` and checked for dtype/shape/finite values. All checked into a staging folder before anything is moved into place. **Liquiflux side (packages/console_core/lib/src/xray_server.dart)** - Sends the Plex token only to `/api/v1/auth/plex`, only over https or to an address on the local network (never plain http across the internet), never in a URL. - After a 401/403/429 it waits 30 minutes before trying to sign in again, so a refused user can't trip the lockout by playing films. ## 3. Accepted risks - Anyone your Plex server is shared with can read any title's timeline by ratingKey, including titles in libraries not shared with them (cast and times only). Use `XRAY_ALLOWED_USERS` / `XRAY_OWNER_ONLY` if that matters. The allow list matches the account id or username, which Plex fixes; the free-text display name only counts for Plex Home members, who have no username and whose names the owner sets. - Removing someone's Plex access takes up to `XRAY_SESSION_HOURS` to lock them out of X-Ray (restart `xray-api` to do it immediately). - Cloudflare terminates TLS, so Plex tokens of people signing in through the tunnel pass through Cloudflare (as for every service behind a tunnel, Seerr included). - Packs are parsed in the scanner container, which holds the Plex token. Uploads are owner + LAN only, and the folder drop needs write access to appdata. - The scanner runs its own Python code with fixed ONNX models; onnxruntime 1.26 / cuDNN 9.10 are pinned below the newest for Pascal support (see above). - On the LAN, anyone can send a made-up `Cf-Connecting-Ip` to the public port (default trust is all private ranges); that only changes which bucket their rate limit lands in. Set `XRAY_TRUSTED_PROXIES` to cloudflared's address to stop it. ## 4. Keeping it current ```bash # Newest digests for the pinned base images (compare with the Dockerfiles): docker buildx imagetools inspect golang:1.27-trixie | head -3 docker buildx imagetools inspect gcr.io/distroless/static-debian13:nonroot | head -3 docker buildx imagetools inspect python:3.13-slim-trixie | head -3 ``` Then change the `FROM` lines (version and digest) and push: the `images` workflow rebuilds and publishes both images (or build locally with `docker compose up -d --build`). Python: change versions in `scanner/requirements.lock` (sha256s from each package's PyPI "Download files" page) and keep onnxruntime-gpu <= 1.26 / cuDNN <= 9.10.2 on a GTX 10-series. ## 5. Checks you can run on Unraid ```bash cd /path/to/liquiflux-xray-server # this repository IP=127.0.0.1 # or the Unraid IP from another machine # Container hardening as Docker sees it for c in liquiflux-xray-api liquiflux-xray-scanner; do docker inspect -f '{{.Name}} user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} caps={{.HostConfig.CapDrop}} sec={{.HostConfig.SecurityOpt}} priv={{.HostConfig.Privileged}} ports={{.HostConfig.PortBindings}}' $c done docker exec liquiflux-xray-api sh -c id # fails: there is no shell docker inspect -f '{{.Config.Env}}' liquiflux-xray-scanner | grep -ci token # 0: no token in env # Vulnerability scans of both images (Trivy, by Aqua Security) docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --scanners vuln,secret ghcr.io/foehammer1987/liquiflux-xray-api:latest docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --scanners vuln,secret ghcr.io/foehammer1987/liquiflux-xray-scanner:latest # Go: known vulnerabilities in the code paths actually used docker run --rm -v "$PWD/api":/src -w /src golang:1.27-trixie go run golang.org/x/vuln/cmd/govulncheck@latest ./... # Python packages against the PyPA advisory database docker run --rm -v "$PWD/scanner":/s python:3.13-slim-trixie sh -c 'pip install -q pip-audit && pip-audit --disable-pip -r /s/requirements.lock' # Behaviour curl -s http://$IP:8090/api/v1/timeline/1 ; echo # 401 not signed in curl -s http://$IP:8090/api/v1/status ; echo # 404: no admin on the public port curl -s -H 'Cf-Ray: x' http://$IP:8089/api/v1/me ; echo # 421: LAN port refuses tunnel traffic curl -s -X POST -H 'Content-Type: application/json' -d '{"plexToken":"nope-nope-nope"}' http://$IP:8090/api/v1/auth/plex ; echo # 401 curl -s "http://$IP:8090/api/v1/timeline/..%2f..%2fetc%2fpasswd" ; echo # 401/404, never a file curl -sI http://$IP:8090/ | grep -iE 'content-security|x-frame|nosniff|referrer' # Through the tunnel (from outside): the same, plus curl -sI https://xray.yourdomain/ | grep -i strict-transport curl -s https://xray.yourdomain/api/v1/status ; echo # 404 ``` ## 6. Asking another model to review it Give it this repository (and, if you have it, Liquiflux's `packages/console_core/lib/src/xray_server.dart`), and ask: "This is an internet-facing service (through Cloudflare Tunnel) that authenticates with Plex tokens. Review api/*.go for auth bypass, session forgery, token leakage (logs, URLs, errors), SSRF, path traversal, request smuggling or DoS, CSRF on the web page, and the trust in Cf-Connecting-Ip; review scanner/importer.py for zip and numpy parsing attacks; review compose.yaml, unraid/*.xml and both Dockerfiles for container escape or privilege issues; and check SECURITY.md's claims against the code."