Skip to content

Server functions: requests without the x-tsr-serverFn header return unhandled 500s instead of a 4xx #8237

Description

@puncsky

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 of return unwrapped, or answer 400 when the request carries a payload/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-start 1.168.49, @tanstack/react-router 1.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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions