Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-reference/veryfront/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Chat>` (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 `<Chat>` (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 `<ChatInput>` / `<Chat>`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-chat-input.ts#L155) |
Expand Down
135 changes: 135 additions & 0 deletions src/agent/react/use-chat/use-chat.csrf.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
'<!doctype html><html><body><div id="root"></div></body></html>',
{ url: "https://example.test/" },
);
const window = dom.window;
const keys = [
"window",
"document",
"navigator",
"self",
"Node",
"Element",
"HTMLElement",
] as const;
const previous: Record<string, unknown> = {};
for (const key of keys) previous[key] = (globalThis as Record<string, unknown>)[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<void> {
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<typeof useChat>[0],
): Promise<Headers> {
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(<Capture />));
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();
}
});
});
8 changes: 6 additions & 2 deletions src/agent/react/use-chat/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions src/security/csrf/browser-mutation-headers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 2 additions & 17 deletions src/workflow/react/mutation-headers.ts
Original file line number Diff line number Diff line change
@@ -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);
}