Security hardening: API key system, container hardening

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
This commit is contained in:
xpltd 2026-03-22 00:42:10 -05:00
parent 82f78e567b
commit 4b766bb0e7
8 changed files with 268 additions and 1 deletions

3
.gitignore vendored
View file

@ -4,6 +4,9 @@
.claude/ .claude/
.bg-shell/ .bg-shell/
.planning/ .planning/
DEPLOY-TEST-PROMPT.md
PROJECT.md
Caddyfile.example
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.swp *.swp

View file

@ -54,7 +54,15 @@ COPY --from=frontend-builder /build/frontend/dist ./static
# Create default directories # Create default directories
RUN mkdir -p /downloads /data && \ 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 USER mediarip

View file

@ -40,6 +40,7 @@ class ServerConfig(BaseModel):
log_level: str = "info" log_level: str = "info"
db_path: str = "mediarip.db" db_path: str = "mediarip.db"
data_dir: str = "/data" data_dir: str = "/data"
api_key: str = "" # Managed via admin panel — not typically set via env
class DownloadsConfig(BaseModel): class DownloadsConfig(BaseModel):

View file

@ -475,6 +475,59 @@ async def change_password(
return {"status": "ok", "message": "Password changed successfully"} 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: def _start_purge_scheduler(state, config, db) -> None:
"""Start the APScheduler purge job if not already running.""" """Start the APScheduler purge job if not already running."""
try: try:

View file

@ -10,6 +10,8 @@ from __future__ import annotations
import logging import logging
import os import os
import secrets
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -22,6 +24,42 @@ logger = logging.getLogger("mediarip.api.downloads")
router = APIRouter(tags=["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) @router.post("/downloads", response_model=Job, status_code=201)
async def create_download( async def create_download(
job_create: JobCreate, job_create: JobCreate,
@ -29,6 +67,7 @@ async def create_download(
session_id: str = Depends(get_session_id), session_id: str = Depends(get_session_id),
) -> Job: ) -> Job:
"""Submit a URL for download.""" """Submit a URL for download."""
_check_api_access(request)
logger.debug("POST /downloads session=%s url=%s", session_id, job_create.url) logger.debug("POST /downloads session=%s url=%s", session_id, job_create.url)
download_service = request.app.state.download_service download_service = request.app.state.download_service
job = await download_service.enqueue(job_create, session_id) job = await download_service.enqueue(job_create, session_id)

View file

@ -34,6 +34,7 @@ ADMIN_WRITABLE_KEYS = {
"admin_password_hash", "admin_password_hash",
"purge_enabled", "purge_enabled",
"purge_max_age_minutes", "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"] config.purge.privacy_mode = settings["privacy_mode"]
if "privacy_retention_minutes" in settings: if "privacy_retention_minutes" in settings:
config.purge.privacy_retention_minutes = settings["privacy_retention_minutes"] 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)) logger.info("Applied %d persisted settings to config", len(settings))

View file

@ -24,6 +24,7 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...options?.headers, ...options?.headers,
}, },
}) })

View file

@ -43,6 +43,11 @@ const changingPassword = ref(false)
const passwordChanged = ref(false) const passwordChanged = ref(false)
const passwordError = ref<string | null>(null) const passwordError = ref<string | null>(null)
// API key state
const apiKey = ref<string | null>(null)
const showApiKey = ref(false)
const apiKeyCopied = ref(false)
const canChangePassword = computed(() => const canChangePassword = computed(() =>
currentPassword.value.length > 0 && currentPassword.value.length > 0 &&
newPassword.value.length >= 4 && newPassword.value.length >= 4 &&
@ -84,6 +89,7 @@ async function switchTab(tab: typeof activeTab.value) {
} catch { } catch {
// Keep current values // Keep current values
} }
await loadApiKey()
} }
} }
@ -160,6 +166,53 @@ async function changePassword() {
} }
} }
function authHeaders(): Record<string, string> {
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) { async function toggleSession(sessionId: string) {
if (expandedSessions.value.has(sessionId)) { if (expandedSessions.value.has(sessionId)) {
expandedSessions.value.delete(sessionId) expandedSessions.value.delete(sessionId)
@ -558,6 +611,38 @@ function formatFilesize(bytes: number | null): string {
<span v-if="passwordError" class="password-error">{{ passwordError }}</span> <span v-if="passwordError" class="password-error">{{ passwordError }}</span>
</div> </div>
</div> </div>
<hr class="settings-divider" />
<!-- API Key management -->
<div class="settings-field">
<label>API Key</label>
<p class="field-hint">
When set, external API access requires this key via <code>X-API-Key</code> header.
Browser users are not affected. Without a key, the download API is open to anyone who can reach the server.
</p>
<div v-if="apiKey" class="api-key-display">
<div class="api-key-value">
<code class="mono api-key-text">{{ showApiKey ? apiKey : '•'.repeat(32) }}</code>
<button class="btn-icon" @click="showApiKey = !showApiKey" :title="showApiKey ? 'Hide' : 'Show'">
<svg v-if="showApiKey" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="btn-icon" @click="copyApiKey" title="Copy to clipboard">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="api-key-actions">
<button class="btn-regen" @click="regenerateApiKey">Regenerate</button>
<button class="btn-revoke" @click="revokeApiKey">Revoke</button>
</div>
<span v-if="apiKeyCopied" class="save-confirm"> Copied</span>
</div>
<div v-else class="api-key-empty">
<p class="field-hint" style="margin-bottom: var(--space-sm);">No API key set download API is open.</p>
<button class="btn-save" @click="generateApiKey">Generate API Key</button>
</div>
</div>
</div> </div>
</template> </template>
</div> </div>
@ -1140,4 +1225,78 @@ h3 {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: var(--font-size-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;
}
</style> </style>