{ + console.error("LiveKit room error", err); + alert( + err?.message?.toLowerCase().includes("permission") || + err?.message?.toLowerCase().includes("device") + ? "Microphone access failed. Allow mic permission and try again." + : `Connection error: ${err?.message ?? "unknown"}. Disconnect and retry.`, + ); + }} > diff --git a/moss-live-labs/examples/voice-agent/web/components/AgentSide.tsx b/moss-live-labs/examples/voice-agent/web/components/AgentSide.tsx index d86c4f9a..5c606bc2 100644 --- a/moss-live-labs/examples/voice-agent/web/components/AgentSide.tsx +++ b/moss-live-labs/examples/voice-agent/web/components/AgentSide.tsx @@ -13,21 +13,30 @@ const STATE_LABEL: Record = { speaking: "speaking", connecting: "connecting", initializing: "warming up", + "pre-connect-buffering": "buffering", idle: "idle", disconnected: "disconnected", + failed: "failed", }; export function AgentSide() { const { state, audioTrack } = useVoiceAssistant(); const label = STATE_LABEL[state] ?? state; - const idle = state === "idle" || state === "disconnected"; + const inactive = + state === "idle" || state === "disconnected" || state === "failed"; return (
-
{label}
+
+ {label} +
diff --git a/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx b/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx index 0dbb6b37..2a595c14 100644 --- a/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx +++ b/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx @@ -42,8 +42,9 @@ export function RetrievalPanel() { const connState = useConnectionState(); const [data, setData] = useState(null); const [region, setRegion] = useState("US"); - const regionRef = useRef(region); - regionRef.current = region; + const [regionError, setRegionError] = useState(null); + // Committed region used for retrieval filtering — only advances after a successful publish. + const committedRegionRef = useRef("US"); useDataChannel( "moss.retrieval", @@ -57,7 +58,7 @@ export function RetrievalPanel() { return; } // Ignore stale results from a previous region after the picker changed. - if (parsed.region && parsed.region !== regionRef.current) return; + if (parsed.region && parsed.region !== committedRegionRef.current) return; setData(parsed); } catch (err) { console.error("failed to parse moss.retrieval payload", err); @@ -66,38 +67,56 @@ export function RetrievalPanel() { ); const publishRegion = useCallback( - (r: Region) => { - const publish = room.localParticipant?.publishData( - new TextEncoder().encode(JSON.stringify({ region: r })), - { reliable: true, topic: "moss.region" }, - ); - void publish?.catch((err: unknown) => { + async (r: Region): Promise => { + try { + await room.localParticipant?.publishData( + new TextEncoder().encode(JSON.stringify({ region: r })), + { reliable: true, topic: "moss.region" }, + ); + return true; + } catch (err) { console.error("failed to publish region", err); - }); + return false; + } }, [room], ); - // Sync the agent to the picker whenever we connect, the region changes, or - // the agent participant joins (covers packets sent before the agent listened). + // Sync the agent to the committed picker region whenever we connect or the agent joins. useEffect(() => { if (connState !== ConnectionState.Connected) return; - publishRegion(region); - const onParticipant = () => publishRegion(regionRef.current); + const sync = () => { + void publishRegion(committedRegionRef.current).then((ok) => { + if (!ok) setRegionError("Couldn't sync region with the agent — try again."); + }); + }; + sync(); + const onParticipant = () => sync(); room.on(RoomEvent.ParticipantConnected, onParticipant); return () => { room.off(RoomEvent.ParticipantConnected, onParticipant); }; - }, [connState, region, publishRegion, room]); + }, [connState, publishRegion, room]); const selectRegion = (r: Region) => { + if (r === region) return; + const previous = committedRegionRef.current; setRegion(r); - setData(null); + setRegionError(null); + void publishRegion(r).then((ok) => { + if (!ok) { + setRegion(previous); + setRegionError("Couldn't update region — try again."); + return; + } + committedRegionRef.current = r; + setData(null); + }); }; return (
-
+
Moss · knowledge base @@ -130,6 +149,11 @@ export function RetrievalPanel() {
region: {region} + global
+ {regionError ? ( +
+ {regionError} +
+ ) : null} {data ? ( <> diff --git a/moss-live-labs/examples/voice-agent/web/components/Transcript.tsx b/moss-live-labs/examples/voice-agent/web/components/Transcript.tsx index c069b188..4023f348 100644 --- a/moss-live-labs/examples/voice-agent/web/components/Transcript.tsx +++ b/moss-live-labs/examples/voice-agent/web/components/Transcript.tsx @@ -6,6 +6,8 @@ import { RoomEvent, type TranscriptionSegment, type Participant } from "livekit- type Turn = { id: string; text: string; isUser: boolean }; +const MAX_TURNS = 100; + // Renders live STT (user) + TTS (agent) transcriptions. Keyed by segment id so // interim results update in place; object insertion order preserves turn order. export function Transcript() { @@ -19,13 +21,17 @@ export function Transcript() { setTurns((prev) => { const next = { ...prev }; for (const seg of segments) { - next[seg.id] = { id: seg.id, text: seg.text, isUser: Boolean(participant?.isLocal) }; + const text = seg.text; + if (!text.trim()) { + // Drop blank interim artifacts so they never consume the history cap. + delete next[seg.id]; + continue; + } + next[seg.id] = { id: seg.id, text, isUser: Boolean(participant?.isLocal) }; } - // cap history so a long call doesn't grow memory without bound const ids = Object.keys(next); - const MAX = 100; - if (ids.length > MAX) { - for (const id of ids.slice(0, ids.length - MAX)) delete next[id]; + if (ids.length > MAX_TURNS) { + for (const id of ids.slice(0, ids.length - MAX_TURNS)) delete next[id]; } return next; }); @@ -36,7 +42,7 @@ export function Transcript() { }; }, [room]); - const ordered = Object.values(turns).filter((t) => t.text.trim().length > 0); + const ordered = Object.values(turns); useEffect(() => { const el = containerRef.current; @@ -53,7 +59,7 @@ export function Transcript() { const el = containerRef.current; if (!el || !stickToBottomRef.current) return; el.scrollTop = el.scrollHeight; - }, [ordered]); + }, [turns]); return (
{ordered.length === 0 ? ( "Say hello to start the conversation." diff --git a/moss-live-labs/examples/voice-agent/web/eslint.config.mjs b/moss-live-labs/examples/voice-agent/web/eslint.config.mjs new file mode 100644 index 00000000..bee1b085 --- /dev/null +++ b/moss-live-labs/examples/voice-agent/web/eslint.config.mjs @@ -0,0 +1,20 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +/** @type {import("eslint").Linter.Config[]} */ +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [".next/**", "out/**", "build/**", "node_modules/**", "next-env.d.ts"], + }, +]; + +export default eslintConfig; diff --git a/moss-live-labs/examples/voice-agent/web/package-lock.json b/moss-live-labs/examples/voice-agent/web/package-lock.json index e05b20b2..22661d18 100644 --- a/moss-live-labs/examples/voice-agent/web/package-lock.json +++ b/moss-live-labs/examples/voice-agent/web/package-lock.json @@ -18,6 +18,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.6", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/moss-live-labs/examples/voice-agent/web/package.json b/moss-live-labs/examples/voice-agent/web/package.json index 8c761f12..19954624 100644 --- a/moss-live-labs/examples/voice-agent/web/package.json +++ b/moss-live-labs/examples/voice-agent/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint ." }, "dependencies": { "@livekit/components-react": "^2.9.11", @@ -19,6 +19,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.6", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", From cb18de58d56a4dce42f291838c9d207bdc6fb15c Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 14:04:33 -0700 Subject: [PATCH 08/17] examples(voice-agent): harden token boundary, region races, and payload limits Bind the Next server to loopback, stop trusting Host for auth, serialize region publishes, re-query on mid-flight region changes, truncate retrieval packets, restore the base voice pipeline, and fix remaining FAQ/CSS compatibility issues. Co-authored-by: Cursor --- moss-live-labs/examples/voice-agent/README.md | 4 +- moss-live-labs/examples/voice-agent/agent.py | 125 ++++++++++++------ .../examples/voice-agent/data/faqs.json | 4 +- .../voice-agent/web/.env.local.example | 4 + .../voice-agent/web/app/api/token/route.ts | 60 ++++++--- .../examples/voice-agent/web/app/globals.css | 3 + .../web/components/RetrievalPanel.tsx | 64 ++++++--- .../voice-agent/web/package-lock.json | 16 +-- .../examples/voice-agent/web/package.json | 7 +- 9 files changed, 199 insertions(+), 88 deletions(-) diff --git a/moss-live-labs/examples/voice-agent/README.md b/moss-live-labs/examples/voice-agent/README.md index 0364a8a0..c3bb22db 100644 --- a/moss-live-labs/examples/voice-agent/README.md +++ b/moss-live-labs/examples/voice-agent/README.md @@ -54,10 +54,10 @@ cd web npm install # create once; do not overwrite an existing Cloud-configured .env.local [ -f .env.local ] || cp .env.local.example .env.local -npm run dev # → http://localhost:3000 +npm run dev # → http://127.0.0.1:3000 (loopback-only; use `npm run dev:lan` + ALLOW_REMOTE_TOKEN=1 for LAN) ``` -Open http://localhost:3000, click **Start the demo**, and talk. The right-hand panel +Open http://127.0.0.1:3000, click **Start the demo**, and talk. The right-hand panel shows what Moss retrieves on each turn. > Prefer no UI? `uv run python agent.py console` still works for a mic-only, terminal session. diff --git a/moss-live-labs/examples/voice-agent/agent.py b/moss-live-labs/examples/voice-agent/agent.py index 11c0fe15..50d2ef3d 100644 --- a/moss-live-labs/examples/voice-agent/agent.py +++ b/moss-live-labs/examples/voice-agent/agent.py @@ -5,7 +5,6 @@ from dotenv import load_dotenv from livekit import rtc from livekit.plugins import openai, deepgram, silero, cartesia -from livekit.plugins.turn_detector.multilingual import MultilingualModel from livekit.agents import ( JobContext, WorkerOptions, @@ -42,6 +41,9 @@ "and offer to help with something else. Do not make up specifics." ) +# LiveKit reliable data packets are capped around 15 KiB; stay under that. +_MAX_RETRIEVAL_BYTES = 14 * 1024 + logging.basicConfig(level=logging.INFO) logger = logging.getLogger("moss-agent") @@ -69,6 +71,56 @@ def __init__(self, moss_client: MossClient, room: rtc.Room, region: str = REGION self.room = room self.region = region # live-updated from the UI region picker + def _encode_retrieval_payload(self, query: str, docs: list, took_ms: float, region: str) -> bytes: + """Build a moss.retrieval JSON payload that fits LiveKit's reliable size limit.""" + docs_out = [ + { + "id": getattr(d, "id", None), + "text": d.text, + "score": float(getattr(d, "score", 0.0)), + } + for d in docs + ] + # Prefer highest-scoring docs if we must drop some for size. + docs_out.sort(key=lambda d: d["score"], reverse=True) + + def encode(q: str, doc_list: list) -> bytes: + return json.dumps( + { + "query": q, + "docs": doc_list, + "took_ms": round(took_ms, 2), + "region": region, + }, + ensure_ascii=False, + ).encode("utf-8") + + q = query + raw = encode(q, docs_out) + while len(raw) > _MAX_RETRIEVAL_BYTES: + if docs_out: + last = docs_out[-1] + text = last.get("text") or "" + if len(text) > 120: + last["text"] = text[: max(40, len(text) // 2)].rstrip() + "…" + else: + docs_out.pop() + raw = encode(q, docs_out) + continue + if len(q) > 80: + q = q[: max(40, len(q) // 2)].rstrip() + "…" + raw = encode(q, docs_out) + continue + break + + if len(raw) > _MAX_RETRIEVAL_BYTES: + logger.warning( + "moss.retrieval payload still %s bytes after truncation; publishing empty docs", + len(raw), + ) + raw = encode(q[:80], []) + return raw + async def _publish_retrieval( self, query: str, @@ -80,29 +132,28 @@ async def _publish_retrieval( # Use Moss's own server-reported search time; fall back to wall-clock. server_ms = getattr(results, "time_taken_ms", None) if results is not None else None took_ms = float(server_ms) if server_ms is not None else fallback_ms - docs = results.docs if results and getattr(results, "docs", None) else [] - payload = { - "query": query, - "docs": [ - { - "id": getattr(d, "id", None), - "text": d.text, - "score": float(getattr(d, "score", 0.0)), - } - for d in docs - ], - "took_ms": round(took_ms, 2), - "region": region, - } + docs = list(results.docs) if results and getattr(results, "docs", None) else [] + payload = self._encode_retrieval_payload(query, docs, took_ms, region) try: await self.room.local_participant.publish_data( - payload=json.dumps(payload).encode("utf-8"), + payload=payload, reliable=True, topic="moss.retrieval", ) except Exception as e: logger.warning(f"Failed to publish retrieval data: {e}") + async def _query_moss(self, user_query: str, region: str): + region_filter = {"field": "region", "condition": {"$in": [region, "all"]}} + t0 = time.perf_counter() + results = await self.moss.query( + INDEX_NAME, + user_query, + QueryOptions(top_k=5, alpha=0.8, filter=region_filter), + ) + took_ms = (time.perf_counter() - t0) * 1000.0 + return results, took_ms + async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage) -> None: """ Intercept user message -> Search Moss -> Inject Context -> Continue @@ -117,15 +168,18 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM region = self.region try: - # 1. Automatic Search — metadata-filtered to this region + global docs - region_filter = {"field": "region", "condition": {"$in": [region, "all"]}} - t0 = time.perf_counter() - results = await self.moss.query( - INDEX_NAME, - user_query, - QueryOptions(top_k=5, alpha=0.8, filter=region_filter), - ) - took_ms = (time.perf_counter() - t0) * 1000.0 + results, took_ms = await self._query_moss(user_query, region) + + # Picker changed while moss.query was in flight — drop stale region results + # and answer for the region the UI now shows. + if self.region != region: + logger.info( + "Region changed during retrieval (%s → %s); re-querying", + region, + self.region, + ) + region = self.region + results, took_ms = await self._query_moss(user_query, region) # 2. Stream the retrieval to the web UI (the Moss knowledge-base panel) await self._publish_retrieval(user_query, results, took_ms, region) @@ -198,22 +252,13 @@ def _on_data(pkt: rtc.DataPacket): "(region metadata filtering needs the index loaded locally)." ) - # Create Session + # Keep the voice pipeline identical to the base example; this PR only adds retrieval/UI. session = AgentSession( - stt=deepgram.STT(model="nova-2", language="en-US"), - llm=openai.LLM(model="gpt-4o-mini"), - # sonic-turbo = Cartesia's lowest-latency model; "Jacqueline" voice. - # Swap the id for any voice from play.cartesia.ai. - tts=cartesia.TTS(model="sonic-turbo", voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc"), - # activation_threshold above the 0.5 default + a short silence window - # cuts false triggers so the agent doesn't talk over the caller. - vad=silero.VAD.load(min_silence_duration=0.5, activation_threshold=0.6), - # A real turn-detection model + endpointing delays make turn-taking - # feel crisp instead of guessing on raw VAD. - turn_handling={ - "turn_detection": MultilingualModel(), - "endpointing": {"min_delay": 0.5, "max_delay": 1.5}, - }, + stt=deepgram.STT(), + llm=openai.LLM(model="gpt-4o"), + tts=cartesia.TTS(model="sonic-3-2026-01-12"), + vad=silero.VAD.load(), + turn_handling={"interruption": {"mode": "vad"}}, ) agent = MossSemanticRetrievalAgent(moss_client, ctx.room, region=pending_region["value"]) diff --git a/moss-live-labs/examples/voice-agent/data/faqs.json b/moss-live-labs/examples/voice-agent/data/faqs.json index 0f329286..bc0fc6e8 100644 --- a/moss-live-labs/examples/voice-agent/data/faqs.json +++ b/moss-live-labs/examples/voice-agent/data/faqs.json @@ -3,7 +3,7 @@ "id": "returns-policy", "category": "returns", "region": "all", - "text": "For eligible items, Northwind accepts returns for a refund as long as they are unworn, unwashed, and in their original packaging with tags attached. Eligibility and return windows follow your region's returns policy (for example final-sale rules in the US, or statutory withdrawal rules in the EU). Start a return from the Orders page in your account and print the prepaid label." + "text": "For Northwind's standard (voluntary) returns program, eligible items can be returned for a refund when they are unworn, unwashed, and in their original packaging with tags attached — subject to your region's returns policy. In the EU, the separate 14-day statutory right of withdrawal is not lost merely because packaging or tags are missing; see the EU returns policy for statutory exceptions. Start a standard return from the Orders page in your account and print the prepaid label." }, { "id": "return-window-us", @@ -105,7 +105,7 @@ "id": "warranty-eu", "category": "product", "region": "EU", - "text": "In the EU, you have a minimum two-year legal guarantee against goods that are not in conformity at delivery. If a manufacturing defect appears within that period, we'll repair or replace the item at no cost. Wear and tear and accidental damage aren't covered by this guarantee." + "text": "In the EU, you have a minimum two-year legal guarantee when goods are not in conformity with the contract at delivery — including manufacturing defects and other lack-of-conformity issues (for example, goods that do not match the description, are unfit for their normal purpose, or lack advertised qualities). Remedies are repair or replacement in the first instance; if that is impossible or fails, you are entitled to an appropriate price reduction or a contract termination refund. Wear and tear and accidental damage after delivery are not covered by this guarantee." }, { "id": "damaged-item", diff --git a/moss-live-labs/examples/voice-agent/web/.env.local.example b/moss-live-labs/examples/voice-agent/web/.env.local.example index d296dc41..296a7945 100644 --- a/moss-live-labs/examples/voice-agent/web/.env.local.example +++ b/moss-live-labs/examples/voice-agent/web/.env.local.example @@ -1,3 +1,7 @@ LIVEKIT_URL=ws://localhost:7880 LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret + +# Optional: allow token minting beyond loopback (also use `npm run dev:lan`). +# ALLOW_REMOTE_TOKEN=1 +# TRUST_PROXY=1 diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index eb2d993b..b0698fb7 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -6,30 +6,58 @@ const LIVEKIT_URL = process.env.LIVEKIT_URL; const API_KEY = process.env.LIVEKIT_API_KEY; const API_SECRET = process.env.LIVEKIT_API_SECRET; const ALLOW_REMOTE_TOKEN = process.env.ALLOW_REMOTE_TOKEN === "1"; +const TRUST_PROXY = process.env.TRUST_PROXY === "1"; export const revalidate = 0; -function isLocalDevHost(request: Request): boolean { - const host = (request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "") - .split(",")[0] - ?.trim() - .toLowerCase(); - if (!host) return false; - const hostname = host.replace(/:\d+$/, ""); - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "[::1]" || - hostname === "::1" - ); +function isLoopbackIp(ip: string): boolean { + const normalized = ip.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if ( + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + normalized === "::ffff:127.0.0.1" + ) { + return true; + } + return /^127(?:\.\d{1,3}){3}$/.test(normalized); } -// Local-dev demo: refuse token minting for non-localhost hosts unless explicitly opted in. -export async function GET(request: Request) { - if (!ALLOW_REMOTE_TOKEN && !isLocalDevHost(request)) { +/** + * Resolve the caller address. Never use Host / X-Forwarded-Host — those name the + * virtual host and are trivially spoofable. Prefer the socket-derived peer via a + * trusted proxy's forwarding headers, or deny when the peer cannot be verified. + */ +function peerIp(request: Request): string | null { + if (!TRUST_PROXY) return null; + const xff = request.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0]?.trim() || null; + const realIp = request.headers.get("x-real-ip"); + return realIp?.trim() || null; +} + +function assertLocalDevOnly(request: Request): NextResponse | null { + if (ALLOW_REMOTE_TOKEN) return null; + + // Host-header checks are intentionally not used here (spoofable). + // Primary control: `next dev` / `next start` bind to 127.0.0.1 (see package.json). + // Secondary: production builds always deny; with TRUST_PROXY, require loopback peer. + if (process.env.NODE_ENV === "production") { + return new NextResponse("Token endpoint is local-dev only", { status: 403 }); + } + + const ip = peerIp(request); + if (ip !== null && !isLoopbackIp(ip)) { return new NextResponse("Token endpoint is local-dev only", { status: 403 }); } + return null; +} + +// Local-dev demo: mint tokens only for loopback-bound servers unless explicitly opted in. +export async function GET(request: Request) { + const denied = assertLocalDevOnly(request); + if (denied) return denied; + try { if (!LIVEKIT_URL) throw new Error("LIVEKIT_URL is not defined"); if (!API_KEY) throw new Error("LIVEKIT_API_KEY is not defined"); diff --git a/moss-live-labs/examples/voice-agent/web/app/globals.css b/moss-live-labs/examples/voice-agent/web/app/globals.css index fcaa332d..db91c108 100644 --- a/moss-live-labs/examples/voice-agent/web/app/globals.css +++ b/moss-live-labs/examples/voice-agent/web/app/globals.css @@ -220,6 +220,7 @@ body { color: var(--text); margin: 14px 0 6px; min-height: 27px; + overflow-wrap: anywhere; } .retrieval-query .label { font-family: var(--font-mono); @@ -272,6 +273,7 @@ body { font-size: 14px; line-height: 1.45; color: var(--muted); + overflow-wrap: anywhere; } .retrieval-foot { font-family: var(--font-mono); @@ -297,6 +299,7 @@ body { /* ---- footer ---- */ .footer { display: flex; + flex-wrap: wrap; align-items: center; justify-content: center; gap: 20px; diff --git a/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx b/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx index 2a595c14..490cba65 100644 --- a/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx +++ b/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx @@ -43,8 +43,13 @@ export function RetrievalPanel() { const [data, setData] = useState(null); const [region, setRegion] = useState("US"); const [regionError, setRegionError] = useState(null); - // Committed region used for retrieval filtering — only advances after a successful publish. + // Filter/display region — updated immediately on click so stale chunks never + // appear under the wrong label while publish is in flight. + const filterRegionRef = useRef("US"); const committedRegionRef = useRef("US"); + // Monotonic id so only the latest select/sync callback may mutate UI state. + const opIdRef = useRef(0); + const publishChainRef = useRef(Promise.resolve()); useDataChannel( "moss.retrieval", @@ -57,8 +62,7 @@ export function RetrievalPanel() { console.error("invalid moss.retrieval payload shape"); return; } - // Ignore stale results from a previous region after the picker changed. - if (parsed.region && parsed.region !== committedRegionRef.current) return; + if (parsed.region && parsed.region !== filterRegionRef.current) return; setData(parsed); } catch (err) { console.error("failed to parse moss.retrieval payload", err); @@ -82,12 +86,45 @@ export function RetrievalPanel() { [room], ); + const runRegionOp = useCallback( + (r: Region, opts: { clearResults: boolean; failMessage: string; rollbackTo?: Region }) => { + const opId = ++opIdRef.current; + filterRegionRef.current = r; + setRegion(r); + setRegionError(null); + if (opts.clearResults) setData(null); + + publishChainRef.current = publishChainRef.current + .catch(() => undefined) + .then(async () => { + if (opId !== opIdRef.current) return; + const ok = await publishRegion(r); + if (opId !== opIdRef.current) return; + if (!ok) { + const rollback = opts.rollbackTo ?? committedRegionRef.current; + filterRegionRef.current = rollback; + setRegion(rollback); + setRegionError(opts.failMessage); + return; + } + committedRegionRef.current = r; + filterRegionRef.current = r; + setRegionError(null); + }); + }, + [publishRegion], + ); + // Sync the agent to the committed picker region whenever we connect or the agent joins. useEffect(() => { if (connState !== ConnectionState.Connected) return; const sync = () => { - void publishRegion(committedRegionRef.current).then((ok) => { - if (!ok) setRegionError("Couldn't sync region with the agent — try again."); + // Re-send the UI's current selection (may be in-flight), not only the last commit, + // so an agent join during a pending EU switch cannot overwrite it back to US. + runRegionOp(filterRegionRef.current, { + clearResults: false, + failMessage: "Couldn't sync region with the agent — try again.", + rollbackTo: committedRegionRef.current, }); }; sync(); @@ -96,21 +133,14 @@ export function RetrievalPanel() { return () => { room.off(RoomEvent.ParticipantConnected, onParticipant); }; - }, [connState, publishRegion, room]); + }, [connState, runRegionOp, room]); const selectRegion = (r: Region) => { if (r === region) return; - const previous = committedRegionRef.current; - setRegion(r); - setRegionError(null); - void publishRegion(r).then((ok) => { - if (!ok) { - setRegion(previous); - setRegionError("Couldn't update region — try again."); - return; - } - committedRegionRef.current = r; - setData(null); + runRegionOp(r, { + clearResults: true, + failMessage: "Couldn't update region — try again.", + rollbackTo: committedRegionRef.current, }); }; diff --git a/moss-live-labs/examples/voice-agent/web/package-lock.json b/moss-live-labs/examples/voice-agent/web/package-lock.json index 22661d18..e9eda8ec 100644 --- a/moss-live-labs/examples/voice-agent/web/package-lock.json +++ b/moss-live-labs/examples/voice-agent/web/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", - "@types/node": "^22", + "@types/node": "^18.19.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1111,13 +1111,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~5.26.4" } }, "node_modules/@types/react": { @@ -5707,9 +5707,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, "license": "MIT" }, diff --git a/moss-live-labs/examples/voice-agent/web/package.json b/moss-live-labs/examples/voice-agent/web/package.json index 19954624..0fd2c82d 100644 --- a/moss-live-labs/examples/voice-agent/web/package.json +++ b/moss-live-labs/examples/voice-agent/web/package.json @@ -3,9 +3,10 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -H 127.0.0.1", + "dev:lan": "next dev", "build": "next build", - "start": "next start", + "start": "next start -H 127.0.0.1", "lint": "eslint ." }, "dependencies": { @@ -20,7 +21,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", - "@types/node": "^22", + "@types/node": "^18.19.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", From aa2fc542ff1224ad02bddc0d31ee4724e8c93c66 Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 14:19:41 -0700 Subject: [PATCH 09/17] examples(voice-agent): close remaining token and region race gaps Require verified loopback binding for null peer IPs, take XFF from the trusted rightmost hop, make retrieval region mandatory, commit successful publishes before UI suppression, and revalidate region after every await. Co-authored-by: Cursor --- moss-live-labs/examples/voice-agent/README.md | 4 +- moss-live-labs/examples/voice-agent/agent.py | 51 ++++++++++++------- .../examples/voice-agent/data/faqs.json | 2 +- .../voice-agent/web/.env.local.example | 7 ++- .../voice-agent/web/app/api/token/route.ts | 50 ++++++++++++++---- .../web/components/RetrievalPanel.tsx | 25 +++++---- .../examples/voice-agent/web/lib/types.ts | 3 +- .../examples/voice-agent/web/package.json | 6 +-- 8 files changed, 102 insertions(+), 46 deletions(-) diff --git a/moss-live-labs/examples/voice-agent/README.md b/moss-live-labs/examples/voice-agent/README.md index c3bb22db..cb3fcb92 100644 --- a/moss-live-labs/examples/voice-agent/README.md +++ b/moss-live-labs/examples/voice-agent/README.md @@ -54,7 +54,7 @@ cd web npm install # create once; do not overwrite an existing Cloud-configured .env.local [ -f .env.local ] || cp .env.local.example .env.local -npm run dev # → http://127.0.0.1:3000 (loopback-only; use `npm run dev:lan` + ALLOW_REMOTE_TOKEN=1 for LAN) +npm run dev # → http://127.0.0.1:3000 (loopback-only; `npm run dev:lan` enables LAN + ALLOW_REMOTE_TOKEN) ``` Open http://127.0.0.1:3000, click **Start the demo**, and talk. The right-hand panel @@ -74,7 +74,7 @@ on the `moss.retrieval` data channel: "query": "string", "docs": [{ "id": "string | null (optional)", "text": "string", "score": 0.0 }], "took_ms": 0.0, - "region": "US | EU (optional)" + "region": "US | EU" } ``` diff --git a/moss-live-labs/examples/voice-agent/agent.py b/moss-live-labs/examples/voice-agent/agent.py index 50d2ef3d..7531a045 100644 --- a/moss-live-labs/examples/voice-agent/agent.py +++ b/moss-live-labs/examples/voice-agent/agent.py @@ -164,28 +164,43 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM raise StopResponse() logger.info(f"User asked: {user_query}") - # Snapshot once per turn so filter + panel stay aligned if the picker changes mid-query. - region = self.region try: - results, took_ms = await self._query_moss(user_query, region) - - # Picker changed while moss.query was in flight — drop stale region results - # and answer for the region the UI now shows. - if self.region != region: - logger.info( - "Region changed during retrieval (%s → %s); re-querying", - region, - self.region, - ) + # Re-query / re-publish until the region is still current after every await, + # so a mid-turn picker change cannot leave the spoken answer on the old region. + results = None + took_ms = 0.0 + region = self.region + for _attempt in range(4): region = self.region results, took_ms = await self._query_moss(user_query, region) + if self.region != region: + logger.info( + "Region changed during retrieval (%s → %s); retrying", + region, + self.region, + ) + continue + + await self._publish_retrieval(user_query, results, took_ms, region) + if self.region != region: + logger.info( + "Region changed during retrieval publish (%s → %s); retrying", + region, + self.region, + ) + continue + break + else: + # Exhausted retries while the picker kept moving — stay grounded. + region = self.region + await self._publish_retrieval(user_query, None, 0.0, region) + turn_ctx.add_message(role="system", content=NO_MATCH_CONTEXT) + await super().on_user_turn_completed(turn_ctx, new_message) + return - # 2. Stream the retrieval to the web UI (the Moss knowledge-base panel) - await self._publish_retrieval(user_query, results, took_ms, region) - - # 3. Context Injection - if results.docs: + # 3. Context Injection (region still matches after query + publish) + if results and results.docs: context_str = "\n".join([f"- {d.text}" for d in results.docs]) injection = ( f"Relevant information:\n{context_str}\n\n" @@ -201,7 +216,7 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM except Exception as e: logger.error(f"Moss search failed: {e}", exc_info=True) # Clear stale panel docs and keep the reply grounded when retrieval fails. - await self._publish_retrieval(user_query, None, 0.0, region) + await self._publish_retrieval(user_query, None, 0.0, self.region) turn_ctx.add_message(role="system", content=NO_MATCH_CONTEXT) # 3. Proceed with standard generation diff --git a/moss-live-labs/examples/voice-agent/data/faqs.json b/moss-live-labs/examples/voice-agent/data/faqs.json index bc0fc6e8..69bd4041 100644 --- a/moss-live-labs/examples/voice-agent/data/faqs.json +++ b/moss-live-labs/examples/voice-agent/data/faqs.json @@ -105,7 +105,7 @@ "id": "warranty-eu", "category": "product", "region": "EU", - "text": "In the EU, you have a minimum two-year legal guarantee when goods are not in conformity with the contract at delivery — including manufacturing defects and other lack-of-conformity issues (for example, goods that do not match the description, are unfit for their normal purpose, or lack advertised qualities). Remedies are repair or replacement in the first instance; if that is impossible or fails, you are entitled to an appropriate price reduction or a contract termination refund. Wear and tear and accidental damage after delivery are not covered by this guarantee." + "text": "In the EU, you have a minimum two-year legal guarantee when goods are not in conformity with the contract at delivery — including manufacturing defects and other lack-of-conformity issues (for example, goods that do not match the description, are unfit for their normal purpose, or lack advertised qualities). Remedies are repair or replacement in the first instance; if that is impossible or fails, you are entitled to an appropriate price reduction or, except where the lack of conformity is minor, a contract termination refund. Wear and tear and accidental damage after delivery are not covered by this guarantee." }, { "id": "damaged-item", diff --git a/moss-live-labs/examples/voice-agent/web/.env.local.example b/moss-live-labs/examples/voice-agent/web/.env.local.example index 296a7945..cc79d84a 100644 --- a/moss-live-labs/examples/voice-agent/web/.env.local.example +++ b/moss-live-labs/examples/voice-agent/web/.env.local.example @@ -2,6 +2,11 @@ LIVEKIT_URL=ws://localhost:7880 LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret -# Optional: allow token minting beyond loopback (also use `npm run dev:lan`). +# Optional: allow token minting for non-loopback clients. +# `npm run dev:lan` sets this automatically — you do not need TRUST_PROXY for LAN access. # ALLOW_REMOTE_TOKEN=1 + +# Only for deployments behind a reverse proxy that overwrites X-Real-IP +# (preferred) or appends X-Forwarded-For. Not required for local or LAN demos. # TRUST_PROXY=1 +# TRUSTED_PROXY_HOPS=1 diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index b0698fb7..c3829a65 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -7,6 +7,9 @@ const API_KEY = process.env.LIVEKIT_API_KEY; const API_SECRET = process.env.LIVEKIT_API_SECRET; const ALLOW_REMOTE_TOKEN = process.env.ALLOW_REMOTE_TOKEN === "1"; const TRUST_PROXY = process.env.TRUST_PROXY === "1"; +const TRUSTED_PROXY_HOPS = Math.max(1, Number(process.env.TRUSTED_PROXY_HOPS || "1") || 1); +/** Set by `npm run dev` / `start` so unverified peers are allowed only on loopback binds. */ +const LISTEN_HOST = (process.env.MOSS_LISTEN_HOST || "").trim().toLowerCase(); export const revalidate = 0; @@ -22,35 +25,60 @@ function isLoopbackIp(ip: string): boolean { return /^127(?:\.\d{1,3}){3}$/.test(normalized); } +function isLoopbackListenHost(host: string): boolean { + const hostname = host.replace(/^\[|\]$/g, "").replace(/:\d+$/, ""); + return ( + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "::1" || + hostname === "0:0:0:0:0:0:0:1" + ); +} + /** - * Resolve the caller address. Never use Host / X-Forwarded-Host — those name the - * virtual host and are trivially spoofable. Prefer the socket-derived peer via a - * trusted proxy's forwarding headers, or deny when the peer cannot be verified. + * Resolve the caller address. Never use Host / X-Forwarded-Host. + * Prefer X-Real-IP (proxy-overwritten). For X-Forwarded-For, take the entry + * TRUSTED_PROXY_HOPS from the right — the leftmost value is attacker-controlled + * when clients send a forged header and the proxy only appends. */ function peerIp(request: Request): string | null { if (!TRUST_PROXY) return null; + + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + const xff = request.headers.get("x-forwarded-for"); - if (xff) return xff.split(",")[0]?.trim() || null; - const realIp = request.headers.get("x-real-ip"); - return realIp?.trim() || null; + if (!xff) return null; + const parts = xff + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length < TRUSTED_PROXY_HOPS) return null; + return parts[parts.length - TRUSTED_PROXY_HOPS] || null; } function assertLocalDevOnly(request: Request): NextResponse | null { if (ALLOW_REMOTE_TOKEN) return null; // Host-header checks are intentionally not used here (spoofable). - // Primary control: `next dev` / `next start` bind to 127.0.0.1 (see package.json). - // Secondary: production builds always deny; with TRUST_PROXY, require loopback peer. if (process.env.NODE_ENV === "production") { return new NextResponse("Token endpoint is local-dev only", { status: 403 }); } const ip = peerIp(request); - if (ip !== null && !isLoopbackIp(ip)) { - return new NextResponse("Token endpoint is local-dev only", { status: 403 }); + if (ip !== null) { + return isLoopbackIp(ip) + ? null + : new NextResponse("Token endpoint is local-dev only", { status: 403 }); + } + + // No verified peer IP (TRUST_PROXY unset). Allow only when the npm script + // marked this process as loopback-bound — never treat null IP as "safe" on LAN. + if (LISTEN_HOST && isLoopbackListenHost(LISTEN_HOST)) { + return null; } - return null; + return new NextResponse("Token endpoint is local-dev only", { status: 403 }); } // Local-dev demo: mint tokens only for loopback-bound servers unless explicitly opted in. diff --git a/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx b/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx index 490cba65..f72cf0fc 100644 --- a/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx +++ b/moss-live-labs/examples/voice-agent/web/components/RetrievalPanel.tsx @@ -26,7 +26,7 @@ function parseRetrievalPayload(value: unknown): RetrievalPayload | null { if (typeof raw.query !== "string") return null; if (!Array.isArray(raw.docs) || !raw.docs.every(isRetrievalDoc)) return null; if (typeof raw.took_ms !== "number" || Number.isNaN(raw.took_ms)) return null; - if (raw.region !== undefined && typeof raw.region !== "string") return null; + if (typeof raw.region !== "string" || !raw.region) return null; return { query: raw.query, docs: raw.docs, @@ -62,7 +62,8 @@ export function RetrievalPanel() { console.error("invalid moss.retrieval payload shape"); return; } - if (parsed.region && parsed.region !== filterRegionRef.current) return; + // region is mandatory — drop mismatches (and never accept untagged payloads). + if (parsed.region !== filterRegionRef.current) return; setData(parsed); } catch (err) { console.error("failed to parse moss.retrieval payload", err); @@ -87,7 +88,7 @@ export function RetrievalPanel() { ); const runRegionOp = useCallback( - (r: Region, opts: { clearResults: boolean; failMessage: string; rollbackTo?: Region }) => { + (r: Region, opts: { clearResults: boolean; failMessage: string }) => { const opId = ++opIdRef.current; filterRegionRef.current = r; setRegion(r); @@ -97,25 +98,33 @@ export function RetrievalPanel() { publishChainRef.current = publishChainRef.current .catch(() => undefined) .then(async () => { - if (opId !== opIdRef.current) return; + // Always run the publish (chain serializes). Record successful agent + // sync even if a newer op has superseded this one for UI updates. const ok = await publishRegion(r); + if (ok) { + committedRegionRef.current = r; + } + if (opId !== opIdRef.current) return; + if (!ok) { - const rollback = opts.rollbackTo ?? committedRegionRef.current; + // Roll back to whatever the agent last successfully accepted. + const rollback = committedRegionRef.current; filterRegionRef.current = rollback; setRegion(rollback); setRegionError(opts.failMessage); return; } - committedRegionRef.current = r; + filterRegionRef.current = r; + setRegion(r); setRegionError(null); }); }, [publishRegion], ); - // Sync the agent to the committed picker region whenever we connect or the agent joins. + // Sync the agent to the picker whenever we connect or the agent joins. useEffect(() => { if (connState !== ConnectionState.Connected) return; const sync = () => { @@ -124,7 +133,6 @@ export function RetrievalPanel() { runRegionOp(filterRegionRef.current, { clearResults: false, failMessage: "Couldn't sync region with the agent — try again.", - rollbackTo: committedRegionRef.current, }); }; sync(); @@ -140,7 +148,6 @@ export function RetrievalPanel() { runRegionOp(r, { clearResults: true, failMessage: "Couldn't update region — try again.", - rollbackTo: committedRegionRef.current, }); }; diff --git a/moss-live-labs/examples/voice-agent/web/lib/types.ts b/moss-live-labs/examples/voice-agent/web/lib/types.ts index 3c4cec08..457c8349 100644 --- a/moss-live-labs/examples/voice-agent/web/lib/types.ts +++ b/moss-live-labs/examples/voice-agent/web/lib/types.ts @@ -9,5 +9,6 @@ export type RetrievalPayload = { query: string; docs: RetrievalDoc[]; took_ms: number; - region?: string; + /** Region used for the Moss filter on this turn (required). */ + region: string; }; diff --git a/moss-live-labs/examples/voice-agent/web/package.json b/moss-live-labs/examples/voice-agent/web/package.json index 0fd2c82d..8cc24303 100644 --- a/moss-live-labs/examples/voice-agent/web/package.json +++ b/moss-live-labs/examples/voice-agent/web/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -H 127.0.0.1", - "dev:lan": "next dev", + "dev": "MOSS_LISTEN_HOST=127.0.0.1 next dev -H 127.0.0.1", + "dev:lan": "ALLOW_REMOTE_TOKEN=1 next dev", "build": "next build", - "start": "next start -H 127.0.0.1", + "start": "MOSS_LISTEN_HOST=127.0.0.1 next start -H 127.0.0.1", "lint": "eslint ." }, "dependencies": { From 8b42ad3f158f59ddf06317556b8fb132a4ac607e Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 14:42:41 -0700 Subject: [PATCH 10/17] Update moss-live-labs/examples/voice-agent/web/app/api/token/route.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- moss-live-labs/examples/voice-agent/web/app/api/token/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index c3829a65..970187ff 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -26,7 +26,8 @@ function isLoopbackIp(ip: string): boolean { } function isLoopbackListenHost(host: string): boolean { - const hostname = host.replace(/^\[|\]$/g, "").replace(/:\d+$/, ""); + const bracketed = host.match(/^\[([^\]]+)\](?::\d+)?$/); + const hostname = bracketed?.[1] ?? host.replace(/^([^:]+):\d+$/, "$1"); return ( hostname === "127.0.0.1" || hostname === "localhost" || From 58b57d6ac4ba87a0905ccb05a63b8dedbfe3e732 Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 14:42:48 -0700 Subject: [PATCH 11/17] Update moss-live-labs/examples/voice-agent/web/app/api/token/route.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- moss-live-labs/examples/voice-agent/web/app/api/token/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index 970187ff..f43705c9 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -75,7 +75,7 @@ function assertLocalDevOnly(request: Request): NextResponse | null { // No verified peer IP (TRUST_PROXY unset). Allow only when the npm script // marked this process as loopback-bound — never treat null IP as "safe" on LAN. - if (LISTEN_HOST && isLoopbackListenHost(LISTEN_HOST)) { + if (!TRUST_PROXY && LISTEN_HOST && isLoopbackListenHost(LISTEN_HOST)) { return null; } From c8adde1e7fa995d21a9967fae15a7f124ba14e9e Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 14:45:41 -0700 Subject: [PATCH 12/17] examples(voice-agent): fix Windows env scripts, prod tokens, proxy header, region deferral Use cross-env for portable scripts, allow loopback-bound production starts, make TRUST_PROXY_HEADER an exclusive strategy, and defer region changes until the current reply finishes so spoken policy matches retrieval. Co-authored-by: Cursor --- moss-live-labs/examples/voice-agent/agent.py | 48 +++++++++++++++++-- .../voice-agent/web/.env.local.example | 11 +++-- .../voice-agent/web/app/api/token/route.ts | 35 +++++++++----- .../voice-agent/web/package-lock.json | 25 +++++++--- .../examples/voice-agent/web/package.json | 7 +-- 5 files changed, 99 insertions(+), 27 deletions(-) diff --git a/moss-live-labs/examples/voice-agent/agent.py b/moss-live-labs/examples/voice-agent/agent.py index 7531a045..0d24855b 100644 --- a/moss-live-labs/examples/voice-agent/agent.py +++ b/moss-live-labs/examples/voice-agent/agent.py @@ -70,6 +70,37 @@ def __init__(self, moss_client: MossClient, room: rtc.Room, region: str = REGION self.moss = moss_client self.room = room self.region = region # live-updated from the UI region picker + # Region updates that arrive while a turn/reply is in flight are deferred so the + # spoken answer stays aligned with the retrieval that already ran. + self._pending_region: str | None = None + self._turn_busy = False + + def apply_region(self, region: str) -> None: + """Apply a UI region change, or queue it until the current reply finishes.""" + if region not in ALLOWED_REGIONS: + return + session = getattr(self, "_session", None) + state = getattr(session, "agent_state", None) if session is not None else None + if self._turn_busy or state in ("thinking", "speaking"): + self._pending_region = region + logger.info( + "Region %s queued until current reply finishes (busy=%s state=%s)", + region, + self._turn_busy, + state, + ) + return + self.region = region + self._pending_region = None + logger.info(f"Region filter set to {region}") + + def flush_pending_region(self) -> None: + self._turn_busy = False + if self._pending_region is None: + return + self.region = self._pending_region + logger.info(f"Region filter set to {self.region} (applied after reply)") + self._pending_region = None def _encode_retrieval_payload(self, query: str, docs: list, took_ms: float, region: str) -> bytes: """Build a moss.retrieval JSON payload that fits LiveKit's reliable size limit.""" @@ -164,6 +195,7 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM raise StopResponse() logger.info(f"User asked: {user_query}") + self._turn_busy = True try: # Re-query / re-publish until the region is still current after every await, @@ -219,7 +251,7 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM await self._publish_retrieval(user_query, None, 0.0, self.region) turn_ctx.add_message(role="system", content=NO_MATCH_CONTEXT) - # 3. Proceed with standard generation + # 3. Proceed with standard generation (_turn_busy cleared when agent returns to listening) await super().on_user_turn_completed(turn_ctx, new_message) @@ -233,6 +265,7 @@ async def entrypoint(ctx: JobContext): # to the topic (browser often publishes as soon as the agent participant appears). pending_region = {"value": REGION} agent_holder: dict[str, MossSemanticRetrievalAgent | None] = {"agent": None} + session_holder: dict[str, AgentSession | None] = {"session": None} @ctx.room.on("data_received") def _on_data(pkt: rtc.DataPacket): @@ -243,8 +276,9 @@ def _on_data(pkt: rtc.DataPacket): pending_region["value"] = r agent = agent_holder["agent"] if agent is not None: - agent.region = r - logger.info(f"Region filter set to {r}") + agent.apply_region(r) + else: + logger.info(f"Region filter pending until agent ready: {r}") else: logger.warning(f"Ignoring unknown region {r!r} (allowed: {sorted(ALLOWED_REGIONS)})") except Exception as e: @@ -275,10 +309,18 @@ def _on_data(pkt: rtc.DataPacket): vad=silero.VAD.load(), turn_handling={"interruption": {"mode": "vad"}}, ) + session_holder["session"] = session agent = MossSemanticRetrievalAgent(moss_client, ctx.room, region=pending_region["value"]) + agent._session = session # used by apply_region to detect in-flight replies agent_holder["agent"] = agent + @session.on("agent_state_changed") + def _on_agent_state(ev): + # Once the agent returns to listening, apply any region queued mid-reply. + if ev.new_state in ("listening", "idle"): + agent.flush_pending_region() + # Start the session with our custom MossSemanticRetrievalAgent await session.start(agent=agent, room=ctx.room) diff --git a/moss-live-labs/examples/voice-agent/web/.env.local.example b/moss-live-labs/examples/voice-agent/web/.env.local.example index cc79d84a..d62f4b57 100644 --- a/moss-live-labs/examples/voice-agent/web/.env.local.example +++ b/moss-live-labs/examples/voice-agent/web/.env.local.example @@ -6,7 +6,10 @@ LIVEKIT_API_SECRET=secret # `npm run dev:lan` sets this automatically — you do not need TRUST_PROXY for LAN access. # ALLOW_REMOTE_TOKEN=1 -# Only for deployments behind a reverse proxy that overwrites X-Real-IP -# (preferred) or appends X-Forwarded-For. Not required for local or LAN demos. -# TRUST_PROXY=1 -# TRUSTED_PROXY_HOPS=1 +# Only for deployments behind a reverse proxy. Pick ONE client-IP strategy: +# TRUST_PROXY=1 +# TRUST_PROXY_HEADER=x-forwarded-for # default; append-only proxies (trusted hop from the right) +# TRUSTED_PROXY_HOPS=1 +# or: +# TRUST_PROXY=1 +# TRUST_PROXY_HEADER=x-real-ip # proxies that overwrite a single validated header diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index f43705c9..79892626 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -8,6 +8,13 @@ const API_SECRET = process.env.LIVEKIT_API_SECRET; const ALLOW_REMOTE_TOKEN = process.env.ALLOW_REMOTE_TOKEN === "1"; const TRUST_PROXY = process.env.TRUST_PROXY === "1"; const TRUSTED_PROXY_HOPS = Math.max(1, Number(process.env.TRUSTED_PROXY_HOPS || "1") || 1); +/** + * Explicit trusted-proxy client strategy (only when TRUST_PROXY=1): + * - "x-forwarded-for" (default): append-only proxies; use the rightmost trusted hop. + * - "x-real-ip": proxies that overwrite a single validated header. + * Never fall back between the two — forged X-Real-IP must not bypass XFF hop math. + */ +const TRUST_PROXY_HEADER = (process.env.TRUST_PROXY_HEADER || "x-forwarded-for").trim().toLowerCase(); /** Set by `npm run dev` / `start` so unverified peers are allowed only on loopback binds. */ const LISTEN_HOST = (process.env.MOSS_LISTEN_HOST || "").trim().toLowerCase(); @@ -38,15 +45,21 @@ function isLoopbackListenHost(host: string): boolean { /** * Resolve the caller address. Never use Host / X-Forwarded-Host. - * Prefer X-Real-IP (proxy-overwritten). For X-Forwarded-For, take the entry - * TRUSTED_PROXY_HOPS from the right — the leftmost value is attacker-controlled - * when clients send a forged header and the proxy only appends. + * Strategy is selected only via TRUST_PROXY_HEADER — not both at once. */ function peerIp(request: Request): string | null { if (!TRUST_PROXY) return null; - const realIp = request.headers.get("x-real-ip")?.trim(); - if (realIp) return realIp; + if (TRUST_PROXY_HEADER === "x-real-ip") { + return request.headers.get("x-real-ip")?.trim() || null; + } + + if (TRUST_PROXY_HEADER !== "x-forwarded-for") { + console.error( + `Invalid TRUST_PROXY_HEADER=${JSON.stringify(TRUST_PROXY_HEADER)}; use "x-forwarded-for" or "x-real-ip"`, + ); + return null; + } const xff = request.headers.get("x-forwarded-for"); if (!xff) return null; @@ -55,6 +68,8 @@ function peerIp(request: Request): string | null { .map((part) => part.trim()) .filter(Boolean); if (parts.length < TRUSTED_PROXY_HOPS) return null; + // Rightmost trusted hop — leftmost is attacker-controlled when clients forge XFF + // and the proxy only appends. return parts[parts.length - TRUSTED_PROXY_HOPS] || null; } @@ -62,10 +77,8 @@ function assertLocalDevOnly(request: Request): NextResponse | null { if (ALLOW_REMOTE_TOKEN) return null; // Host-header checks are intentionally not used here (spoofable). - if (process.env.NODE_ENV === "production") { - return new NextResponse("Token endpoint is local-dev only", { status: 403 }); - } - + // Production loopback (`npm start` with MOSS_LISTEN_HOST=127.0.0.1) is allowed; + // remote production still requires ALLOW_REMOTE_TOKEN=1. const ip = peerIp(request); if (ip !== null) { return isLoopbackIp(ip) @@ -73,8 +86,8 @@ function assertLocalDevOnly(request: Request): NextResponse | null { : new NextResponse("Token endpoint is local-dev only", { status: 403 }); } - // No verified peer IP (TRUST_PROXY unset). Allow only when the npm script - // marked this process as loopback-bound — never treat null IP as "safe" on LAN. + // No verified peer IP. Allow only when the npm script marked this process as + // loopback-bound (works for both `next dev` and production `next start`). if (!TRUST_PROXY && LISTEN_HOST && isLoopbackListenHost(LISTEN_HOST)) { return null; } diff --git a/moss-live-labs/examples/voice-agent/web/package-lock.json b/moss-live-labs/examples/voice-agent/web/package-lock.json index e9eda8ec..464ec2a4 100644 --- a/moss-live-labs/examples/voice-agent/web/package-lock.json +++ b/moss-live-labs/examples/voice-agent/web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@livekit/components-react": "^2.9.11", "@livekit/components-styles": "^1.1.6", + "cross-env": "^7.0.3", "geist": "^1.3.1", "livekit-client": "^2.15.4", "livekit-server-sdk": "^2.13.1", @@ -2262,11 +2263,28 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3984,7 +4002,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -4675,7 +4692,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5157,7 +5173,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5170,7 +5185,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5793,7 +5807,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/moss-live-labs/examples/voice-agent/web/package.json b/moss-live-labs/examples/voice-agent/web/package.json index 8cc24303..d42e8d5e 100644 --- a/moss-live-labs/examples/voice-agent/web/package.json +++ b/moss-live-labs/examples/voice-agent/web/package.json @@ -3,15 +3,16 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "MOSS_LISTEN_HOST=127.0.0.1 next dev -H 127.0.0.1", - "dev:lan": "ALLOW_REMOTE_TOKEN=1 next dev", + "dev": "cross-env MOSS_LISTEN_HOST=127.0.0.1 next dev -H 127.0.0.1", + "dev:lan": "cross-env ALLOW_REMOTE_TOKEN=1 next dev", "build": "next build", - "start": "MOSS_LISTEN_HOST=127.0.0.1 next start -H 127.0.0.1", + "start": "cross-env MOSS_LISTEN_HOST=127.0.0.1 next start -H 127.0.0.1", "lint": "eslint ." }, "dependencies": { "@livekit/components-react": "^2.9.11", "@livekit/components-styles": "^1.1.6", + "cross-env": "^7.0.3", "geist": "^1.3.1", "livekit-client": "^2.15.4", "livekit-server-sdk": "^2.13.1", From 1c811dc932c78b151a6d87e905ea37b8c416dc73 Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 15:00:08 -0700 Subject: [PATCH 13/17] examples(voice-agent): test token guard and drop dead region/session code Extract a pure tokenGuard module with focused node:test coverage, simplify turn retrieval to a single query/publish under deferral, and remove unused session_holder. Co-authored-by: Cursor --- moss-live-labs/examples/voice-agent/agent.py | 44 +- .../voice-agent/web/app/api/token/route.ts | 91 +-- .../voice-agent/web/lib/tokenGuard.test.ts | 168 ++++++ .../voice-agent/web/lib/tokenGuard.ts | 96 ++++ .../voice-agent/web/package-lock.json | 519 ++++++++++++++++++ .../examples/voice-agent/web/package.json | 4 +- 6 files changed, 796 insertions(+), 126 deletions(-) create mode 100644 moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts create mode 100644 moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts diff --git a/moss-live-labs/examples/voice-agent/agent.py b/moss-live-labs/examples/voice-agent/agent.py index 0d24855b..50aca3e0 100644 --- a/moss-live-labs/examples/voice-agent/agent.py +++ b/moss-live-labs/examples/voice-agent/agent.py @@ -196,42 +196,14 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM logger.info(f"User asked: {user_query}") self._turn_busy = True + region = self.region try: - # Re-query / re-publish until the region is still current after every await, - # so a mid-turn picker change cannot leave the spoken answer on the old region. - results = None - took_ms = 0.0 - region = self.region - for _attempt in range(4): - region = self.region - results, took_ms = await self._query_moss(user_query, region) - if self.region != region: - logger.info( - "Region changed during retrieval (%s → %s); retrying", - region, - self.region, - ) - continue - - await self._publish_retrieval(user_query, results, took_ms, region) - if self.region != region: - logger.info( - "Region changed during retrieval publish (%s → %s); retrying", - region, - self.region, - ) - continue - break - else: - # Exhausted retries while the picker kept moving — stay grounded. - region = self.region - await self._publish_retrieval(user_query, None, 0.0, region) - turn_ctx.add_message(role="system", content=NO_MATCH_CONTEXT) - await super().on_user_turn_completed(turn_ctx, new_message) - return + # Snapshot region for this turn. Mid-turn picker changes are deferred via + # apply_region() until the reply finishes, so a single query/publish is enough. + results, took_ms = await self._query_moss(user_query, region) + await self._publish_retrieval(user_query, results, took_ms, region) - # 3. Context Injection (region still matches after query + publish) if results and results.docs: context_str = "\n".join([f"- {d.text}" for d in results.docs]) injection = ( @@ -248,10 +220,10 @@ async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatM except Exception as e: logger.error(f"Moss search failed: {e}", exc_info=True) # Clear stale panel docs and keep the reply grounded when retrieval fails. - await self._publish_retrieval(user_query, None, 0.0, self.region) + await self._publish_retrieval(user_query, None, 0.0, region) turn_ctx.add_message(role="system", content=NO_MATCH_CONTEXT) - # 3. Proceed with standard generation (_turn_busy cleared when agent returns to listening) + # Proceed with standard generation (_turn_busy cleared when agent returns to listening) await super().on_user_turn_completed(turn_ctx, new_message) @@ -265,7 +237,6 @@ async def entrypoint(ctx: JobContext): # to the topic (browser often publishes as soon as the agent participant appears). pending_region = {"value": REGION} agent_holder: dict[str, MossSemanticRetrievalAgent | None] = {"agent": None} - session_holder: dict[str, AgentSession | None] = {"session": None} @ctx.room.on("data_received") def _on_data(pkt: rtc.DataPacket): @@ -309,7 +280,6 @@ def _on_data(pkt: rtc.DataPacket): vad=silero.VAD.load(), turn_handling={"interruption": {"mode": "vad"}}, ) - session_holder["session"] = session agent = MossSemanticRetrievalAgent(moss_client, ctx.room, region=pending_region["value"]) agent._session = session # used by apply_region to detect in-flight replies diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index 79892626..71ce37c6 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -1,103 +1,18 @@ import { NextResponse } from "next/server"; import { AccessToken, TrackSource, type VideoGrant } from "livekit-server-sdk"; +import { assertLocalDevOnly, configFromEnv } from "@/lib/tokenGuard"; // Copy web/.env.local.example to web/.env.local to get the `livekit-server --dev` defaults. const LIVEKIT_URL = process.env.LIVEKIT_URL; const API_KEY = process.env.LIVEKIT_API_KEY; const API_SECRET = process.env.LIVEKIT_API_SECRET; -const ALLOW_REMOTE_TOKEN = process.env.ALLOW_REMOTE_TOKEN === "1"; -const TRUST_PROXY = process.env.TRUST_PROXY === "1"; -const TRUSTED_PROXY_HOPS = Math.max(1, Number(process.env.TRUSTED_PROXY_HOPS || "1") || 1); -/** - * Explicit trusted-proxy client strategy (only when TRUST_PROXY=1): - * - "x-forwarded-for" (default): append-only proxies; use the rightmost trusted hop. - * - "x-real-ip": proxies that overwrite a single validated header. - * Never fall back between the two — forged X-Real-IP must not bypass XFF hop math. - */ -const TRUST_PROXY_HEADER = (process.env.TRUST_PROXY_HEADER || "x-forwarded-for").trim().toLowerCase(); -/** Set by `npm run dev` / `start` so unverified peers are allowed only on loopback binds. */ -const LISTEN_HOST = (process.env.MOSS_LISTEN_HOST || "").trim().toLowerCase(); +const TOKEN_GUARD = configFromEnv(); export const revalidate = 0; -function isLoopbackIp(ip: string): boolean { - const normalized = ip.trim().toLowerCase().replace(/^\[|\]$/g, ""); - if ( - normalized === "::1" || - normalized === "0:0:0:0:0:0:0:1" || - normalized === "::ffff:127.0.0.1" - ) { - return true; - } - return /^127(?:\.\d{1,3}){3}$/.test(normalized); -} - -function isLoopbackListenHost(host: string): boolean { - const bracketed = host.match(/^\[([^\]]+)\](?::\d+)?$/); - const hostname = bracketed?.[1] ?? host.replace(/^([^:]+):\d+$/, "$1"); - return ( - hostname === "127.0.0.1" || - hostname === "localhost" || - hostname === "::1" || - hostname === "0:0:0:0:0:0:0:1" - ); -} - -/** - * Resolve the caller address. Never use Host / X-Forwarded-Host. - * Strategy is selected only via TRUST_PROXY_HEADER — not both at once. - */ -function peerIp(request: Request): string | null { - if (!TRUST_PROXY) return null; - - if (TRUST_PROXY_HEADER === "x-real-ip") { - return request.headers.get("x-real-ip")?.trim() || null; - } - - if (TRUST_PROXY_HEADER !== "x-forwarded-for") { - console.error( - `Invalid TRUST_PROXY_HEADER=${JSON.stringify(TRUST_PROXY_HEADER)}; use "x-forwarded-for" or "x-real-ip"`, - ); - return null; - } - - const xff = request.headers.get("x-forwarded-for"); - if (!xff) return null; - const parts = xff - .split(",") - .map((part) => part.trim()) - .filter(Boolean); - if (parts.length < TRUSTED_PROXY_HOPS) return null; - // Rightmost trusted hop — leftmost is attacker-controlled when clients forge XFF - // and the proxy only appends. - return parts[parts.length - TRUSTED_PROXY_HOPS] || null; -} - -function assertLocalDevOnly(request: Request): NextResponse | null { - if (ALLOW_REMOTE_TOKEN) return null; - - // Host-header checks are intentionally not used here (spoofable). - // Production loopback (`npm start` with MOSS_LISTEN_HOST=127.0.0.1) is allowed; - // remote production still requires ALLOW_REMOTE_TOKEN=1. - const ip = peerIp(request); - if (ip !== null) { - return isLoopbackIp(ip) - ? null - : new NextResponse("Token endpoint is local-dev only", { status: 403 }); - } - - // No verified peer IP. Allow only when the npm script marked this process as - // loopback-bound (works for both `next dev` and production `next start`). - if (!TRUST_PROXY && LISTEN_HOST && isLoopbackListenHost(LISTEN_HOST)) { - return null; - } - - return new NextResponse("Token endpoint is local-dev only", { status: 403 }); -} - // Local-dev demo: mint tokens only for loopback-bound servers unless explicitly opted in. export async function GET(request: Request) { - const denied = assertLocalDevOnly(request); + const denied = assertLocalDevOnly(request, TOKEN_GUARD); if (denied) return denied; try { diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts new file mode 100644 index 00000000..d4829aae --- /dev/null +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + assertLocalDevOnly, + isLoopbackIp, + isLoopbackListenHost, + peerIp, + type TokenGuardConfig, +} from "./tokenGuard"; + +function req(headers: Record = {}): Request { + return new Request("http://example.test/api/token", { headers }); +} + +function baseConfig(overrides: Partial = {}): TokenGuardConfig { + return { + allowRemoteToken: false, + trustProxy: false, + trustedProxyHops: 1, + trustProxyHeader: "x-forwarded-for", + listenHost: "", + ...overrides, + }; +} + +describe("isLoopbackIp", () => { + it("accepts IPv4 and IPv6 loopback forms", () => { + assert.equal(isLoopbackIp("127.0.0.1"), true); + assert.equal(isLoopbackIp("127.1.2.3"), true); + assert.equal(isLoopbackIp("::1"), true); + assert.equal(isLoopbackIp("[::1]"), true); + assert.equal(isLoopbackIp("::ffff:127.0.0.1"), true); + }); + + it("rejects non-loopback addresses", () => { + assert.equal(isLoopbackIp("10.0.0.1"), false); + assert.equal(isLoopbackIp("192.168.1.1"), false); + assert.equal(isLoopbackIp("8.8.8.8"), false); + }); +}); + +describe("isLoopbackListenHost", () => { + it("accepts loopback hosts with optional ports / brackets", () => { + assert.equal(isLoopbackListenHost("127.0.0.1"), true); + assert.equal(isLoopbackListenHost("127.0.0.1:3000"), true); + assert.equal(isLoopbackListenHost("localhost"), true); + assert.equal(isLoopbackListenHost("[::1]"), true); + assert.equal(isLoopbackListenHost("[::1]:3000"), true); + }); + + it("rejects non-loopback listen hosts", () => { + assert.equal(isLoopbackListenHost("0.0.0.0"), false); + assert.equal(isLoopbackListenHost("192.168.0.5"), false); + }); +}); + +describe("peerIp strategies", () => { + it("returns null when TRUST_PROXY is off", () => { + assert.equal( + peerIp(req({ "x-forwarded-for": "8.8.8.8", "x-real-ip": "127.0.0.1" }), baseConfig()), + null, + ); + }); + + it("x-forwarded-for uses the rightmost trusted hop, not the forged leftmost", () => { + const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", trustedProxyHops: 1 }); + assert.equal(peerIp(req({ "x-forwarded-for": "8.8.8.8, 10.0.0.5" }), config), "10.0.0.5"); + assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1, 8.8.8.8" }), config), "8.8.8.8"); + }); + + it("x-forwarded-for respects TRUSTED_PROXY_HOPS", () => { + const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", trustedProxyHops: 2 }); + assert.equal( + peerIp(req({ "x-forwarded-for": "client, proxy1, proxy2" }), config), + "proxy1", + ); + assert.equal(peerIp(req({ "x-forwarded-for": "only-one" }), config), null); + }); + + it("x-real-ip strategy ignores X-Forwarded-For entirely", () => { + const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-real-ip" }); + assert.equal( + peerIp(req({ "x-real-ip": "10.0.0.9", "x-forwarded-for": "127.0.0.1" }), config), + "10.0.0.9", + ); + assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1" }), config), null); + }); + + it("returns null for missing headers or invalid strategy", () => { + assert.equal( + peerIp(req({}), baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for" })), + null, + ); + assert.equal( + peerIp(req({ "x-forwarded-for": "1.2.3.4" }), baseConfig({ trustProxy: true, trustProxyHeader: "nope" })), + null, + ); + }); +}); + +describe("assertLocalDevOnly", () => { + it("allows everything when ALLOW_REMOTE_TOKEN is set", () => { + assert.equal(assertLocalDevOnly(req(), baseConfig({ allowRemoteToken: true })), null); + }); + + it("allows loopback-bound listen host without proxy (dev/start scripts)", () => { + assert.equal( + assertLocalDevOnly(req(), baseConfig({ listenHost: "127.0.0.1" })), + null, + ); + assert.equal( + assertLocalDevOnly(req(), baseConfig({ listenHost: "localhost" })), + null, + ); + }); + + it("denies when listen host is missing or non-loopback and peer IP is unknown", () => { + const denied = assertLocalDevOnly(req(), baseConfig({ listenHost: "" })); + assert.ok(denied); + assert.equal(denied.status, 403); + + const deniedLan = assertLocalDevOnly(req(), baseConfig({ listenHost: "0.0.0.0" })); + assert.ok(deniedLan); + assert.equal(deniedLan.status, 403); + }); + + it("with TRUST_PROXY + xff, allows loopback peer and denies remote peer", () => { + const config = baseConfig({ + trustProxy: true, + trustProxyHeader: "x-forwarded-for", + listenHost: "127.0.0.1", // must not bypass a verified non-loopback peer + }); + assert.equal( + assertLocalDevOnly(req({ "x-forwarded-for": "evil, 127.0.0.1" }), config), + null, + ); + const denied = assertLocalDevOnly(req({ "x-forwarded-for": "127.0.0.1, 8.8.8.8" }), config); + assert.ok(denied); + assert.equal(denied.status, 403); + }); + + it("with TRUST_PROXY + x-real-ip, forged loopback real-ip is evaluated alone", () => { + const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-real-ip" }); + assert.equal(assertLocalDevOnly(req({ "x-real-ip": "127.0.0.1" }), config), null); + const denied = assertLocalDevOnly(req({ "x-real-ip": "8.8.8.8" }), config); + assert.ok(denied); + assert.equal(denied.status, 403); + }); + + it("does not treat Host spoofing as authorization", () => { + const denied = assertLocalDevOnly( + req({ host: "localhost", "x-forwarded-host": "127.0.0.1" }), + baseConfig({ listenHost: "" }), + ); + assert.ok(denied); + assert.equal(denied.status, 403); + }); + + it("denies null peer when TRUST_PROXY is on even if listen host is loopback", () => { + // TRUST_PROXY without a usable client header must not fall open via MOSS_LISTEN_HOST. + const denied = assertLocalDevOnly( + req({}), + baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", listenHost: "127.0.0.1" }), + ); + assert.ok(denied); + assert.equal(denied.status, 403); + }); +}); diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts new file mode 100644 index 00000000..421d0cd2 --- /dev/null +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; + +export type TokenGuardConfig = { + allowRemoteToken: boolean; + trustProxy: boolean; + trustedProxyHops: number; + /** Exclusive strategy: "x-forwarded-for" | "x-real-ip" */ + trustProxyHeader: string; + listenHost: string; +}; + +export function configFromEnv(env: NodeJS.ProcessEnv = process.env): TokenGuardConfig { + return { + allowRemoteToken: env.ALLOW_REMOTE_TOKEN === "1", + trustProxy: env.TRUST_PROXY === "1", + trustedProxyHops: Math.max(1, Number(env.TRUSTED_PROXY_HOPS || "1") || 1), + trustProxyHeader: (env.TRUST_PROXY_HEADER || "x-forwarded-for").trim().toLowerCase(), + listenHost: (env.MOSS_LISTEN_HOST || "").trim().toLowerCase(), + }; +} + +export function isLoopbackIp(ip: string): boolean { + const normalized = ip.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if ( + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + normalized === "::ffff:127.0.0.1" + ) { + return true; + } + return /^127(?:\.\d{1,3}){3}$/.test(normalized); +} + +export function isLoopbackListenHost(host: string): boolean { + const bracketed = host.match(/^\[([^\]]+)\](?::\d+)?$/); + const hostname = bracketed?.[1] ?? host.replace(/^([^:]+):\d+$/, "$1"); + return ( + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "::1" || + hostname === "0:0:0:0:0:0:0:1" + ); +} + +/** + * Resolve the caller address. Never use Host / X-Forwarded-Host. + * Strategy is selected only via trustProxyHeader — not both at once. + */ +export function peerIp(request: Request, config: TokenGuardConfig): string | null { + if (!config.trustProxy) return null; + + if (config.trustProxyHeader === "x-real-ip") { + return request.headers.get("x-real-ip")?.trim() || null; + } + + if (config.trustProxyHeader !== "x-forwarded-for") { + return null; + } + + const xff = request.headers.get("x-forwarded-for"); + if (!xff) return null; + const parts = xff + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length < config.trustedProxyHops) return null; + // Rightmost trusted hop — leftmost is attacker-controlled when clients forge XFF + // and the proxy only appends. + return parts[parts.length - config.trustedProxyHops] || null; +} + +/** Returns a 403 response when the caller is not allowed; otherwise null. */ +export function assertLocalDevOnly( + request: Request, + config: TokenGuardConfig, +): NextResponse | null { + if (config.allowRemoteToken) return null; + + // Host-header checks are intentionally not used here (spoofable). + // Production loopback (`npm start` with MOSS_LISTEN_HOST=127.0.0.1) is allowed; + // remote production still requires ALLOW_REMOTE_TOKEN=1. + const ip = peerIp(request, config); + if (ip !== null) { + return isLoopbackIp(ip) + ? null + : new NextResponse("Token endpoint is local-dev only", { status: 403 }); + } + + // No verified peer IP. Allow only when the npm script marked this process as + // loopback-bound (works for both `next dev` and production `next start`). + if (!config.trustProxy && config.listenHost && isLoopbackListenHost(config.listenHost)) { + return null; + } + + return new NextResponse("Token endpoint is local-dev only", { status: 403 }); +} diff --git a/moss-live-labs/examples/voice-agent/web/package-lock.json b/moss-live-labs/examples/voice-agent/web/package-lock.json index 464ec2a4..1376c947 100644 --- a/moss-live-labs/examples/voice-agent/web/package-lock.json +++ b/moss-live-labs/examples/voice-agent/web/package-lock.json @@ -25,6 +25,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^15.5.4", + "tsx": "^4.19.0", "typescript": "^5" } }, @@ -67,6 +68,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2668,6 +3111,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3238,6 +3723,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5575,6 +6075,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/moss-live-labs/examples/voice-agent/web/package.json b/moss-live-labs/examples/voice-agent/web/package.json index d42e8d5e..7a8f8c00 100644 --- a/moss-live-labs/examples/voice-agent/web/package.json +++ b/moss-live-labs/examples/voice-agent/web/package.json @@ -7,7 +7,8 @@ "dev:lan": "cross-env ALLOW_REMOTE_TOKEN=1 next dev", "build": "next build", "start": "cross-env MOSS_LISTEN_HOST=127.0.0.1 next start -H 127.0.0.1", - "lint": "eslint ." + "lint": "eslint .", + "test": "tsx --test lib/tokenGuard.test.ts" }, "dependencies": { "@livekit/components-react": "^2.9.11", @@ -27,6 +28,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^15.5.4", + "tsx": "^4.19.0", "typescript": "^5" } } From 03053359f7a050f6b033773269b1ab0a67566060 Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 15:22:17 -0700 Subject: [PATCH 14/17] examples(voice-agent): harden tokenGuard against spoofed proxy and Host abuse Validate 127/8 octets, require TRUSTED_PROXIES before honoring forwarded IPs, restore TRUST_PROXY_HEADER misconfig warnings, and allowlist Host on the loopback listen path to block DNS rebinding. Co-authored-by: Cursor --- .../voice-agent/web/.env.local.example | 10 +- .../voice-agent/web/app/api/token/route.ts | 8 +- .../voice-agent/web/lib/tokenGuard.test.ts | 170 +++++++++++++----- .../voice-agent/web/lib/tokenGuard.ts | 89 +++++++-- 4 files changed, 215 insertions(+), 62 deletions(-) diff --git a/moss-live-labs/examples/voice-agent/web/.env.local.example b/moss-live-labs/examples/voice-agent/web/.env.local.example index d62f4b57..d80915d7 100644 --- a/moss-live-labs/examples/voice-agent/web/.env.local.example +++ b/moss-live-labs/examples/voice-agent/web/.env.local.example @@ -6,10 +6,10 @@ LIVEKIT_API_SECRET=secret # `npm run dev:lan` sets this automatically — you do not need TRUST_PROXY for LAN access. # ALLOW_REMOTE_TOKEN=1 -# Only for deployments behind a reverse proxy. Pick ONE client-IP strategy: +# Reverse-proxy mode (optional). Origin must not be publicly reachable. +# TRUSTED_PROXIES is required when TRUST_PROXY=1 — only those immediate peers may +# supply forwarded client IPs. Use "loopback" and/or concrete proxy addresses. # TRUST_PROXY=1 -# TRUST_PROXY_HEADER=x-forwarded-for # default; append-only proxies (trusted hop from the right) +# TRUSTED_PROXIES=loopback,10.0.0.2 +# TRUST_PROXY_HEADER=x-forwarded-for # default; or x-real-ip (overwrite strategy) # TRUSTED_PROXY_HOPS=1 -# or: -# TRUST_PROXY=1 -# TRUST_PROXY_HEADER=x-real-ip # proxies that overwrite a single validated header diff --git a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts index 71ce37c6..ad92f1a8 100644 --- a/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts +++ b/moss-live-labs/examples/voice-agent/web/app/api/token/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { AccessToken, TrackSource, type VideoGrant } from "livekit-server-sdk"; -import { assertLocalDevOnly, configFromEnv } from "@/lib/tokenGuard"; +import { + assertLocalDevOnly, + configFromEnv, + immediatePeerFromRequest, +} from "@/lib/tokenGuard"; // Copy web/.env.local.example to web/.env.local to get the `livekit-server --dev` defaults. const LIVEKIT_URL = process.env.LIVEKIT_URL; @@ -12,7 +16,7 @@ export const revalidate = 0; // Local-dev demo: mint tokens only for loopback-bound servers unless explicitly opted in. export async function GET(request: Request) { - const denied = assertLocalDevOnly(request, TOKEN_GUARD); + const denied = assertLocalDevOnly(request, TOKEN_GUARD, immediatePeerFromRequest(request)); if (denied) return denied; try { diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts index d4829aae..6319f464 100644 --- a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts @@ -4,12 +4,14 @@ import { assertLocalDevOnly, isLoopbackIp, isLoopbackListenHost, + isTrustedProxyPeer, + isValidIpv4, peerIp, type TokenGuardConfig, } from "./tokenGuard"; function req(headers: Record = {}): Request { - return new Request("http://example.test/api/token", { headers }); + return new Request("http://127.0.0.1:3000/api/token", { headers }); } function baseConfig(overrides: Partial = {}): TokenGuardConfig { @@ -19,11 +21,12 @@ function baseConfig(overrides: Partial = {}): TokenGuardConfig trustedProxyHops: 1, trustProxyHeader: "x-forwarded-for", listenHost: "", + trustedProxies: [], ...overrides, }; } -describe("isLoopbackIp", () => { +describe("isValidIpv4 / isLoopbackIp", () => { it("accepts IPv4 and IPv6 loopback forms", () => { assert.equal(isLoopbackIp("127.0.0.1"), true); assert.equal(isLoopbackIp("127.1.2.3"), true); @@ -32,10 +35,14 @@ describe("isLoopbackIp", () => { assert.equal(isLoopbackIp("::ffff:127.0.0.1"), true); }); - it("rejects non-loopback addresses", () => { + it("rejects non-loopback and malformed IPv4 octets", () => { assert.equal(isLoopbackIp("10.0.0.1"), false); assert.equal(isLoopbackIp("192.168.1.1"), false); assert.equal(isLoopbackIp("8.8.8.8"), false); + assert.equal(isValidIpv4("127.999.999.999"), false); + assert.equal(isLoopbackIp("127.999.999.999"), false); + assert.equal(isLoopbackIp("127.0.0.01"), false); + assert.equal(isLoopbackIp("::ffff:127.999.0.1"), false); }); }); @@ -51,50 +58,103 @@ describe("isLoopbackListenHost", () => { it("rejects non-loopback listen hosts", () => { assert.equal(isLoopbackListenHost("0.0.0.0"), false); assert.equal(isLoopbackListenHost("192.168.0.5"), false); + assert.equal(isLoopbackListenHost("evil.example"), false); }); }); describe("peerIp strategies", () => { + const trusted = baseConfig({ + trustProxy: true, + trustProxyHeader: "x-forwarded-for", + trustedProxies: ["loopback", "10.0.0.2"], + }); + it("returns null when TRUST_PROXY is off", () => { assert.equal( - peerIp(req({ "x-forwarded-for": "8.8.8.8", "x-real-ip": "127.0.0.1" }), baseConfig()), + peerIp(req({ "x-forwarded-for": "8.8.8.8", "x-real-ip": "127.0.0.1" }), baseConfig(), "127.0.0.1"), null, ); }); - it("x-forwarded-for uses the rightmost trusted hop, not the forged leftmost", () => { - const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", trustedProxyHops: 1 }); - assert.equal(peerIp(req({ "x-forwarded-for": "8.8.8.8, 10.0.0.5" }), config), "10.0.0.5"); - assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1, 8.8.8.8" }), config), "8.8.8.8"); + it("ignores forwarded headers when TRUSTED_PROXIES is empty", () => { + const config = baseConfig({ trustProxy: true, trustedProxies: [] }); + assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1" }), config, "127.0.0.1"), null); }); - it("x-forwarded-for respects TRUSTED_PROXY_HOPS", () => { - const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", trustedProxyHops: 2 }); + it("ignores forwarded headers when the immediate peer is not a trusted proxy", () => { assert.equal( - peerIp(req({ "x-forwarded-for": "client, proxy1, proxy2" }), config), - "proxy1", + peerIp(req({ "x-forwarded-for": "127.0.0.1" }), trusted, "8.8.8.8"), + null, + ); + assert.equal( + peerIp(req({ "x-forwarded-for": "127.0.0.1" }), trusted, null), + null, ); - assert.equal(peerIp(req({ "x-forwarded-for": "only-one" }), config), null); }); - it("x-real-ip strategy ignores X-Forwarded-For entirely", () => { - const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-real-ip" }); + it("x-forwarded-for uses the rightmost trusted hop after peer verification", () => { + assert.equal(isTrustedProxyPeer("127.0.0.1", ["loopback"]), true); assert.equal( - peerIp(req({ "x-real-ip": "10.0.0.9", "x-forwarded-for": "127.0.0.1" }), config), - "10.0.0.9", + peerIp(req({ "x-forwarded-for": "8.8.8.8, 10.0.0.5" }), trusted, "127.0.0.1"), + "10.0.0.5", + ); + assert.equal( + peerIp(req({ "x-forwarded-for": "127.0.0.1, 8.8.8.8" }), trusted, "10.0.0.2"), + "8.8.8.8", ); - assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1" }), config), null); }); - it("returns null for missing headers or invalid strategy", () => { + it("x-forwarded-for respects TRUSTED_PROXY_HOPS", () => { + const config = baseConfig({ + trustProxy: true, + trustProxyHeader: "x-forwarded-for", + trustedProxyHops: 2, + trustedProxies: ["loopback"], + }); assert.equal( - peerIp(req({}), baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for" })), - null, + peerIp(req({ "x-forwarded-for": "client, proxy1, proxy2" }), config, "127.0.0.1"), + "proxy1", ); + assert.equal(peerIp(req({ "x-forwarded-for": "only-one" }), config, "127.0.0.1"), null); + }); + + it("x-real-ip strategy ignores X-Forwarded-For entirely", () => { + const config = baseConfig({ + trustProxy: true, + trustProxyHeader: "x-real-ip", + trustedProxies: ["loopback"], + }); assert.equal( - peerIp(req({ "x-forwarded-for": "1.2.3.4" }), baseConfig({ trustProxy: true, trustProxyHeader: "nope" })), - null, + peerIp(req({ "x-real-ip": "10.0.0.9", "x-forwarded-for": "127.0.0.1" }), config, "127.0.0.1"), + "10.0.0.9", ); + assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1" }), config, "127.0.0.1"), null); + }); + + it("returns null and warns for missing headers or invalid strategy", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(String(args[0])); + }; + try { + assert.equal(peerIp(req({}), trusted, "127.0.0.1"), null); + assert.equal( + peerIp( + req({ "x-forwarded-for": "1.2.3.4" }), + baseConfig({ + trustProxy: true, + trustProxyHeader: "x-forwardedfor", + trustedProxies: ["loopback"], + }), + "127.0.0.1", + ), + null, + ); + assert.ok(warnings.some((w) => w.includes("TRUST_PROXY_HEADER"))); + } finally { + console.warn = original; + } }); }); @@ -103,51 +163,76 @@ describe("assertLocalDevOnly", () => { assert.equal(assertLocalDevOnly(req(), baseConfig({ allowRemoteToken: true })), null); }); - it("allows loopback-bound listen host without proxy (dev/start scripts)", () => { + it("allows loopback-bound listen host with loopback Host header", () => { assert.equal( - assertLocalDevOnly(req(), baseConfig({ listenHost: "127.0.0.1" })), + assertLocalDevOnly(req({ host: "127.0.0.1:3000" }), baseConfig({ listenHost: "127.0.0.1" })), null, ); assert.equal( - assertLocalDevOnly(req(), baseConfig({ listenHost: "localhost" })), + assertLocalDevOnly(req({ host: "localhost:3000" }), baseConfig({ listenHost: "localhost" })), null, ); }); + it("denies DNS-rebinding Host values even when listen host is loopback", () => { + const denied = assertLocalDevOnly( + req({ host: "evil.example" }), + baseConfig({ listenHost: "127.0.0.1" }), + ); + assert.ok(denied); + assert.equal(denied.status, 403); + }); + it("denies when listen host is missing or non-loopback and peer IP is unknown", () => { - const denied = assertLocalDevOnly(req(), baseConfig({ listenHost: "" })); + const denied = assertLocalDevOnly(req({ host: "127.0.0.1" }), baseConfig({ listenHost: "" })); assert.ok(denied); assert.equal(denied.status, 403); - const deniedLan = assertLocalDevOnly(req(), baseConfig({ listenHost: "0.0.0.0" })); + const deniedLan = assertLocalDevOnly( + req({ host: "127.0.0.1" }), + baseConfig({ listenHost: "0.0.0.0" }), + ); assert.ok(deniedLan); assert.equal(deniedLan.status, 403); }); - it("with TRUST_PROXY + xff, allows loopback peer and denies remote peer", () => { + it("with TRUST_PROXY, only trusts forwarded IPs from a configured immediate proxy peer", () => { const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", - listenHost: "127.0.0.1", // must not bypass a verified non-loopback peer + trustedProxies: ["loopback"], + listenHost: "127.0.0.1", }); assert.equal( - assertLocalDevOnly(req({ "x-forwarded-for": "evil, 127.0.0.1" }), config), + assertLocalDevOnly(req({ "x-forwarded-for": "evil, 127.0.0.1" }), config, "127.0.0.1"), null, ); - const denied = assertLocalDevOnly(req({ "x-forwarded-for": "127.0.0.1, 8.8.8.8" }), config); + // Untrusted immediate peer — headers ignored, listenHost path blocked by trustProxy. + const denied = assertLocalDevOnly( + req({ host: "127.0.0.1", "x-forwarded-for": "127.0.0.1" }), + config, + "8.8.8.8", + ); assert.ok(denied); assert.equal(denied.status, 403); }); - it("with TRUST_PROXY + x-real-ip, forged loopback real-ip is evaluated alone", () => { - const config = baseConfig({ trustProxy: true, trustProxyHeader: "x-real-ip" }); - assert.equal(assertLocalDevOnly(req({ "x-real-ip": "127.0.0.1" }), config), null); - const denied = assertLocalDevOnly(req({ "x-real-ip": "8.8.8.8" }), config); + it("with TRUST_PROXY + x-real-ip, requires trusted immediate peer", () => { + const config = baseConfig({ + trustProxy: true, + trustProxyHeader: "x-real-ip", + trustedProxies: ["10.0.0.2"], + }); + assert.equal( + assertLocalDevOnly(req({ "x-real-ip": "127.0.0.1" }), config, "10.0.0.2"), + null, + ); + const denied = assertLocalDevOnly(req({ "x-real-ip": "127.0.0.1" }), config, "10.0.0.9"); assert.ok(denied); assert.equal(denied.status, 403); }); - it("does not treat Host spoofing as authorization", () => { + it("does not treat Host spoofing as authorization without listen host", () => { const denied = assertLocalDevOnly( req({ host: "localhost", "x-forwarded-host": "127.0.0.1" }), baseConfig({ listenHost: "" }), @@ -157,10 +242,15 @@ describe("assertLocalDevOnly", () => { }); it("denies null peer when TRUST_PROXY is on even if listen host is loopback", () => { - // TRUST_PROXY without a usable client header must not fall open via MOSS_LISTEN_HOST. const denied = assertLocalDevOnly( - req({}), - baseConfig({ trustProxy: true, trustProxyHeader: "x-forwarded-for", listenHost: "127.0.0.1" }), + req({ host: "127.0.0.1" }), + baseConfig({ + trustProxy: true, + trustProxyHeader: "x-forwarded-for", + trustedProxies: ["loopback"], + listenHost: "127.0.0.1", + }), + null, ); assert.ok(denied); assert.equal(denied.status, 403); diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts index 421d0cd2..9a9a881a 100644 --- a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts @@ -7,6 +7,11 @@ export type TokenGuardConfig = { /** Exclusive strategy: "x-forwarded-for" | "x-real-ip" */ trustProxyHeader: string; listenHost: string; + /** + * Immediate TCP peers allowed to supply forwarded client IPs when trustProxy is on. + * Use concrete IPs and/or the shorthand "loopback". Empty = fail closed (ignore headers). + */ + trustedProxies: string[]; }; export function configFromEnv(env: NodeJS.ProcessEnv = process.env): TokenGuardConfig { @@ -16,19 +21,38 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): TokenGuardC trustedProxyHops: Math.max(1, Number(env.TRUSTED_PROXY_HOPS || "1") || 1), trustProxyHeader: (env.TRUST_PROXY_HEADER || "x-forwarded-for").trim().toLowerCase(), listenHost: (env.MOSS_LISTEN_HOST || "").trim().toLowerCase(), + trustedProxies: (env.TRUSTED_PROXIES || "") + .split(",") + .map((part) => part.trim().toLowerCase()) + .filter(Boolean), }; } +function normalizeIp(ip: string): string { + return ip.trim().toLowerCase().replace(/^\[|\]$/g, ""); +} + +/** Valid dotted IPv4 with each octet in 0–255 (no leading junk). */ +export function isValidIpv4(ip: string): boolean { + const parts = ip.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + if (!/^\d{1,3}$/.test(part)) return false; + const n = Number(part); + return n >= 0 && n <= 255 && String(n) === part; // reject "127.0.0.01" / "127.0.0.999" + }); +} + export function isLoopbackIp(ip: string): boolean { - const normalized = ip.trim().toLowerCase().replace(/^\[|\]$/g, ""); - if ( - normalized === "::1" || - normalized === "0:0:0:0:0:0:0:1" || - normalized === "::ffff:127.0.0.1" - ) { + const normalized = normalizeIp(ip); + if (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") { return true; } - return /^127(?:\.\d{1,3}){3}$/.test(normalized); + if (normalized.startsWith("::ffff:")) { + const v4 = normalized.slice("::ffff:".length); + return isValidIpv4(v4) && v4.startsWith("127."); + } + return isValidIpv4(normalized) && normalized.startsWith("127."); } export function isLoopbackListenHost(host: string): boolean { @@ -42,18 +66,45 @@ export function isLoopbackListenHost(host: string): boolean { ); } +export function isTrustedProxyPeer(immediatePeer: string, trustedProxies: string[]): boolean { + const peer = normalizeIp(immediatePeer); + return trustedProxies.some((entry) => { + if (entry === "loopback") return isLoopbackIp(peer); + return normalizeIp(entry) === peer; + }); +} + /** - * Resolve the caller address. Never use Host / X-Forwarded-Host. - * Strategy is selected only via trustProxyHeader — not both at once. + * Resolve the caller address from forwarded headers. + * Headers are ignored unless TRUST_PROXY is on, TRUSTED_PROXIES is configured, and + * the immediate TCP peer is in that allowlist (origin isolation / trusted proxy). */ -export function peerIp(request: Request, config: TokenGuardConfig): string | null { +export function peerIp( + request: Request, + config: TokenGuardConfig, + immediatePeer: string | null, +): string | null { if (!config.trustProxy) return null; + if (config.trustedProxies.length === 0) { + console.warn( + "TRUST_PROXY=1 but TRUSTED_PROXIES is empty; ignoring forwarded client IP headers", + ); + return null; + } + + if (!immediatePeer || !isTrustedProxyPeer(immediatePeer, config.trustedProxies)) { + return null; + } + if (config.trustProxyHeader === "x-real-ip") { return request.headers.get("x-real-ip")?.trim() || null; } if (config.trustProxyHeader !== "x-forwarded-for") { + console.warn( + `Invalid TRUST_PROXY_HEADER=${JSON.stringify(config.trustProxyHeader)}; use "x-forwarded-for" or "x-real-ip"`, + ); return null; } @@ -69,17 +120,21 @@ export function peerIp(request: Request, config: TokenGuardConfig): string | nul return parts[parts.length - config.trustedProxyHops] || null; } +/** Socket / platform peer when available (NextRequest.ip). Never trust Host for this. */ +export function immediatePeerFromRequest(request: Request): string | null { + const ip = (request as Request & { ip?: string | null }).ip; + return typeof ip === "string" && ip.trim() ? ip.trim() : null; +} + /** Returns a 403 response when the caller is not allowed; otherwise null. */ export function assertLocalDevOnly( request: Request, config: TokenGuardConfig, + immediatePeer: string | null = null, ): NextResponse | null { if (config.allowRemoteToken) return null; - // Host-header checks are intentionally not used here (spoofable). - // Production loopback (`npm start` with MOSS_LISTEN_HOST=127.0.0.1) is allowed; - // remote production still requires ALLOW_REMOTE_TOKEN=1. - const ip = peerIp(request, config); + const ip = peerIp(request, config, immediatePeer); if (ip !== null) { return isLoopbackIp(ip) ? null @@ -87,8 +142,12 @@ export function assertLocalDevOnly( } // No verified peer IP. Allow only when the npm script marked this process as - // loopback-bound (works for both `next dev` and production `next start`). + // loopback-bound AND the browser Host is also loopback (DNS-rebinding boundary). if (!config.trustProxy && config.listenHost && isLoopbackListenHost(config.listenHost)) { + const host = request.headers.get("host") ?? ""; + if (!isLoopbackListenHost(host)) { + return new NextResponse("Token endpoint is local-dev only", { status: 403 }); + } return null; } From 75a9443562cc592621cd19e954bf8f663d529285 Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 15:37:29 -0700 Subject: [PATCH 15/17] Update moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../examples/voice-agent/web/lib/tokenGuard.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts index 9a9a881a..07cfea18 100644 --- a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts @@ -29,7 +29,16 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): TokenGuardC } function normalizeIp(ip: string): string { - return ip.trim().toLowerCase().replace(/^\[|\]$/g, ""); + const normalized = ip.trim().toLowerCase(); + const unwrapped = + normalized.startsWith("[") && normalized.endsWith("]") + ? normalized.slice(1, -1) + : normalized; + if (unwrapped.startsWith("::ffff:")) { + const v4 = unwrapped.slice("::ffff:".length); + if (isValidIpv4(v4)) return v4; + } + return unwrapped; } /** Valid dotted IPv4 with each octet in 0–255 (no leading junk). */ From 15987bc05099bf4fd9547c271f7f8f0cee41e70f Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Fri, 17 Jul 2026 15:38:49 -0700 Subject: [PATCH 16/17] examples(voice-agent): log TRUST_PROXY misconfig once at startup Move empty TRUSTED_PROXIES and invalid TRUST_PROXY_HEADER warnings out of per-request peerIp so token traffic cannot flood the logs. Co-authored-by: Cursor --- .../voice-agent/web/lib/tokenGuard.test.ts | 36 ++++++++++++++++++- .../voice-agent/web/lib/tokenGuard.ts | 35 +++++++++++++----- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts index 6319f464..642c5388 100644 --- a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.test.ts @@ -2,11 +2,13 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { assertLocalDevOnly, + configFromEnv, isLoopbackIp, isLoopbackListenHost, isTrustedProxyPeer, isValidIpv4, peerIp, + warnTokenGuardMisconfig, type TokenGuardConfig, } from "./tokenGuard"; @@ -131,7 +133,7 @@ describe("peerIp strategies", () => { assert.equal(peerIp(req({ "x-forwarded-for": "127.0.0.1" }), config, "127.0.0.1"), null); }); - it("returns null and warns for missing headers or invalid strategy", () => { + it("returns null for missing headers or invalid strategy without per-request warnings", () => { const warnings: string[] = []; const original = console.warn; console.warn = (...args: unknown[]) => { @@ -151,6 +153,38 @@ describe("peerIp strategies", () => { ), null, ); + assert.equal(warnings.length, 0); + } finally { + console.warn = original; + } + }); +}); + +describe("warnTokenGuardMisconfig", () => { + it("logs empty TRUSTED_PROXIES and invalid TRUST_PROXY_HEADER once at config time", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(String(args[0])); + }; + try { + warnTokenGuardMisconfig( + baseConfig({ + trustProxy: true, + trustedProxies: [], + trustProxyHeader: "x-forwardedfor", + }), + ); + assert.ok(warnings.some((w) => w.includes("TRUSTED_PROXIES"))); + assert.ok(warnings.some((w) => w.includes("TRUST_PROXY_HEADER"))); + + warnings.length = 0; + configFromEnv({ + TRUST_PROXY: "1", + TRUST_PROXY_HEADER: "x-forwardedfor", + TRUSTED_PROXIES: "", + }); + assert.ok(warnings.some((w) => w.includes("TRUSTED_PROXIES"))); assert.ok(warnings.some((w) => w.includes("TRUST_PROXY_HEADER"))); } finally { console.warn = original; diff --git a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts index 9a9a881a..3762eff5 100644 --- a/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts +++ b/moss-live-labs/examples/voice-agent/web/lib/tokenGuard.ts @@ -14,8 +14,8 @@ export type TokenGuardConfig = { trustedProxies: string[]; }; -export function configFromEnv(env: NodeJS.ProcessEnv = process.env): TokenGuardConfig { - return { +export function configFromEnv(env: NodeJS.ProcessEnv | Record = process.env): TokenGuardConfig { + const config: TokenGuardConfig = { allowRemoteToken: env.ALLOW_REMOTE_TOKEN === "1", trustProxy: env.TRUST_PROXY === "1", trustedProxyHops: Math.max(1, Number(env.TRUSTED_PROXY_HOPS || "1") || 1), @@ -26,6 +26,29 @@ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): TokenGuardC .map((part) => part.trim().toLowerCase()) .filter(Boolean), }; + // One-time startup diagnostics — never log these invariants on every /api/token hit. + warnTokenGuardMisconfig(config); + return config; +} + +/** Log TRUST_PROXY misconfiguration once when config is constructed. */ +export function warnTokenGuardMisconfig(config: TokenGuardConfig): void { + if (!config.trustProxy) return; + + if (config.trustedProxies.length === 0) { + console.warn( + "TRUST_PROXY=1 but TRUSTED_PROXIES is empty; ignoring forwarded client IP headers", + ); + } + + if ( + config.trustProxyHeader !== "x-forwarded-for" && + config.trustProxyHeader !== "x-real-ip" + ) { + console.warn( + `Invalid TRUST_PROXY_HEADER=${JSON.stringify(config.trustProxyHeader)}; use "x-forwarded-for" or "x-real-ip"`, + ); + } } function normalizeIp(ip: string): string { @@ -86,10 +109,9 @@ export function peerIp( ): string | null { if (!config.trustProxy) return null; + // Misconfig (empty TRUSTED_PROXIES / bad TRUST_PROXY_HEADER) is warned once in + // configFromEnv / warnTokenGuardMisconfig — fail closed quietly per request. if (config.trustedProxies.length === 0) { - console.warn( - "TRUST_PROXY=1 but TRUSTED_PROXIES is empty; ignoring forwarded client IP headers", - ); return null; } @@ -102,9 +124,6 @@ export function peerIp( } if (config.trustProxyHeader !== "x-forwarded-for") { - console.warn( - `Invalid TRUST_PROXY_HEADER=${JSON.stringify(config.trustProxyHeader)}; use "x-forwarded-for" or "x-real-ip"`, - ); return null; } From a821b5644390aa474dc3bd7ee525922cd0839a60 Mon Sep 17 00:00:00 2001 From: samanyugoyal2010 Date: Tue, 28 Jul 2026 14:27:50 -0700 Subject: [PATCH 17/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- moss-live-labs/examples/voice-agent/seed_index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moss-live-labs/examples/voice-agent/seed_index.py b/moss-live-labs/examples/voice-agent/seed_index.py index 74f40120..bef5e0aa 100644 --- a/moss-live-labs/examples/voice-agent/seed_index.py +++ b/moss-live-labs/examples/voice-agent/seed_index.py @@ -33,7 +33,7 @@ async def main() -> None: "Missing MOSS_PROJECT_ID / MOSS_PROJECT_KEY. Copy .env.example to .env and fill them in." ) - faqs = json.loads(FAQS_PATH.read_text()) + faqs = json.loads(FAQS_PATH.read_text(encoding="utf-8")) docs = [ DocumentInfo( id=f["id"],