59 lines
2.4 KiB
TypeScript
59 lines
2.4 KiB
TypeScript
/**
|
|
* Shared server-function RPC helpers used by both the dev server (index.ts)
|
|
* and the production server (prod.ts). Kept in their own module so prod.ts
|
|
* can import them without creating a circular dependency on index.ts (which
|
|
* itself re-exports `createProductionServer`/`createProductionHandlers` from
|
|
* prod.ts).
|
|
*/
|
|
|
|
import { createContext, runWithRequestContext } from "@wrnexus/core";
|
|
|
|
/**
|
|
* CSRF check for `/__wrnexus/rpc` (the server-function RPC endpoint the
|
|
* browser runtime calls for `server.fn()`). Requires a same-origin request
|
|
* carrying a matching CSRF cookie + header pair — the standard double-submit
|
|
* cookie pattern.
|
|
*/
|
|
export function validateRpcCsrf(request: Request): boolean {
|
|
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
|
|
const url = new URL(request.url);
|
|
const origin = request.headers.get("origin");
|
|
if (origin && origin !== url.origin) return false;
|
|
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
const cookieToken =
|
|
/(?:^|;\s*)wrn-csrf=([^;]+)/.exec(cookieHeader)?.[1] ??
|
|
/(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1];
|
|
const headerToken = request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf");
|
|
if (!cookieToken || !headerToken) return false;
|
|
let decodedToken: string;
|
|
try {
|
|
decodedToken = decodeURIComponent(cookieToken);
|
|
} catch {
|
|
return false;
|
|
}
|
|
let diff = decodedToken.length ^ headerToken.length;
|
|
for (let i = 0; i < Math.max(decodedToken.length, headerToken.length); i++) {
|
|
diff |= decodedToken.charCodeAt(i) ^ headerToken.charCodeAt(i);
|
|
}
|
|
return diff === 0;
|
|
}
|
|
|
|
/**
|
|
* Wrap the server-function RPC handler so it runs inside the request's
|
|
* AsyncLocalStorage context. `/__wrnexus/rpc` is intercepted BEFORE
|
|
* `handlers.fetch` (fetchHandler) in both dev and production, so a server
|
|
* function called via `server.fn()` from the browser runs entirely outside
|
|
* fetchHandler's own context wrap. It runs user code directly, so — like
|
|
* every other entry point that runs user server code — it needs the request
|
|
* context too.
|
|
*/
|
|
export function withServerFnRequestContext(
|
|
handler: (request: Request) => Promise<Response>,
|
|
): (request: Request) => Promise<Response> {
|
|
return (request: Request) => {
|
|
const url = new URL(request.url);
|
|
const ctx = createContext(request, url);
|
|
return runWithRequestContext(ctx, () => handler(request));
|
|
};
|
|
}
|