From 1.143.12, released on 27 December 2025, until the fix on 30 September 2026, TanStack Start let an unauthenticated attacker build a link to one of an app's own server functions that made the app return attacker-controlled HTML. Anyone who opened the link ran the attacker's JavaScript on the app's origin, with that visitor's session. This is CVE-2026-102989 (GHSA-qx66-fv34-fjm8), a reflected cross-site scripting bug rated critical, CVSS 3.1 score 9.3. The fixed versions are @tanstack/start-server-core 1.169.39, @tanstack/react-start 1.168.60, @tanstack/solid-start 1.168.57 and @tanstack/vue-start 1.168.56. TanStack credits Lovable with finding and reporting it.
This post is for teams building on TanStack Start with React, Solid or Vue. It explains how a server-function URL became an XSS sink, what the fix changed, why npm audit only started reporting it on 15 October, and how to check your lockfiles and your edge rule with one script.
The short answer
Upgrade to @tanstack/react-start 1.168.60, @tanstack/solid-start 1.168.57 or @tanstack/vue-start 1.168.56 or later, which pin @tanstack/start-server-core 1.169.39 or later. Every release from 1.143.12 up to those versions is affected, and there are no backports to older lines. If you can't upgrade today, block browser document navigations to your server-function path, /_serverFn/* by default, at the edge, and set a restrictive Content Security Policy on those responses. The bug needs a victim to open a crafted link, but the attacker needs no account.
How server functions are called
A TanStack Start server function is a function you write once and call from the client as if it were local. The build turns each call into an HTTP request to a path under the server-function base, /_serverFn unless you set serverFns.base in the Vite plugin. The client's fetcher marks these requests with an x-tsr-serverFn: true header. GET functions put their serialized input in a payload query parameter, and POST functions send it as JSON.
That design means every server function has a URL. A GET server function can be reached by anything that can make a GET request, including a browser following a link in an email or a chat message.
What went wrong
The fix commit, d521abd ("align server function transport behavior", PR #8573), shows three problems that combined into the bug.
Client fields reached middleware state. Before the fix, server-functions-handler.ts deserialized the whole client payload, set context and method on it, and passed the entire object to the server function. In createServerFn, that object was then spread into the middleware context. Any extra field the client added went along with it. The advisory calls this passing request payload fields "into internal middleware state instead of limiting them to public input fields".
Requests without the header got the raw result. After the function ran, the handler picked res.result, or res.error if there was no result. Then, for any request without x-tsr-serverFn: true, it returned that value as is, without the serialization a real client call gets. A browser following a link doesn't send that header.
A response-shaped object was trusted. Further down, router-core's isSsrResponse accepted any object with response and serverSsrCleanup keys as a response to send. Put together, a malformed payload could leave an attacker-chosen value in the error path, and the response handling would serve it as the HTTP response from the app's own origin.
We aren't publishing a working request. The advisory describes the mechanism at this level, and the fix is the same whatever the payload.
What the fix changed
PR #8573 closes each gap:
- The handler now builds the function's input from three fields only,
data,contextandmethod, instead of passing the client's object through.createServerFncopiesdataandmethodinstead of spreading everything. - The error now takes priority over the result when choosing what to return, and requests without the header only get the raw value back when it's a real
Responseor a primitive. isSsrResponserequiresresponseto be an actualResponseinstance, andnormalizeSsrResponsethrows on anything else. The request handler turns any non-Responsevalue into a plain 500.
The only code change in 1.143.12 was a middleware refactor, PR #5517, merged at 00:06 UTC on 27 December 2025. The release went to npm two minutes later, so the exposure window ran just over nine months.
Nine months exposed, fifteen days unflagged
TanStack published its repository advisory at 17:57 UTC on 30 September, nine minutes after the fixed packages reached npm. That was quick, but repository advisories don't feed most scanners on their own:
| Date (UTC) | Event |
|---|---|
| 27 Dec 2025 | 1.143.12 published, bug introduced |
| 30 Sep 2026, 17:48 | Fixed packages published to npm |
| 30 Sep 2026, 17:57 | Repository advisory published |
| 13 Oct 2026 | CVE record published |
| 15 Oct 2026, 20:22 | GitHub reviewed advisory published; OSV lists it |
npm audit reads GitHub's reviewed advisory database, so for 15 days after the fix, npm audit on a project pinned to 1.168.59 said nothing about this bug. The commit title doesn't mention security either, so teams that read release notes rather than advisories had little reason to hurry. If you upgraded routinely in early October, you're already fine. If you pin versions and wait for an audit warning, it only arrived this week.
This isn't the first security advisory for the server-function transport. In May, @tanstack/start-server-core 1.167.30 fixed GHSA-9m65-766c-r333, a lower-impact bug where deserializing one function's payload could invoke a second client-reachable function. The same month, a worm published malicious versions of 42 @tanstack/* packages, which we covered in our Mini Shai-Hulud teardown. The checker below picks up those malicious versions too when you run it with --osv.
Check your projects and your edge rule
This script has two modes. scan walks a directory, reads npm, pnpm, Yarn and Bun lockfiles, reports each version of the four affected packages, and finds the server-function path in your Vite config so you know which path the edge rule has to cover. With --osv it sends every @tanstack version it found to OSV and lists all known advisories, not just this one. probe sends one browser-style navigation to your server-function path and tells you whether the edge refused it. Only point it at apps you run. It uses only the Python standard library.
#!/usr/bin/env python3
"""Check a TanStack Start project for CVE-2026-102989 (GHSA-qx66-fv34-fjm8).
scan DIR [--osv] read lockfiles and config under DIR
probe URL [--base] check that an edge rule blocks document navigations
to the server-function path (send only to your own app)
"""
import argparse, json, os, re, sys, urllib.error, urllib.request
# package: (first affected, first fixed)
RANGES = {
"@tanstack/start-server-core": ("1.143.12", "1.169.39"),
"@tanstack/react-start": ("1.143.12", "1.168.60"),
"@tanstack/solid-start": ("1.143.12", "1.168.57"),
"@tanstack/vue-start": ("1.143.12", "1.168.56"),
}
SKIP_DIRS = {"node_modules", ".git", "dist", ".output", ".vinxi", ".nitro"}
CONFIG_NAMES = re.compile(r"^(vite|app|rsbuild)\.config\.(m|c)?[jt]s$")
def vtuple(v):
core = re.match(r"(\d+)\.(\d+)\.(\d+)", v)
return tuple(int(x) for x in core.groups()) if core else None
def status(pkg, version):
lo, hi = RANGES[pkg]
t = vtuple(version)
if t is None:
return "UNKNOWN"
if "-" in version:
return "PRERELEASE, check by hand"
return "VULNERABLE" if vtuple(lo) <= t < vtuple(hi) else "ok"
def from_npm(path):
data = json.load(open(path))
for key, meta in data.get("packages", {}).items():
name = key.split("node_modules/")[-1]
if name and "version" in meta:
yield name, meta["version"]
for name, meta in data.get("dependencies", {}).items(): # lockfile v1
yield name, meta.get("version", "")
def from_bun(path):
text = open(path).read()
for name, ver in re.findall(r'"(@tanstack/[\w.-]+)": \["@tanstack/[\w.-]+@([^"]+)"', text):
yield name, ver
def from_pnpm(path):
for line in open(path):
m = re.match(r"\s+'?/?(@tanstack/[\w.-]+)@(\d[^(':\s]*)", line)
if m:
yield m.group(1), m.group(2)
def from_yarn(path):
name = None
for line in open(path):
if not line.startswith(" ") and "@tanstack/" in line:
m = re.search(r"(@tanstack/[\w.-]+)@", line)
name = m.group(1) if m else None
m = re.match(r'\s+version:? "?([^"\s]+)"?', line)
if name and m:
yield name, m.group(1)
name = None
LOCKFILES = {"package-lock.json": from_npm, "npm-shrinkwrap.json": from_npm,
"bun.lock": from_bun, "pnpm-lock.yaml": from_pnpm, "yarn.lock": from_yarn}
def walk(root):
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for f in filenames:
yield os.path.join(dirpath, f), f
def osv_lookup(pairs):
queries = [{"package": {"name": n, "ecosystem": "npm"}, "version": v} for n, v in pairs]
req = urllib.request.Request("https://api.osv.dev/v1/querybatch",
data=json.dumps({"queries": queries}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
results = json.load(r)["results"]
return {pair: [v["id"] for v in res.get("vulns", [])] for pair, res in zip(pairs, results)}
def scan(root, use_osv):
found, bases = {}, []
for path, name in walk(root):
rel = os.path.relpath(path, root)
if name in LOCKFILES:
for pkg, ver in LOCKFILES[name](path):
if pkg.startswith("@tanstack/"):
found.setdefault((pkg, ver), set()).add(rel)
elif CONFIG_NAMES.match(name):
text = open(path, errors="replace").read()
m = re.search(r"serverFns\s*:\s*\{[^}]*?base\s*:\s*['\"`]([^'\"`]+)", text, re.S)
if "tanstackStart" in text:
bases.append((rel, m.group(1) if m else "/_serverFn (default)"))
if not found:
print("No @tanstack packages found in any lockfile.")
return 0
bad = 0
print("CVE-2026-102989 (fixed 30 Sep 2026):")
for (pkg, ver), where in sorted(found.items()):
if pkg in RANGES:
s = status(pkg, ver)
bad += s == "VULNERABLE"
fix = RANGES[pkg][1]
print(f" {pkg}@{ver}: {s}" + (f", upgrade to {fix} or later" if s == "VULNERABLE" else "")
+ f" [{', '.join(sorted(where))}]")
for rel, base in bases:
print(f" server-function path in {rel}: {base}")
if bad:
print(" Until you upgrade, block document navigations to that path at the edge (see probe).")
if use_osv:
pairs = sorted(found)
hits = {p: ids for p, ids in osv_lookup(pairs).items() if ids}
print(f"OSV, all {len(pairs)} @tanstack package versions:")
for (pkg, ver), ids in sorted(hits.items()):
print(f" {pkg}@{ver}: {', '.join(ids)}")
if not hits:
print(" no known advisories")
bad += len(hits)
return 1 if bad else 0
def probe(url, base):
target = url.rstrip("/") + base.rstrip("/") + "/sg-edge-rule-probe"
req = urllib.request.Request(target, headers={
"Accept": "text/html", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document",
"User-Agent": "tanstack-start-102989-check"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
code, ctype = r.status, r.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
code, ctype = e.code, e.headers.get("Content-Type", "")
print(f"GET {target} as a browser navigation: {code} {ctype}".rstrip())
if code in (401, 403, 405, 406):
print(" Edge rule appears active: navigations to the server-function path are refused.")
return 0
print(" Not refused (expected 401, 403, 405 or 406). The edge rule is missing or does not match.")
return 1
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("scan"); s.add_argument("dir"); s.add_argument("--osv", action="store_true")
p = sub.add_parser("probe"); p.add_argument("url"); p.add_argument("--base", default="/_serverFn")
a = ap.parse_args()
sys.exit(scan(a.dir, a.osv) if a.cmd == "scan" else probe(a.url, a.base))
We ran scan on 16 October against a fixture monorepo with five apps. The npm and Bun lockfiles were generated by npm 11.19 and Bun 1.4.2 from real @tanstack/react-start releases (1.168.59 and 1.169.2). The pnpm and Yarn lockfiles are hand-written samples:
$ python3 tanstack_start_102989_check.py scan . --osv
CVE-2026-102989 (fixed 30 Sep 2026):
@tanstack/react-start@1.168.59: VULNERABLE, upgrade to 1.168.60 or later [apps/admin/bun.lock, apps/web/package-lock.json]
@tanstack/react-start@1.169.2: ok [apps/docs/package-lock.json]
@tanstack/solid-start@1.168.57: ok [apps/marketing/pnpm-lock.yaml]
@tanstack/start-server-core@1.169.20: VULNERABLE, upgrade to 1.169.39 or later [apps/status/yarn.lock]
@tanstack/start-server-core@1.169.38: VULNERABLE, upgrade to 1.169.39 or later [apps/admin/bun.lock, apps/web/package-lock.json]
@tanstack/start-server-core@1.169.39: ok [apps/marketing/pnpm-lock.yaml]
@tanstack/start-server-core@1.169.43: ok [apps/docs/package-lock.json]
@tanstack/vue-start@1.168.40: VULNERABLE, upgrade to 1.168.56 or later [apps/status/yarn.lock]
server-function path in apps/web/vite.config.ts: /api/fn
server-function path in apps/admin/vite.config.ts: /_serverFn (default)
Until you upgrade, block document navigations to that path at the edge (see probe).
OSV, all 36 @tanstack package versions:
@tanstack/react-start@1.168.59: GHSA-qx66-fv34-fjm8
@tanstack/start-server-core@1.169.20: GHSA-qx66-fv34-fjm8
@tanstack/start-server-core@1.169.38: GHSA-qx66-fv34-fjm8
@tanstack/vue-start@1.168.40: GHSA-qx66-fv34-fjm8
Note the pinning. react-start 1.168.59 requires start-server-core 1.169.38 exactly, and 1.168.60 requires 1.169.39, so upgrading the framework package is enough. A lockfile that shows a fixed framework package next to an old core package has been edited by hand or by an override, and is worth a look.
We tested probe against two local stub servers, one with a rule that refuses navigations to /_serverFn/* without the header and one without:
$ python3 tanstack_start_102989_check.py probe http://127.0.0.1:8781
GET http://127.0.0.1:8781/_serverFn/sg-edge-rule-probe as a browser navigation: 403 text/plain
Edge rule appears active: navigations to the server-function path are refused.
$ python3 tanstack_start_102989_check.py probe http://127.0.0.1:8782
GET http://127.0.0.1:8782/_serverFn/sg-edge-rule-probe as a browser navigation: 404 text/html; charset=utf-8
Not refused (expected 401, 403, 405 or 406). The edge rule is missing or does not match.
What to do
- Upgrade the framework package: React 1.168.60, Solid 1.168.57, Vue 1.168.56, or any later release. Regenerate the lockfile and confirm
start-server-coreis 1.169.39 or later. - If you can't upgrade today, add an edge or WAF rule for your server-function path that blocks requests without the literal
x-tsr-serverFn: trueheader and requests that look like document navigations,Sec-Fetch-Mode: navigateorAccept: text/html, as the advisory suggests. Test it against your own GET and POST server functions first. The header is set by the client, so this is a speed bump, not authentication. - Add a Content Security Policy that blocks inline scripts on server-function responses. Our guide to CSP bypass techniques covers what makes a policy hold up.
- Review your logs for GET requests to the server-function path with long
payloadparameters and browser user agents, especially from links in email or chat. - Treat sessions as exposed if you find any. The script ran with the visitor's authority, so rotate sessions and check for actions taken on users' behalf.
The fair view
Reflected XSS needs a victim to click, and a CSP that blocks inline scripts would have limited it. TanStack fixed it cleanly, published the advisory minutes after the release, and wrote a usable mitigation into it. Many teams on a regular upgrade cadence were patched before the CVE existed.
But the shape of the bug is worth remembering. Frameworks that turn function calls into URLs make every function a request handler, reachable by anything that can send a request, not just the framework's own client. Here, a branch for requests that didn't come from the client returned internal values directly, and a type check that looked for the right keys instead of the right type let one of those values become the page. The fix replaced "anything shaped like a response" with "an actual Response", which is the right default for any code that decides what a browser renders.