Skip to content

Multiple XSS / arbitrary JS execution vectors in default config (v2.4.1) #309

Description

@vaadata-poyetont

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions