
React will encode text you interpolate into JSX. It will not save you from dangerouslySetInnerHTML, a raw href, or a Server Components channel that became an interpreter.
The December 2025 Flight CVE was a reminder: a protocol that was supposed to carry data executed it instead. Patch the RSC packages you actually install. Then grep the hatches in your own components.
The usual mistake is ‘React escapes XSS’ as a complete program, or pinning an old 19.0.x from memory.
This page is the checklist: default escape, the HTML hatch, href, RSC, and the eval-ish sinks React will not see.
CVE-2025-55182 scored CVSS 10.0. The React team published the fix on 3 December 2025. The Flight decoder treated a Server Function request as a trusted tree. That is the hatch miss this page is for: a channel that was supposed to carry data became an interpreter.
The control is a text sink for names, a named sanitizer for the rare HTML field, an http/https allowlist for links, and a CI grep for the APIs that turn React back into innerHTML.
Read the XSS guide for encoding, Trusted Types, and CSP. Read injection when the interpreter is SQL, the shell, or a query grammar. If the SPA still parks a JWT in localStorage, stop and read JWT in Express before the next hatch tour.
JSX still encodes a name into a text node. The hatch is the API that writes HTML anyway, and it is not the default interpolation.
SecureCoding
The December 2025 Flight miss
React team advisory dated 3 December 2025. Lachlan Davidson reported it on 29 November 2025. The decoder for React Server Function HTTP payloads did not treat the request as hostile. An unauthenticated request could become code on the server. Affected packages were react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack at 19.0, 19.1.0, 19.1.1, and 19.2.0. First fixes: 19.0.1, 19.1.2, 19.2.1.
The same advisory was updated on 26 January 2026. It added CVE-2025-55184 and CVE-2025-67779 (DoS, CVSS 7.5), CVE-2025-55183 (source exposure, CVSS 5.3), and CVE-2026-23864 (DoS, 26 January 2026). The install line is the current patched release on your line, not a December version you memorized.
Next.js App Router, React Router RSC, Waku, Parcel RSC, the Vite RSC plugin, and Redwood SDK sat on those packages. A client-only React 19 app that never loaded a server-dom package is outside that CVE. It is still inside the JSX hatch list below. Framework patches lag the React line. Read the advisory’s update table for your Next line rather than bumping react and assuming App Router followed.
JSX escape is the default, not a policy
React 19.0.0 shipped on 5 December 2024. Text children and attribute values still go through the encoder. {user.name} in a heading is a text node. < stays characters. That is the default. It is not a Content Security Policy, a sanitizer, or a promise about URLs.
Francis John said the hatch split on Stack Overflow in 2016. The accepted answer is still the reason the prop exists:
A ref that assigns el.innerHTML is the same opt-out without the warning in the name. createRoot does not see it. Trusted Types will, if you turned them on. See the XSS page for the policy shape. This page stays on the React-shaped sinks.
The HTML hatch still has one name
Use dangerouslySetInnerHTML only when the product must render stored HTML: a CMS body, a sanitized markdown preview, a help article. A comment, a display name, a search term, and a toast are text. Put those in {value} or textContent.
As of 22 August 2026, DOMPurify npm page. It listed 3.4.14, last updated 19 August 2026. That is the sanitizer this page names. Pin it. Run it on the server if the HTML is stored, and again on the way out if a second feature reads the same column. A save-time pass is not a render-time pass.
// src/sanitize.js
import DOMPurify from "dompurify";
export function safeHtml(html) {
return {
__html: DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["style"],
FORBID_ATTR: ["style"],
}),
};
}
export function CommentBody({ html }) {
return <div dangerouslySetInnerHTML={safeHtml(html)} />;
}
Identifiers on this page stay src/sanitize.js, safeHtml, safeHref, and CommentBody. The test below imports that file. A second helper that also returns { __html } is a second review. Server Components that cannot see window need isomorphic-dompurify or a sanitizer you run before the string crosses the Flight boundary. Do not invent a regex that strips script tags. That is not a sanitizer.
Markdown is the usual way the hatch arrives without the word “danger” in the file. rehype-raw plus a renderer that dumps HTML is the hatch. rehype-sanitize is the review. React’s own docs still walk a markdown preview through the hatch and tell you that you are trusting the parser.
Trusted Types do not replace safeHtml. A CSP of require-trusted-types-for 'script' makes a raw string assignment to innerHTML throw. Your policy still has to call DOMPurify. Name one policy, throw in createScript, and put that name on the trusted-types list. The XSS page has the policy. This page only needs the React call site to go through safeHtml so the policy and the helper stay one function.
href is a URL sink
HTML-entity encoding does not neutralize a URL scheme. javascript: is a legal URL. React 19.0.0 release notes say JavaScript URLs are replaced with functions that throw. Pull 29808 later extended that rewrite to <object>. That is a belt. It is not an allowlist you own.
href, src, action, and formAction still need a parse you wrote. dangerouslySetInnerHTML and a ref assignment skip the React rewrite entirely. Treat the changelog line as extra friction, then allowlist.
// still src/sanitize.js
const HREF_OK = new Set(["http:", "https:"]);
export function safeHref(raw) {
try {
const parsed = new URL(raw, "https://app.example");
if (!HREF_OK.has(parsed.protocol)) return "#";
if (parsed.username || parsed.password) return "#";
return parsed.href;
} catch (err) {
return "#";
}
}
export function ProfileLink({ url, label }) {
return <a href={safeHref(url)} rel="noopener noreferrer">{label}</a>;
}
Use the URL constructor, not a regex. Tabs and newlines inside javascript are why hand-rolled scheme checks fail. Relative paths resolve against the base you pass. If you only want absolute http(s) to other hosts, also reject a parsed host that matches your own login origin when the field is “user website.”
RSC data that becomes HTML
A Server Component can fetch a CMS string and pass it to a Client Component as a prop. Flight will serialize the string. It will not encode it for an HTML sink. The child that then does dangerouslySetInnerHTML={{ __html: body }} is the same hatch as a SPA. Run safeHtml before the prop crosses, or inside the child. Do both if two children render the same field.
The other RSC leak is embedding JSON in a <script> tag so the client can hydrate. JSON.stringify does not escape </script> or line separators the way a browser script block needs. The package with a 2025-2026 release for that job is serialize-javascript. Prefer passing props through Flight and skipping the inline script. If you must emit a script block, serialize, then put the result in a <script type="application/json"> that your boot reader parses, not in an executable script body.
Do not put an access token in that blob. A token in a page is a token every extension and every log line can see. That is the JWT page. Cookie session in __Host-session, or an in-memory access token plus __Host-refresh.
eval-ish sinks React will not catch
React will not save you from eval, new Function, setTimeout(string), or setInterval(string). Those are CWE-95. They show up in feature-flag clients, “safe” formula parsers, and AI-generated snippets people paste into a route. There is no React wrapper that makes a string into a function safely. Delete the call. If a product requirement is a formula, use a parser that returns an AST and evaluates named operations, not Function(userText).
// BAD: do not copy. Named so the grep in the next section hits it.
// const fn = new Function("value", userFormula);
// const out = eval(userFormula);
// FIX: named operations only. userFormula never becomes source.
const OPS = {
upper: (value) => String(value).toUpperCase(),
lower: (value) => String(value).toLowerCase(),
};
export function runNamedOp(op, value) {
const fn = OPS[op];
if (!fn) return String(value);
return fn(value);
}
Enable no-eval, no-implied-eval, and no-new-func in ESLint. Enable react/no-danger so every hatch is a lint event. A lint event is a review, not a lecture. eslint-plugin-react 7.x still ships react/no-danger. rule doc. It flags the prop. It does not flag a ref that writes innerHTML. Keep the grep.
Grep and a test you can run
You are not walking an exploit. You are proving the renderer stayed on a text sink, and that the lockfile is off the December versions.
rg -n "dangerouslySetInnerHTML|innerHTML|insertAdjacentHTML" \
--glob '!node_modules' --glob '!dist'
rg -n "\\beval\\(|new Function|setTimeout\\(\\s*['\"]" \
--glob '!node_modules'
rg -n "react-server-dom-(webpack|parcel|turbopack)" package-lock.json \
| head
Every dangerouslySetInnerHTML hit must call safeHtml. A raw {{ __html: props.body }} is the miss. Lockfile lines still on 19.0.0, 19.1.0, 19.1.1, or 19.2.0 of a server-dom package are the December miss.
import { renderToStaticMarkup } from "react-dom/server";
import { CommentBody, safeHref } from "./sanitize"; // src/sanitize.js
test("comment renderer strips a script node", () => {
const html = renderToStaticMarkup(
<CommentBody html={'<p>ok</p><script>window.x=1</script>'} />
);
expect(html).toContain("<p>ok</p>");
expect(html).not.toMatch(/<script/i);
});
test("safeHref refuses a javascript scheme", () => {
expect(safeHref("javascript:void(0)")).toBe("#");
expect(safeHref("https://docs.example/a")).toMatch(/^https:/);
});
That first test should fail on a helper that returns { __html: html } with no sanitizer. It should pass on safeHtml. The second test is the URL belt. Add react/no-danger to CI so a new hatch cannot land quiet.
Questions we keep getting
Does React 19 make dangerouslySetInnerHTML safe?
No. The name is the warning. React 19 still hands the string to the DOM as HTML. The December 2024 release rewrote some javascript: URLs on element props. It did not sanitize HTML. Use safeHtml or do not open the hatch.
We only use Server Components. Are we outside XSS?
No. A server string that a client then inserts as HTML is the same sink. Flight serializes data. It does not encode for HTML. Patch the server-dom packages for the 2025-2026 CVEs, then treat CMS fields like any other hatch.
Is a token in localStorage a React bug?
It is a storage bug the SPA usually introduces. XSS in any hatch can read localStorage and leave with the JWT. Put the session in an HttpOnly __Host- cookie, or keep a short access token in memory and the refresh token in __Host-refresh. That pairing lives on the JWT page.



