The CSRF origin allowlist is never consulted for a cross-site caller, so a static client on another origin cannot call server functions
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 36.1k
- Forks
- 1.1k
- Avg merge
- 9h 18m
- Merged PRs (30d)
- 195
Description
Summary
configureServerFunctionsServer({ csrf: { origin } }) accepts a string, a list, or a matcher, which reads as "these origins may call server functions". But the gate refuses Sec-Fetch-Site: cross-site before the matcher is consulted, so an explicitly listed cross-origin caller is refused by every current browser and WebView. The matcher only ever runs for clients that send no fetch metadata. There is no supported way to call server functions from a static client hosted on another origin.
Motivating case (Discord, 2026-09-18): a client-only Solid build bundled into a Capacitor app, with the server bundle on Netlify. The WebView origin is capacitor://localhost / https://localhost; Capacitor's local server intercepts every path on its own hostname, so the request cannot be made same-origin by routing (ionic-team/capacitor#6875). Same shape: browser extensions, embedded widgets, a marketing site calling the app's API.
Current behavior
// packages/web/server-functions/src/server.ts
async function allowsServerFunctionRequest(request, options) {
const fetchSite = request.headers.get("Sec-Fetch-Site");
if (fetchSite === "same-origin") return true;
if (fetchSite === "same-site" || fetchSite === "cross-site" || fetchSite === "none") {
return false; // matcher never reached
}
const origin = request.headers.get("Origin");
if (origin !== null) return matchesOrigin(origin, request, options.origin);
...
}
Chromium, Firefox, and WebKit (Safari / WKWebView since 16.4) all send Sec-Fetch-Site, so the origin option has no effect on any of them for a cross-origin call. The client side already works mechanically: configureServerFunctionsClient({ endpoint: "https://api.example.com/_server" }) builds absolute addresses, and prepareRequest names OAuth bearer tokens as its motivating case, which is the auth model a cross-origin client would use. The client JSDoc says "keep the call same-origin, since a cross-origin send is stamped Sec-Fetch-Site: cross-site and the handler's origin gate refuses it", so the two halves agree that this is unsupported today; the origin option is what suggests otherwise.
The handler also emits no Access-Control-* headers, so even an allowed cross-origin response would be unreadable by the page.
Proposal
Make cross-origin an explicit, production-grade opt-in governed by the existing matcher:
- When
Sec-Fetch-Siteiscross-site(orsame-site) and anOriginheader is present, consultoptions.origininstead of refusing outright. Refuse when no matcher is configured (today's default) or when the matcher does not returntrue.nonestays refused. - For a request the matcher allowed, emit CORS headers on the response: echo the exact
OrigininAccess-Control-Allow-Origin,Access-Control-Allow-Credentials: trueonly if cookies are meant to travel,Vary: Origin, and answer theOPTIONSpreflight for the transport's content types and headers. Only listed origins get these headers; the default same-origin path is unchanged. - Document
endpointas accepting an absolute URL on the client, andcsrf.originas the allowlist that makes it work.
Security posture
Originis browser-enforced; a page cannot forge it. Allowing a listed origin is the same trust decision the option already claims to make. Default stays same-origin only.- CSRF protection is for cookie-bearing browser users. A non-browser caller can already hit the endpoint directly with any headers; the gate never protected against that, and the change does not alter it.
- Credentials: a cross-origin client should prefer
prepareRequestbearer tokens. If cookies are used, they needSameSite=None; Secure, and the handler should only setAllow-Credentialswhen configured to, so an allowlist entry does not silently turn on cookie sharing. matchesOriginalready fails closed on non-truereturns (#3169), which is the right shape for this to sit on.
Not in scope
Splitting SSR from the function host. A render calls server functions in-process with the request event and locals, so pages and functions remain one deployment. This issue is only about a static client on another origin calling the server bundle.
Repro
- Client-only build,
configureServerFunctionsClient({ endpoint: "https://api.example.test/_server" }). - Server:
configureServerFunctionsServer({ csrf: { origin: ["https://app.example.test"] } }). - Load the client from
https://app.example.test, call any server function. - Observed: 403 from the gate.
Sec-Fetch-Site: cross-siteshort-circuits beforematchesOriginruns. With the gate patched to consult the matcher, the browser then blocks the response for want ofAccess-Control-Allow-Origin.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/web/server-functions/src/server.ts at allowsServerFunctionRequest and trace matchesOrigin, then inspect configureServerFunctionsClient and prepareRequest for the client request path. Done means an explicitly matched cross-origin request is handled with the specified CORS behavior while unmatched and default same-origin cases retain their existing protection, with the endpoint and csrf.origin documentation updated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend-api-design, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100