Skip to content

The TanStack Start server-function XSS

A link to your own app's server-function endpoint could make your server answer with HTML an attacker chose, and run it as whoever clicked. Disclosed on 30 September 2026. This is how it worked, layer by layer, and what to do now.

  • CVE-2026-102989
  • CVSS 9.3
  • Fresh · still developing
  • No exploitation reported

In the drawing, the link's payload steps inward and turns amber where its extra fields become internal state; the reply then climbs back out to the browser as HTML.

A link's path into the server and back out to the browser, drawn as nested layersSix nested layers recede toward the lower right: a link anyone can send, the victim's browser, the /_serverFn/ endpoint, the decoded payload, internal state and the not-found reply. A pale line descends through the first four. Where it crosses into internal state, because no allowlist stopped extra payload fields, it turns amber and glows. It reaches the not-found reply, turns, and climbs straight back out through every layer to the victim's browser, where the reply is served as HTML and script runs on the app's own origin as the signed-in user.a link anyone can sendvictim's browser/_serverFn/payloadinternal statenot-found replyno allowlistserved backas text/htmlscript runs on your origin,as the signed-in user

What happened

TanStack Start's server-function endpoint copied every field of the payload a caller sent into internal state, where only the function's argument belonged. Steered into a “not found” reply, that state could make your own server answer with HTML an attacker chose. Anyone could build such a link to your app; whoever opened it ran the attacker's script on your origin, signed in as themselves.

fresh Fixed, released and disclosed within an hour on 30 September 2026, credited to Lovable. Neither TanStack nor GitHub reports exploitation in the wild; it is not on CISA's Known Exploited Vulnerabilities list. This is a code bug, not a malicious package: upgrade, rebuild and redeploy. No server compromise is described.1

Am I affected?

If you run a public TanStack Start app on React, Solid or Vue, built on any release from 1.143.12 (27 December 2025) until the fix. The fix lives in @tanstack/start-server-core 1.169.39, which your framework package pulls in: check what your lockfile resolves, not just package.json. The four version lines are independent: vue-start 1.168.56 is patched, react-start 1.168.56 is not.4

A guide built from the advisory, not a scanner. Run npm ls @tanstack/start-server-core (or pnpm why) to see the core your lockfile resolves. Try

Affected and first patched versions (GHSA-qx66-fv34-fjm8)
PackageVulnerableFirst patchedOn npm (UTC, 30 Sep)
@tanstack/start-server-core≥1.143.12 <1.169.391.169.3917:48:11
@tanstack/react-start≥1.143.12 <1.168.601.168.6017:48:34
@tanstack/solid-start≥1.143.12 <1.168.571.168.5717:50:09
@tanstack/vue-start≥1.143.12 <1.168.561.168.5617:48:42

start-server-core is the package that carries the fix. TanStack asks users to confirm it resolves to 1.169.39 or later, because a lockfile can pin an old core under a new framework package. Sources: GHSA-qx66-fv34-fjm8; TanStack's security update; npm registry metadata.5

Do this now

  1. Upgrade until @tanstack/start-server-core resolves to 1.169.39 or later. Move your framework package to react-start 1.168.60, solid-start 1.168.57 or vue-start 1.168.56, then check the resolved tree, not only package.json.1
  2. Commit the lockfile, rebuild and redeploy. Server code is bundled at build time: a merged upgrade protects nobody until the new build is serving.
  3. Until then, filter at the edge. The advisory suggests blocking /_serverFn/* requests without x-tsr-serverFn: true, filtering browser navigations to those paths, and a restrictive Content Security Policy. TanStack says such rules “cannot replace the patch”.4
  4. Watch an advisory feed, not the changelog. The fix's pull request was titled as a behaviour alignment; only the advisory said it was a security fix.
Contents
  1. Field marks
  2. How it works
  3. Still developing
  4. The real package
  5. Timeline
  6. The score
  7. Edge rules
  8. Two incidents
  9. Defenses
  10. Related
  11. Sources

An attack arrives as a link someone opens, not as a call your app makes.

In requests

  • Requests under /_serverFn/ that are browser navigations (a Sec-Fetch-Mode: navigate header, or text/html in Accept) or that lack the x-tsr-serverFn: true header your own client sends. These are exactly what Appwrite's edge rules block. inference from the mitigations8
  • A GET to a server-function path carrying its payload in the query string, arriving from a link in an email, a chat or another site rather than from your own pages.

After a click

  • Actions a user doesn't remember taking, made with their own session from their own browser, since the script makes authenticated requests as them. inference from the impact
  • Nothing on the server itself: the advisory describes no server compromise.4

What isn't known yet

The sources give no date for when Lovable found the issue or reported it, and neither TanStack nor GitHub says either way whether it was exploited before 30 September. Treat those as open questions, not as a clean bill of health.

A JSON endpoint was talked into serving a web page.

The bug sits where data from a browser becomes state inside the server. TanStack Start trusted the decoded payload as if it were its own internal object, so fields a caller added travelled into places only the server should set. One of those places decided how an error reply was labelled, and a reply labelled HTML is something a browser runs.

Scroll, or focus the stage and use the arrow keys. The amber dot follows the link's payload; the camera moves to the part each step is about. Field names are conceptual; there is no payload here.

  • Mass assignmentCopying every field of client data into an internal object instead of picking the allowed ones. Also called over-posting.
  • Reflected XSSCross-site scripting where the attacker's input travels in the request (here, a link) and the server sends it straight back in a page the browser runs.
  • CWE-79The catalogue entry for cross-site scripting: input that ends up in a web page without being neutralized.
Path of a crafted link through a TanStack Start server and back to the browserAn attacker with no account builds a link to the app's /_serverFn/<id> endpoint and sends it to a victim, whose browser is signed in to the app. The click is a GET with the payload in the query string. Inside the app's origin, the endpoint decodes the payload with Seroval: it should carry one public field, data, but can carry other fields chosen by the caller. Before the fix, the whole payload was spread into the middleware context, so extra fields became internal state. The function and its middleware run; the handler preferred the result over the error. A not-found branch built a Response from the result, applying the JSON content type first and then headers taken from the result, so the caller's value won. Instead of the expected application/json, which a browser shows as inert text, the reply came back as text/html from the app's own URL, and the attacker's script ran in the victim's browser on the app's origin, able to read page data, call the app's APIs as the user and read cookies without HttpOnly. The fix adds an allowlist gate that passes only data, context and method, prefers the error over the result, forces JSON on not-found replies after headers, and turns any non-Response handler output into a generic 500.Attackerno account neededbuilds a link to your appA link/_serverFn/<id>?…GET, payload in the query stringsent by email, chat or another siteVictim's browsersigned in to your appsession cookie · same-origin APIs · page dataScript runs on your originreads page data · calls your APIs as the userreads cookies that lack HttpOnlyYour originthe TanStack Start server/_serverFn/<id>server-function endpointclient calls send x-tsr-serverFn: truea clicked link is a plain GET navigationGET or POST both reach the functionDecoded payloadSeroval fromJSONdatathe function's argument…other fieldschosen by whoever built the requestallowlist{ data,context,method }nothing elsecrossesMiddleware contextinternal stateshould hold only server-set values...optsbefore the fix, the whole payloadwas spread in: extra fieldsbecame internal stateYour functionand its middleware runreturns a result or an errorthe handler picked the resultover the errorNot-found branchbuilds a Response from the result1Content-Type: application/json2...headersfrom the result, so from the callerspread after the default: the last one winsexpectedapplication/jsonthe browser shows it as inert textthe vulnerable pathtext/htmlthe browser renders it as a pageat your origin's own URLThe error wins over the result · not-found replies are forced to JSONany handler output that is not a real Response becomes a generic 500
Diagram of the CVE-2026-102989 request path. Focus it and use the left and right arrow keys to move between the eight steps listed after it; each step's text describes that part in full.

  1. A server function is an HTTP endpoint on your own origin.

    TanStack Start lets you write server functions: code that runs on the server, which your pages call over HTTP. By default the call goes to an internal endpoint under /_serverFn/, with the function's id in the path and the header x-tsr-serverFn: true.

    The endpoint lives on your app's own origin, next to your pages and your session cookie. A server function can be called by POST or by GET, with the payload in the query string.4

    path
    /_serverFn/<id>
    header
    x-tsr-serverFn: true
    methods
    GET or POST
    origin
    your app's own
  2. The payload should carry one public field: data.

    The server decodes the serialized payload with Seroval's fromJSON, runs the middleware and the function, then serializes the result back to the browser.

    The only field meant to cross from the browser is data, the function's argument. Anything else in the object was written by whoever built the request, which need not be your page.

    decoder
    Seroval fromJSON
    public field
    data
    set by the server
    context, method
    other fields
    chosen by the caller
  3. Before the fix, the whole object crossed the boundary.

    The handler took the entire decoded payload, set context and method on it, and passed it on. Inside createServerFn that object was spread (...opts) into the internal middleware context, so every extra field became internal state.

    This is mass assignment: trusting a client-built object as if the server had built it, instead of copying across only the allowed fields. The advisory puts it plainly: payload fields can reach “internal middleware state instead of limiting them to public input fields.”4

    handed on
    the whole payload
    in createServerFn
    ...opts spread in
    extra fields
    became internal state
    should cross
    data only
  4. An error path built its reply from that state.

    The handler picked the function's result over its error, and had a “not found” branch that built an HTTP response from fields of the result object. It set Content-Type: application/json first and spread the supplied headers after it, so a caller-supplied value could override the JSON default.

    our reading of the fix diff The header order comes from the fix's diff. The advisory itself says only that there was “an error path where attacker content persists.”2

    picked
    result over error
    first
    Content-Type: application/json
    then
    ...headers from the result
    last writer
    the caller
  5. Server functions answer GET, so the payload fits in a link.

    Because a server function can be called by GET with its payload in the query string, the whole crafted request fits in one URL on your site. The attacker sends that link by email, in a chat or from another website.

    Building it needs no account and no access to your app. What it does need is a victim who opens it: that is the “user interaction required” in the score.4

    method
    GET
    payload
    in the query string
    account needed
    no
    victim must
    open the link
  6. The reply comes back as HTML, from your own origin.

    The victim's browser navigates to https://your-app/_serverFn/…. The reply really does come from your app, and it is labelled with an HTML content type, so the browser renders it as a page.

    Labelled application/json, the same bytes would have been shown as inert text. One header decided whether the attacker's content was data or a document.

    expected
    application/json, shown as text
    served
    text/html
    from
    your app's own URL
    browser
    renders it as a page
  7. Script runs with the victim's session.

    Because the page is on your origin, the attacker's script runs with the same access as the signed-in user: it can read same-origin data and page contents, and make authenticated requests as them. Cookies without the HttpOnly flag are exposed too.

    That is the “scope changed” in the vector: the flaw is in the server, but the code runs in the victim's browser. Any public app on an affected version, on React, Solid or Vue, had such an endpoint. No server compromise is described.4

    runs as
    the signed-in user
    can
    read same-origin data
    can
    make authenticated requests
    cookies
    exposed unless HttpOnly
    server
    not compromised
  8. The fix: an allowlist on the way in, JSON on the way out.

    Commit d521abd71c (pull request #8573, merged 30 Sep at 17:29:55 UTC) builds the action's input explicitly as { data, context, method } in the HTTP handler, the SSR path and the serialization adapter. Nothing else from the payload crosses.

    It also prefers the error over the result, forces Content-Type: application/json on not-found replies after applying headers, returns non-object results only when they are real responses or primitives, and turns any other handler output into a generic 500.2

    input
    { data, context, method }
    error vs result
    error first
    not-found reply
    JSON, forced last
    other output
    generic 500

The numbered markers on the diagram match the steps.

[The fix] restricts client-supplied input and validates responses at the server boundary.

TanStack, “TanStack Start security update: CVE-2026-102989”, 30 Sep 2026

Disclosed on 30 September 2026. Some answers aren't public yet.

This advisory went public on 30 September 2026, and this page was written from sources checked that same day. The fix and the version numbers are settled. Several things are not.

  • Fixed versionsPublished on npm, 17:48–17:50 UTC
  • SeverityCritical, CVSS 3.1 9.3 (GitHub advisory)
  • NVD and CVE.orgNo record yet
  • Exploited in the wildNo statement either way
  • Found and reportedDates not public
  • CISA KEVNot listed

Facts may still change

An NVD record, a listing in CISA's Known Exploited Vulnerabilities catalog or a report of exploitation would each be news. Upgrading now means none of them can catch you out.

The fix lives one package down from the one you import.

Your app depends on @tanstack/react-start, solid-start or vue-start. The vulnerable code is in @tanstack/start-server-core, a dependency they pull in. A bumped framework package with a stale lockfile can still resolve an old core, which is why TanStack's post asks you to check that the core itself resolves to 1.169.39 or later.1

The four packages also number their releases independently. Each range starts at 1.143.12, but each ends somewhere different, so a version number means nothing until you know which package it belongs to.

Vulnerable range and first patched version for each packageEvery range starts at 1.143.12. @tanstack/start-server-core, which carries the fix, is vulnerable below 1.169.39. @tanstack/react-start is vulnerable below 1.168.60, @tanstack/solid-start below 1.168.57 and @tanstack/vue-start below 1.168.56. A dashed line at 1.168.56 crosses the three framework rows: that number is patched for vue-start but still vulnerable for react-start and solid-start.@tanstack/start-server-corecarries the fix; own numbering1.143.121.169.39the framework packages, one line each@tanstack/react-start1.168.60@tanstack/solid-start1.168.57@tanstack/vue-start1.168.561.168.56 is patched for vue-start,still vulnerable for react-start and solid-startvulnerable, from 1.143.12first patched and laternot to scale; the tail is magnified past the break
Fig. 1. Same start, four different ends. Each bar is one package's vulnerable range, from 1.143.12 to its first patched version. The dashed line marks 1.168.56, patched for vue-start and still vulnerable for the other two framework packages. Sources: GHSA-qx66-fv34-fjm8; npm registry.
  • Transitive dependencyA package you get through another package rather than by naming it yourself. It still appears in the lockfile.
  • Rebuild and redeployServer code is bundled when the app is built, so a new lockfile changes nothing until a new build is running.

Nine months in the releases. Under an hour from fix to advisory.

  • ~9months

    from the first affected release (27 Dec 2025) to the fix.

  • ~25

    minor and patch releases shipped inside the vulnerable range.

  • 21min

    from the fix's pull request opening (17:09) to its merge (17:29:55).

  • 27min

    from the merge to the public advisory at 17:57:08 UTC.

Exposure window, 27 December 2025 to 30 September 2026The first affected version, 1.143.12, was published on 27 December 2025 at 00:08 UTC. The vulnerable range shipped for about nine months; the last vulnerable releases came on 27 September 2026 at 14:07. The unrelated npm compromise of 11 May 2026 falls inside the window. The final hour on 30 September, magnified: fix pull request opened at 17:09, merged at 17:29:55, release commit at 17:41, patched packages on npm from 17:48:11 to 17:50:09, and the GitHub advisory published at 17:57:08, 27 minutes after the merge.vulnerable releases on npm, about nine monthsJanFebMarAprMayJunJulAugSep1.143.12 published, 27 Dec 2025 00:08the first affected version of all four packages11 May: the separate npm compromiselast vulnerable releases, 27 Sep 14:0730 Sep, 17:00 to 18:00 UTC17:0017:1017:2017:3017:4017:5018:0017:09 fix PR #8573 opened17:29:55 merged17:41 release commit17:48–17:50 patched packages on npm17:57:08 advisory published27 min from merge to advisorymagnified
Fig. 2. A long exposure and a short fuse. The upper axis is to scale in days; the lower axis magnifies 17:00 to 18:00 UTC on 30 September. Amber marks the patched releases and the advisory. Sources: npm registry timestamps, GitHub API (pull request #8573, release commit, advisory).

A fix that didn't say it was one

The pull request was titled “fix(start): align server function transport behavior” and its changeset “Align server function request and response handling”. Neither says security. Merging and releasing a fix shortly before the advisory goes public is a common pattern in coordinated disclosure; it means only an advisory feed tells you in time.

Critical, even though someone has to click.

A 9.3 for a bug that needs a victim to open a link can look high. The vector explains it: anyone on the network can build the link without an account, and once it is opened the damage lands outside the vulnerable component, in the user's browser, with high impact on both what they can see and what they can do.4

The CVSS 3.1 vector of CVE-2026-102989, decodedEight metrics adding up to 9.3, critical. Attack vector network: reachable with a URL from anywhere. Attack complexity low. Privileges required none: no account needed to build the link. User interaction required: a victim has to open the link. Scope changed: a server flaw whose code runs in the victim's browser. Confidentiality high and integrity high: same-origin data and requests as the signed-in user. Availability none. Six metrics take their worst value; user interaction and availability do not.AV:NAttack vector: networkreachable with a URL,from anywhereAC:LComplexity: lownothing special has toline upPR:NPrivileges: noneno account needed tobuild the linkUI:RUser interaction: requireda victim has to openthe linkS:CScope: changeda server flaw, but the coderuns in the victim's browserC:HConfidentiality: highsame-origin data, readwith the user's sessionI:HIntegrity: highrequests made as thesigned-in userA:NAvailability: nonenothing is taken downthe metric's worst value= 9.3, critical (GitHub advisory, CVSS 3.1)
Fig. 3. The vector, decoded. Each tile is one CVSS 3.1 metric from the GitHub advisory, with its value in plain words. Amber tiles take the metric's worst value; user interaction and availability do not.

Edge rules bought time. They only cover the default path.

The advisory lists three interim mitigations: block /_serverFn/* requests that lack x-tsr-serverFn: true, filter browser navigations to those paths, and use a restrictive Content Security Policy.4

Appwrite applied the first two for every site on Appwrite Cloud the same day: its edge requires x-tsr-serverfn: true and blocks requests with Sec-Fetch-Mode: navigate or text/html in Accept. Those rules cover only the default server-function path; apps that moved it need their own firewall rules.8

[Edge rules] cannot replace the patch.

TanStack, “TanStack Start security update: CVE-2026-102989”, 30 Sep 2026

Same project, four months apart, opposite advice.

Two TanStack security events landed in 2026, and several package names appear in both. They need different responses, so it is worth keeping them apart.

The May compromise and the September XSS
ComparedMay 2026 · CVE-2026-45321September 2026 · CVE-2026-102989
KindMalicious publish: 84 versions of 42 packagesA bug in code TanStack published honestly
SeverityCVSS 9.6 · CWE-506CVSS 9.3 · CWE-79
What ranA credential stealer, on the machine that installed itAn attacker's script, in a victim's browser on your origin
ExploitedOn CISA KEV since 2026-05-27None reported; not on KEV
What to doRotate every credential the install host could reachUpgrade, rebuild and redeploy

Two of the May malicious versions, react-start 1.167.68 and 1.167.71, also fall inside this advisory's range. If you ever installed one, the May advice comes first. Sources: TanStack's May postmortem; GHSA-qx66-fv34-fjm8.3

Only the upgrade closes it. The rest limits what a click can do.

Which controls would have stopped or limited this one. It is a bug in honestly published code, so the controls that guard against malicious packages do not apply.

ControlEffect hereWhy
Upgrade, rebuild, redeployStops itThe only fix: start-server-core 1.169.39 or later, actually running. Check the resolved core, not just the framework package.
Edge rule on /_serverFn/*Breaks step 6 on the default pathRequiring the x-tsr-serverFn header and blocking navigations stops a clicked link from reaching the endpoint. Custom server-function paths need their own rules, and TanStack says rules cannot replace the patch.
Restrictive Content Security PolicyLimits damageListed by the advisory as an interim mitigation. A strict policy can stop injected script from running even when the HTML reaches the browser (step 7).
HttpOnly session cookiesLimits damageKeeps the session cookie out of the script's reach. The script can still make authenticated requests as the user from their own browser.
An advisory feed (GHSA, Dependabot, OSV)Finds it soonerThe fix's title never said security; only the advisory did. Automated update pull requests with CI keep the time to patched short on a framework that ships often.
Minimum release age, provenance, 2FA, credential rotationDoesn't applyThese answer malicious publishes and stolen secrets. Here the maintainers published the code honestly, and nothing ran on your servers.

The server-function transport can pass request payload fields into internal middleware state instead of limiting them to public input fields.

GitHub advisory (TanStack), GHSA-qx66-fv34-fjm8, 30 Sep 2026

Sources

Primary sources are the maintainer, the advisory database, the registry and government. Secondary sources are other organizations' own analyses.

Primary · maintainer

Primary · advisory databases, registry and government

Secondary · hosting provider

Written from sources checked on 2026-09-30, the day of disclosure; facts may still develop. Every fact above traces to one of them; nothing unconfirmed is included. Pull quotes are verbatim from the linked primary pages; the bracketed words in TanStack's are ours.