diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2aaabd3..203d4ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1224,7 +1224,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a | Concern | Mechanism | |---------|-----------| | **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). | -| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). | +| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. | | **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. | | **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. | @@ -1232,7 +1232,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a | Concern | Mechanism | |---------|-----------| -| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. | +| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. | | **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. | | **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. | | **Body size** | `express.json` body limit: 64 KB. | diff --git a/CHANGELOG.md b/CHANGELOG.md index cc678c9..249cb00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.13] - 2026-05-24 + +### Changed + +- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes). +- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic. +- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`. + +### Added + +- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks. + +--- + ## [1.7.12] - 2026-05-24 ### Added diff --git a/SECURITY.md b/SECURITY.md index cc363bd..754386d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -162,12 +162,13 @@ server { ## Rate Limits -| Endpoint | Limit | -|----------|-------| -| `POST /api/auth/login` | 10 failed attempts per 15 min per IP | -| All `/api/*` routes | 300 requests per 15 min per IP | -| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) | -| `GET /api/swagger` | No rate limit (public documentation) | +| Endpoint | Limit | Details & Exemptions | +|----------|-------|----------------------| +| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. | +| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. | +| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. | +| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. | +| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. | --- diff --git a/package-lock.json b/package-lock.json index 3d16e68..8fd43ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.12", + "version": "1.7.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.12", + "version": "1.7.13", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 6ea931a..d43f4f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.12", + "version": "1.7.13", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/server/app.js b/server/app.js index a98fb7c..3cc47a2 100644 --- a/server/app.js +++ b/server/app.js @@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express'); const swaggerJsdoc = require('swagger-jsdoc'); const YAML = require('yamljs'); const path = require('path'); +const { version } = require('../package.json'); const sabnzbdRoutes = require('./routes/sabnzbd'); const sonarrRoutes = require('./routes/sonarr'); @@ -128,13 +129,17 @@ function createApp({ skipRateLimits = false } = {}) { * type: number * description: Server uptime in seconds * example: 3600.5 + * version: + * type: string + * description: sofarr version + * example: "1.7.13" * x-code-samples: * - lang: curl * label: cURL * source: curl http://localhost:3001/health */ app.get('/health', (req, res) => { - res.json({ status: 'ok', uptime: process.uptime() }); + res.json({ status: 'ok', uptime: process.uptime(), version }); }); /** diff --git a/server/index.js b/server/index.js index 4c3d84b..849379e 100644 --- a/server/index.js +++ b/server/index.js @@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads * version: * type: string * description: sofarr version - * example: "1.6.0" + * example: "1.7.13" */ app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), version }); diff --git a/server/openapi.yaml b/server/openapi.yaml index 30ef42a..47608e9 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -12,13 +12,17 @@ info: 4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE) ## Rate Limiting - - General API: 300 requests per 15 minutes per IP - - Login: 10 failed attempts per 15 minutes per IP - - Webhooks: 60 requests per minute per IP + To protect the system from resource exhaustion, rate limiters are enforced at different levels: + - **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints. + - *Exemption:* Requests starting with `/api/dashboard/cover-art` are completely exempted from this limit to avoid normal dashboard image browsing triggering blocks. + - **Login Rate Limiter**: Enforces a strict limit of **10 attempts per 15 minutes** per IP on `POST /api/auth/login`. + - *Exemption:* This limiter only tracks and counts *failed* login attempts (`skipSuccessfulRequests: true`). Successful logins do not count towards the lockout threshold. + - **Webhook Limiter**: Enforces a limit of **60 requests per minute** per IP on stateful webhook receiver endpoints (`/api/webhook/*`). + - **Health and Readiness Probes**: The public `/health` and `/ready` endpoints are mounted at the root directory level rather than under `/api/*` and are completely exempt from both rate limiting and authentication controls. ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.6.0 + version: 1.7.13 contact: name: sofarr license: diff --git a/tests/integration/swagger-coverage.test.js b/tests/integration/swagger-coverage.test.js index dafc5b9..87ebadf 100644 --- a/tests/integration/swagger-coverage.test.js +++ b/tests/integration/swagger-coverage.test.js @@ -196,6 +196,18 @@ describe('Swagger Coverage', () => { expect(paths['/api/ombi/webhook/test'].post).toBeDefined(); }); + it('should have Debug logging endpoints documented', () => { + const paths = openapiSpec.paths; + + expect(paths['/api/debug/status']).toBeDefined(); + expect(paths['/api/debug/status'].get).toBeDefined(); + expect(paths['/api/debug/server-logs']).toBeDefined(); + expect(paths['/api/debug/server-logs'].get).toBeDefined(); + expect(paths['/api/debug/client-logs']).toBeDefined(); + expect(paths['/api/debug/client-logs'].get).toBeDefined(); + expect(paths['/api/debug/client-logs'].post).toBeDefined(); + }); + it('should return 200 for Swagger UI endpoint', async () => { const response = await request(app).get('/api/swagger').redirects(1); expect(response.status).toBe(200);