Skip to content

fix(chat): send the double-submit CSRF token on attachment uploads - #3640

Merged
kojiwakayama merged 1 commit into
mainfrom
fix/chat-csrf-double-submit
Aug 12, 2026
Merged

fix(chat): send the double-submit CSRF token on attachment uploads#3640
kojiwakayama merged 1 commit into
mainfrom
fix/chat-csrf-double-submit

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The bug

Chat attachments still 403 in production.

#3611 fixed the AG-UI
chat turn — useChat now sends the x-csrf-token double-submit header. But
attachments travel over two separate transports, and neither was covered:

Hook Request Before
useUpload — what <Chat uploadApi> actually wires POST {api} via XMLHttpRequest no x-csrf-token403
useAttachments — the durable uploads registry POST {url} (upload) no x-csrf-token403
useAttachments DELETE {url}?id= (remove) no x-csrf-token403
useAttachments GET {url} (list) safe method, no token needed

useUpload is the important one: app-mode-chat.tsx:113 and
controlled-chat.tsx:99 both send files through it, so it is the transport the
advertised chat-with-attachments flow actually 403s on. It uploads over
XMLHttpRequest (fetch has no upload-progress event) and applied only
caller-supplied headers.

A production build defaults security.csrf to on, so a deployed chat with
attachments
still answers 403 Forbidden – invalid or missing CSRF token
after #3611. veryfront dev does not enable CSRF, so it works locally and
fails only in production.

The fix

All three mutations route their headers through csrfMutationHeaders — the
helper #3611 already extracted to
src/security/csrf/browser-mutation-headers.ts. No new CSRF implementation
is added here
, and none of #3611's design is revisited. The registry DELETE
passes the actual ?id= target rather than the bare endpoint, so the helper's
same-origin guard evaluates the URL that is really being hit.

The list GET is untouched.

Red → green

The tests drive the real hooks and pipe whatever they emit through the
real CsrfHandler with securityConfig: { csrf: true }, so they fail on
an actual 403 rather than on a header assertion. For useUpload that means a
fake XMLHttpRequest that replays whatever the hook sets into a real
Request — the assertion is still the handler's verdict, not a header check.

Red, with both source files reverted to current origin/main:

chat attachment CSRF ...
  sends the double-submit token on a <Chat uploadApi> upload ... FAILED
  does not leak the page CSRF token from <Chat uploadApi> cross-origin ... ok
  sends the double-submit token on an attachment upload ... FAILED
  sends the double-submit token on an attachment removal ... FAILED
  does not leak the page CSRF token to a cross-origin upload endpoint ... ok

error: AssertionError: Values are not equal.
    [Diff] Actual / Expected
-   403
+   200

Green:

chat attachment CSRF ...
  sends the double-submit token on a <Chat uploadApi> upload ... ok
  does not leak the page CSRF token from <Chat uploadApi> cross-origin ... ok
  sends the double-submit token on an attachment upload ... ok
  sends the double-submit token on an attachment removal ... ok
  does not leak the page CSRF token to a cross-origin upload endpoint ... ok
ok | 1 passed (5 steps) | 0 failed

The two cross-origin tests pass in both columns by design — they guard against
the fix over-reaching and leaking the page token off-origin.

loadDocumentCookie() calls the real applyCsrfCookie and throws if the
Set-Cookie it produces is HttpOnly, so if anyone ever flips that default the
test fails loudly instead of the fix silently becoming a no-op.

Wider gates: deno test over react/components/chat, security,
agent/react255 passed, 0 failed. deno task lint and
deno task typecheck clean. The full pre-push suite passed on push.

(A broader run including src/workflow/ also trips
src/workflow/blob/veryfront-cloud-storage.test.ts, which fails identically
on a clean origin/main worktree
— environmental, the cloud blob tests time
out at 10s without credentials. Nothing here touches it.)

Scope note — the AG-UI half was already fixed in #3611

This PR originally also carried the useChat fix and a move of
workflow/react/mutation-headers.ts into security/. That work branched from
a stale origin/main and duplicated #3611, which had already landed the
same design under the same helper name. That duplication has been dropped and
the branch rebuilt on current main; what remains is only the attachment fix,
which #3611 does not cover (use-chat.csrf.test.tsx on main has no
attachment coverage).

Thanks to @chatgpt-codex-connector for catching that the first version of this
reduced PR patched only useAttachments — the registry hook <Chat> never
calls — and left the actual <Chat uploadApi> upload path still broken.

Follow-ups, not done here

  1. A headers/fetch escape hatch on Chat. useChat already accepts
    headers, but app-mode <Chat> never forwards them, so there was no
    client-side workaround at all. That is a public API-surface change and
    deserves its own PR.
  2. The nested-csrf release manifest bug that blocked the correct
    csrf: { excludePaths: [...] } fix. It is the reason a customer demo is
    currently running with security: { csrf: false }.
  3. Other first-party browser transports that mutate (use-completion,
    use-streaming, use-agents) have the same gap and can adopt
    csrfMutationHeaders in a follow-up.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 46 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: 0f513a1e-5a65-49f7-a393-e0c7da5afa29

📥 Commits

Reviewing files that changed from the base of the PR and between 0bf8946 and e9f5d6b.

⛔ Files ignored due to path filters (1)
  • src/server/handlers/dev/framework-candidates.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (3)
  • docs/api-reference/veryfront/chat.md
  • src/react/components/chat/chat/hooks/attachment-csrf.test.tsx
  • src/react/components/chat/chat/hooks/use-upload.ts
📝 Walkthrough

Walkthrough

Chat attachment upload and removal requests now use csrfMutationHeaders. Existing headers remain supported. Integration tests cover successful same-origin mutations and cross-origin token isolation.

Changes

Chat attachment CSRF protection

Layer / File(s) Summary
Attachment mutation integration
src/react/components/chat/chat/hooks/use-uploads-registry.ts
Upload POST and file-removal DELETE requests now generate CSRF mutation headers with their final URLs and existing headers.
Attachment CSRF integration tests
src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx
Tests validate cookie setup, successful upload and removal requests, simulated CsrfHandler checks, and cross-origin token isolation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UploadHook
  participant csrfMutationHeaders
  participant BrowserDocument
  participant CsrfHandler
  UploadHook->>csrfMutationHeaders: provide upload or removal URL and existing headers
  csrfMutationHeaders->>BrowserDocument: read CSRF cookie
  BrowserDocument-->>csrfMutationHeaders: return cookie value
  csrfMutationHeaders-->>UploadHook: return mutation headers
  UploadHook->>CsrfHandler: send attachment mutation request
  CsrfHandler-->>UploadHook: validate request and return response
Loading

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: sending the double-submit CSRF token with attachment uploads.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/chat-csrf-double-submit

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c02e73c54b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/react/components/chat/chat/hooks/use-uploads-registry.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/agent/react/use-chat/use-chat.csrf.test.tsx (1)

110-134: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a negative control so the suite cannot pass vacuously.

Every edge assertion expects 200. If CsrfHandler stopped enforcing, or csrfCtx() stopped enabling csrf, all three protected-request tests would still pass. Add one case that sends a mutation without the token through the same edge and expect 403. This pins the enforcement that the positive cases rely on.

🧪 Proposed control test
+  it("rejects the same mutation when the client omits the token", async () => {
+    const restoreDom = installDom();
+    loadDocumentCookie();
+    const edge = installCsrfEdge(() => agUiResponse());
+    try {
+      await fetch("/api/ag-ui", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: "{}",
+      });
+
+      assertEquals(edge.statuses.get("POST /api/ag-ui"), 403);
+    } finally {
+      edge.restore();
+      restoreDom();
+    }
+  });

Also applies to: 144-167

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/react/use-chat/use-chat.csrf.test.tsx` around lines 110 - 134, Add
a negative-control test using installCsrfEdge that sends a protected mutation
without the CSRF token and asserts a 403 response. Keep it alongside the
existing protected-request cases so the suite verifies CsrfHandler enforcement
and csrfCtx() configuration rather than passing vacuously.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/agent/react/use-chat/use-chat.csrf.test.tsx`:
- Around line 110-134: Add a negative-control test using installCsrfEdge that
sends a protected mutation without the CSRF token and asserts a 403 response.
Keep it alongside the existing protected-request cases so the suite verifies
CsrfHandler enforcement and csrfCtx() configuration rather than passing
vacuously.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: baa495b0-7fb7-48a7-89ff-b0fc01ccc1bb

📥 Commits

Reviewing files that changed from the base of the PR and between d25c803 and c02e73c.

⛔ Files ignored due to path filters (1)
  • src/server/handlers/dev/framework-candidates.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (8)
  • src/agent/react/use-chat/use-chat.csrf.test.tsx
  • src/agent/react/use-chat/use-chat.ts
  • src/react/components/chat/chat/hooks/use-uploads-registry.ts
  • src/security/csrf/mutation-headers.ts
  • src/workflow/react/mutation-headers.ts
  • src/workflow/react/use-approval.ts
  • src/workflow/react/use-workflow-start.ts
  • src/workflow/react/use-workflow.ts
💤 Files with no reviewable changes (1)
  • src/workflow/react/mutation-headers.ts

@kwakayama kwakayama added the needs-human-input Maintainer action required label Aug 12, 2026
@kojiwakayama
kojiwakayama force-pushed the fix/chat-csrf-double-submit branch from c02e73c to 0bf8946 Compare August 12, 2026 16:19
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@kojiwakayama kojiwakayama changed the title fix(chat): send the double-submit CSRF token on chat mutations fix(chat): send the double-submit CSRF token on attachment mutations Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx (3)

143-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move test setup inside try so patched globals always restore.

Each test patches globalThis before the try block. loadDocumentCookie throws by design when the CSRF cookie is HttpOnly. installCsrfEdge and renderAttachments can also throw. In those cases restoreDom never runs, so jsdom window, document, and the patched fetch leak into the following tests in the same process. The failure then appears in an unrelated test.

Acquire the restore function first, then perform the remaining setup inside try.

♻️ Proposed structure for each test
     const restoreDom = installDom();
-    loadDocumentCookie();
-    const edge = installCsrfEdge((req) =>
-      req.method === "GET"
-        ? Response.json({ items: [] })
-        : Response.json({ id: "up-1", name: "a.txt", url: "/files/a.txt", size: 1 })
-    );
-    const view = renderAttachments("/api/uploads");
+    let edge: ReturnType<typeof installCsrfEdge> | undefined;
+    let view: ReturnType<typeof renderAttachments> | undefined;
     try {
+      loadDocumentCookie();
+      edge = installCsrfEdge((req) =>
+        req.method === "GET"
+          ? Response.json({ items: [] })
+          : Response.json({ id: "up-1", name: "a.txt", url: "/files/a.txt", size: 1 })
+      );
+      view = renderAttachments("/api/uploads");
       view.attachments().upload([new File(["a"], "a.txt", { type: "text/plain" })]);
       await waitFor(() => edge.statuses.has("POST /api/uploads"));
 
       assertEquals(edge.statuses.get("POST /api/uploads"), 200);
     } finally {
-      view.unmount();
-      edge.restore();
+      view?.unmount();
+      edge?.restore();
       restoreDom();
     }

The same pattern applies to the removal test at lines 164-166 and the cross-origin test at lines 187-188.

Also applies to: 164-166, 187-188

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx`
around lines 143 - 150, Move loadDocumentCookie, installCsrfEdge, and
renderAttachments setup inside the try block for each affected test, including
the removal and cross-origin tests, while acquiring restoreDom first. Keep
cleanup in finally so restoreDom always runs when any setup step throws.

106-114: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The simulated edge does not reproduce the multipart body.

installCsrfEdge builds a native Request from init.body, which is a jsdom FormData instance. A native Request does not recognize a FormData from another realm, so the body is coerced instead of encoded as multipart. The CSRF assertions do not read the body, so the current tests still pass. If a later test asserts on upload content, the body will be wrong.

String(input) also fails if a caller passes a Request or URL. Normalize the input instead.

♻️ Proposed hardening of the fetch stub
   globalThis.fetch = async (input, init) => {
-    const url = new URL(String(input), document.baseURI);
+    const raw = input instanceof Request ? input.url : String(input);
+    const url = new URL(raw, document.baseURI);
     const headers = new Headers(init?.headers);
     if (document.cookie) headers.set("cookie", document.cookie);
     const req = new Request(url, {
       method: init?.method ?? "GET",
       headers,
       body: init?.body as BodyInit | undefined,
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx`
around lines 106 - 114, Update installCsrfEdge’s globalThis.fetch stub to
normalize input via the existing Request/URL-aware URL handling instead of
String(input). Preserve jsdom FormData when constructing the native Request by
converting or re-encoding the cross-realm form data as multipart, including the
correct boundary-bearing Content-Type, so upload bodies remain valid for future
assertions.

186-206: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider covering the cross-origin removal request too.

This test proves that the upload POST omits the token for a cross-origin endpoint. The DELETE removal path calls csrfMutationHeaders(target, ...) with a different URL built by setQueryParameter, so it has its own origin computation. A cross-origin DELETE case would guard that second call site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx`
around lines 186 - 206, Add a separate test alongside the existing cross-origin
upload test that exercises attachment removal and verifies the DELETE request
sends no page CSRF token. Use a cross-origin upload endpoint, capture headers
for DELETE, remove the uploaded attachment, and assert the captured x-csrf-token
is null, covering the URL produced through setQueryParameter and its
csrfMutationHeaders call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx`:
- Around line 143-150: Move loadDocumentCookie, installCsrfEdge, and
renderAttachments setup inside the try block for each affected test, including
the removal and cross-origin tests, while acquiring restoreDom first. Keep
cleanup in finally so restoreDom always runs when any setup step throws.
- Around line 106-114: Update installCsrfEdge’s globalThis.fetch stub to
normalize input via the existing Request/URL-aware URL handling instead of
String(input). Preserve jsdom FormData when constructing the native Request by
converting or re-encoding the cross-realm form data as multipart, including the
correct boundary-bearing Content-Type, so upload bodies remain valid for future
assertions.
- Around line 186-206: Add a separate test alongside the existing cross-origin
upload test that exercises attachment removal and verifies the DELETE request
sends no page CSRF token. Use a cross-origin upload endpoint, capture headers
for DELETE, remove the uploaded attachment, and assert the captured x-csrf-token
is null, covering the URL produced through setQueryParameter and its
csrfMutationHeaders call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b840b2d-627d-4549-8e04-23aa7779292c

📥 Commits

Reviewing files that changed from the base of the PR and between e99187b and 0bf8946.

⛔ Files ignored due to path filters (1)
  • src/server/handlers/dev/framework-candidates.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (2)
  • src/react/components/chat/chat/hooks/use-uploads-registry.csrf.test.tsx
  • src/react/components/chat/chat/hooks/use-uploads-registry.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/react/components/chat/chat/hooks/use-uploads-registry.ts

@kojiwakayama
kojiwakayama force-pushed the fix/chat-csrf-double-submit branch from 0bf8946 to b6820dd Compare August 12, 2026 16:25
@kojiwakayama kojiwakayama changed the title fix(chat): send the double-submit CSRF token on attachment mutations fix(chat): send the double-submit CSRF token on attachment uploads 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 force-pushed the fix/chat-csrf-double-submit branch from b6820dd to e9f5d6b Compare August 12, 2026 16:31
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 870bcde Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/chat-csrf-double-submit branch August 12, 2026 16:58
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