fix(security): WebSocket auth gate + audit-driven hardening sweep

This commit is contained in:
maziggy
2026-06-02 10:02:17 +02:00
parent 9c8df1744d
commit b7d7c82501
32 changed files with 1420 additions and 234 deletions
+8 -2
View File
@@ -153,10 +153,16 @@ function SetupRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<ErrorBoundary>
<ThemeProvider>
<ToastProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
{/* ThemeProvider sits inside AuthProvider so its initial
``api.getSettings()`` fetch can wait for AuthContext to
resolve — otherwise it fires unconditionally on every
login page load and returns 401. ErrorBoundary uses
inline styles, so a missing theme on a crash screen is
not a regression. */}
<ThemeProvider>
<ColorCatalogProvider>
<SliceJobTrackerProvider>
<StreamTokenSync />
@@ -213,10 +219,10 @@ function App() {
</BrowserRouter>
</SliceJobTrackerProvider>
</ColorCatalogProvider>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
</ToastProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
@@ -24,11 +24,14 @@ function createWrapper() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>
<ToastProvider>
<AuthProvider>{children}</AuthProvider>
</ToastProvider>
</ThemeProvider>
{/* ThemeProvider sits inside AuthProvider (matches App.tsx
after the GHSA-r2qv login-page-quiet fix) so its
useAuth() call resolves. */}
<AuthProvider>
<ThemeProvider>
<ToastProvider>{children}</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
@@ -27,13 +27,13 @@ function createWrapper() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<ColorCatalogProvider>{children}</ColorCatalogProvider>
</AuthProvider>
</ToastProvider>
</ThemeProvider>
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
@@ -5,6 +5,8 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { ThemeProvider, useTheme } from '../../contexts/ThemeContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
// Helper to create a controllable matchMedia mock for individual tests
@@ -41,7 +43,21 @@ function mockMatchMedia(prefersDark: boolean) {
}
function wrapper({ children }: { children: ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
// ThemeProvider now calls useAuth() internally to gate its initial
// api.getSettings() fetch (GHSA-r2qv login-page-quiet fix), so we
// need AuthProvider in the tree. QueryClientProvider is required by
// any AuthProvider descendant that calls useQuery (none here, but
// AuthProvider itself uses ``api.request`` via getAuthStatus).
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
describe('ThemeContext', () => {
@@ -107,8 +107,27 @@ function createWrapper(queryClient: QueryClient) {
};
}
function getLatestWs(): MockWebSocket | undefined {
return wsInstances[wsInstances.length - 1];
/**
* After GHSA-r2qv, useWebSocket awaits a ws-token fetch before constructing
* the WebSocket. The MockWebSocket isn't pushed into ``wsInstances`` until
* that promise resolves. ``waitFor`` from testing-library uses real-time
* polling and so wedges under ``vi.useFakeTimers()``; flushing microtasks
* manually works under both real and fake timers because Promise resolution
* runs on the microtask queue, not on the mocked clock.
*
* Two iterations suffice for ``await fetch(...)`` → ``await resp.json()``;
* a small headroom lets future awaits land here without changing every
* call site.
*/
async function waitForWs(): Promise<MockWebSocket> {
for (let i = 0; i < 10 && wsInstances.length === 0; i++) {
await Promise.resolve();
}
const ws = wsInstances[wsInstances.length - 1];
if (!ws) {
throw new Error('WebSocket was not constructed after microtask flush');
}
return ws;
}
describe('useWebSocket hook', () => {
@@ -122,10 +141,28 @@ describe('useWebSocket hook', () => {
// Save original and install mock
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
// After GHSA-r2qv, useWebSocket fetches a ws-token via api.getWebSocketToken
// before opening the socket. ``api.request`` reads ``response.headers``
// and ``response.status``; the stub must expose those (a missing
// ``headers`` field throws inside request() and the silent catch in
// useWebSocket then proceeds with an undefined token, so the assertion
// "URL contains ?token=" fails without making the cause obvious).
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
headers: { get: () => null },
json: async () => ({ token: 'test-ws-token' }),
})),
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
// Restore original WebSocket
globalThis.WebSocket = originalWebSocket;
});
@@ -190,9 +227,11 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs();
const ws = await waitForWs();
expect(ws).toBeDefined();
expect(ws?.url).toContain('/api/v1/ws');
expect(ws.url).toContain('/api/v1/ws');
// GHSA-r2qv: the ws-token mint result is appended as ?token=...
expect(ws.url).toContain('token=test-ws-token');
});
it('reports connected state when WebSocket opens', async () => {
@@ -206,9 +245,9 @@ describe('useWebSocket hook', () => {
expect(result.current.isConnected).toBe(false);
// Simulate connection opening
const ws = getLatestWs();
const ws = await waitForWs();
act(() => {
ws?.open();
ws.open();
});
await waitFor(() => {
@@ -285,7 +324,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -327,7 +366,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -368,7 +407,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -405,7 +444,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
act(() => {
ws.open();
});
@@ -435,7 +474,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -460,7 +499,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -490,7 +529,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -519,7 +558,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -542,7 +581,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Don't open connection - still in CONNECTING state
@@ -564,7 +603,11 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const firstWs = getLatestWs()!;
// GHSA-r2qv: connect() awaits a ws-token fetch before constructing
// the WebSocket. Flush microtasks under fake timers so the await
// resolves and MockWebSocket is pushed into wsInstances.
await vi.advanceTimersByTimeAsync(0);
const firstWs = wsInstances[wsInstances.length - 1]!;
// Open connection
act(() => {
@@ -578,14 +621,13 @@ describe('useWebSocket hook', () => {
firstWs.close();
});
// Wait for reconnect timeout (3 seconds)
act(() => {
vi.advanceTimersByTime(3000);
});
// Wait for reconnect timeout (3 seconds) + microtask flush for the
// async connect() that the reconnect schedules.
await vi.advanceTimersByTimeAsync(3000);
// Should have created new WebSocket
expect(wsInstances.length).toBe(instanceCountBefore + 1);
expect(getLatestWs()).not.toBe(firstWs);
expect(wsInstances[wsInstances.length - 1]).not.toBe(firstWs);
vi.useRealTimers();
});
@@ -597,7 +639,7 @@ describe('useWebSocket hook', () => {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
const ws = await waitForWs();
// Open connection
act(() => {
@@ -44,15 +44,15 @@ function renderCameraPage(printerId: number, search = '') {
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<MemoryRouter initialEntries={[`/cameras/${printerId}${search}`]}>
<ThemeProvider>
<AuthProvider>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/cameras/:printerId" element={<CameraPage />} />
</Routes>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</ThemeProvider>
</AuthProvider>
</MemoryRouter>
</I18nextProvider>
</QueryClientProvider>
@@ -237,18 +237,18 @@ describe('GroupEditPage', () => {
const wrapper = (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<MemoryRouter initialEntries={['/groups/2/edit']}>
<Routes>
<Route path="/groups/:id/edit" element={<GroupEditPage />} />
<Route path="/settings" element={<div>Settings</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
</ToastProvider>
</ThemeProvider>
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
rtlRender(wrapper);
@@ -11,6 +11,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
const mockPrinter = {
id: 1,
@@ -60,13 +61,15 @@ function renderOverlayPage(printerId: number, queryParams = '') {
return rtlRender(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
</Routes>
</ToastProvider>
</ThemeProvider>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
</Routes>
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
);
+46
View File
@@ -87,6 +87,52 @@ vi.stubGlobal('WebSocket', MockWebSocket);
// Mock scrollTo
window.scrollTo = vi.fn();
// Silence jsdom's "Not implemented: navigation (except hash changes)"
// warning when production code does ``window.location.href = '/setup'``
// (AuthContext setup-redirect) or other full-page nav assignments.
//
// jsdom defines ``href`` as a non-configurable accessor on
// ``Location.prototype``, so it cannot be redefined on the instance via
// ``Object.defineProperty``. We wrap the real jsdom Location in a Proxy
// that turns ``href = "..."`` writes into silent no-ops; everything
// else (reads of ``pathname`` / ``search`` / ``hash``, writes to
// ``hash``, ``assign()`` / ``replace()`` calls, ``history.replaceState``
// updating ``search``) passes through unchanged. The ``get`` trap is
// deliberately permissive: returning a substitute value for a non-
// configurable target property violates Proxy invariants and the spread
// operator (``{ ...window.location }``) walks every key — tests that
// use the spread to copy the location object must keep working.
{
const realLocation = window.location;
const locationProxy = new Proxy(realLocation, {
set(target, prop, value) {
if (prop === 'href') {
// Silently swallow "navigation not implemented". Tests asserting
// on the redirect should replace ``window.location`` themselves
// (several existing tests do exactly this).
return true;
}
Reflect.set(target, prop, value);
return true;
},
get(target, prop, receiver) {
// Return the exact value present on the target. Returning a
// bound/wrapped version of a non-configurable function (``assign``
// is one) violates Proxy invariants (the spread operator at one
// call site triggers this). Production code that does
// ``window.location.assign(url)`` calls the function with the
// proxy as ``this``, which jsdom still accepts because its
// Location methods unwrap their receiver internally.
return Reflect.get(target, prop, receiver);
},
});
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: locationProxy,
});
}
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
+9 -4
View File
@@ -36,11 +36,16 @@ function AllProviders({ children }: AllProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
{/* ThemeProvider is now mounted inside AuthProvider in App.tsx so
its initial ``api.getSettings()`` sync can gate on auth state.
Tests follow the same nesting; otherwise ThemeProvider's
``useAuth()`` throws "AuthContext must be used inside
AuthProvider". */}
<AuthProvider>
<ThemeProvider>
<ToastProvider>{children}</ToastProvider>
</AuthProvider>
</ThemeProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
+6
View File
@@ -5088,6 +5088,12 @@ export const api = {
getCameraStreamToken: () =>
request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
// WebSocket auth (GHSA-r2qv follow-up) — mint a short-lived token for
// the /ws connection. Browsers can't attach Authorization headers to a
// WebSocket handshake, so the token rides in the ?token= query param.
getWebSocketToken: () =>
request<{ token: string }>('/auth/ws-token', { method: 'POST' }),
// Long-lived camera-stream tokens (#1108)
createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
request<LongLivedCameraToken>('/auth/tokens', {
+19 -2
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { api } from '../api/client';
import { useAuth } from './AuthContext';
type ThemeMode = 'light' | 'dark' | 'system';
type ThemeStyle = 'classic' | 'glow' | 'vibrant';
@@ -32,6 +33,14 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
// Auth-aware: read state from AuthContext so the initial getSettings()
// sync waits until we know whether (a) auth is enabled and (b) there
// is a logged-in user. Without this the fetch fires on the login page
// and returns 401 — harmless (the catch swallows it) but noisy in the
// network panel and wasteful. ThemeProvider is mounted inside
// AuthProvider in App.tsx specifically so this hook is callable.
const { authEnabled, user, loading: authLoading } = useAuth();
// Mode
const [mode, setModeState] = useState<ThemeMode>(() => {
const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
@@ -78,8 +87,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
});
// Sync from API on mount
// Sync from API once auth state is known. Same gate shape as
// useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
// settle, then only fetch when we can actually expect a 200 (auth
// disabled, or auth enabled with a logged-in user).
useEffect(() => {
if (authLoading) return;
if (authEnabled && user === null) return;
api.getSettings().then((settings) => {
// Dark settings
if (settings.dark_style) {
@@ -108,7 +122,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
localStorage.setItem('light-accent', settings.light_accent);
}
}).catch(() => {});
}, []);
// Re-fetch when auth state transitions (e.g. login completes); the
// gate above short-circuits subsequent calls once we already have a
// valid sync.
}, [authLoading, authEnabled, user]);
// Apply theme classes based on current mode
useEffect(() => {
+9 -2
View File
@@ -41,17 +41,24 @@ export function rewriteMediaSrcWithToken(root: ParentNode, token: string): numbe
* Components that need token-protected URLs can import withStreamToken directly.
*/
export function useStreamTokenSync() {
const { authEnabled, user } = useAuth();
const { authEnabled, user, loading: authLoading } = useAuth();
const queryClient = useQueryClient();
const refreshingRef = useRef(false);
// Key the token by user id so a login/logout invalidates the cache
// automatically — otherwise a failed anonymous fetch on the login page
// would be cached and never retried after sign-in.
//
// Race-aware gate (same shape as ColorCatalogProvider): wait for
// ``checkAuthStatus`` to finish before deciding whether to fetch.
// The previous form ``authEnabled ? !!user : true`` evaluated to
// ``true`` on first render because ``authEnabled`` defaults to false,
// firing a 401 POST on the login page before AuthContext had a chance
// to settle on ``authEnabled=true, user=null``.
const { data } = useQuery({
queryKey: ['camera-stream-token', user?.id ?? null],
queryFn: () => api.getCameraStreamToken(),
enabled: authEnabled ? !!user : true,
enabled: !authLoading && (!authEnabled || user !== null),
staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
refetchInterval: 50 * 60 * 1000,
});
+28 -3
View File
@@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
interface WebSocketMessage {
type: string;
@@ -64,13 +65,34 @@ export function useWebSocket() {
processNext();
}, []);
const connect = useCallback(() => {
const connect = useCallback(async () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
// GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
// minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
// helper (via ``api.getWebSocketToken``) so the JWT Authorization header
// is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
// miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
// Auth-disabled deployments accept connections without a token, so we
// treat a missing/failed token mint as non-fatal here and let the
// WebSocket close with code 4401 if the server actually rejects us.
let token: string | undefined;
try {
const resp = await api.getWebSocketToken();
token = resp.token;
} catch {
// Token mint failed — most likely auth is disabled (no JWT to attach,
// 401 response) or the user isn't authenticated yet. Fall through and
// try the WebSocket anyway. Auth-disabled deployments succeed;
// auth-enabled deployments close with 4401 and the reconnect loop
// kicks in once the user logs in.
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
const ws = new WebSocket(wsUrl);
@@ -356,7 +378,10 @@ export function useWebSocket() {
}, [handleMessage]);
useEffect(() => {
connect();
// connect() is async after the GHSA-r2qv fix (mints a ws-token first).
// Fire-and-forget at mount; the inner reconnect loop also calls
// connect() in the ws.onclose handler.
void connect();
return () => {
if (reconnectTimeoutRef.current) {
+32 -8
View File
@@ -144,11 +144,33 @@ export function StreamOverlayPage() {
useEffect(() => {
if (!id) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
const ws = new WebSocket(wsUrl);
let ws: WebSocket | null = null;
let cancelled = false;
ws.onmessage = (event) => {
// GHSA-r2qv follow-up: mint a ws-token before connecting. Uses
// api.getWebSocketToken so the JWT Authorization header rides along
// (raw fetch+credentials:'include' would miss it — Bambuddy uses
// Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
// succeed even without a token.
(async () => {
let token: string | undefined;
try {
const resp = await api.getWebSocketToken();
token = resp.token;
} catch {
// Token mint failed — auth disabled, no JWT yet, or transient
// network error. Fall through; auth-disabled deployments still
// succeed, auth-enabled ones close with 4401 and the page's
// polling fallback continues to refresh the status.
}
if (cancelled) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'printer_status' && data.printer_id === id) {
@@ -159,12 +181,14 @@ export function StreamOverlayPage() {
}
};
ws.onerror = () => {
// WebSocket error - polling will continue as fallback
};
ws.onerror = () => {
// WebSocket error - polling will continue as fallback
};
})();
return () => {
ws.close();
cancelled = true;
if (ws) ws.close();
};
}, [id, queryClient]);