Javascript + React: Protecting the DOM from Scammers who Mutate Content with Devtools

Jay Kariesch
5 min readApr 27, 2021
Photo by Anna Shvets from Pexels

A month ago, YouTube sensation Mark Rober teamed up with popular scambaiter Jim Browning to deliver a satisfying dose of techno super hero justice to notorious call center swindlers and their money mules.

If you haven’t seen it, you can check it out here. The short, relevant bit is, scammers use devtools to change currency values on a victim’s bank website to make it appear as though the victim mistakenly received a significant amount of money ($20k) from a refund that was supposed to be just $200. The victim believes they’ve made an honest mistake, and the scammer provides them with instructions on where to mail the excess refund money.

If you‘re a frontend developer at Bank of America, I’d wager within a day of Rober’s video going live your job turned into a hellscape of business lines and product folks asking you to disable devtools for the BoA website.

So, what can we do to prevent value changes via devtools?

Proof of Concept

The idea sounds easy enough:

  1. Watch the subtree of a component for changes with a MutationObserver.
  2. Force a re-render when a mutation is observed to re-display the original content, overwriting any changes that occurred through devtools.
  3. Publish a medium article.
  4. $$$$$$$$$

Simple enough, right?

Let’s create the initial scaffolding. At the moment, let’s focus on handling strings and strings inside a single-level DOM node, allowing our component to handle the following use-cases:

Let’s start by making sure the children prop is a string, and then add our onSubtreeMutation handler, which will be passed into the MutationObserver and fire every time a mutation occurs.

The MutationObserver interface provides the ability to watch for changes being made to the DOM tree.

We also need to wrap our text children in a DOM element to expose a ref that can be used with our MutationObserver.

A few notes:

  • The return statement will always add an additional parent node. Extra nodes can at times impact styles, and if you’re a perfectionist, it’s likely painful to see unnecessary DOM nodes floating around. I’ve used cloneElement to add a ref to incoming children if the prop is not a string.
  • We still have to do something when a subtree modification occurs.
  • A re-render must occur to overwrite changes made via devtools with the original value that was passed into ImmutableString.

Let’s try a few things out in a codepen.

If you open the console and modify one of the values, you’ll notice that the MutationObserver executes the onSubtreeMutation function, which in turn updates the incrementer to force a re-render; however, when the component re-renders, no DOM changes are reconciled. This is because, as far as React is concerned, no changes occurred. “The” DOM subtree was modified, but that doesn’t propagate changes to the React component. The takeaway here is, the React component remains completely untouched.

Reconciliation + “V-DOM”, and Native DOM

Let’s demystify re-renders in React really quick to understand the immediate issue with handling changes that occur via devtools.

  1. A re-render triggers internal component operations. Without getting into nuances and for the sake of brevity, any operations inside of a component that aren’t wrapped in certain hooks will run every re-render.
  2. A re-render will not always update the DOM. Not even this.forceUpdate() within a class component can accomplish this. This means that despite your component’s internal functions having ran, the actual output returned by a component might not change at all, having no impact on the DOM. This is due to Reconciliation(tm), AKA the algorithm that compares a component against its stored “V-DOM” state.

Looking at our first example, children represents our component. Since mutating via devtools never actually triggers an update within children , there are no differences to reconcile.

But what if we update an attribute on children? Will that force it to re-render?

Nope.

Check out the example below. I’ve added an attribute called data-rerender to the root node of children.

If you change some of the text in the above codepen via devtools, you’ll notice data-rerender updates, but nothing else. It’s simply making a tiny patch to the DOM. Reconciliation is pretty freakin’ cool, but it’s clear we need to figure out how to work around it.

Avoiding Reconciliation with Native DOM

Forking my naive example, one approach to bypass reconciliation is to assign values directly to the DOM reference created via useRef .

Here’s an example of this update applied to our mutation handler:

This works great for simple use cases where only text and single nodes containing text must remain unchangeable. If we used a similar approach for patching an entire subtree, we’d discover that events would stop working. Ordinarily this would be a deal breaker, but in our case disabling events could be a bug-turned-feature since the goal is to prevent scammers from updating sensitive content. If a page has been tampered with, a product owner may want to disable events.

Below is an example of our ImmutableString component thus far.

Remounting to Avoid Reconciliation

To maintain events and avoid reconciliation, we need a way to unmount a component, then remount it. This is actually pretty easy. There may be a better way to achieve this (please comment if so). This approach leverages the ReactDOM methods render and unmountComponentAtNode , and has the same effect as createPortal.

There are a few additional changes, but let’s take a look at our mutation handler:

And here’s a pen with all of our updates so far:

Alright, it’s looking a little better.

Putting it all together

Below is the sum of each pen, along with an additional feature which allows a subtree to be secured:

Caveat

unmountComponentAtNode does not work with text node types. A text node type is a line of text that isn’t wrap in an element node. This means that each text child within ImmutableDOM must be wrapped in an element, like p, h1, etc.

Conclusion

I hope you found this article insightful. And if you have another approach, feel free to post it in the comments.

--

--