mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-02 18:43:59 -06:00
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:
parent
82f78e567b
commit
4b766bb0e7
8 changed files with 268 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@
|
|||
.claude/
|
||||
.bg-shell/
|
||||
.planning/
|
||||
DEPLOY-TEST-PROMPT.md
|
||||
PROJECT.md
|
||||
Caddyfile.example
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
|
|
|
|||
10
Dockerfile
10
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue