Skip to content

fix(tanstack): reject GET on /deco/invoke to close CSRF vector - #462

Open
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/tanstack-invoke-get-csrf
Open

fix(tanstack): reject GET on /deco/invoke to close CSRF vector#462
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/tanstack-invoke-get-csrf

Conversation

@0xcucumbersalad

@0xcucumbersalad 0xcucumbersalad commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

The TanStack admin route (adminRoutes.ts) registered a GET handler on /deco/invoke that called handleInvoke:

const decoInvokeRoute = {
  server: { handlers: {
    GET:  withCors(({ request }) => withTracing("deco.admin.invoke", () => handleInvoke(request), ...)),
    POST: withCors(({ request }) => withTracing("deco.admin.invoke", () => handleInvoke(request), ...)),
    OPTIONS: optionsHandler,
  } },
};

handleInvoke has no auth of its own and honors a ?props= query string on GET (invoke.ts parseBody). A GET is a CORS simple request (no preflight), so it fires regardless of CORS policy.

Impact — CSRF

A third-party page can trigger a mutating action with the victim's cookies attached:

<img src="https://victim/deco/invoke/site/actions/checkout/updateCart.ts?props=%7B...%7D">

No JS, no preflight — the victim just loads the attacker's page and the action runs as them.

The Next dispatcher already blocks this (routeHandlers.ts:170-188 405s non-POST invoke, with a comment naming this exact attack). The TanStack wiring diverged and kept a live GET.

Fix

GET now returns 405 (Allow: POST) and never reaches handleInvoke, mirroring the Next dispatcher. POST is unchanged; withCors/OPTIONS preflight still work.

GET: withCors(() =>
  new Response(
    JSON.stringify({ error: "Method not allowed: invoke is POST-only (CSRF protection)" }),
    { status: 405, headers: { "Content-Type": "application/json", Allow: "POST" } },
  )),

Tests

adminRoutes.test.ts — new CSRF guard (2 tests):

  • a forged cross-site GET with an action + ?props= returns 405 and asserts the action handler is never invoked (expect(handleInvoke).not.toHaveBeenCalled() — the load-bearing assertion: the mutation never fires);
  • POST still dispatches (handleInvoke called once, 200).
Test Files  1 passed (1)
     Tests  15 passed (15)   # 2 new + 13 existing

Scope

Finding F3. Closes the no-preflight GET CSRF vector. Note handleInvoke remains unauthenticated by design (loaders power the public storefront); a cross-site POST with Content-Type: application/json still triggers a preflight — but is only actually blocked once the CORS policy stops reflecting arbitrary origins (F2, separate PR). Ship both for full coverage.

Companion audit PRs: #459 (F5), #460 (F4), #461 (F6).

🤖 Generated with Claude Code


Summary by cubic

Blocked GET requests to /deco/invoke in the TanStack admin routes to close a CSRF vector; the route is now POST-only and mirrors the Next dispatcher. GET returns 405 with Allow: POST; POST behavior is unchanged.

  • Bug Fixes
    • Reject GET on /deco/invoke with a JSON 405 response and never call handleInvoke.
    • Added tests to assert GET is blocked and POST still dispatches.

Written for commit 4420b6e. Summary will update on new commits.

Review in cubic

The TanStack admin route registered a GET handler on /deco/invoke that
called handleInvoke. handleInvoke has no auth of its own and honors a
`?props=` query string on GET (parseBody). A GET is a CORS "simple
request" (no preflight), so a third-party page's

  <img src="https://site/deco/invoke/site/actions/checkout/updateCart.ts?props=...">

fired the mutating action with the victim's cookies attached — CSRF.

The Next dispatcher (routeHandlers.ts) already rejects non-POST invoke
for exactly this reason; the TanStack wiring diverged. Mirror it: GET now
returns 405 (Allow: POST) and never reaches handleInvoke. POST is
unchanged.

Adds a CSRF regression to adminRoutes.test.ts: a forged cross-site GET
with an action + props returns 405 and asserts the action handler is
NEVER invoked; POST still dispatches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@0xcucumbersalad
0xcucumbersalad requested a review from a team August 11, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant