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:
@@ -1,3 +1,4 @@
|
|||||||
|
import hmac
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@@ -26,7 +27,7 @@ async def liveview_canvas(
|
|||||||
"""
|
"""
|
||||||
if not settings.liveview_key:
|
if not settings.liveview_key:
|
||||||
raise HTTPException(status_code=403, detail="Live view is disabled")
|
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")
|
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||||
|
|
||||||
nodes = (await db.execute(select(Node))).scalars().all()
|
nodes = (await db.execute(select(Node))).scalars().all()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type { NodeData } from '@/types'
|
|||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
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() {
|
function LiveViewCanvas() {
|
||||||
const { nodes, edges, loadCanvas } = useCanvasStore()
|
const { nodes, edges, loadCanvas } = useCanvasStore()
|
||||||
@@ -81,7 +81,8 @@ function LiveViewCanvas() {
|
|||||||
setViewState('ready')
|
setViewState('ready')
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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')
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas])
|
||||||
@@ -104,6 +105,7 @@ function LiveViewCanvas() {
|
|||||||
disabled: 'Live view is disabled on this instance.',
|
disabled: 'Live view is disabled on this instance.',
|
||||||
'invalid-key': 'Invalid or expired live view key.',
|
'invalid-key': 'Invalid or expired live view key.',
|
||||||
'no-key': 'Missing key — use ?key=your-secret in the URL.',
|
'no-key': 'Missing key — use ?key=your-secret in the URL.',
|
||||||
|
'network-error': 'Could not reach the server. Check your connection.',
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
|
<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')
|
setSearch('?key=anything')
|
||||||
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
|
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
|
||||||
render(<LiveView />)
|
render(<LiveView />)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/Invalid or expired/)).toBeDefined()
|
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user