Skip to content

feat: security hardening for production-readiness - #64

Open
jesp1999 wants to merge 15 commits into
johnpc:mainfrom
jesp1999:feat/hardening
Open

jesp1999 wants to merge 15 commits into
johnpc:mainfrom
jesp1999:feat/hardening

Conversation

@jesp1999

@jesp1999 jesp1999 commented Oct 16, 2026 •

Copy link
Copy Markdown

Depends on ◇ feat: optional server mode that keeps the admin API key off the browser. Based on that branch.

Summary

Server mode keeps the admin key out of the browser, but the backend still took it from an environment variable and the Apache image ran with its defaults. This reads the key from a file and locks down the web server for public hosting. It also updates dependencies with known vulnerabilities and fixes two unreadable-text problems on the device stats page found while testing.

Changes

  • Key from a file. The backend reads the key from the file named by JELLYFIN_API_KEY_FILE, so it no longer appears in the container environment or in docker inspect.
  • Web server. Module whitelist; only GET and HEAD are allowed (Require method, since LimitExcept inside an outer Require all granted does not deny); FallbackResource /index.html is disabled under /assets, so a missing asset returns 404; dotfiles and .map files are denied; security headers including a CSP. The CSP keeps style-src 'unsafe-inline' because stitches and radix inject inline styles.
  • Read-only root filesystem. env.js, csp.conf and proxy.conf are written at start-up into a generated directory (a tmpfs in Docker). The Apache PidFile is set to that directory too, since it is not covered by DefaultRuntimeDir.
  • Dependencies. Updated packages with known vulnerabilities.
  • Device stats page. The subtitle was --gray-11 on a violet background; it is now white. The pie chart legend sat outside the SVG and was clipped, and its text and the chart titles had no fill. The legend now sits below each pie and the text is white.

Testing

  • Built and run as two images behind a reverse proxy on a Docker host: both healthy
  • /api/health returns ok; /, /movies and /env.js return 200; CSP header present
  • The key is not in the backend environment or in env.js
  • POST /api/playback-report without a token returns 401; POST / returns 403; a missing asset returns 404; /.env returns 403
  • Non-admin sign-in against Jellyfin 12.1.0 loads stats
  • Device stats page viewed in a browser: subtitle, chart titles and legend readable
  • A pre-12 Jellyfin

…tion header

Jellyfin 12 removed the legacy X-Emby-Token / api_key authentication, so the
plugin request returned 401 and every page failed to load. Send the
Authorization: MediaBrowser header (also accepted by older servers) alongside the
legacy header.
Without JELLYFIN_API_KEY the frontend sends the signed-in user's own
Jellyfin token to a small backend (server/). The backend verifies the
token with Jellyfin, copies only that user's PlaybackActivity rows into
an in-memory SQLite database (sql.js) and runs the page's unchanged SQL
against it, read-only, with a time limit and a row cap.

With JELLYFIN_API_KEY set, behavior is unchanged.
- per-user request rate limit and per-user concurrent query cap
- failed sign-in limit per client address (verified users unaffected)
- trusted-proxy aware client address (TRUST_PROXY_HOPS)
- cap on users loading data from Jellyfin at once
- result size cap in bytes, and a SQLite heap limit so a query that builds
  huge strings fails instead of exhausting the process
- request timeouts, JSON event log (never logs tokens or SQL), stricter
  response headers
- server/README.md: design, security model, every setting, proxy setup
- docker-entrypoint.sh writes the /api proxy config at start (validated URL)
- Apache: no version banner, TRACE off, basic security headers
- CI job for the server (tests and image build)
- root npm scripts server:install, server:test, server:dev
- hardened example compose for server mode
ProxyTimeout is not allowed inside <Location>, so httpd failed to start in server mode.
The plugin sends no column names when a query returns no rows, so the
per-user database was created with an empty column list and every request
for a user without history failed. Fall back to the table layout.
The app sends a burst of queries when a page loads. Anything beyond the
per-user running limit was refused with a 429 and the client retries only
once, so some pages could end up without data. Run up to
MAX_INFLIGHT_PER_USER at once, hold up to MAX_QUEUED_PER_USER more, and
refuse only past that.
Files copied from public/ keep the mode they had in the checkout. Built
with a restrictive umask, vite.svg ended up unreadable and returned 403.
npm audit fix, no version range changes. Clears axios, follow-redirects,
form-data, lodash and react-router advisories in the production tree.
JELLYFIN_API_KEY_FILE names a file holding the key. The key then stays out
of the process environment, docker inspect output and crash reports.
Setting both variables is an error.
- Load only the Apache modules the site uses; refuse everything but GET and HEAD
  on the static site, and GET and POST on /api/
- Content-Security-Policy limited to this site and the Jellyfin server, no inline
  scripts (the app settings are served as /env.js instead of injected into the page)
- Cross-origin isolation and permissions headers; dotfiles and source maps denied
- Request size, header and read-time limits; fewer worker threads
- Hashed assets cached for good, index.html always revalidated; a missing asset is a
  real 404 instead of index.html
- Runtime files go to one directory, so the rest of the image can be read-only
- The entrypoint validates every value it writes and warns when the admin key
  would be sent to browsers
PidFile has its own default under logs/, which DefaultRuntimeDir does not
change, so Apache could not start with a read-only filesystem.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant