diff --git a/Dockerfile b/Dockerfile index 0311a55..63f3dff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ LABEL custom.hardware.requirement="None - runs on any Docker-supported platform # The /app directory is owned by root; data directory is owned by node WORKDIR /app -# Copy production deps from deps stage (includes pre-built better-sqlite3) +# Copy production deps from deps stage COPY --from=deps /app/node_modules ./node_modules # Copy application source owned by root (read-only at runtime) @@ -35,7 +35,7 @@ COPY --chown=root:root server/ ./server/ COPY --chown=root:root public/ ./public/ COPY --chown=root:root package.json ./ -# Persistent data directory owned by node user (SQLite token store, logs) +# Persistent data directory owned by node user (token store, logs) RUN mkdir -p /app/data && chown node:node /app/data ENV NODE_ENV=production diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8aca5c6..0101a12 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,18 +80,42 @@ Admin users can view all users' downloads, see server status, cache statistics, ## 2. Technology Stack +### Runtime & Framework + | Layer | Technology | Purpose | -|-------|-----------|---------| -| **Runtime** | Node.js 18+ | Server runtime | +|-------|-----------|------| +| **Runtime** | Node.js 22 (Alpine) | Server runtime | | **Framework** | Express 4.x | HTTP server, routing, middleware | | **HTTP Client** | axios 1.x | External API communication | | **Frontend** | Vanilla JS + CSS | Single-page app, no build step | -| **Auth** | Emby API + httpOnly cookies | Session management | -| **Caching** | In-memory Map with TTL | Reduce external API load | -| **Scheduling** | `setInterval` | Background polling | -| **Containerisation** | Docker (Alpine) | Production deployment | +| **Containerisation** | Docker multi-stage (Alpine) | Production deployment | | **Logging** | Custom logger + `console.*` | File + stdout logging with levels | +### Security Middleware + +| Package | Purpose | +|---------|--------| +| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) | +| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter | +| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) | + +### Auth & Session + +| Component | Technology | Details | +|-----------|-----------|--------| +| **Identity** | Emby API | `POST /Users/authenticatebyname` | +| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set | +| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header | +| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning | + +### Testing + +| Tool | Purpose | +|------|---------| +| `vitest` 4.x | Test runner (V8 coverage built-in) | +| `supertest` 7.x | HTTP integration testing | +| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) | + --- ## 3. Directory Structure @@ -99,22 +123,26 @@ Admin users can view all users' downloads, see server status, cache statistics, ``` sofarr/ ├── server/ # Backend application -│ ├── index.js # Entry point: Express setup, middleware, startup +│ ├── index.js # Entry point: logging setup, server listen, poller start +│ ├── app.js # Express app factory (imported by index.js and tests) │ ├── routes/ -│ │ ├── auth.js # POST /login, GET /me, POST /logout -│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status +│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout +│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art │ │ ├── emby.js # Proxy routes to Emby API │ │ ├── sabnzbd.js # Proxy routes to SABnzbd API │ │ ├── sonarr.js # Proxy routes to Sonarr API │ │ └── radarr.js # Proxy routes to Radarr API │ ├── middleware/ -│ │ └── requireAuth.js # httpOnly cookie auth middleware +│ │ ├── requireAuth.js # httpOnly cookie auth enforcement +│ │ └── verifyCsrf.js # CSRF double-submit cookie validation │ └── utils/ │ ├── cache.js # MemoryCache class (Map + TTL + stats) │ ├── config.js # Multi-instance service configuration parser -│ ├── logger.js # File logger (server.log) +│ ├── logger.js # File logger (DATA_DIR/server.log) │ ├── poller.js # Background polling engine + timing -│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping +│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping +│ ├── sanitizeError.js # Redacts secrets from error messages before logging +│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL) ├── public/ # Static frontend (served by Express) │ ├── index.html # HTML shell: splash, login, dashboard │ ├── app.js # All frontend logic (auth, rendering, status) @@ -123,8 +151,21 @@ sofarr/ │ ├── favicon-32.png # 32px PNG favicon │ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA) │ └── images/ # Logo / splash screen assets -├── Dockerfile # Production container image +├── tests/ +│ ├── README.md # Testing approach, design decisions, coverage targets +│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass +│ ├── unit/ # Pure unit tests (no HTTP) +│ └── integration/ # Supertest integration tests (nock for external HTTP) +├── docs/ +│ ├── ARCHITECTURE.md # This document +│ └── diagrams/ # PlantUML source files +├── .gitea/workflows/ +│ ├── ci.yml # Security audit + test/coverage CI jobs +│ ├── build-image.yml # Docker image build and push +│ └── create-release.yml # Release tagging workflow +├── Dockerfile # Multi-stage production container image (node:22-alpine) ├── docker-compose.yaml # Example compose deployment +├── vitest.config.js # Test runner configuration with per-file coverage thresholds ├── package.json # Dependencies and scripts ├── .env.sample # Annotated environment variable template └── README.md # User-facing documentation @@ -134,30 +175,45 @@ sofarr/ ## 4. Component Architecture -### 4.1 Server Entry Point (`server/index.js`) +### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`) -Responsibilities: -- Load environment variables via `dotenv` -- Configure structured logging with level filtering (`LOG_LEVEL`) -- Redirect `console.*` to both stdout and `server.log` -- Mount Express middleware (cookie-parser, JSON, static files) -- Mount route modules under `/api/*` +**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller. + +`createApp` responsibilities: +- Configure `trust proxy` from `TRUST_PROXY` env var +- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts +- Add `Permissions-Policy` header +- Apply the general API rate limiter (300 req / 15 min per IP) +- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set) +- Mount `express.json` (64 KB body limit) +- Expose `/health` and `/ready` endpoints (no auth, no rate limit) +- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt) +- Mount `verifyCsrf` for all subsequent `/api` routes +- Mount remaining route modules under `/api/*` +- Register global error handler (500 with sanitized message) + +**`server/index.js`** entry point responsibilities: +- Load `.env` via `dotenv` +- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log` +- Call `createApp()`, serve `public/` as static files, start `app.listen()` - Start the background poller ### 4.2 Route Modules -| Module | Mount Point | Auth Required | Purpose | -|--------|------------|---------------|---------| -| `auth.js` | `/api/auth` | No (public) | Login, session check, logout | -| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status | -| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API | -| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API | -| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API | -| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API | +| Module | Mount Point | Auth Required | CSRF Required | Purpose | +|--------|------------|:-------------:|:-------------:|--------| +| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout | +| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy | +| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API | +| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API | +| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API | +| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API | -`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed. +**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid. -> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache. +**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection). + +> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes. ### 4.3 Utility Modules @@ -167,9 +223,13 @@ Responsibilities: **`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request. -**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities. +**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`). -**`logger.js`** — Simple file appender writing timestamped messages to `server.log`. +**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly. + +**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs. + +**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`. --- @@ -212,12 +272,19 @@ When a user requests `/api/dashboard/user-downloads`: ### Flow -1. User submits credentials via the login form -2. Backend calls Emby `POST /Users/authenticatebyname` -3. On success, fetches full user profile to determine admin status -4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie -5. Cookie expires after 24 hours -6. All subsequent dashboard requests read this cookie for identity +1. User submits credentials (+ optional `rememberMe`) via the login form +2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count) +3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login +4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status +5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client) +6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`: + - **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days + - **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes) + - `secure` flag enabled when `NODE_ENV=production` + - Signed with HMAC when `COOKIE_SECRET` is set +7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token +8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests +9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth` ### Authorisation Matrix @@ -336,47 +403,76 @@ Each matched download produces an object with: ### `POST /api/auth/login` -Authenticate a user via Emby. +Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**. **Request Body:** ```json -{ "username": "string", "password": "string" } +{ "username": "string", "password": "string", "rememberMe": false } ``` +| Field | Required | Description | +|-------|:--------:|-----------| +| `username` | Yes | Max 128 chars, must be a non-empty string | +| `password` | Yes | Max 256 chars | +| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie | + **Response (200):** ```json { "success": true, - "user": { "id": "string", "name": "string", "isAdmin": true } + "user": { "id": "string", "name": "string", "isAdmin": false }, + "csrfToken": "64-char hex string" } ``` +**Response (400):** Invalid input (empty/overlong username or password). + **Response (401):** ```json { "success": false, "error": "Invalid username or password" } ``` -**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored. +**Response (429):** Too many failed attempts from this IP. + +**Side Effects:** +- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included. +- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex. +- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout). --- ### `GET /api/auth/me` -Check current session. +Check current session (no auth required — returns unauthenticated state rather than 401). -**Response:** +**Response (authenticated):** ```json -{ - "authenticated": true, - "user": { "id": "string", "name": "string", "isAdmin": false } -} +{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } } ``` +**Response (not authenticated):** +```json +{ "authenticated": false } +``` + +--- + +### `GET /api/auth/csrf` + +Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost. + +**Response (200):** +```json +{ "csrfToken": "64-char hex string" } +``` + +**Side Effect:** Sets a new `csrf_token` cookie. + --- ### `POST /api/auth/logout` -Clear session cookie. +Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection). --- @@ -518,26 +614,47 @@ The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Of ### Environment Variables +#### Core + | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `PORT` | No | `3001` | Server listen port | -| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL | -| `EMBY_API_KEY` | Yes | — | Emby API key | +| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades | +| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). | +| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. | +| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. | + +#### Emby + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) | +| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) | + +#### Service Instances + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| | `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances | -| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL | -| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key | +| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL | +| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key | | `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances | -| `RADARR_URL` | Yes* | — | Legacy single Radarr URL | -| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key | +| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL | +| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key | | `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances | -| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL | -| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key | +| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL | +| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key | | `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances | -| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable | -| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` | \* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required. +#### Tuning + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). | +| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` | + ### Instance JSON Format ```json @@ -561,24 +678,20 @@ qBittorrent instances use `username` and `password` instead of `apiKey`. ## 12. Deployment -### Docker +### Docker image -```dockerfile -FROM node:18-alpine -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci --omit=dev -COPY server/ ./server/ -COPY public/ ./public/ -EXPOSE 3001 -ENV NODE_ENV=production -CMD ["node", "server/index.js"] -``` +The production image uses a two-stage build on `node:22-alpine`: + +1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies. +2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs. + +Key environment variables set in the image: +- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive +- `DATA_DIR=/app/data` — token store and log file location ### Docker Compose ```yaml -version: "3" services: sofarr: image: docker.i3omb.com/sofarr:latest @@ -587,6 +700,10 @@ services: ports: - "3001:3001" environment: + - NODE_ENV=production + - DATA_DIR=/app/data + - COOKIE_SECRET=change-me-to-a-long-random-string + - TRUST_PROXY=1 # set if behind nginx/Traefik - EMBY_URL=https://emby.example.com - EMBY_API_KEY=your-emby-api-key - SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] @@ -595,8 +712,31 @@ services: - QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}] - POLL_INTERVAL=5000 - LOG_LEVEL=info + volumes: + - sofarr-data:/app/data # persists tokens.json and server.log + +volumes: + sofarr-data: ``` +### Security hardening checklist + +- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery. +- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires. +- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates. +- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`. +- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP. + +### CI / CD + +The `.gitea/workflows/` directory contains three pipeline definitions: + +| File | Trigger | Purpose | +|------|---------|--------| +| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage | +| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` | +| `create-release.yml` | Tag push (`v*`) | Create a Gitea release | + --- ## 13. UML Diagrams (PlantUML) diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml index fb9719a..fce162f 100644 --- a/docs/diagrams/class-server.puml +++ b/docs/diagrams/class-server.puml @@ -9,19 +9,33 @@ package "server/index.js" as entry { - logFile : WriteStream + shouldLog(level) : boolean -- - Configures Express app, - mounts routes, starts poller + Logging setup, app.listen(), + static files, startPoller() + } +} + +package "server/app.js" as appfactory { + class "createApp(options?)" as appfn <> { + + createApp(skipRateLimits?) : Express + -- + Mounts helmet (CSP nonce), + rate limiters, cookie-parser, + auth routes (pre-CSRF), + verifyCsrf, all other routes, + /health, /ready, error handler } } package "server/routes" { class "auth.js" as auth <> { - + POST /login + + POST /login (rate-limited) + GET /me + + GET /csrf + POST /logout -- Authenticates via Emby API - Sets/reads httpOnly cookie + Issues emby_user + csrf_token cookies + Stores/revokes Emby tokens server-side } class "dashboard.js" as dashboard <> { @@ -76,9 +90,20 @@ package "server/middleware" { class "requireAuth.js" as requireauth <> { + requireAuth(req, res, next) : void -- - Reads emby_user cookie - Attaches parsed user to req.user - Returns 401 if absent/invalid + Reads emby_user cookie (signed if COOKIE_SECRET) + Validates schema: id, name, isAdmin + Attaches user to req.user + Returns 401 if absent/tampered/invalid + } + + class "verifyCsrf.js" as verifycsrf <> { + + verifyCsrf(req, res, next) : void + -- + Exempt: GET, HEAD, OPTIONS + Compares csrf_token cookie + vs X-CSRF-Token header + using crypto.timingSafeEqual + Returns 403 on mismatch/missing } } @@ -171,6 +196,27 @@ package "server/utils" { + logToFile(message) : void } + class "TokenStore" as tokenstore <> { + - store : Object (in-memory) + - STORE_PATH : string (DATA_DIR/tokens.json) + - TOKEN_TTL_MS : 31 days + -- + + storeToken(userId, accessToken) : void + + getToken(userId) : {accessToken}|null + + clearToken(userId) : void + -- + Atomic write (.tmp → rename) + Pruned on startup + hourly + } + + class "SanitizeError" as sanitize <> { + + sanitizeError(err) : string + -- + Redacts: query-param secrets, + auth headers, bearer tokens, + basic-auth URLs + } + class "TagBadge" as tb <> { + label : string + matchedUser : string | null @@ -184,19 +230,24 @@ package "server/utils" { } ' Relationships -ep --> auth -ep --> dashboard -ep --> emby_r -ep --> sab_r -ep --> sonarr_r -ep --> radarr_r +ep --> appfn : createApp() +ep --> poller : startPoller() + +appfn --> auth : /api/auth (pre-CSRF) +appfn --> verifycsrf : /api (all routes below) +appfn --> dashboard +appfn --> emby_r +appfn --> sab_r +appfn --> sonarr_r +appfn --> radarr_r dashboard --> requireauth : uses emby_r --> requireauth : uses sab_r --> requireauth : uses sonarr_r --> requireauth : uses radarr_r --> requireauth : uses -ep --> poller : startPoller() + +auth --> tokenstore : storeToken / getToken / clearToken dashboard --> cache : read/write dashboard --> poller : pollAllServices() @@ -218,4 +269,7 @@ dashboard *-- ci : stores in activeClients config ..> inst : returns +auth ..> sanitize : sanitizeError on catch +dashboard ..> sanitize : sanitizeError on catch + @enduml diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml index 7567ad4..59eead1 100644 --- a/docs/diagrams/component.puml +++ b/docs/diagrams/component.puml @@ -15,15 +15,21 @@ package "Browser" as browser { package "Express Server" as server { + [index.js\nEntry Point] as entry + [app.js\ncreatApp() factory] as appfactory + package "Middleware" { - [cookie-parser] as cp - [express.json] as ej + [helmet\n(CSP nonce, HSTS)] as hm + [express-rate-limit\n(API + login)] as rl + [cookie-parser\n(signed cookies)] as cp + [express.json\n(64kb limit)] as ej [express.static] as es [requireAuth.js] as requireauth + [verifyCsrf.js\n(double-submit)] as verifycsrf } package "Routes" as routes { - [auth.js\n/api/auth] as auth + [auth.js\n/api/auth\n(pre-CSRF)] as auth [dashboard.js\n/api/dashboard] as dashboard [emby.js\n/api/emby] as emby_route [sabnzbd.js\n/api/sabnzbd] as sab_route @@ -36,27 +42,34 @@ package "Express Server" as server { [cache.js\nMemoryCache] as cache [config.js] as config [qbittorrent.js\nQBittorrentClient] as qbt + [tokenStore.js\n(tokens.json)] as tokenstore + [sanitizeError.js] as sanitize [logger.js] as logger } - [index.js\nEntry Point] as entry + entry --> appfactory : createApp() + entry --> es : serve public/ + entry --> poller : startPoller() - entry --> cp - entry --> ej - entry --> es - entry --> auth - entry --> dashboard - entry --> emby_route - entry --> sab_route - entry --> sonarr_route - entry --> radarr_route + appfactory --> hm + appfactory --> rl + appfactory --> cp + appfactory --> ej + appfactory --> auth : mount before verifyCsrf + appfactory --> verifycsrf : applied to all /api below + appfactory --> dashboard + appfactory --> emby_route + appfactory --> sab_route + appfactory --> sonarr_route + appfactory --> radarr_route emby_route --> requireauth sab_route --> requireauth sonarr_route --> requireauth radarr_route --> requireauth dashboard --> requireauth - entry --> poller : startPoller() + + auth --> tokenstore : storeToken / getToken / clearToken dashboard --> cache : read poll:* keys dashboard --> poller : pollAllServices()\n(on-demand mode) @@ -70,6 +83,9 @@ package "Express Server" as server { qbt --> config : getQbittorrentInstances() qbt --> logger + + auth ..> sanitize + dashboard ..> sanitize } cloud "External Services" as external { diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml index e559c95..8282fe3 100644 --- a/docs/diagrams/seq-auth.puml +++ b/docs/diagrams/seq-auth.puml @@ -5,6 +5,7 @@ title sofarr — Authentication Sequence actor User as user participant "Browser\n(app.js)" as browser participant "Express\n/api/auth" as auth +participant "TokenStore\n(tokens.json)" as tokens participant "Emby\nServer" as emby == Page Load == @@ -12,14 +13,19 @@ user -> browser : Navigate to sofarr activate browser browser -> auth : GET /api/auth/me activate auth -auth -> auth : Read emby_user cookie +auth -> auth : Read emby_user cookie\n(signed if COOKIE_SECRET set) alt Cookie exists and valid auth --> browser : { authenticated: true, user: { name, isAdmin } } + browser -> auth : GET /api/auth/csrf + activate auth + auth -> auth : Generate 32-byte hex csrfToken + auth --> browser : { csrfToken } + Set csrf_token cookie + deactivate auth + browser -> browser : store csrfToken in memory browser -> browser : showDashboard() - browser -> browser : fetchUserDownloads(true) browser -> browser : startAutoRefresh() browser -> browser : dismissSplash() -else No cookie +else No cookie / tampered auth --> browser : { authenticated: false } browser -> browser : dismissSplash() browser -> browser : showLogin() @@ -27,38 +33,69 @@ end deactivate auth == Login == -user -> browser : Enter username + password -browser -> auth : POST /api/auth/login\n{ username, password } +user -> browser : Enter username + password\n(+ optional rememberMe checkbox) +browser -> auth : POST /api/auth/login\n{ username, password, rememberMe } activate auth -auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw } +note right of auth + Rate limiter: max 10 failed + attempts per IP / 15 min + (successful requests excluded) +end note +auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16] activate emby alt Valid credentials - emby --> auth : { User: { Id, ... }, AccessToken } - auth -> emby : GET /Users/{userId} + emby --> auth : { User: { Id }, AccessToken } + auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken emby --> auth : { Name, Policy: { IsAdministrator } } deactivate emby - auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored - auth --> browser : { success: true, user: { name, isAdmin } } + auth -> tokens : storeToken(userId, AccessToken) + note right of tokens + Stored server-side only. + Never sent to the client. + 31-day TTL, atomic JSON write. + end note + auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie + auth -> auth : Generate csrfToken\n(32-byte random hex) + auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod) + auth --> browser : { success: true, user, csrfToken } + browser -> browser : store csrfToken in memory browser -> browser : fadeOutLogin() - browser -> browser : showSplash() browser -> browser : showDashboard() - browser -> browser : fetchUserDownloads(true) browser -> browser : startAutoRefresh() browser -> browser : dismissSplash() else Invalid credentials emby --> auth : 401 Error deactivate emby - auth --> browser : { success: false, error: "Invalid..." } + auth --> browser : { success: false, error: "Invalid username or password" } browser -> browser : showLoginError() end deactivate auth +== CSRF Token Refresh (after page reload) == +note over browser : csrfToken lost from memory\non hard page reload +browser -> auth : GET /api/auth/csrf +activate auth +auth -> auth : Generate new csrfToken +auth --> browser : { csrfToken } + new csrf_token cookie +deactivate auth +browser -> browser : store new csrfToken in memory + == Logout == user -> browser : Click Logout browser -> browser : stopAutoRefresh() -browser -> auth : POST /api/auth/logout +browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects) activate auth -auth -> auth : Clear emby_user cookie +auth -> auth : Parse emby_user cookie → user +auth -> tokens : getToken(user.id) +activate tokens +tokens --> auth : { accessToken } +deactivate tokens +auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken +activate emby +emby --> auth : 204 / error (ignored) +deactivate emby +auth -> tokens : clearToken(user.id) +auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token) auth --> browser : { success: true } deactivate auth browser -> browser : showLogin() diff --git a/server/routes/auth.js b/server/routes/auth.js index e87b46f..de37fc1 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -4,7 +4,7 @@ const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); const router = express.Router(); -// Persistent SQLite-backed token store — survives restarts +// Persistent JSON file-backed token store — survives restarts const { storeToken, getToken, clearToken } = require('../utils/tokenStore'); // Read EMBY_URL at request time (not module load time) so the value