Server functions: requests without the x-tsr-serverFn header return unhandled 500s instead of a 4xx
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
Summary
handleServerAction returns unhandled 500s (instead of 4xx responses) for two request shapes on the /_serverFn/<id> endpoint, reproducible on a fresh app with the latest packages (@tanstack/react-start 1.168.49, @tanstack/react-router 1.170.32, vite 7). Bots hit this daily in production on our site: one server-function endpoint answered 7,230×200, 6,263×403 (CSRF, working as intended) and 6×500 in a 24-hour window — the 500s are crawlers replaying captured /_serverFn/…?payload=… URLs with browser headers minus the one custom header.
Reproduction
A minimal app (5 source files, no extra deps):
// src/serverFn.ts
import { createServerFn } from "@tanstack/react-start";
export const getPost = createServerFn({ method: "GET" }).handler(
async () => ({ title: "hello from the server function" }),
);
// src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { getPost } from "../serverFn";
export const Route = createFileRoute("/")({
loader: async () => ({ post: await getPost() }),
component: () => <h1>repro</h1>,
});
plus src/routes/__root.tsx (createRootRoute with <Outlet />), src/router.tsx exporting createRouter()/getRouter() over routeTree.gen, and a vite config of tanstackStart() + react(). After vite build, serve dist/server/server.js through any fetch(Request) host (we used a 20-line node:http adapter).
Shape 1 — request without x-tsr-serverFn (the main one)
curl -H 'Sec-Fetch-Site: same-origin' \
'http://localhost:3000/_serverFn/<id>?payload=<valid seroval envelope>' # → 500
curl -H 'x-tsr-serverFn: true' -H 'Sec-Fetch-Site: same-origin' \
'http://localhost:3000/_serverFn/<id>?payload=<same envelope>' # → 200
The envelope is exactly what the client proxy sends: JSON.stringify(seroval.toJSON({data: {...}})), URL-encoded as ?payload=…. The same valid payload returns 200 with the header and 500 without it. A no-payload request without the header also 500s.
Server-side stderr shows only the masked form — the cause repeats the message, so nothing names the defect:
Error: Internal Server Error
at throwRouteHandlerError (.../dist/server/server.js:1385:9)
... 5 lines matching cause stack trace ...
cause: Error: Internal Server Error
unhandled: true
Cause: in the built handleServerAction:
const isServerFn = request.headers.get("x-tsr-serverFn") === "true";
...
const unwrapped = res.result || res.error;
if (!isServerFn) return unwrapped; // ← returns a raw JS object
When the header is absent, the action's return value (any plain object) is returned from the route handler to the host instead of a Response, which the route wrapper converts into the masked 500 above. Any request that passes the CSRF check but does not speak the RPC protocol therefore 500s, regardless of payload validity — including requests that would otherwise succeed (we measured handler-scale work: ~300 ms posts fully rendered before the response path discards them).
Shape 2 — non-JSON GET payload
curl -H 'x-tsr-serverFn: true' -H 'Sec-Fetch-Site: same-origin' \
'http://localhost:3000/_serverFn/<id>?payload=%7Bgarbage%7D' # → 500
const payload2 = payloadParam ? parsePayload(JSON.parse(payloadParam)) : {};
The bare JSON.parse throws SyntaxError before the handler runs; it is logged via the Server Fn Error! catch but still surfaces to the client as an unhandled 500.
Control (working as intended)
A request with no same-origin evidence at all is correctly rejected by the CSRF middleware with 403.
Expected behavior
Both shapes are malformed/protocol-less requests and should answer 400 (or serialize the result consistently for the no-header case). Today they produce unhandled 500s with masked causes, which read as server faults in monitoring and cost a full handler execution before dying.
Suggested fixes
if (!isServerFn): serialize/serializeResult(res)instead ofreturn unwrapped, or answer 400 when the request carries apayload/expects RPC semantics without the protocol header.- Wrap the GET
JSON.parse(payloadParam)in a try/catch that answers 400 with the parse error message.
Environment
@tanstack/react-start1.168.49,@tanstack/react-router1.170.32, vite 7, node 24- Also reproduced on our production build (mixed 1.166–1.171 resolution), so it is not new in 1.168
Related: #3429 (server functions should not assume JSON payloads).
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 at the server-function route and the handleServerAction entry point corresponding to the built dist/server/server.js, then inspect the GET payload parsing around parsePayload(JSON.parse(payloadParam)). Reproduce both curl shapes from the issue, including malformed JSON, and verify that protocol-less or malformed requests return a handled 4xx or consistently serialized response without an unhandled 500.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript, vite
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100