From 9c5fb3e8bcd108dc6bde4799ace49ae05e0f0b79 Mon Sep 17 00:00:00 2001 From: Alex Israelov Date: Thu, 18 Jun 2026 19:09:57 -0700 Subject: [PATCH] Fix IDOR: derive identity from verified token, not client-supplied userId The app trusted client-supplied user identifiers in several places, allowing any caller to read/write another user's private notes and transcripts (IDOR). - WS sync (/ws/sync): upgraded on a raw ?userId= query param with no auth. Now requires a MentraOS frontend token (?aos_frontend_token= or Bearer), verified by regenerating the SDK token and constant-time comparing; the userId is derived from the verified token. Rejects with 401 otherwise. - Search endpoints (/api/search, /search/sentences, /search/backfill-status): removed the explicit "|| query userId" auth bypass; identity now comes only from the verified auth middleware (requireAuth). - Dev test endpoints (/api/test/simulate-*): returned data for an arbitrary user with no auth and shipped to prod. Now 404 unless NODE_ENV=development. - Removed dead useSSE hook that hit an unimplemented /api/events?userId= route (misleading insecure pattern; nothing rendered it). Frontend: WS sync and search now authenticate with the verified frontendToken from useMentraAuth() instead of sending a raw userId. useSynced reads the token internally so call sites are unchanged. Verified: token verification logic unit-checked against the real SDK (valid tokens accepted; forged/tampered/wrong-key/empty rejected). No new type errors. Co-Authored-By: Claude Opus 4.8 --- src/backend/api/router.ts | 28 ++- src/frontend/hooks/useSSE.ts | 293 ----------------------- src/frontend/hooks/useSynced.ts | 21 +- src/frontend/pages/search/SearchPage.tsx | 34 ++- src/index.ts | 41 +++- 5 files changed, 90 insertions(+), 327 deletions(-) delete mode 100644 src/frontend/hooks/useSSE.ts diff --git a/src/backend/api/router.ts b/src/backend/api/router.ts index a3f8140..cbf84c5 100644 --- a/src/backend/api/router.ts +++ b/src/backend/api/router.ts @@ -25,6 +25,7 @@ import { rewriteR2Urls } from "../../shared/constants"; const API_KEY = process.env.MENTRAOS_API_KEY || ""; const PACKAGE_NAME = process.env.PACKAGE_NAME || ""; const COOKIE_SECRET = process.env.COOKIE_SECRET || API_KEY; +const IS_DEVELOPMENT = process.env.NODE_ENV === "development"; export const api = new Hono(); @@ -129,6 +130,11 @@ api.get("/health", (c) => { * -d '{"userId":"test@example.com"}' */ api.post("/test/simulate-transcript", async (c) => { + // Dev-only test tooling — must not be reachable in production. (Injects + // transcript into an arbitrary user's session with no auth.) + if (!IS_DEVELOPMENT) { + return c.json({ error: "Not Found" }, 404); + } const { userId, text } = await c.req.json(); if (!userId || !text) { return c.json({ error: "userId and text required" }, 400); @@ -150,6 +156,10 @@ api.post("/test/simulate-transcript", async (c) => { }); api.post("/test/simulate-conversation", async (c) => { + // Dev-only test tooling — must not be reachable in production. + if (!IS_DEVELOPMENT) { + return c.json({ error: "Not Found" }, 404); + } const { userId } = await c.req.json(); if (!userId) { return c.json({ error: "userId required" }, 400); @@ -1211,12 +1221,10 @@ api.get("/conversations/:date", authMiddleware, async (c) => { */ api.get("/search", authMiddleware, async (c) => { try { - // Try standard auth first, fall back to userId query param - // (cookie auth may not work in all browser contexts through ngrok) - const userId = getUserId(c) || c.req.query("userId") as string; - if (!userId) { - throw { error: "Unauthorized", status: 401 }; - } + // Identity comes ONLY from the verified auth token. Never fall back to a + // client-supplied userId query param — that's an IDOR (any caller could + // read another user's notes/transcripts). + const userId = requireAuth(c); const query = c.req.query("q"); const limitParam = c.req.query("limit"); const aiParam = c.req.query("ai"); @@ -1251,8 +1259,7 @@ api.get("/search", authMiddleware, async (c) => { */ api.get("/search/backfill-status", authMiddleware, async (c) => { try { - const userId = getUserId(c) || (c.req.query("userId") as string); - if (!userId) throw { error: "Unauthorized", status: 401 }; + const userId = requireAuth(c); const { getBackfillStatus, backfillUserSearchIndex } = await import( "../services/searchBackfill.service" ); @@ -1280,10 +1287,7 @@ api.get("/search/backfill-status", authMiddleware, async (c) => { */ api.get("/search/sentences", authMiddleware, async (c) => { try { - const userId = getUserId(c) || (c.req.query("userId") as string); - if (!userId) { - throw { error: "Unauthorized", status: 401 }; - } + const userId = requireAuth(c); const query = c.req.query("q"); if (!query || !query.trim()) { return c.json({ error: "Query parameter 'q' is required" }, 400); diff --git a/src/frontend/hooks/useSSE.ts b/src/frontend/hooks/useSSE.ts deleted file mode 100644 index 8262295..0000000 --- a/src/frontend/hooks/useSSE.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * useSSE Hook - SSE Connection with Typed Events - * - * Connects to the backend SSE stream for real-time updates. - * Includes TypeScript types for all events from backend design. - * - * Events from /docs/backend-design-doc.md (SSE Events section): - * - transcript: Live transcription from glasses - * - state_update: Session state changes - * - meeting_started: Meeting detected - * - meeting_ended: Meeting concluded - * - meeting_processing: Note generation in progress - * - notes_ready: Notes generated - * - research_started: Research initiated - * - research_progress: Research updates - * - research_complete: Research finished - * - command_received: Voice command detected - * - command_executed: Command processed - * - sensitive_detected: Sensitive topic found - */ - -import { useState, useEffect, useRef, useCallback } from "react"; - -// ============================================================================= -// Base Event Type -// ============================================================================= - -export interface SSEEvent { - type: string; - timestamp: number; - [key: string]: any; -} - -// ============================================================================= -// Typed Event Interfaces - Matching backend design -// ============================================================================= - -export interface TranscriptEvent extends SSEEvent { - type: 'transcript'; - text: string; - speakerHint?: string; - speakerLabel?: string; - isFinal: boolean; -} - -export interface StateUpdateEvent extends SSEEvent { - type: 'state_update'; - status: 'idle' | 'meeting_active' | 'meeting_ended' | 'processing'; - activeMeetingId?: string; - lastAnalysisAt?: string; - detectedSensitiveTopics?: string[]; - effectiveAutonomyLevel?: 'capture_only' | 'suggest' | 'act_with_constraints'; -} - -export interface MeetingStartedEvent extends SSEEvent { - type: 'meeting_started'; - meetingId: string; - classification?: { - presetMatch: string; - category: string; - confidence: number; - }; - startTime: string; -} - -export interface MeetingEndedEvent extends SSEEvent { - type: 'meeting_ended'; - meetingId: string; - endTime: string; -} - -export interface MeetingProcessingEvent extends SSEEvent { - type: 'meeting_processing'; - meetingId: string; - stage: string; -} - -export interface NotesReadyEvent extends SSEEvent { - type: 'notes_ready'; - meetingId: string; - noteId: string; - summary: string; - keyDecisions: string[]; - actionItems: Array<{ - id: string; - task: string; - priority: 'low' | 'medium' | 'high'; - owner: string; - dueDate?: string; - }>; -} - -export interface ResearchStartedEvent extends SSEEvent { - type: 'research_started'; - researchId: string; - query: string; - queryType: string; -} - -export interface ResearchProgressEvent extends SSEEvent { - type: 'research_progress'; - researchId: string; - stage: string; - message: string; - partialResults?: Array<{ - title: string; - url: string; - snippet: string; - }>; -} - -export interface ResearchCompleteEvent extends SSEEvent { - type: 'research_complete'; - researchId: string; - results: Array<{ - title: string; - url: string; - snippet: string; - content?: string; - }>; - summary?: string; -} - -export interface CommandReceivedEvent extends SSEEvent { - type: 'command_received'; - command: { - type: 'research' | 'summarize' | 'end_meeting' | 'enable_transcript' | 'disable_transcript'; - target?: string; - isComplete: boolean; - }; -} - -export interface CommandExecutedEvent extends SSEEvent { - type: 'command_executed'; - command: string; - result: any; -} - -export interface SensitiveDetectedEvent extends SSEEvent { - type: 'sensitive_detected'; - topics: string[]; - action: string; -} - -// Union type for all possible events -export type AnySSEEvent = - | TranscriptEvent - | StateUpdateEvent - | MeetingStartedEvent - | MeetingEndedEvent - | MeetingProcessingEvent - | NotesReadyEvent - | ResearchStartedEvent - | ResearchProgressEvent - | ResearchCompleteEvent - | CommandReceivedEvent - | CommandExecutedEvent - | SensitiveDetectedEvent - | SSEEvent; - -export interface UseSSEReturn { - isConnected: boolean; - events: AnySSEEvent[]; - lastEvent: AnySSEEvent | null; - error: string | null; - reconnect: () => void; - clearEvents: () => void; - getEventsByType: (type: T) => AnySSEEvent[]; -} - -const MAX_EVENTS = 100; -const RECONNECT_DELAY = 3000; - -export function useSSE(userId: string | null): UseSSEReturn { - const [isConnected, setIsConnected] = useState(false); - const [events, setEvents] = useState([]); - const [error, setError] = useState(null); - - const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const isMountedRef = useRef(true); - - const connect = useCallback(() => { - if (!userId) return; - - // Close existing connection - if (eventSourceRef.current) { - eventSourceRef.current.close(); - } - - // Clear pending reconnect - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - - try { - const url = `/api/events?userId=${encodeURIComponent(userId)}`; - const eventSource = new EventSource(url); - - eventSource.onopen = () => { - if (!isMountedRef.current) return; - console.log("[SSE] Connected"); - setIsConnected(true); - setError(null); - }; - - eventSource.onmessage = (event) => { - if (!isMountedRef.current) return; - - try { - const data = JSON.parse(event.data) as AnySSEEvent; - console.log(`[SSE] Received event: ${data.type}`); - setEvents((prev) => { - const updated = [...prev, data]; - return updated.slice(-MAX_EVENTS); - }); - } catch (err) { - console.error("[SSE] Failed to parse event:", err); - } - }; - - eventSource.onerror = () => { - if (!isMountedRef.current) return; - - console.warn("[SSE] Connection error, reconnecting..."); - setIsConnected(false); - eventSource.close(); - - // Reconnect after delay - reconnectTimeoutRef.current = setTimeout(() => { - if (isMountedRef.current) { - connect(); - } - }, RECONNECT_DELAY); - }; - - eventSourceRef.current = eventSource; - } catch (err) { - console.error("[SSE] Failed to connect:", err); - setError("Failed to connect to event stream"); - setIsConnected(false); - } - }, [userId]); - - const reconnect = useCallback(() => { - console.log("[SSE] Manual reconnect"); - connect(); - }, [connect]); - - const clearEvents = useCallback(() => { - setEvents([]); - }, []); - - const getEventsByType = useCallback( - (type: string) => { - return events.filter((event) => event.type === type); - }, - [events] - ); - - // Connect on mount, disconnect on unmount - useEffect(() => { - isMountedRef.current = true; - - if (userId) { - connect(); - } - - return () => { - isMountedRef.current = false; - - if (eventSourceRef.current) { - eventSourceRef.current.close(); - } - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - }; - }, [userId, connect]); - - const lastEvent = events.length > 0 ? events[events.length - 1] : null; - - return { - isConnected, - events, - lastEvent, - error, - reconnect, - clearEvents, - getEventsByType, - }; -} diff --git a/src/frontend/hooks/useSynced.ts b/src/frontend/hooks/useSynced.ts index 61dc799..751f240 100644 --- a/src/frontend/hooks/useSynced.ts +++ b/src/frontend/hooks/useSynced.ts @@ -11,6 +11,7 @@ */ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useMentraAuth } from "@mentra/react"; import type { WSMessageToClient, WSMessageToServer } from "../../shared/types"; // ============================================================================= @@ -28,13 +29,15 @@ class SyncClient { private _isReconnecting = false; private _hasConnectedOnce = false; private userId: string; + private frontendToken: string; private reconnectTimer: ReturnType | null = null; private _version = 0; private _notifyScheduled = false; private _visibilityHandler: (() => void) | null = null; - constructor(userId: string) { + constructor(userId: string, frontendToken: string) { this.userId = userId; + this.frontendToken = frontendToken; this.connect(); this.setupVisibilityHandler(); } @@ -43,7 +46,9 @@ class SyncClient { if (this.ws?.readyState === WebSocket.OPEN) return; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const url = `${protocol}//${window.location.host}/ws/sync?userId=${encodeURIComponent(this.userId)}`; + // Authenticate with the MentraOS-verified frontend token. The server derives + // the userId from this token — it never trusts a raw userId (IDOR risk). + const url = `${protocol}//${window.location.host}/ws/sync?aos_frontend_token=${encodeURIComponent(this.frontendToken)}`; console.log("[Synced] Connecting..."); this.ws = new WebSocket(url); @@ -281,18 +286,24 @@ export interface UseSyncedResult { /** * React hook that connects to backend and returns a typed session. * + * The WebSocket connection is authenticated with the MentraOS-verified frontend + * token (read internally from the auth context) — the server derives the userId + * from that token and never trusts a client-supplied userId (IDOR protection). + * The connection is not established until the token is available. + * * @param userId - The user ID to connect as * @returns Session object with synced state and RPC methods */ export function useSynced(userId: string): UseSyncedResult { + const { frontendToken } = useMentraAuth(); const clientRef = useRef | null>(null); const [version, setVersion] = useState(0); - // Get or create client (only if userId is provided) - if (userId && !clientRef.current) { + // Get or create client (only once we have both a userId and an auth token) + if (userId && frontendToken && !clientRef.current) { let client = clientCache.get(userId); if (!client) { - client = new SyncClient(userId); + client = new SyncClient(userId, frontendToken); clientCache.set(userId, client); } clientRef.current = client; diff --git a/src/frontend/pages/search/SearchPage.tsx b/src/frontend/pages/search/SearchPage.tsx index 5a5dc94..9f546fc 100644 --- a/src/frontend/pages/search/SearchPage.tsx +++ b/src/frontend/pages/search/SearchPage.tsx @@ -213,7 +213,12 @@ let searchCache: SearchCache | null = null; export function SearchPage() { const { push } = useNavigation(); - const { userId } = useMentraAuth(); + const { userId, frontendToken } = useMentraAuth(); + // Authenticate requests with the MentraOS-verified frontend token. The server + // derives the userId from this token; it no longer trusts a userId query param. + const authHeaders: Record = frontendToken + ? { Authorization: `Bearer ${frontendToken}` } + : {}; const [query, setQuery] = useState(() => searchCache?.query ?? ""); const [results, setResults] = useState(() => searchCache?.results ?? []); const [isSearching, setIsSearching] = useState(false); @@ -271,9 +276,11 @@ export function SearchPage() { // Kick the backfill check once per mount. The endpoint itself fires the job // in the background if the user isn't yet backfilled. useEffect(() => { - if (!userId) return; - const userParam = `?userId=${encodeURIComponent(userId)}`; - fetch(`/api/search/backfill-status${userParam}`, { credentials: "include" }) + if (!userId || !frontendToken) return; + fetch(`/api/search/backfill-status`, { + credentials: "include", + headers: authHeaders, + }) .then((r) => r.json()) .then((data) => { setBackfillInProgress(!!data.inProgress && !data.backfilled); @@ -281,14 +288,14 @@ export function SearchPage() { .catch(() => { // Silent — banner is nice-to-have }); - }, [userId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, frontendToken]); const fetchSentencePage = useCallback( async (q: string, offset: number, signal: AbortSignal): Promise => { - const userParam = userId ? `&userId=${encodeURIComponent(userId)}` : ""; const res = await fetch( - `/api/search/sentences?q=${encodeURIComponent(q)}&offset=${offset}&limit=${SENTENCE_PAGE_SIZE}${userParam}`, - { credentials: "include", signal }, + `/api/search/sentences?q=${encodeURIComponent(q)}&offset=${offset}&limit=${SENTENCE_PAGE_SIZE}`, + { credentials: "include", headers: authHeaders, signal }, ).then((r) => r.json()); if (signal.aborted) return; @@ -301,7 +308,8 @@ export function SearchPage() { setSentenceHasMore(!!res?.hasMore); setSentenceOffset(typeof res?.nextOffset === "number" ? res.nextOffset : offset + rows.length); }, - [userId], + // eslint-disable-next-line react-hooks/exhaustive-deps + [userId, frontendToken], ); const doSearch = useCallback(async (q: string) => { @@ -339,10 +347,9 @@ export function SearchPage() { const shouldSearchSentences = trimmed.length >= PHRASE_MIN_QUERY; try { - const userParam = userId ? `&userId=${encodeURIComponent(userId)}` : ""; const mainPromise = fetch( - `/api/search?q=${encodeURIComponent(trimmed)}&limit=10${userParam}`, - { credentials: "include", signal: abortController.signal }, + `/api/search?q=${encodeURIComponent(trimmed)}&limit=10`, + { credentials: "include", headers: authHeaders, signal: abortController.signal }, ).then((r) => r.json()); setSentenceLoading(shouldSearchSentences); @@ -370,7 +377,8 @@ export function SearchPage() { setSentenceLoading(false); } } - }, [userId, fetchSentencePage]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, frontendToken, fetchSentencePage]); // Infinite scroll: load next page when sentinel enters viewport. useEffect(() => { diff --git a/src/index.ts b/src/index.ts index 369b7a9..e90bcfa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,8 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; import { NotesApp } from "./backend/NotesApp"; import { api } from "./backend/api/router"; -import { createMentraAuthRoutes } from "@mentra/sdk"; +import { createMentraAuthRoutes, generateFrontendToken } from "@mentra/sdk"; +import { timingSafeEqual } from "crypto"; import indexDev from "./frontend/index.html"; import indexProd from "./frontend/index.prod.html"; import { sessions } from "./backend/session"; @@ -88,6 +89,28 @@ console.log(""); // Determine environment const isDevelopment = process.env.NODE_ENV === "development"; +/** + * Verifies a MentraOS frontend token and returns the embedded userId, or null. + * + * A frontend token has the form `userId:sha256(userId + sha256(apiKey))`. We + * never trust a client-supplied userId directly (that would be an IDOR — any + * caller could read/write another user's notes & transcripts). Instead we split + * out the claimed userId, regenerate the token the SDK would have issued for it, + * and constant-time compare. Only a token signed with our API key validates. + */ +function verifyFrontendToken(token: string | null | undefined): string | null { + if (!token) return null; + const sep = token.indexOf(":"); + if (sep <= 0) return null; + const claimedUserId = token.slice(0, sep); + // API_KEY is guaranteed non-null here (validated above on startup). + const expected = generateFrontendToken(claimedUserId, API_KEY as string); + const a = Buffer.from(token); + const b = Buffer.from(expected); + if (a.length !== b.length) return null; + return timingSafeEqual(a, b) ? claimedUserId : null; +} + // Start Bun server with HMR support and WebSocket Bun.serve({ port: PORT, @@ -123,11 +146,21 @@ Bun.serve({ return new Response("Not found", { status: 404 }); } - // WebSocket upgrade for synced clients + // WebSocket upgrade for synced clients. + // Identity is taken ONLY from a verified frontend token — never from a + // client-supplied userId, which would let anyone read/write another user's + // notes and live transcripts (IDOR). if (url.pathname === "/ws/sync") { - const userId = url.searchParams.get("userId"); + const token = + url.searchParams.get("aos_frontend_token") || + // EventSource/WebSocket can't set headers, but accept Authorization too + // for non-browser clients. + request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || + null; + + const userId = verifyFrontendToken(token); if (!userId) { - return new Response("userId required", { status: 400 }); + return new Response("Unauthorized", { status: 401 }); } const upgraded = server.upgrade(request, {