This page covers Shrinkerr's threat model, the security measures the app takes by default, and a hardening checklist for production deployments.
What this is: a self-hosted media transcoder. It runs ffmpeg against your library, exposes a web UI over HTTP, and integrates with other services on the network (Plex, Sonarr, NZBGet, etc.). The operator is the user; there's no multi-tenant model.
What we protect against:
- Unauth network neighbours. Anyone who can reach the port shouldn't be able to read settings, queue transcodes, or change anything that can execute code (e.g. the post-conversion script).
- Session hijack / XSS. A leaked cookie or in-browser code-injection attack on the SPA shouldn't immediately hand out the raw API key or stored integration secrets.
- Malicious downloads. The NZBGet / SABnzbd post-processing hook feeds filenames from the downloader — a poisoned filename or path shouldn't allow RCE or filesystem traversal.
- Compromised integration target. A hostile Plex / Sonarr instance shouldn't be able to push Shrinkerr into making requests against cloud metadata endpoints (169.254.169.254) or localhost admin panels.
What we don't protect against:
- The user running the container has read access to their library. If
you run Shrinkerr with
--privilegedor as root, a compromised app process can reach anything the container user can reach. - Physical / filesystem access to the Shrinkerr data volume (
./data). Anyone who can readshrinkerr.dbhas every stored secret. - Full DNS rebinding against configured integration URLs — the SSRF guard validates at save time, not on every request.
- Fresh installs start with auth disabled so the user lands on the
setup wizard rather than a login screen they have no key for. A loud
[SECURITY]warning banner prints on every startup until auth is enabled, telling the operator to set a password before exposing the port. This matches the *arr ecosystem convention (Sonarr / Radarr / Prowlarr / Bazarr all ship auth-off and prompt during setup). Pre-v0.3.65 fresh installs auto-generated an api_key and enabled auth, but most users never read container logs and got stuck on the LoginScreen with no way to retrieve the key short of editing the database. - Existing installs with no auth configured see the same loud
[SECURITY]warning banner on every startup and continue to work unauthenticated — the existing deployment isn't broken mid-upgrade, but the operator is told to fix it. - Passwords are stored as bcrypt hashes (cost 12). Legacy SHA-256 hashes from older installs are transparently upgraded on the next successful login.
- Login is rate-limited to 8 attempts per minute per IP.
- API key and session comparisons are constant-time (
hmac.compare_digest). - Session signing uses a per-install secret generated on first launch; sessions fail closed if the secret is ever missing.
- Integration endpoints (
/api/webhooks/*,/api/nodes/*, backup download/restore, NZBGet/SABnzbd config + scripts) always requireX-Api-Key, regardless of whether password auth is enabled. - Remote worker nodes carry a per-node auth token on top of the
shared
X-Api-Key. The server issues a fresh token on the node's first heartbeat, the worker persists it in/app/data/worker_token(mode 0600), and every subsequent call sends it asX-Node-Token. Even if the shared API key leaks, an attacker who registers a new node can't impersonate an existing one: the server compares the supplied token with the stored value viahmac.compare_digestand returns 401 on mismatch. Admins can force a fresh handshake any time from Nodes → Settings → Rotate token; the worker drops its cached copy on the next 401 and re-bootstraps automatically.
- API key is masked (
****xxxx) in the bulk/api/settings/encodingresponse. A dedicatedGET /api/settings/api-keyreturns the raw value on demand for the UI copy button. - Integration keys and tokens (TMDB, Plex, Jellyfin, Sonarr, Radarr, SMTP password) are all masked the same way.
- Settings export strips every secret row:
api_key,session_secret,auth_password_hash, each integration key, SMTP password, and path-tokened URLs (Discord webhook, generic webhook, Telegram bot token). - TMDB API key is sent as a
params=arg rather than interpolated into URL strings, so it doesn't leak into httpx exception messages that may end up in logs. - Docker
.envfiles are excluded from the build context via.dockerignoreso the only secret baked into images is the TMDB build-arg (which is TMDB-sanctioned for distribution).
- Media directories must be absolute, existing directories. Paths
under
/etc,/root,/proc,/sys,/boot,/dev,/app/data, and the filesystem root itself are rejected. - Backup folder setting validated the same way.
- All user-supplied file paths for delete / queue / probe operations
are resolved (symlinks followed) and checked against configured media
dirs with
os.path.commonpath, not stringstartswith(which the old code used and was trivially defeatable by"/media/../etc/hostname"). - Webhook queue / scan endpoints validate every supplied path before running ffprobe/ffmpeg — stops NZBGet post-processing from being used as an ffmpeg-against-arbitrary-files primitive.
- Rename / backup write destinations refuse to overwrite symlinks.
All user-configured outbound URLs (Plex, Sonarr, Radarr, Discord webhook, generic webhook) are validated at save-time against an IP block-list covering:
- IPv4 link-local
169.254.0.0/16— includes AWS / Azure / GCP / Alibaba cloud metadata endpoints - IPv6 link-local
fe80::/10 - IPv6 site-local
fd00::/8 - IPv4-mapped IPv6 variants of the above
Loopback and RFC 1918 private ranges are allowed by default —
home setups legitimately run integrations on LAN IPs. See
backend/ssrf_guard.py if you want to enable the stricter
block_private=True mode for a cloud deployment.
/api/settings/browse (used when picking a new media directory) is
restricted: refuses to list the filesystem root, any system directory
(_DISALLOWED_MEDIA_DIR_PREFIXES), or non-directory targets.
- ffmpeg / ffprobe subprocess calls use
asyncio.create_subprocess_execwith list args — nevershell=True, no string interpolation into a single command line. Paths with spaces / apostrophes / special chars are safe as individual argv entries. - VMAF filter-complex path is sanitized (alphanumeric +
._-) before interpolation into the filter string, so filenames with apostrophes don't break the filter parser. - LIKE query user input is escaped with
ESCAPE '\\'so%and_in user-supplied search strings don't act as wildcards (stops enumeration).
Every response carries:
X-Content-Type-Options: nosniffX-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'Referrer-Policy: strict-origin-when-cross-origin
Session cookies are HttpOnly, SameSite=Lax, and Secure when the
request arrived over HTTPS (detected via scheme + X-Forwarded-Proto).
For a production / internet-exposed deployment, on top of the defaults:
- Front with HTTPS. Run Shrinkerr behind Traefik / Caddy / Nginx — see installation.md § Reverse proxy. The app doesn't terminate TLS itself.
- Set a strong password in Settings → System → Authentication (if you prefer password auth over raw API keys).
- Rotate the auto-generated API key. The one printed at first launch is cryptographically random, but if you suspect the log was copied somewhere untrusted, Settings → System → API Key → Regenerate.
- Bind the container port to
127.0.0.1unless you actually want LAN access directly. Put the reverse proxy in front. - Enable password auth (
auth_enabled=true) before configuringpost_conversion_script— the app refuses to save that setting with password auth off, but configure it in the intended order. - Keep the
./datavolume private. It's the single source of truth for every stored secret. - Don't expose port 6680 to the public internet without at least password auth + TLS in front. A shared API key over cleartext HTTP on a public IP is not sufficient.
- Update regularly. Security fixes are documented in
CHANGELOG.md and tagged with
Security:/release: …security…commit messages.
Please email issues privately to the repo owner rather than opening public GitHub issues — new-style GitHub Security Advisories also work: https://github.com/I-IAL9000/shrinkerr/security/advisories/new.
- CSRF. Session cookies use
SameSite=Lax, which stops cross-site form POSTs. Self-hosted deployments with the UI behind a reverse proxy on the same origin as nothing else are the primary target; there's no embedded OAuth flow or third-party origin to generate cross-site tokens from. We haven't added double-submit CSRF tokens because they'd add friction without meaningful gain in this threat model. Cookie hijack via XSS is a larger risk and the CSPframe-ancestors 'none'header +HttpOnlycookie are the mitigations there. - Long session lifetime (30 days). A stolen cookie is valid for 30
days;
/api/auth/logoutdrops the client copy but doesn't revoke server-side (the HMAC signature still verifies). This is standard for single-admin self-hosted apps; server-side revocation with a session table is on the roadmap but not urgent. - DNS rebinding against configured integration URLs. SSRF validation runs at save time. A motivated attacker with settings- write can race the DNS resolution. In practice they already have settings-write, which has a bigger blast radius.