Skip to content

fix(chat): send the CSRF double-submit token from useChat - #3611

Merged
kojiwakayama merged 1 commit into
mainfrom
fix/dx-20260811-r2-22
Aug 12, 2026
Merged

fix(chat): send the CSRF double-submit token from useChat#3611
kojiwakayama merged 1 commit into
mainfrom
fix/dx-20260811-r2-22

Conversation

@kojiwakayama

Copy link
Copy Markdown
Contributor

Symptom

Follow the documented ai-agent journey end to end and the app you ship is dead:
type a message, get "API error: 403" and a Retry button. POST /api/ag-ui
returns 403 Forbidden – invalid or missing CSRF token. veryfront dev works
fine on the identical project, so the break only appears after veryfront build
— the exact step getting-started tells people to take. Chat is the template's
only feature, so the deploy journey produces a broken app.

Reproduced on published 0.1.1229 (and 0.1.1228 — not a release regression).

Root cause

A production build defaults security.csrf to on
(src/security/http/config.ts:271), so CsrfHandler requires x-csrf-token
to match the __Host-vf_csrf cookie on every non-GET/HEAD/OPTIONS request.

The server half is already correct — an HTML response sets a JS-readable
__Host-vf_csrf cookie (applyCsrfCookie, httpOnly: false), and the same POST
with the header attached sails through CSRF. The browser half was missing:
useChat's AG-UI fetch sent Content-Type plus the caller's headers and nothing
else.

Verified against the published artifact — the module 0.1.1229 serves to the browser:

fetch(i,{method:"POST",headers:{"Content-Type":"application/json",...e.headers},})

Fix

workflowMutationHeaders already did exactly this for workflow mutations. Lifted
to src/security/csrf/browser-mutation-headers.ts (a zero-dependency leaf, safe
for client bundles; workflowMutationHeaders now delegates to it) and called from
the AG-UI POST. It no-ops on the server, when the caller already set the header,
when the endpoint is cross-origin, and when there is no cookie — the server stays
fail-closed either way.

Proof the original symptom is gone

Same project, same browser flow (agent-browser: open, type "hello", Enter), two
servers side by side:

Build POST /api/ag-ui UI
published 0.1.1229 veryfront serve 403 "API error: 403"
this branch serving the same project 503 — request carries x-csrf-token: B4l0eejEXxz… (from HAR) "No model credentials configured…"

The 503 is the sandbox having no LLM key; the CSRF wall is gone. Server-side
double-check on the published build: POST with cookie only → 403; identical
POST with cookie and header → past CSRF.

Tests

src/agent/react/use-chat/use-chat.csrf.test.tsx — new. Fails on the pre-fix
code with x-csrf-token = null. Covers: token attached from the cookie, no
cookie → no header, caller-supplied header wins, cross-origin endpoint gets
nothing.

Not in scope (same trap, separate symptoms, no repro here)

use-uploads-registry.ts (attachment upload POST / remove DELETE) and
use-completion.ts (POST) issue browser mutations without the token too, so they
will 403 the same way in production. Left alone deliberately — untested behaviour changes in a fix PR
are how symptoms survive green CI. Worth a follow-up with its own repro.

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.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43baead5-16bd-432e-a908-c37539549a63

📥 Commits

Reviewing files that changed from the base of the PR and between 64d6850 and 02f2479.

📒 Files selected for processing (5)
  • docs/api-reference/veryfront/chat.md
  • src/agent/react/use-chat/use-chat.csrf.test.tsx
  • src/agent/react/use-chat/use-chat.ts
  • src/security/csrf/browser-mutation-headers.ts
  • src/workflow/react/mutation-headers.ts

Comment @coderabbitai help to get the list of available commands.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 11, 2026
@kwakayama kwakayama added the needs-human-input Maintainer action required label Aug 12, 2026
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 1930476 Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/dx-20260811-r2-22 branch August 12, 2026 04:10
kojiwakayama added a commit that referenced this pull request Aug 12, 2026
#3611 fixed the AG-UI chat turn, but `useAttachments` is a separate
transport: it issues its own `POST {url}` to upload and `DELETE {url}?id=`
to remove, and both went out with no `x-csrf-token`. A deployed chat *with
attachments* therefore still answered 403 after #3611. `veryfront dev` does
not enable CSRF, so this only ever showed up in production.

Both mutations now route their headers through `csrfMutationHeaders` — the
helper #3611 already extracted to `security/csrf/browser-mutation-headers.ts`.
No new CSRF implementation is added here. The list `GET` is a safe method and
is left alone.

The tests drive the real hook and pipe whatever it emits through the real
`CsrfHandler` with `securityConfig: { csrf: true }`, so they fail on an
actual 403 rather than on a header assertion.
kojiwakayama added a commit that referenced this pull request Aug 12, 2026
#3611 fixed the AG-UI chat turn, but attachments travel over two *other*
transports and neither sent the token:

  - `useUpload` — the one `<Chat uploadApi>` actually wires up
    (`app-mode-chat.tsx:113`, `controlled-chat.tsx:99`). It uploads over
    `XMLHttpRequest`, since fetch has no upload-progress event, and applied
    only caller-supplied headers.
  - `useAttachments` — the durable uploads registry exported from
    `veryfront/chat`. `POST {url}` to upload, `DELETE {url}?id=` to remove.

A deployed chat *with attachments* therefore still answered 403 after #3611.
`veryfront dev` does not enable CSRF, so this only ever showed up in
production.

All three mutations now route their headers through `csrfMutationHeaders` —
the helper #3611 already extracted to
`security/csrf/browser-mutation-headers.ts`. No new CSRF implementation is
added here. The registry's list `GET` is a safe method and is left alone.
The registry `DELETE` passes its real `?id=` target so the helper's
same-origin guard evaluates the URL actually being hit.

The tests drive the real hooks — including a fake `XMLHttpRequest` that
replays whatever `useUpload` sets into a real `Request` — and pipe the result
through the real `CsrfHandler` with `securityConfig: { csrf: true }`, so they
fail on an actual 403 rather than on a header assertion.
kojiwakayama added a commit that referenced this pull request Aug 12, 2026
#3611 fixed the AG-UI chat turn, but attachments travel over two *other*
transports and neither sent the token:

  - `useUpload` — the one `<Chat uploadApi>` actually wires up
    (`app-mode-chat.tsx:113`, `controlled-chat.tsx:99`). It uploads over
    `XMLHttpRequest`, since fetch has no upload-progress event, and applied
    only caller-supplied headers.
  - `useAttachments` — the durable uploads registry exported from
    `veryfront/chat`. `POST {url}` to upload, `DELETE {url}?id=` to remove.

A deployed chat *with attachments* therefore still answered 403 after #3611.
`veryfront dev` does not enable CSRF, so this only ever showed up in
production.

All three mutations now route their headers through `csrfMutationHeaders` —
the helper #3611 already extracted to
`security/csrf/browser-mutation-headers.ts`. No new CSRF implementation is
added here. The registry's list `GET` is a safe method and is left alone.
The registry `DELETE` passes its real `?id=` target so the helper's
same-origin guard evaluates the URL actually being hit.

The tests drive the real hooks — including a fake `XMLHttpRequest` that
replays whatever `useUpload` sets into a real `Request` — and pipe the result
through the real `CsrfHandler` with `securityConfig: { csrf: true }`, so they
fail on an actual 403 rather than on a header assertion.
@kojiwakayama kojiwakayama mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human-input Maintainer action required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants