From 02f24792ce15ac4c70381ad85f265841af4d0a5a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 11 Aug 2026 22:20:23 +0200 Subject: [PATCH] fix(chat): send the CSRF double-submit token from useChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A production build defaults `security.csrf` to on, so `CsrfHandler` rejects any POST whose `x-csrf-token` header does not match the `__Host-vf_csrf` cookie. `useChat` sent `Content-Type` and the caller's headers and nothing else, so every browser chat turn in a production deployment answered `403 Forbidden – invalid or missing CSRF token` and the UI showed "API error: 403" with a Retry button. `veryfront dev` leaves CSRF off, so the break only appeared after `veryfront build` — the exact step the getting-started walk tells people to take. The `ai-agent` template ships chat as its only feature, so the documented deploy journey produced a dead app. The server half was already correct: HTML responses set a JS-readable `__Host-vf_csrf` cookie, and the same request with the header attached gets past `CsrfHandler`. Only the browser half was missing. `workflowMutationHeaders` already implemented exactly this for workflow mutations. Lift it to `security/csrf/browser-mutation-headers.ts` — a zero-dependency leaf safe for client bundles — and call it from the AG-UI POST. It stays a no-op on the server, when the caller set the header itself, when the endpoint leaves the document origin, and when no cookie exists. --- docs/api-reference/veryfront/chat.md | 2 +- .../react/use-chat/use-chat.csrf.test.tsx | 135 ++++++++++++++++++ src/agent/react/use-chat/use-chat.ts | 8 +- src/security/csrf/browser-mutation-headers.ts | 37 +++++ src/workflow/react/mutation-headers.ts | 19 +-- 5 files changed, 181 insertions(+), 20 deletions(-) create mode 100644 src/agent/react/use-chat/use-chat.csrf.test.tsx create mode 100644 src/security/csrf/browser-mutation-headers.ts diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 93a972135a..2a05778532 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -247,7 +247,7 @@ Result returned from use agent. | `useAgentMetadata` | React hook for browser-safe source-defined agent metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-agent-metadata.ts#L201) | | `useAgents` | React hook that lists the browser-safe agents a project exposes, via `GET /api/agents`. Companion to `useAgentMetadata` (single agent) - use it to drive an agent switcher, e.g. only rendering a picker when `agents.length > 1`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-agents.ts#L53) | | `useAttachments` | `useAttachments` - the headless state hook for chat attachments: a persistent, cross-conversation registry of uploaded files with the upload / remove / list actions. This is the domain primitive; render any UI on top of it (the `AttachmentsPanel` / `AttachmentPill` components are one skin - bring your own). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-uploads-registry.ts#L352) | -| `useChat` | The core chat session hook: manages messages, streaming status, input, submit, regenerate, and branch navigation for a conversation. Powers `` (L1) and is the L3 headless entry point for building a fully custom chat UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/use-chat.ts#L131) | +| `useChat` | The core chat session hook: manages messages, streaming status, input, submit, regenerate, and branch navigation for a conversation. Powers `` (L1) and is the L3 headless entry point for building a fully custom chat UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/use-chat.ts#L132) | | `useChatContextOptional` | React hook for chat context optional. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L101) | | `useChatErrorHandler` | Handler for use chat error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/error-boundary.tsx#L92) | | `useChatInput` | L3 headless composer hook. Must be used within a `` / ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-chat-input.ts#L155) | diff --git a/src/agent/react/use-chat/use-chat.csrf.test.tsx b/src/agent/react/use-chat/use-chat.csrf.test.tsx new file mode 100644 index 0000000000..a619da24f1 --- /dev/null +++ b/src/agent/react/use-chat/use-chat.csrf.test.tsx @@ -0,0 +1,135 @@ +/** + * A production build defaults `security.csrf` to on, so the server rejects any + * POST that arrives without the double-submit header. The chat transport is the + * only thing the `ai-agent` template does, so a `useChat` request that omits + * `x-csrf-token` makes every deployed chat answer `403` — while `veryfront dev` + * (CSRF off) keeps working and hides the break. + */ +import "#veryfront/schemas/_test-setup.ts"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "npm:jsdom@28.0.0"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { useChat } from "./use-chat.ts"; +import type { UseChatResult } from "./types.ts"; + +function installDom(): () => void { + const dom = new JSDOM( + '
', + { url: "https://example.test/" }, + ); + const window = dom.window; + const keys = [ + "window", + "document", + "navigator", + "self", + "Node", + "Element", + "HTMLElement", + ] as const; + const previous: Record = {}; + for (const key of keys) previous[key] = (globalThis as Record)[key]; + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + self: window, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + }); + return () => { + Object.assign(globalThis, previous); + dom.window.close(); + }; +} + +async function settle(): Promise { + for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); +} + +/** Drive one `sendMessage` turn and hand back the headers the transport sent. */ +async function captureSendHeaders( + options: Parameters[0], +): Promise { + const originalFetch = globalThis.fetch; + let sent = new Headers(); + globalThis.fetch = (_input, init) => { + sent = new Headers(init?.headers); + return Promise.resolve( + new Response("event: RunFinished\ndata: {}\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + }; + + let latest: UseChatResult | null = null; + function Capture(): null { + latest = useChat(options); + return null; + } + + const root = createRoot(document.getElementById("root")!); + try { + flushSync(() => root.render()); + await latest!.sendMessage({ text: "hello" }); + await settle(); + return sent; + } finally { + flushSync(() => root.unmount()); + await settle(); + globalThis.fetch = originalFetch; + } +} + +describe("react/agent/useChat CSRF double-submit", () => { + it("sends the __Host-vf_csrf cookie back as x-csrf-token", async () => { + const restoreDom = installDom(); + try { + document.cookie = "__Host-vf_csrf=production-token; Path=/; Secure"; + const headers = await captureSendHeaders({ api: "/api/ag-ui" }); + assertEquals(headers.get("x-csrf-token"), "production-token"); + } finally { + restoreDom(); + } + }); + + it("leaves the header alone when the page has no CSRF cookie", async () => { + const restoreDom = installDom(); + try { + const headers = await captureSendHeaders({ api: "/api/ag-ui" }); + assertEquals(headers.get("x-csrf-token"), null); + } finally { + restoreDom(); + } + }); + + it("keeps a caller-supplied token instead of overwriting it", async () => { + const restoreDom = installDom(); + try { + document.cookie = "__Host-vf_csrf=cookie-token; Path=/; Secure"; + const headers = await captureSendHeaders({ + api: "/api/ag-ui", + headers: { "x-csrf-token": "caller-token" }, + }); + assertEquals(headers.get("x-csrf-token"), "caller-token"); + } finally { + restoreDom(); + } + }); + + it("does not leak the token to a cross-origin chat endpoint", async () => { + const restoreDom = installDom(); + try { + document.cookie = "__Host-vf_csrf=production-token; Path=/; Secure"; + const headers = await captureSendHeaders({ api: "https://other.test/api/ag-ui" }); + assertEquals(headers.get("x-csrf-token"), null); + } finally { + restoreDom(); + } + }); +}); diff --git a/src/agent/react/use-chat/use-chat.ts b/src/agent/react/use-chat/use-chat.ts index 3978fa0f46..b6d9013b12 100644 --- a/src/agent/react/use-chat/use-chat.ts +++ b/src/agent/react/use-chat/use-chat.ts @@ -25,6 +25,7 @@ import type { UseChatResult, } from "#veryfront/agent/react/use-chat/types.ts"; import { generateClientId } from "#veryfront/agent/react/use-chat/utils.ts"; +import { csrfMutationHeaders } from "#veryfront/security/csrf/browser-mutation-headers.ts"; type UseChatStreamHandler = typeof handleAgUiStreamingResponse; @@ -246,10 +247,13 @@ function useChatState(options: UseChatOptions): ResettableUseChatResult { const response = await fetch(api, { method: "POST", - headers: { + // A production build turns `security.csrf` on by default, so this + // POST has to echo the `__Host-vf_csrf` cookie back or the server + // answers 403 — dev, where CSRF is off, would never show it. + headers: csrfMutationHeaders(api, { "Content-Type": "application/json", ...options.headers, - }, + }), credentials: options.credentials, body: JSON.stringify({ messages: allMessages, diff --git a/src/security/csrf/browser-mutation-headers.ts b/src/security/csrf/browser-mutation-headers.ts new file mode 100644 index 0000000000..38d588b812 --- /dev/null +++ b/src/security/csrf/browser-mutation-headers.ts @@ -0,0 +1,37 @@ +/** + * Browser half of the CSRF double-submit pattern. + * + * A production response sets a JS-readable `__Host-vf_csrf` cookie; every + * browser-issued mutation has to echo it back in the `x-csrf-token` header or + * the server answers `403`. Keep this module a zero-dependency leaf — it is + * imported by client bundles, so it must not reach for server-capable code. + * + * @module security/csrf/browser-mutation-headers + */ + +import { parseCookies } from "#veryfront/utils/cookie-utils.ts"; + +const DEFAULT_CSRF_COOKIE_NAME = "__Host-vf_csrf"; +const DEFAULT_CSRF_HEADER_NAME = "x-csrf-token"; + +/** + * Add the double-submit token to a browser mutation aimed at this origin. + * + * No-ops on the server, when the caller already set the header, when the + * request leaves the document origin, or when no token cookie exists. + */ +export function csrfMutationHeaders(requestUrl: string | URL, init?: HeadersInit): Headers { + const headers = new Headers(init); + if (headers.has(DEFAULT_CSRF_HEADER_NAME) || typeof document === "undefined") return headers; + + try { + const resolvedUrl = new URL(requestUrl, document.baseURI); + if (resolvedUrl.origin !== document.location.origin) return headers; + const token = parseCookies(document.cookie)[DEFAULT_CSRF_COOKIE_NAME]; + if (token) headers.set(DEFAULT_CSRF_HEADER_NAME, token); + } catch { + // Sandboxed documents can deny cookie access. The server remains fail-closed. + } + + return headers; +} diff --git a/src/workflow/react/mutation-headers.ts b/src/workflow/react/mutation-headers.ts index a854d36a96..b35c11fe45 100644 --- a/src/workflow/react/mutation-headers.ts +++ b/src/workflow/react/mutation-headers.ts @@ -1,21 +1,6 @@ -import { parseCookies } from "#veryfront/utils/cookie-utils.ts"; - -const DEFAULT_CSRF_COOKIE_NAME = "__Host-vf_csrf"; -const DEFAULT_CSRF_HEADER_NAME = "x-csrf-token"; +import { csrfMutationHeaders } from "#veryfront/security/csrf/browser-mutation-headers.ts"; /** Add the production double-submit token to browser workflow mutations. */ export function workflowMutationHeaders(requestUrl: string | URL, init?: HeadersInit): Headers { - const headers = new Headers(init); - if (headers.has(DEFAULT_CSRF_HEADER_NAME) || typeof document === "undefined") return headers; - - try { - const resolvedUrl = new URL(requestUrl, document.baseURI); - if (resolvedUrl.origin !== document.location.origin) return headers; - const token = parseCookies(document.cookie)[DEFAULT_CSRF_COOKIE_NAME]; - if (token) headers.set(DEFAULT_CSRF_HEADER_NAME, token); - } catch { - // Sandboxed documents can deny cookie access. The server remains fail-closed. - } - - return headers; + return csrfMutationHeaders(requestUrl, init); }