Preact's Identity Crisis: When JSON Becomes Code
Jan 8, 2026·6 min read
Executive Summary (TL;DR)
Preact versions 10.26.5 through 10.28.1 introduced a regression that weakened the distinction between trusted Virtual DOM nodes and plain JavaScript objects. Attackers can supply a JSON payload that looks like a VNode (e.g., containing `type`, `props`, and `__v`), causing Preact to render it as a real HTML element with arbitrary attributes (like `onerror`). This turns any unvalidated JSON input passed to a render function into a potential Remote Code Execution vector via XSS.
A critical HTML injection vulnerability in Preact allows attackers to bypass Virtual DOM protections by feeding specifically crafted JSON objects into the render tree. By mimicking the internal structure of a Virtual Node (VNode), malicious JSON is interpreted as executable DOM elements, leading to Cross-Site Scripting (XSS).
The Hook: The 3kB Time Bomb
In the world of frontend development, Preact is the lean, mean, fighting machine—a 3kB alternative to React that promises the same modern API without the bloat. It achieves this by stripping away the heavy synthetic event systems and complex abstractions of its big brother. But minimalism comes at a cost: fewer lines of code mean fewer places to hide, but also fewer layers of defense when a logic error slips through. The core of any Virtual DOM (VDOM) library is trust. The library trusts that the VDOM tree it is diffing was constructed by you, the developer, using safe functions like h() or JSX compilation. It assumes that external data enters the system as props or state, never as the structural nodes of the tree itself.
CVE-2026-22028 breaks this contract of trust in spectacular fashion. It essentially allows an attacker to masquerade a plain JSON object as a legitimate VNode. Imagine you run a secure facility where employees (VNodes) need a high-tech badge to enter. Suddenly, management decides to cut costs and simply check if the person is wearing a blue shirt. That's what happened here. A regression in Preact's validation logic lowered the bar for what constitutes a "valid component," allowing any data source—a REST API, a WebSocket message, or a URL parameter—to inject structural DOM elements directly into your application page. It’s not just injecting a string; it’s injecting the blueprint for the building.
The Flaw: Duck Typing Gone Wrong
The vulnerability relies on a classic programming pitfall: Type Confusion via loose "Duck Typing." In dynamic languages like JavaScript, we often verify objects by their shape rather than their class. If it walks like a duck and quacks like a duck, it's a duck. Preact needs to distinguish between a VNode (a trusted UI element) and a plain object (data to be displayed). Usually, this is done by checking for non-enumerable properties or Symbols that cannot be easily serialized into JSON. However, in version 10.26.5, Preact softened these checks, likely for performance or compatibility reasons.
The regression meant that Preact started identifying VNodes purely by checking for a few public properties—specifically type, props, and an internal version flag __v. The problem? JSON, the language of the web, perfectly supports this structure. If an application takes user input and blindly renders it inside a <div>{userInput}</div>, it expects Preact to treat userInput as a string (creating a Text Node) or a number. But because the validation logic was flawed, if userInput happens to be an object that looks like a VNode, Preact says, "Oh, pardon me, Sir Element," and renders it as a fully functional HTML tag. This transforms a simple data display bug into a full-blown HTML injection vulnerability.
The Code: The Smoking Gun
To understand the severity, we have to look at what the "authenticity check" allows. In a secure VDOM implementation, a VNode should look something like an opaque structure, often stamped with Symbol.for('react.element') or created via a factory that attaches non-enumerable properties. These cannot be replicated via JSON.parse() because JSON doesn't support Symbols or functions.
However, the vulnerable Preact versions allowed a plain object (POJO) to pass the test if it simply contained the right keys. The simplified logic roughly looked like this:
// Pseudo-code of the vulnerable check
function isVNode(obj) {
// If it has a type and props, it's probably one of ours, right?
return obj && obj.type && obj.props && obj.__v;
}This is disastrous. By supplying the following JSON payload, an attacker can bypass the check entirely. The Preact renderer sees the __v property and assumes this object was created by its own createElement function, skipping the string escaping that normally protects against XSS.
{
"content": {
"type": "img",
"props": {
"src": "x",
"onerror": "alert(document.cookie)"
},
"key": null,
"ref": null,
"__v": 1
}
}When the patch (10.26.10, etc.) was applied, the maintainers restored stricter validation, likely ensuring that VNodes originate from trusted factories or checking for properties that cannot be injected via JSON deserialization.
The Exploit: Converting JSON to RCE
Let's construct a realistic kill chain. Imagine a modern dashboard application that fetches user comments from an API. The developer writes code that looks perfectly innocent:
// Frontend: fetching and rendering
const comment = await api.getComment(id);
// Vulnerable line: direct embedding of data
render(<div>{comment.body}</div>, document.body);The developer assumes comment.body is a string. If an attacker sends <script>..., Preact normally escapes it. Safe, right? Wrong. The attacker doesn't send a string. They compromise the API or use a vulnerability in the backend to store a JSON object into the body field of the database. When the frontend fetches it, comment.body is now the malicious JSON object we saw earlier.
- Injection: The attacker POSTs the malicious JSON payload to the API.
- Delivery: The victim visits the dashboard. The app fetches the comment.
- Confusion: The app passes the object to
render(). Preact checks the object, sees thetype: 'img'and__v: 1properties, and decides it is a VNode. - Execution: Preact creates a real DOM
<img>tag. It sets thesrcto 'x' (which fails) and sets theonerrorattribute to the attacker's JavaScript. - Detonation: The image fails to load, the
onerrorhandler fires, and the attacker's script executes in the victim's context.
This requires zero interaction from the victim other than viewing the page. It bypasses traditional XSS filters that only look for < or > characters in strings, because this payload is purely structural JSON.
The Mitigation: Hardening the Render Tree
The immediate fix is to update Preact. The maintainers released hotfixes across three minor versions (10.26.10, 10.27.3, 10.28.2). These patches essentially tell the bouncer at the club to check for a holographic ID card, not just a blue shirt. If you are running a vulnerable version, you are exposed to any API endpoint that returns untyped JSON data.
However, developers shouldn't just rely on library patches. This vulnerability highlights a deeper architectural flaw: Implicit Trust of API Data.
[!NOTE] Defensive Coding Strategy: Never trust that an API returns what you expect.
If a component expects a string, coerce it. Change your render calls to explicit casts: <div>{String(data.content)}</div>. This forces the malicious object to become "[object Object]"—ugly, but harmless. Additionally, use runtime schema validation libraries like Zod or Io-TS at your network boundary. If the API contract says "string", the validation layer should throw an error if an object arrives, stopping the exploit before it even reaches the UI library.
Official Patches
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
preact preactjs | >= 10.26.5 < 10.26.10 | 10.26.10 |
preact preactjs | >= 10.27.0 < 10.27.3 | 10.27.3 |
preact preactjs | >= 10.28.0 < 10.28.2 | 10.28.2 |
| Attribute | Detail |
|---|---|
| CWE | CWE-79 (XSS) / CWE-843 (Type Confusion) |
| CVSS v4.0 | 9.2 (Critical) |
| Attack Vector | Network (JSON Payload) |
| Privileges Required | None |
| User Interaction | None (Passive rendering) |
| Exploit Status | PoC Available |
MITRE ATT&CK Mapping
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.