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/
|
.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
|
||||||
|
|
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue