Flip API key logic: no key = browser-only, add confirmation gates

- No API key configured: external API access blocked, browser-only
- API key generated: external access enabled with that key
- Added 'Sure?' confirmation on Regenerate and Revoke buttons (3s timeout)
- Updated hint text to reflect security-first default
This commit is contained in:
xpltd 2026-03-22 01:16:19 -05:00
parent 9b4ffbb754
commit b0d2781980
2 changed files with 54 additions and 21 deletions

View file

@ -27,37 +27,32 @@ router = APIRouter(tags=["downloads"])
def _check_api_access(request: Request) -> None: def _check_api_access(request: Request) -> None:
"""Verify the caller is a browser user or has a valid API key. """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). Browser users (X-Requested-With: XMLHttpRequest) always pass.
When an API key is set: Non-browser callers must provide a valid X-API-Key header.
- Requests with a valid X-API-Key header pass. If no API key is configured, non-browser requests are blocked entirely.
- 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.
""" """
# Browser users always pass
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return
config = request.app.state.config config = request.app.state.config
api_key = config.server.api_key api_key = config.server.api_key
if not api_key: if not api_key:
return # No key configured — open access # No key configured — block non-browser access
raise_api_key_required("API access is disabled. Generate an API key in the admin panel, then provide it via X-API-Key header.")
# Check API key header # Check API key header
provided_key = request.headers.get("x-api-key", "") provided_key = request.headers.get("x-api-key", "")
if provided_key and secrets.compare_digest(provided_key, api_key): if provided_key and secrets.compare_digest(provided_key, api_key):
return return
# Check browser origin — frontend sends X-Requested-With: XMLHttpRequest
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return
raise_api_key_required() raise_api_key_required()
def raise_api_key_required(): def raise_api_key_required(detail: str = "Invalid or missing API key. Provide X-API-Key header."):
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException( raise HTTPException(status_code=403, detail=detail)
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)

View file

@ -47,6 +47,10 @@ const passwordError = ref<string | null>(null)
const apiKey = ref<string | null>(null) const apiKey = ref<string | null>(null)
const showApiKey = ref(false) const showApiKey = ref(false)
const apiKeyCopied = ref(false) const apiKeyCopied = ref(false)
const regenConfirming = ref(false)
const revokeConfirming = ref(false)
let regenConfirmTimer: ReturnType<typeof setTimeout> | null = null
let revokeConfirmTimer: ReturnType<typeof setTimeout> | null = null
const canChangePassword = computed(() => const canChangePassword = computed(() =>
currentPassword.value.length > 0 && currentPassword.value.length > 0 &&
@ -193,6 +197,7 @@ async function generateApiKey() {
async function regenerateApiKey() { async function regenerateApiKey() {
await generateApiKey() await generateApiKey()
regenConfirming.value = false
} }
async function revokeApiKey() { async function revokeApiKey() {
@ -203,6 +208,27 @@ async function revokeApiKey() {
showApiKey.value = false showApiKey.value = false
} }
} catch { /* ignore */ } } catch { /* ignore */ }
revokeConfirming.value = false
}
function handleRegenClick() {
if (regenConfirming.value) {
regenerateApiKey()
return
}
regenConfirming.value = true
if (regenConfirmTimer) clearTimeout(regenConfirmTimer)
regenConfirmTimer = setTimeout(() => { regenConfirming.value = false }, 3000)
}
function handleRevokeClick() {
if (revokeConfirming.value) {
revokeApiKey()
return
}
revokeConfirming.value = true
if (revokeConfirmTimer) clearTimeout(revokeConfirmTimer)
revokeConfirmTimer = setTimeout(() => { revokeConfirming.value = false }, 3000)
} }
function copyApiKey() { function copyApiKey() {
@ -618,8 +644,8 @@ function formatFilesize(bytes: number | null): string {
<div class="settings-field"> <div class="settings-field">
<label>API Key</label> <label>API Key</label>
<p class="field-hint"> <p class="field-hint">
When set, external API access requires this key via <code>X-API-Key</code> header. Generate a key to enable external API access (e.g. scripts, automation).
Browser users are not affected. Without a key, the download API is open to anyone who can reach the server. Without a key, downloads can only be submitted through the web UI.
</p> </p>
<div v-if="apiKey" class="api-key-display"> <div v-if="apiKey" class="api-key-display">
<div class="api-key-value"> <div class="api-key-value">
@ -633,13 +659,25 @@ function formatFilesize(bytes: number | null): string {
</button> </button>
</div> </div>
<div class="api-key-actions"> <div class="api-key-actions">
<button class="btn-regen" @click="regenerateApiKey">Regenerate</button> <button
<button class="btn-revoke" @click="revokeApiKey">Revoke</button> class="btn-regen"
:class="{ 'btn-confirm': regenConfirming }"
@click="handleRegenClick"
>
{{ regenConfirming ? 'Sure?' : 'Regenerate' }}
</button>
<button
class="btn-revoke"
:class="{ 'btn-confirm': revokeConfirming }"
@click="handleRevokeClick"
>
{{ revokeConfirming ? 'Sure?' : 'Revoke' }}
</button>
</div> </div>
<span v-if="apiKeyCopied" class="save-confirm"> Copied</span> <span v-if="apiKeyCopied" class="save-confirm"> Copied</span>
</div> </div>
<div v-else class="api-key-empty"> <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> <p class="field-hint" style="margin-bottom: var(--space-sm);">No API key set external API access is disabled.</p>
<button class="btn-save" @click="generateApiKey">Generate API Key</button> <button class="btn-save" @click="generateApiKey">Generate API Key</button>
</div> </div>
</div> </div>