mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
fix(security): WebSocket auth gate + audit-driven hardening sweep
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user