Safely Previewing External Sites — Implementing a Proxy + Sanitizer + iframe Sandbox

HeatMapX Engineering Team5 min read
  • engineering
  • security
  • nextjs
  • iframe

Summary

  • Loading an external site inside an iframe on your own origin risks letting that site's external JS touch your own session
  • The fix is defense in depth: an SSRF-guarded proxy + removal of external JS + a restrictive CSP + iframe sandboxing
  • A sandbox that's too strict can block your own scripts too — forgetting allow-scripts is a real pitfall that breaks functionality

HeatMapX's A/B testing includes a feature that lets you "preview what the page will look like with the Variant B changes applied to the target page." Technically, this means "safely rendering a user's external site inside an iframe on our own domain" — a surprisingly delicate piece of engineering. Here's how we designed it, and a pitfall we actually ran into.

Why a naive implementation is dangerous

If you fetch an external site's HTML as-is and serve it from your own origin (say, heatmapx.com), any <script> tags or onclick= handlers embedded in that HTML end up executing with your own origin's privileges. That opens the door to reading cookies or hijacking login sessions. This must be avoided.

Defense-in-depth architecture

To address this, we layer the following defenses.

1. An SSRF-guarded proxy

We validate the fetch target URL, rejecting anything other than http/https, and block localhost, private IP ranges, and cloud metadata endpoints (e.g., 169.254.169.254). We also don't follow redirects automatically — instead we use redirect: 'manual' to re-validate each hop individually, preventing the request from being redirected into an internal network.

2. Removing external JS (sanitization)

From the fetched HTML, we strip <script> tags, on*= inline handlers, and javascript: schemes. There's no need to run the customer site's JS in the preview (we accept as a known limitation that SPAs won't render perfectly).

3. A restrictive CSP and base href

We insert a <base href> right after <head> so relative image and CSS paths resolve correctly, while attaching a restrictive CSP to the response that prevents external JS from running. Images and CSS remain allowed so the page still displays properly.

4. iframe sandbox

Finally, we isolate the rendering side by adding a sandbox attribute to the iframe.

The pitfall: a sandbox that was too strict

The preview works by injecting a small, first-party script into the fetched HTML that applies the changeSet, then running it inside the iframe to update the appearance. However, the preview iframe's sandbox was set to allow-same-origin only — allow-scripts was missing.

As a result, our own script never ran either, and we ended up with a bug where the changes never applied and the original page was shown instead. The editor-side iframe used for editing was correctly configured with allow-scripts allow-same-origin, so the root cause was an inconsistency between the two configurations.

The fix was simple: align the preview side to also use allow-scripts allow-same-origin. We also added a regression test that verifies the iframe's sandbox attribute.

sandbox="allow-same-origin"  →  sandbox="allow-scripts allow-same-origin"

Lessons learned

  • Think of embedding external content in terms of defense in depth. Don't rely on a single safeguard.
  • With sandbox, simply adding it doesn't make you safe — the key is to grant exactly the permissions you need, no more and no less. Too strict, and it breaks your own functionality.
  • Whenever "two code paths do the same thing" (here, the editor and the preview), always verify their security attributes are kept in sync. Fixing one without the other just breaks the other.

Conclusion

Safely previewing external sites can be achieved through defense in depth: an SSRF-guarded proxy, external JS removal, a restrictive CSP, and iframe sandboxing. And sandbox permissions can cause problems whether they're too limited or too permissive. Our missing allow-scripts was a textbook example of the latter.

Heatmaps you run from Claude Code — free to start.

Drop in one tracker tag. Analyze and ship CRO improvement PRs from the CLI. No credit card · 30-second setup.