From 40ffd5b129acb3a840bad668d6ccd6508462ff38 Mon Sep 17 00:00:00 2001 From: Teddy Lee Date: Thu, 16 Apr 2026 20:10:58 +0900 Subject: [PATCH] fix(security): store API key only in httpOnly cookies (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Changed - Add `setApiKeyCookieAction` server action that writes the API key to the `lg_apiKey` cookie with `httpOnly`/`secure` flags in production. - Replace the `document.cookie = "lg_apiKey=..."` write in the API-key login form with a call to the new server action. - Remove the `localStorage.setItem("lg:chat:apiKey", ...)` write in the connection switch handler — `switchConnection()` already routes through `updateConnectionAction`, which sets the same cookie via `cookies().set`. - Delete the now-dead `getApiKey()` helper and its sole consumer in `Thread.tsx`. Client-side requests reach LangGraph through the `/api/[..._path]` proxy, which reads the API key from the httpOnly cookie server-side, so no client-side reader is needed. ## Root Cause Two writers stored the LangSmith API key where JavaScript could read it: `document.cookie` without `httpOnly`, and `localStorage`. Either one is a direct exfiltration channel for any XSS payload that runs in the page. ## Solution Approach Funnel every API-key write through a server action so the value lives only in an httpOnly cookie, matching the pattern already used by `updateConnectionAction`. The proxy already reads that cookie server-side, so no consumer changes are required. The `httpOnly`/`secure` flags follow the existing `process.env.NODE_ENV === "production"` pattern in `actions.ts` so DevTools-based debugging stays available in dev. ## Test Plan - [x] `grep -rn 'document\.cookie\s*=.*lg_apiKey' frontend/src` — 0 hits - [x] `grep -rn 'localStorage\.setItem.*lg:chat:apiKey' frontend/src` — 0 hits - [x] `grep -rn 'getApiKey\b' frontend/src/providers` — 0 hits - [x] `grep -rn 'from "@/lib/api-key"' frontend/src` — 0 hits - [x] `frontend/src/lib/api-key.tsx` deleted - [x] `cd frontend && pnpm tsc --noEmit` — 0 errors - [x] `cd frontend && pnpm lint` — clean - [ ] Manual smoke: API-key login + connection switch in production build --- .../src/app/(auth)/login/ApiKeyLoginForm.tsx | 6 ++-- frontend/src/app/actions.ts | 29 +++++++++++++++++++ frontend/src/lib/api-key.tsx | 10 ------- frontend/src/providers/Thread.tsx | 5 ++-- .../components/settings/ConnectionList.tsx | 8 ++--- 5 files changed, 38 insertions(+), 20 deletions(-) delete mode 100644 frontend/src/lib/api-key.tsx diff --git a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx index b53fc37..84b3d23 100644 --- a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx +++ b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx @@ -8,6 +8,7 @@ import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { LoaderCircle, ArrowRight, Key } from "lucide-react"; import { useAuthContext } from "../AuthLayoutClient"; +import { setApiKeyCookieAction } from "@/app/actions"; const containerVariants = { hidden: { opacity: 0 }, @@ -67,8 +68,9 @@ export function ApiKeyLoginForm() { const data = await response.json(); if (data.valid) { - // Store API key in cookie via the existing connection mechanism - document.cookie = `lg_apiKey=${encodeURIComponent(apiKey.trim())}; path=/; max-age=${365 * 24 * 3600}; samesite=lax`; + // Store the API key as an httpOnly cookie via a server action so it is + // not readable from JS (mitigates XSS exfiltration). + await setApiKeyCookieAction(apiKey.trim()); router.push("/"); router.refresh(); } else { diff --git a/frontend/src/app/actions.ts b/frontend/src/app/actions.ts index 25d44e5..d6607e7 100644 --- a/frontend/src/app/actions.ts +++ b/frontend/src/app/actions.ts @@ -54,6 +54,35 @@ export async function updateConnectionAction(connection: { return { success: true }; } +/** + * Server action to set only the API key as an httpOnly cookie. + * + * Used by the API-key login form, which collects only an apiKey (no apiUrl). + * Replaces direct `document.cookie =` writes that left the key JS-readable. + */ +export async function setApiKeyCookieAction(apiKey: string) { + await requireAuth(); + const trimmed = apiKey.trim(); + + const cookieStore = await cookies(); + + if (!trimmed) { + cookieStore.delete(CONNECTION_COOKIE_NAMES.apiKey); + return { success: true }; + } + + const isProduction = process.env.NODE_ENV === "production"; + cookieStore.set(CONNECTION_COOKIE_NAMES.apiKey, trimmed, { + path: "/", + maxAge: COOKIE_MAX_AGE, + sameSite: "lax" as const, + httpOnly: isProduction, + secure: isProduction, + }); + + return { success: true }; +} + /** * Server action to update only the assistantId */ diff --git a/frontend/src/lib/api-key.tsx b/frontend/src/lib/api-key.tsx deleted file mode 100644 index f50f627..0000000 --- a/frontend/src/lib/api-key.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export function getApiKey(): string | null { - try { - if (typeof window === "undefined") return null; - return window.localStorage.getItem("lg:chat:apiKey") ?? null; - } catch { - // no-op - } - - return null; -} diff --git a/frontend/src/providers/Thread.tsx b/frontend/src/providers/Thread.tsx index c8187d9..ebfcb86 100644 --- a/frontend/src/providers/Thread.tsx +++ b/frontend/src/providers/Thread.tsx @@ -1,3 +1,5 @@ +"use client"; + import { validate } from "uuid"; import { Thread, Client } from "@langchain/langgraph-sdk"; import { @@ -10,7 +12,6 @@ import { SetStateAction, } from "react"; import { createClient } from "./client"; -import { getApiKey } from "@/lib/api-key"; import type { ConnectionConfig } from "./Stream"; export interface ThreadContextType { @@ -45,7 +46,7 @@ export function ThreadProvider({ children, connection }: ThreadProviderProps) { const [threads, setThreads] = useState([]); const [threadsLoading, setThreadsLoading] = useState(false); const finalAssistantId = connection.assistantId?.trim() || undefined; - const apiKey = connection.apiKey || getApiKey() || undefined; + const apiKey = connection.apiKey || undefined; // Create client once and memoize const client = useMemo(() => { diff --git a/frontend/src/shared/components/settings/ConnectionList.tsx b/frontend/src/shared/components/settings/ConnectionList.tsx index 188c76d..35c36f1 100644 --- a/frontend/src/shared/components/settings/ConnectionList.tsx +++ b/frontend/src/shared/components/settings/ConnectionList.tsx @@ -108,14 +108,10 @@ export function ConnectionList({ onConnectionChange }: ConnectionListProps) { await switchConnection(connection.id); - // Save API key to localStorage if provided - if (connection.apiKey) { - localStorage.setItem("lg:chat:apiKey", connection.apiKey); - } - toast.success(`Switching to ${connection.name}...`); - // Reload page to apply connection (cookies are already set by switchConnection) + // Reload page to apply connection (cookies are already set by switchConnection + // via updateConnectionAction, which uses httpOnly cookies in production). setTimeout(() => { window.location.href = window.location.pathname; }, 500);