I've been doing some security work on the parser and ended up with a handful of issues I want to flag before going further. They're all reachable from the jsx prop with the default configuration, so I think they're worth a release.
Leading with the worst one because the others are mostly "the default blacklist is too small", but this first one is a real RCE.
1. Arbitrary JS via Function constructor
<JsxParser jsx={`{"".constructor.constructor("alert(document.domain)")()}`} />
This pops alert, or anything else like fetch('/exfil?c='+document.cookie). No bindings, no extra config, no clicks. It fires the moment the component mounts.
What's happening: #parseMemberExpression does a raw object[property] lookup, so any literal lets the attacker walk the prototype chain. "".constructor is String. String.constructor is Function. Then CallExpression invokes it. There are a bunch of equivalent forms: (0).constructor.constructor(...)(), [].constructor.constructor(...)(), optional-chained variants, the same chain hidden inside .map(() => ...), and so on.
This is the same class of bypass that bit vm2, safe-eval, and similar libraries. The README explicitly says the inline-function restriction exists to prevent this kind of thing, but the protection ends at ArrowFunctionExpression parsing. The rest of the expression evaluator is happy to dereference constructor.
The minimum fix is to deny-list constructor, __proto__, prototype, and the __define* / __lookup* accessors in both #parseMemberExpression and resolvePath. Beyond that, an allow-list on CallExpression callees (callee must come from bindings, scope, or a component prop) would properly contain the evaluator, but the deny-list closes the immediate hole.
2. dangerouslySetInnerHTML is not in the default blacklist
<JsxParser
jsx={`<div dangerouslySetInnerHTML={{__html: "<img src=x onerror=alert(1)>"}} />`}
/>
The default blacklistedAttrs: [/^on.+/i] only catches event handlers. When someone passes JSX that includes dangerouslySetInnerHTML, React injects the payload as raw HTML and the onerror fires on insertion.
This also works through a spread when the application happens to merge user input into a bindings object:
<JsxParser
bindings={{ p: { dangerouslySetInnerHTML: { __html: '<img src=x onerror=alert(1)>' } } }}
jsx={`<div {...p} />`}
/>
Adding dangerouslySetInnerHTML (and probably ref) to the default blacklistedAttrs is a one-line fix.
3. <iframe srcdoc>
<JsxParser jsx={`<iframe srcdoc="<script>parent.pwn=1</script>" />`} />
Neither iframe is in default blacklistedTags, nor srcdoc/srcDoc in default blacklistedAttrs. Same-origin iframe means the script gets at the parent via window.parent. Plain old XSS.
Worth adding iframe, object, embed, link, meta, base to the default tag blacklist, and srcdoc/srcDoc to the attribute one. Or flipping to an allow-list for safe HTML.
4. javascript: URL scheme not filtered
<JsxParser jsx={`<a href="javascript:alert(1)">click me</a>`} />
React 18 emits a console warning ("A future version of React will block javascript: URLs…") but does not actually block it. The rendered DOM has a live javascript: link. Same story for <form action="javascript:…"> and <area href="javascript:…">.
A small URL-scheme check on href, src, action, formaction, srcdoc, xlink:href, background, poster, rejecting javascript:, data:, and vbscript: would do it (or make it configurable).
5. Spread attribute propagates dangerous keys
The spread branch in #parseElement (around line 429) lifts keys out of an object straight into props, with no filter beyond the same regex blacklist. Because the blacklist is small, anything that isn't an event handler comes through. So dangerouslySetInnerHTML, srcDoc, formAction, ref all survive a <div {...x} />. Fixing 2. to 4. fixes this branch for free, but it's worth unit-testing the spread path explicitly because it's the most likely real-world attack surface. Apps merge backend data into bindings all the time.
Small nit while I'm here: the condition at line 430 reads (JSXSpreadAttribute && Identifier) || MemberExpression, which is missing a pair of parentheses. It accidentally accepts non-spread expressions whose argument is a MemberExpression. The earlier if (JSXAttribute) short-circuits before it matters in practice, but it's confusing to audit.
I've been doing some security work on the parser and ended up with a handful of issues I want to flag before going further. They're all reachable from the
jsxprop with the default configuration, so I think they're worth a release.Leading with the worst one because the others are mostly "the default blacklist is too small", but this first one is a real RCE.
1. Arbitrary JS via
FunctionconstructorThis pops
alert, or anything else likefetch('/exfil?c='+document.cookie). No bindings, no extra config, no clicks. It fires the moment the component mounts.What's happening:
#parseMemberExpressiondoes a rawobject[property]lookup, so any literal lets the attacker walk the prototype chain."".constructorisString.String.constructorisFunction. ThenCallExpressioninvokes it. There are a bunch of equivalent forms:(0).constructor.constructor(...)(),[].constructor.constructor(...)(), optional-chained variants, the same chain hidden inside.map(() => ...), and so on.This is the same class of bypass that bit
vm2,safe-eval, and similar libraries. The README explicitly says the inline-function restriction exists to prevent this kind of thing, but the protection ends atArrowFunctionExpressionparsing. The rest of the expression evaluator is happy to dereferenceconstructor.The minimum fix is to deny-list
constructor,__proto__,prototype, and the__define*/__lookup*accessors in both#parseMemberExpressionandresolvePath. Beyond that, an allow-list onCallExpressioncallees (callee must come frombindings,scope, or a component prop) would properly contain the evaluator, but the deny-list closes the immediate hole.2.
dangerouslySetInnerHTMLis not in the default blacklistThe default
blacklistedAttrs: [/^on.+/i]only catches event handlers. When someone passes JSX that includesdangerouslySetInnerHTML, React injects the payload as raw HTML and theonerrorfires on insertion.This also works through a spread when the application happens to merge user input into a
bindingsobject:Adding
dangerouslySetInnerHTML(and probablyref) to the defaultblacklistedAttrsis a one-line fix.3.
<iframe srcdoc>Neither
iframeis in defaultblacklistedTags, norsrcdoc/srcDocin defaultblacklistedAttrs. Same-origin iframe means the script gets at the parent viawindow.parent. Plain old XSS.Worth adding
iframe,object,embed,link,meta,baseto the default tag blacklist, andsrcdoc/srcDocto the attribute one. Or flipping to an allow-list for safe HTML.4.
javascript:URL scheme not filteredReact 18 emits a console warning ("A future version of React will block javascript: URLs…") but does not actually block it. The rendered DOM has a live
javascript:link. Same story for<form action="javascript:…">and<area href="javascript:…">.A small URL-scheme check on
href,src,action,formaction,srcdoc,xlink:href,background,poster, rejectingjavascript:,data:, andvbscript:would do it (or make it configurable).5. Spread attribute propagates dangerous keys
The spread branch in
#parseElement(around line 429) lifts keys out of an object straight into props, with no filter beyond the same regex blacklist. Because the blacklist is small, anything that isn't an event handler comes through. SodangerouslySetInnerHTML,srcDoc,formAction,refall survive a<div {...x} />. Fixing 2. to 4. fixes this branch for free, but it's worth unit-testing the spread path explicitly because it's the most likely real-world attack surface. Apps merge backend data intobindingsall the time.Small nit while I'm here: the condition at line 430 reads
(JSXSpreadAttribute && Identifier) || MemberExpression, which is missing a pair of parentheses. It accidentally accepts non-spread expressions whoseargumentis aMemberExpression. The earlierif (JSXAttribute)short-circuits before it matters in practice, but it's confusing to audit.