UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

78 lines (77 loc) 3.38 kB
// context(justinvdm, 2026-04-06): Extracted from rscActionHandler() for testability. // The real getServerModuleExport and decodeReply require the react-server environment // (virtual module lookup, react-server-dom-webpack), so we accept them as injected // dependencies. worker.ts passes the real implementations; tests pass fakes. // context(justinvdm, 2026-04-20): Origin validation for non-GET action requests. // Method enforcement alone does not distinguish a legitimate same-origin POST // from a POST driven by a same-site sibling origin (e.g. sibling subdomain, // another localhost port). SameSite=Lax cookies are attached in both cases, and // the action request's Content-Type is CORS-safelisted, so no preflight fires. // We require the request's Origin header to match the app's own origin, unless // the caller origin is listed in allowedOrigins. function validateSameOrigin(req, allowedOrigins) { const origin = req.headers.get("Origin"); if (!origin) { return { valid: false, response: new Response("Missing Origin header", { status: 403 }), }; } const selfOrigin = new URL(req.url).origin; if (origin === selfOrigin) { return { valid: true }; } if (allowedOrigins && allowedOrigins.includes(origin)) { return { valid: true }; } return { valid: false, response: new Response("Origin not allowed", { status: 403 }), }; } export async function rscActionHandler(req, { getServerModuleExport, decodeReply, allowedOrigins }) { const url = new URL(req.url); const contentType = req.headers.get("content-type"); // context(justinvdm, 2026-04-20): Enforce Origin/Host match for non-GET action // requests. GET (serverQuery) is expected to be idempotent and is not subject // to this check. if (req.method !== "GET") { const originCheck = validateSameOrigin(req, allowedOrigins); if (!originCheck.valid) { return originCheck.response; } } let args = []; if (req.method === "GET") { const argsParam = url.searchParams.get("args"); if (argsParam) { args = JSON.parse(argsParam); } } else { const data = contentType?.startsWith("multipart/form-data") ? await req.formData() : await req.text(); args = (await decodeReply(data, null)); } const actionId = url.searchParams.get("__rsc_action_id"); if (import.meta.env.VITE_IS_DEV_SERVER && actionId === "__rsc_hot_update") { return null; } const action = await getServerModuleExport(actionId); if (typeof action !== "function") { throw new Error(`Action ${actionId} is not a function`); } // context(justinvdm, 2026-04-06): Validate the declared HTTP method before // invocation. serverAction() attaches .method = "POST" at creation time via // createServerFunction(), serverQuery() attaches "GET". Functions without // .method default to POST to match serverAction() semantics. const actionMethod = action.method ?? "POST"; if (actionMethod !== req.method) { return new Response(`Method ${req.method} is not allowed for this action. Allowed: ${actionMethod}.`, { status: 405, headers: { Allow: actionMethod }, }); } return action(...args); }