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/
.bg-shell/
.planning/
DEPLOY-TEST-PROMPT.md
PROJECT.md
Caddyfile.example
.DS_Store
Thumbs.db
*.swp

View file

@ -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

View file

@ -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):

View file

@ -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:

View file

@ -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)

View file

@ -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))

View file

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

View file

@ -43,6 +43,11 @@ const changingPassword = ref(false)
const passwordChanged = ref(false)
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(() =>
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<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) {
if (expandedSessions.value.has(sessionId)) {
expandedSessions.value.delete(sessionId)
@ -558,6 +611,38 @@ function formatFilesize(bytes: number | null): string {
<span v-if="passwordError" class="password-error">{{ passwordError }}</span>
</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>
</template>
</div>
@ -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;
}
</style>