Safely Previewing External Sites — Implementing a Proxy + Sanitizer + iframe Sandbox
- 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-scriptsis 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.