From 4b766bb0e7e610c38184af87fe7c6484924af6f9 Mon Sep 17 00:00:00 2001 From: xpltd Date: Sun, 22 Mar 2026 00:42:10 -0500 Subject: [PATCH] Security hardening: API key system, container hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Key (Sonarr/Radarr style): - Admin panel → Settings: Generate / Show / Copy / Regenerate / Revoke - Persisted in SQLite via settings system - When set, POST /api/downloads requires X-API-Key header or browser origin - Browser users unaffected (X-Requested-With: XMLHttpRequest auto-sent) - No key configured = open access (backward compatible) Container hardening: - Strip SUID/SGID bits from all binaries in image - Make /app source directory read-only (only /downloads and /data writable) Download endpoint: - New _check_api_access guard on POST /api/downloads - Timing-safe key comparison via secrets.compare_digest --- .gitignore | 3 + Dockerfile | 10 +- backend/app/core/config.py | 1 + backend/app/routers/admin.py | 53 +++++++++ backend/app/routers/downloads.py | 39 ++++++ backend/app/services/settings.py | 3 + frontend/src/api/client.ts | 1 + frontend/src/components/AdminPanel.vue | 159 +++++++++++++++++++++++++ 8 files changed, 268 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fb9cd76..084c7b3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ .claude/ .bg-shell/ .planning/ +DEPLOY-TEST-PROMPT.md +PROJECT.md +Caddyfile.example .DS_Store Thumbs.db *.swp diff --git a/Dockerfile b/Dockerfile index d8eba07..82f720c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,15 @@ COPY --from=frontend-builder /build/frontend/dist ./static # Create default directories RUN mkdir -p /downloads /data && \ - chown -R mediarip:mediarip /app /downloads /data + chown -R mediarip:mediarip /downloads /data + +# Harden: strip SUID/SGID bits (unnecessary in a single-purpose container) +RUN find / -perm -4000 -exec chmod u-s {} + 2>/dev/null; \ + find / -perm -2000 -exec chmod g-s {} + 2>/dev/null; \ + true + +# Harden: make app source read-only (only /downloads and /data are writable) +RUN chmod -R a-w /app USER mediarip diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f158016..bb551aa 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -40,6 +40,7 @@ class ServerConfig(BaseModel): log_level: str = "info" db_path: str = "mediarip.db" data_dir: str = "/data" + api_key: str = "" # Managed via admin panel — not typically set via env class DownloadsConfig(BaseModel): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 07575bd..9b73f46 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -475,6 +475,59 @@ async def change_password( return {"status": "ok", "message": "Password changed successfully"} +# --------------------------------------------------------------------------- +# API key management (Sonarr/Radarr style) +# --------------------------------------------------------------------------- + + +@router.get("/api-key") +async def get_api_key( + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """Get the current API key (or null if none set).""" + config = request.app.state.config + key = config.server.api_key + return {"api_key": key if key else None} + + +@router.post("/api-key") +async def generate_api_key( + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """Generate a new API key (replaces any existing one).""" + import secrets as _secrets + + new_key = _secrets.token_hex(32) + config = request.app.state.config + config.server.api_key = new_key + + from app.services.settings import save_settings + db = request.app.state.db + await save_settings(db, {"api_key": new_key}) + + logger.info("API key generated by admin '%s'", _admin) + return {"api_key": new_key} + + +@router.delete("/api-key") +async def revoke_api_key( + request: Request, + _admin: str = Depends(require_admin), +) -> dict: + """Revoke the API key (disables API access, browser-only).""" + config = request.app.state.config + config.server.api_key = "" + + from app.services.settings import save_settings, delete_setting + db = request.app.state.db + await delete_setting(db, "api_key") + + logger.info("API key revoked by admin '%s'", _admin) + return {"status": "ok", "message": "API key revoked"} + + def _start_purge_scheduler(state, config, db) -> None: """Start the APScheduler purge job if not already running.""" try: diff --git a/backend/app/routers/downloads.py b/backend/app/routers/downloads.py index a423f1b..75cdca6 100644 --- a/backend/app/routers/downloads.py +++ b/backend/app/routers/downloads.py @@ -10,6 +10,8 @@ from __future__ import annotations import logging import os +import secrets + from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse @@ -22,6 +24,42 @@ logger = logging.getLogger("mediarip.api.downloads") router = APIRouter(tags=["downloads"]) +def _check_api_access(request: Request) -> None: + """Verify the caller is a browser user or has a valid API key. + + When no API key is configured, all requests are allowed (open access). + When an API key is set: + - Requests with a valid X-API-Key header pass. + - Requests from the web UI pass (have a Referer from the same origin + or an X-Requested-With header set by the frontend). + - All other requests are rejected with 403. + """ + config = request.app.state.config + api_key = config.server.api_key + + if not api_key: + return # No key configured — open access + + # Check API key header + provided_key = request.headers.get("x-api-key", "") + if provided_key and secrets.compare_digest(provided_key, api_key): + return + + # Check browser origin — frontend sends X-Requested-With: XMLHttpRequest + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return + + raise_api_key_required() + + +def raise_api_key_required(): + from fastapi import HTTPException + raise HTTPException( + status_code=403, + detail="API key required. Provide X-API-Key header or use the web UI.", + ) + + @router.post("/downloads", response_model=Job, status_code=201) async def create_download( job_create: JobCreate, @@ -29,6 +67,7 @@ async def create_download( session_id: str = Depends(get_session_id), ) -> Job: """Submit a URL for download.""" + _check_api_access(request) logger.debug("POST /downloads session=%s url=%s", session_id, job_create.url) download_service = request.app.state.download_service job = await download_service.enqueue(job_create, session_id) diff --git a/backend/app/services/settings.py b/backend/app/services/settings.py index 76968f5..e9735b8 100644 --- a/backend/app/services/settings.py +++ b/backend/app/services/settings.py @@ -34,6 +34,7 @@ ADMIN_WRITABLE_KEYS = { "admin_password_hash", "purge_enabled", "purge_max_age_minutes", + "api_key", } @@ -110,6 +111,8 @@ def apply_persisted_to_config(config, settings: dict) -> None: config.purge.privacy_mode = settings["privacy_mode"] if "privacy_retention_minutes" in settings: config.purge.privacy_retention_minutes = settings["privacy_retention_minutes"] + if "api_key" in settings: + config.server.api_key = settings["api_key"] logger.info("Applied %d persisted settings to config", len(settings)) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c3c6987..1d9f6d5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -24,6 +24,7 @@ async function request(url: string, options?: RequestInit): Promise { ...options, headers: { 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', ...options?.headers, }, }) diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 67cc965..3eb65f0 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -43,6 +43,11 @@ const changingPassword = ref(false) const passwordChanged = ref(false) const passwordError = ref(null) +// API key state +const apiKey = ref(null) +const showApiKey = ref(false) +const apiKeyCopied = ref(false) + const canChangePassword = computed(() => currentPassword.value.length > 0 && newPassword.value.length >= 4 && @@ -84,6 +89,7 @@ async function switchTab(tab: typeof activeTab.value) { } catch { // Keep current values } + await loadApiKey() } } @@ -160,6 +166,53 @@ async function changePassword() { } } +function authHeaders(): Record { + return { 'Authorization': 'Basic ' + btoa(store.username + ':' + store.password) } +} + +async function loadApiKey() { + try { + const res = await fetch('/api/admin/api-key', { headers: authHeaders() }) + if (res.ok) { + const data = await res.json() + apiKey.value = data.api_key + } + } catch { /* ignore */ } +} + +async function generateApiKey() { + try { + const res = await fetch('/api/admin/api-key', { method: 'POST', headers: authHeaders() }) + if (res.ok) { + const data = await res.json() + apiKey.value = data.api_key + showApiKey.value = true + } + } catch { /* ignore */ } +} + +async function regenerateApiKey() { + await generateApiKey() +} + +async function revokeApiKey() { + try { + const res = await fetch('/api/admin/api-key', { method: 'DELETE', headers: authHeaders() }) + if (res.ok) { + apiKey.value = null + showApiKey.value = false + } + } catch { /* ignore */ } +} + +function copyApiKey() { + if (apiKey.value) { + navigator.clipboard.writeText(apiKey.value) + apiKeyCopied.value = true + setTimeout(() => { apiKeyCopied.value = false }, 2000) + } +} + async function toggleSession(sessionId: string) { if (expandedSessions.value.has(sessionId)) { expandedSessions.value.delete(sessionId) @@ -558,6 +611,38 @@ function formatFilesize(bytes: number | null): string { {{ passwordError }} + +
+ + +
+ +

+ When set, external API access requires this key via X-API-Key header. + Browser users are not affected. Without a key, the download API is open to anyone who can reach the server. +

+
+
+ {{ showApiKey ? apiKey : '•'.repeat(32) }} + + +
+
+ + +
+ ✓ Copied +
+
+

No API key set — download API is open.

+ +
+
@@ -1140,4 +1225,78 @@ h3 { border-radius: var(--radius-sm); font-size: var(--font-size-sm); } + +/* API Key */ +.api-key-display { + margin-top: var(--space-sm); +} + +.api-key-value { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-bottom: var(--space-sm); +} + +.api-key-text { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-sm); + word-break: break-all; + flex: 1; + max-width: 420px; +} + +.btn-icon { + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + padding: 6px; + cursor: pointer; + display: flex; + align-items: center; +} + +.btn-icon:hover { + color: var(--color-text); + border-color: var(--color-text-muted); +} + +.api-key-actions { + display: flex; + gap: var(--space-sm); +} + +.btn-regen { + background: transparent; + color: var(--color-accent); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + padding: var(--space-xs) var(--space-sm); + cursor: pointer; + font-size: var(--font-size-sm); +} + +.btn-regen:hover { + background: var(--color-accent); + color: var(--color-bg); +} + +.btn-revoke { + background: transparent; + color: var(--color-error, #e74c3c); + border: 1px solid var(--color-error, #e74c3c); + border-radius: var(--radius-sm); + padding: var(--space-xs) var(--space-sm); + cursor: pointer; + font-size: var(--font-size-sm); +} + +.btn-revoke:hover { + background: var(--color-error, #e74c3c); + color: white; +}