fix(security): store API key only in httpOnly cookies (#77)#87
Open
teddylee777 wants to merge 1 commit into
Open
fix(security): store API key only in httpOnly cookies (#77)#87teddylee777 wants to merge 1 commit into
teddylee777 wants to merge 1 commit into
Conversation
## 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
Open
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context Block / 컨텍스트 블록
Summary / 요약
document.cookie =write inApiKeyLoginFormand alocalStorage.setIteminConnectionList. Deletes the now-deadgetApiKey()helper.setApiKeyCookieActioninfrontend/src/app/actions.tsthat mirrors the existingsecureOptionspattern fromupdateConnectionAction. The proxy atfrontend/src/app/api/[..._path]/route.ts:122-127already reads the cookie server-side, so no consumer changes are needed.switchConnectionserver action already wrote the same cookie). No backend changes.Motivation / Context / 동기 / 배경
Problem Statement / 문제 정의
ApiKeyLoginForm.tsx:71set the API key viadocument.cookie = "lg_apiKey=..."without anhttpOnlyflag, andConnectionList.tsx:113mirrored the key intolocalStorage. Either copy is readable from any JavaScript that runs in the page (XSS, malicious dependency), so an attacker could exfiltrate the LangSmith API key and run up usage charges or download trace data.Trigger / 트리거
Detected by
omb:issuescan (issue #77, checklistISS-UI-R9). Same vulnerability class as #58 (NEXT_PUBLIC_LANGCHAIN_API_KEYbrowser exposure).Prior State / 이전 상태
A safe path already existed:
updateConnectionActioninfrontend/src/app/actions.tswrites connection cookies withhttpOnly: isProductionandsecure: isProduction. The connection-switch flow correctly used it viaswitchConnection(), but ConnectionList then wrote the key tolocalStorageagain, and the API-key login form bypassed the server action entirely.Changes / 변경 사항
frontend/src/app/actions.tssetApiKeyCookieAction(apiKey)server action that sets only thelg_apiKeycookie viacookies().setwithhttpOnly/securein production; deletes the cookie when given an empty stringapiUrl/assistantId), so reusingupdateConnectionAction(which requiresapiUrl) would couple the form to default-connection knowledgefrontend/src/app/(auth)/login/ApiKeyLoginForm.tsxdocument.cookie = "lg_apiKey=..."withawait setApiKeyCookieAction(apiKey.trim())frontend/src/shared/components/settings/ConnectionList.tsxif (connection.apiKey) { localStorage.setItem("lg:chat:apiKey", ...) }block;switchConnection()already callsupdateConnectionAction(...)which sets the same cookie viacookies().setfrontend/src/lib/api-key.tsxgetApiKey()helperThread.tsx) is client-side, where the LangGraph SDK client ignoresapiKey(proxy handles it server-side); kept around it would invite future regressionsfrontend/src/providers/Thread.tsxgetApiKeyimport; simplifyconnection.apiKey || getApiKey() || undefinedtoconnection.apiKey || undefinedDependency & Impact Analysis / 의존성 및 영향 분석
requireAuth()(frontend/src/lib/auth/require-auth.ts) —setApiKeyCookieActioncalls it, matchingupdateConnectionAction. Inapi-keyandcustom-jwtmodesrequireAuth()returnsnull(no NextAuth session needed), so the login flow is unaffected.frontend/src/middleware.ts:107readsreq.cookies.get("lg_apiKey")— server-side, works the same with the httpOnly cookie.frontend/src/app/api/[..._path]/route.ts:122-127reads the same cookie and injectsx-api-keyinto the upstream LangGraph request — unchanged.frontend/src/providers/client.ts:32-36already explicitly skipsapiKeywhen running in the browser (isClient ? undefined : apiKey), so removing the localStorage fallback inThread.tsxdoes not change request behaviour.Test Plan / 테스트 계획
grep -rn 'document\.cookie\s*=.*lg_apiKey' frontend/srcgrep -rn 'localStorage\.setItem.*lg:chat:apiKey' frontend/srcgrep -rn 'getApiKey\b' frontend/src/providersgrep -rn 'from \"@/lib/api-key\"' frontend/srctest ! -f frontend/src/lib/api-key.tsxcd frontend && pnpm tsc --noEmitcd frontend && pnpm lintdocument.cookiein DevToolslg_apiKeyvaluelocalStorage.getItem('lg:chat:apiKey')nullReviewer Notes / 리뷰어 참고 사항
httpOnly/secureflags follow the existing dev-vs-prod gate (process.env.NODE_ENV === \"production\") used byupdateConnectionAction. In local dev the cookie is still readable from JS so DevTools-based debugging keeps working; in production it is httpOnly. This trade-off is preserved deliberately for consistency.frontend/src/shared/components/settings/SettingsDialog.tsx:59still callslocalStorage.removeItem(\"lg:chat:apiKey\")in the reset flow. That call is idempotent and helps clean up data for users who already had the key in localStorage from a previous version, so it is intentionally kept.Follow-up Issues (recommended)
frontend/src/lib/connections/storage.tsstill serialisesconnection.apiKeyinto thelg:connectionslocalStorage entry (addConnection/loadConnections). Same vulnerability class, separate writer. Out of scope for this PR.frontend/so we can write unit tests for security-sensitive server actions likesetApiKeyCookieAction.Related Issues / 관련 이슈
Closes #77
Checklist / 체크리스트
fix/issue-77-apikey-storage)pnpm tsc --noEmit)pnpm lint)