From 4420b6e87e6deae95c9434f41960396d3385abc2 Mon Sep 17 00:00:00 2001 From: Jaynel Patiarba Date: Wed, 12 Aug 2026 00:08:43 +0800 Subject: [PATCH] fix(tanstack): reject GET on /deco/invoke to close CSRF vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../tanstack/src/routes/adminRoutes.test.ts | 51 +++++++++++++++++++ packages/tanstack/src/routes/adminRoutes.ts | 13 ++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/tanstack/src/routes/adminRoutes.test.ts b/packages/tanstack/src/routes/adminRoutes.test.ts index 4b985d8e..ebede1fe 100644 --- a/packages/tanstack/src/routes/adminRoutes.test.ts +++ b/packages/tanstack/src/routes/adminRoutes.test.ts @@ -11,9 +11,16 @@ vi.mock("@decocms/blocks/middleware/observability", () => ({ withTracing: vi.fn((_name: string, fn: () => unknown) => fn()), })); +import { handleInvoke } from "@decocms/blocks-admin"; import * as adminRoutes from "./adminRoutes"; import { decoInvokeRouteConfig, decoMetaRouteConfig, decoRenderRouteConfig } from "./adminRoutes"; +type HandlerFn = (ctx: { request: Request }) => Promise | Response; +const handlerFor = ( + config: unknown, + method: string, +): HandlerFn => (config as { server: { handlers: Record } }).server.handlers[method]; + /** * Regression guard for the dev-HMR brick: * @@ -97,3 +104,47 @@ describe("admin route config factories", () => { ]); }); }); + +/** + * CSRF guard for `/deco/invoke`. + * + * `handleInvoke` has no auth of its own and honors a `?props=` query string on + * GET. A GET is a CORS "simple request" (no preflight), so a third-party page's + * `` used to fire mutating + * actions with the victim's cookies. The route must reject GET (POST-only, + * mirroring the Next dispatcher) so the action handler NEVER runs on a GET. + */ +describe("deco/invoke is POST-only (CSRF protection)", () => { + it("does NOT execute the action on GET, and returns 405", async () => { + vi.mocked(handleInvoke).mockClear(); + const get = handlerFor(decoInvokeRouteConfig(), "GET"); + + const res = await get({ + // A forged cross-site GET carrying the action + props, as an would. + request: new Request( + "https://victim.example/deco/invoke/site/actions/checkout/updateCart.ts?props=%7B%22qty%22%3A999%7D", + ), + }); + + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + // The load-bearing assertion: the mutating handler was never invoked. + expect(handleInvoke).not.toHaveBeenCalled(); + }); + + it("still dispatches the action on POST", async () => { + vi.mocked(handleInvoke).mockClear(); + vi.mocked(handleInvoke).mockResolvedValue(new Response("{}", { status: 200 })); + const post = handlerFor(decoInvokeRouteConfig(), "POST"); + + const res = await post({ + request: new Request("https://site.example/deco/invoke/site/actions/checkout/updateCart.ts", { + method: "POST", + body: JSON.stringify({ qty: 1 }), + }), + }); + + expect(handleInvoke).toHaveBeenCalledTimes(1); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/tanstack/src/routes/adminRoutes.ts b/packages/tanstack/src/routes/adminRoutes.ts index 16dc910a..df753e75 100644 --- a/packages/tanstack/src/routes/adminRoutes.ts +++ b/packages/tanstack/src/routes/adminRoutes.ts @@ -115,8 +115,17 @@ const decoRenderRoute = { const decoInvokeRoute = { server: { handlers: { - GET: withCors(({ request }) => - withTracing("deco.admin.invoke", () => handleInvoke(request), invokeAttrs(request)), + // POST only. `handleInvoke` has no auth of its own and honors a `?props=` + // query string on GET; a GET is a CORS "simple request" (no preflight), so + // an `` on a + // third-party page would fire mutating actions with the victim's cookies + // (CSRF). The Next dispatcher (routeHandlers.ts) rejects non-POST invoke + // for exactly this reason — mirror it here. + 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" } }, + ), ), POST: withCors(({ request }) => withTracing("deco.admin.invoke", () => handleInvoke(request), invokeAttrs(request)),