fix: timing-safe key comparison and network-error state in liveview

Use hmac.compare_digest() to prevent timing-based key enumeration.
Distinguish network failures from invalid-key errors in the frontend.
This commit is contained in:
Pouzor
2026-03-28 15:30:09 +01:00
parent 210304394e
commit 5897be70c2
3 changed files with 8 additions and 5 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import hmac
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -26,7 +27,7 @@ async def liveview_canvas(
"""
if not settings.liveview_key:
raise HTTPException(status_code=403, detail="Live view is disabled")
if not key or key != settings.liveview_key:
if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all()
+4 -2
View File
@@ -33,7 +33,7 @@ import type { NodeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'ready'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() {
const { nodes, edges, loadCanvas } = useCanvasStore()
@@ -81,7 +81,8 @@ function LiveViewCanvas() {
setViewState('ready')
})
.catch((err) => {
const detail: string = err.response?.data?.detail ?? ''
if (!err.response) { setViewState('network-error'); return }
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
}, [loadCanvas])
@@ -104,6 +105,7 @@ function LiveViewCanvas() {
disabled: 'Live view is disabled on this instance.',
'invalid-key': 'Invalid or expired live view key.',
'no-key': 'Missing key — use ?key=your-secret in the URL.',
'network-error': 'Could not reach the server. Check your connection.',
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
@@ -88,12 +88,12 @@ describe('LiveView (non-standalone)', () => {
})
})
it('shows invalid-key error for unexpected API errors', async () => {
it('shows network-error for non-response errors (offline, CORS, 500)', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Invalid or expired/)).toBeDefined()
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})