Executing Dangerously Injected Scripts Inside React Components

alexmacarthur

Alex MacArthur

Posted on November 11, 2023

Executing Dangerously Injected Scripts Inside React Components

First off, I hereby declare myself not responsible for you shooting yourself in the foot after reading this.

I'm doing something a little weird with JamComments integrations. When a site's page is built, all of the HTML, CSS and JavaScript needed for comments to function are pulled in via REST API. I like this pattern. It makes for fewer dependencies, quicker iteration on the product, and it just feels tidier.

For the React-based integrations (Next, Gatsby, or Remix), I'm using the dangerouslySetInnerHTML prop to inject all of that code content into a <JamComments /> component. Using that prop is necessary because I want the code to run as code when the page is rendered.

The Problem: Script Tags Don't Run

The injected HTML + CSS execute just fine this way, but not the <script> tags needed for the experience to come alive. React's dangerouslySetInnerHTML prop relies on innerHTML, which deliberately doesn't execute scripts for [legitimate] security reasons. It's even in bold, Christmas text within the HTML spec:

screenshot from the HTML spec indicating script tags aren't executed by innerHTML

I respect that. Security matters. But in my case, executing JamComments' scripts "dangerously" was just an implementation hurdle by nature of integrating with React. Their own documentation even makes the caveat that using it might be appropriate, as long as the injected code is coming from an "extremely trusted source." I know exactly what's being injected here, so I feel like I deserve a hall pass.

Executing Injected Scripts

Fortunately, there's a straightforward way to make this happen if you ever need it. The TL;DR: after the component mounts, scoop up the stringified code and re-run it in a document fragment.

Let's build that out. Here's the shell of what we'll start with:

import { useRef, useLayoutEffect } from 'react';

export function DangrousElement({ markup }) {
    const elRef = useRef<HTMLDivElement>();

    useLayoutEffect(() => {
        // Magic goes here.
    }, []);

    return (
        <div 
            ref={elRef} 
            dangerouslySetInnerHTML={{ __html: markup }}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Reaching for useLayoutEffect() is very intentional, by the way. More on that later. For now, let's continue fleshing this out with the following snippet code-as-a-string being passed to the component:

const markup = `
    <span>Dangerous markup!</span>

    <script>console.log("Script has executed!");</script>
`;

<DangrousElement markup={markup} />

Enter fullscreen mode Exit fullscreen mode

If we do this right, we'll see that console.log fire after our component mounts in our application.

Make a Little Document Fragment

If we wanted, we could query that <div> for the <script> tags ourselves, build clones of them, and then execute them by appending them to an existing DOM node. But there's a cleaner way to pull this off: a document fragment, or a little version of an HTML document we can use to contain (and execute) code.

We're going to reach for a specific piece of that API. First, we'll create a document "range" to hold our fragment. Think of this as a slice of the document we'll use to "hold" everything. After that, we'll set the context of that range to the specific DOM node we're building the component around, and finally, create the fragment. Here's what we'll put into that useLayoutEffect() hook:

// Create a range/slice of the document. 
const range = document.createRange();

// Set the context to our containing node. 
range.selectNode(elRef.current);

// Create a new fragment within that range. 
const documentFragment = range.createContextualFragment(markup);
Enter fullscreen mode Exit fullscreen mode

The benefit to this approach is that the code will execute in the context of that node. We can verify this by creating a couple of empty ranges. First, we'll make one with no "selected" node.

const openRange = document.createRange();
Enter fullscreen mode Exit fullscreen mode

When we access the children of the startContainer property, it's wrapped around the entire document itself:

openRange has context of entire document

But that changes if we target a specific node:

const contextualRange = document.createRange();
contextualRange.selectNode(document.getElementById("app"));
Enter fullscreen mode Exit fullscreen mode

This time, the range is wrapped around the node we selected, giving it a little more specific context:

Depending on the script you're running, this might not make for anything practically meaningful, but it's nice to know that the code will run as if it were always in that spot, and maybe prevent some unexpected context-related bugs at the same time.

Re-Run the Code

The last part's simple. Wipe the already-rendered code and replace it with our fragment:

import { useRef, useLayoutEffect } from 'react';

export function DangrousElement({ markup }) {
    const elRef = useRef<HTMLDivElement>();

    useLayoutEffect(() => {
        const range = document.createRange();
        range.selectNode(elRef.current);
        const documentFragment = range.createContextualFragment(markup);

+ // Inject the markup, triggering a re-run! 
+ elRef.current.innerHTML = '';
+ elRef.current.append(documentFragment);
    }, []);

    return (
        <div 
            ref={elRef} 
            dangerouslySetInnerHTML={{ __html: markup }}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Now, we can see that script execute like desired:

screenshot showing console contains log

Reaching for useLayoutEffect() vs useEffect()

It's uncommon to see, but choosing useLayoutEffect() here is important. This hook fires before the component has a chance to be rendered (and painted) to the screen. This differs from useEffect(), which runs after render. If we want our code to execute as if it were there from the beginning, our work needs to come before any possible paint, which could cause an unwelcome "flicker" for scripts that mess with the DOM. To satisfy your curiosity, here's a modified version of the code string:

const markup = `
    <span id="message">First!</span>

    <script>
        synchronousWait(1500);

        document.getElementById("message").innerText = "Second!";
    </script>
`;
Enter fullscreen mode Exit fullscreen mode

I've made a synchronousWait() method that blocks the main thread for 1.5 seconds. After that, the text is updated in the injected <span>. Because React gives the component a chance to do an initial render + paint with our code, mounting it allows us to see the initial text before it's swapped out.

GIF showing flash caused by useEffect()

Replacing that with useLayoutEffect() guarantees execution before paint, preventing this flash. Instead, you just get a blank, 1.5s delay – just like you'd get if the snippet were to run in a fresh HTML document.

The trade-off should be obvious, though: because the hook executes synchronously, you're at risk for introducing some annoying performance issues. So, be wary about what the injected script is doing, and be sure to lean into asynchronous patterns as needed.

By the Way! "Strict" Mode Disabled for Simplicity

You'll notice our log fired once, which is what'd we'd expect in a production environment. But with React's strict mode enabled, useLayoutEffect() will fire twice in development mode, meaning our log would also fire twice unless extra steps are made to avoid it. I've intentionally disabled strict mode here, just for the sake of conceptual simplicity.

If you'd like to see a version that works regardless of strict mode, you can see the StackBlitz environment here. It's not much – just another useRef() hook and some value setting/getting.

Don't Be Stupid

I think the dangerouslySetInnerHTML is appropriately named, even if it doesn't execute <script> tags by default. So, I highly advise you to use it with even more caution if you do so in such a way described here. Despite that risk, I think knowing how to navigate around "limitations" like this, for the right reasons, is valuable, and even powerful. Just don't be an idiot when wielding it. We don't need another Samy.

💖 💪 🙅 🚩
alexmacarthur
Alex MacArthur

Posted on November 11, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related