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)),