Secure an AI HTML Preview

Harden an iframe that safely previews untrusted AI-generated HTML.

Introduction

30 Second Summary

AI can draft a web page in seconds. The risky moment starts when an app displays that draft as live content.

In this project, you will build a local preview page that renders pasted HTML inside an iframe. You will attack the preview before each browser defence so every security claim has visible proof.

What You'll Build

Your finished demo renders hostile HTML inside a frame where every attack fails while one legitimate resize message still works.

By the end of this project, you'll have:

  • A preview with origin isolation that renders pasted HTML while keeping the host's localStorage plus cookies unreachable.
  • Visible proof that a sandbox blocks escape attempts while a Content Security Policy stops outbound requests.
  • A guarded message channel that resizes the frame for valid input while logging malformed payloads or wrong-source messages.
  • Secret Mission: Add a visual security log that counts accepted resize messages plus rejected resize messages.

Are there any prerequisites?

You'll need a browser, a text editor, plus a local HTTP server. The entire project runs locally with no API key or paid service.

Before We Start

A browser security preview needs a real local origin. Opening an HTML file directly from disk cannot reproduce the cross-origin behavior you need to test.

You'll use Visual Studio Code to edit the preview. Google Chrome will expose each attack through Chrome DevTools.

The Live Server extension gives Visual Studio Code a local static-file server. You will install it now without creating any preview files or starting a server.

In this step, get ready to:
  • Install Visual Studio Code for editing the preview files.
  • Add Live Server as the local static-file server.
  • Prepare Google Chrome with Chrome DevTools.
Install Visual Studio Code

The preview grows across several HTML files during this project. A code editor keeps those files together while making scripts and security policies easier to inspect.

You should see the Visual Studio Code installer in your browser's downloads.

  • Run the downloaded installer.
  • Complete the installer prompts with their default choices.

You should reach the installer's completion screen.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code into the search bar.
  • Press Enter to open Visual Studio Code.

You should see the Visual Studio Code window. The editor is ready for the preview files you create in the next step.

Visual Studio Code will not open?

Confirm the installer completed before searching again. On Windows, check that you downloaded the Windows installer from the official page.

On macOS, check your Applications folder if search cannot find the editor.

Help me fix my Visual Studio Code installation

Add Live Server

Live Server turns a folder of static files into a website on localhost. This matters because a page opened through file:// does not reproduce the origin boundaries used by the security tests.

Visual Studio Code may ask you to confirm trust in the extension publisher. Check that the publisher is Ritwick Dey before approving the installation.

  • Select the Extensions icon in the Activity Bar on the far left.
  • Type Live Server into the extension search box.
  • Select Live Server by Ritwick Dey from the results.
  • Confirm the publisher line names Ritwick Dey.
  • Click Install on the extension details page.
  • Approve the publisher confirmation if it appears.

You should see the Install button change to a Manage gear. Live Server is installed without running a server.

Why use a local server?

Browser security rules depend on where a page comes from. A local server gives the host page an origin that the browser can compare with the preview origin.

Later, two localhost ports approximate separate hosted origins. That separation makes the cross-origin checks visible in your browser.

Cannot install Live Server?

Confirm your internet connection before searching again. Make sure the selected result names Ritwick Dey as the publisher.

Help me install the Live Server extension

Prepare Chrome DevTools

The browser is your security test bench. Its Console reveals script errors while its Network panel shows whether a preview sends data away.

You should see the Chrome installer in your browser's downloads.

  • Run the downloaded installer.
  • Complete the installer prompts.

You should reach the installation completion screen.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Google Chrome into the search bar.
  • Press Enter to open Google Chrome.

Before you open DevTools, which panel do you expect to show messages from scripts running inside the future preview?

  • Press Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) to open the Console panel.

You should see Chrome DevTools docked beside or below the browser page. The Console panel should be selected.

  • Select the Network tab in Chrome DevTools.

You should see the Network panel with controls for recording browser requests. This confirms that your browser can expose both script behavior and outbound traffic.

Chrome DevTools did not open?

Click inside the browser page before trying the shortcut again. On Windows, you can also press F12 to open the last-used DevTools panel.

Help me open Chrome DevTools

Your security test bench is ready. Visual Studio Code can edit the preview while Live Server provides its local origin.

Next up, you'll serve the first host page and watch an iframe script reach data that should have stayed private.

Build the Naive HTML Preview

AI-written HTML needs somewhere to render before you can inspect it. Many preview tools place that string in an iframe. The frame looks separate from the host page.

In this step, you'll build that common baseline with srcdoc. You'll test the boundary by asking the frame to read a value stored by the host.

In this step, get ready to:
  • Create a host page with an HTML input and preview frame.
  • Serve the page on localhost port 3000.
  • Test whether the rendered HTML can read the host's localStorage value.
Create the host preview page

The host page needs three controls. A textarea accepts HTML while a button sends that HTML into the frame.

  • Return to the terminal from your setup.
  • Create the ai-html-preview folder on your Desktop by running these commands:
cd ~/Desktop
mkdir ai-html-preview
ls

What do these commands do?

  • The first command moves your terminal to the Desktop.
  • The second command creates the ai-html-preview folder.
  • The final command lists the Desktop contents so you can confirm the folder exists.

You should see ai-html-preview in the terminal output.

  • Move into the new folder by running this command:
cd ai-html-preview

Where are you now?

Your terminal now points at the ai-html-preview folder. The local server later serves files from this location.

  • Switch back to the code editor from your setup.
  • Use the editor's folder controls to open the ai-html-preview folder from your Desktop.
  • Use the editor's file controls to create index.html inside the ai-html-preview folder.
  • Build the host controls by adding this code to index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Naive HTML Preview</title>
</head>
<body>
  <h1>Naive HTML Preview</h1>
  <!-- Let the host accept HTML before sending it into the frame. -->
  <label for="html-input">HTML to preview</label>
  <textarea id="html-input" rows="10" cols="60"><h2>Hello from the preview</h2></textarea>
  <button id="render-button" type="button">Render preview</button>
  <p id="storage-status"></p>
  <!-- Receive the pasted HTML in a dedicated preview frame. -->
  <iframe id="preview-frame" title="HTML preview"></iframe>
</body>
</html>

What does this page contain?

  • The html-input textarea holds the HTML that you want to preview.
  • The render-button button gives the learner a clear render action.
  • The preview-frame iframe provides the nested area where the pasted HTML renders.
  • Save index.html.
  • Return to the terminal from earlier.
  • Start the static server on port 3000 by running this command:
python -m http.server 3000

What does this command do?

Python's local HTTP server serves the files in ai-html-preview on port 3000. This terminal stays occupied while the server is running.

  • Switch back to the browser from your setup.
  • Enter http://localhost:3000 in the address bar.

You should see the Naive HTML Preview heading. You should also see the textarea, the render button, and an empty preview frame.

Page not loading?

Check that the server terminal is still running. Confirm that you started it from the ai-html-preview folder.

If the terminal reports that port 3000 is already in use, stop the process using that port before retrying the command.

Help me troubleshoot the local preview server.

Connect the render action

The controls now need browser logic. That logic stores a demonstration value on the host before assigning the textarea contents to the frame's srcdoc property.

  • Switch back to index.html in your code editor.
  • Find the closing </body> tag.
  • Add this script directly above the closing tag:
  <script>
    // Keep references to the host controls used during each render.
    const htmlInput = document.getElementById("html-input");
    const renderButton = document.getElementById("render-button");
    const previewFrame = document.getElementById("preview-frame");
    const storageStatus = document.getElementById("storage-status");

    // Create a host value that the preview should not be able to read.
    localStorage.setItem("preview-secret", "host-only-demo");
    storageStatus.textContent = "Host storage value: " + localStorage.getItem("preview-secret");

    // Send the pasted HTML into the unsandboxed iframe.
    renderButton.addEventListener("click", () => {
      previewFrame.srcdoc = htmlInput.value;
    });
  </script>

What does this script do?

  • The four constants keep references to the textarea, button, frame, and status line.
  • The localStorage.setItem() call creates the host's preview-secret demonstration value.
  • The click listener copies the textarea value into previewFrame.srcdoc so the browser parses it as the frame's document.
  • Save index.html.
  • Switch back to the browser.
  • Refresh http://localhost:3000.

You should see Host storage value: host-only-demo below the render button. This confirms that the host created its demonstration value.

  • Click Render preview.

You should see Hello from the preview inside the iframe. Your textarea now controls the frame through srcdoc.

Preview still empty?

Confirm that the script sits above </body>. Check that render-button and preview-frame match the IDs in the page controls.

Help me fix the empty srcdoc preview.

✔️ Awesome, I've got everything!

Your saved index.html now renders textarea content inside the iframe.

ⓧ I'd like to double check the full code

  • Compare your saved index.html with this complete file:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Naive HTML Preview</title>
</head>
<body>
  <h1>Naive HTML Preview</h1>
  <!-- Let the host accept HTML before sending it into the frame. -->
  <label for="html-input">HTML to preview</label>
  <textarea id="html-input" rows="10" cols="60"><h2>Hello from the preview</h2></textarea>
  <button id="render-button" type="button">Render preview</button>
  <p id="storage-status"></p>
  <!-- Receive the pasted HTML in a dedicated preview frame. -->
  <iframe id="preview-frame" title="HTML preview"></iframe>

  <script>
    // Keep references to the host controls used during each render.
    const htmlInput = document.getElementById("html-input");
    const renderButton = document.getElementById("render-button");
    const previewFrame = document.getElementById("preview-frame");
    const storageStatus = document.getElementById("storage-status");

    // Create a host value that the preview should not be able to read.
    localStorage.setItem("preview-secret", "host-only-demo");
    storageStatus.textContent = "Host storage value: " + localStorage.getItem("preview-secret");

    // Send the pasted HTML into the unsandboxed iframe.
    renderButton.addEventListener("click", () => {
      previewFrame.srcdoc = htmlInput.value;
    });
  </script>
</body>
</html>
Test the frame's storage access

A visual boundary only tells you where the preview appears. This test asks whether the frame also has a separate browser origin from the host.

  • Select the existing contents of the HTML to preview textarea.
  • Replace the textarea contents by pasting this test HTML:
<h2>Frame storage test</h2>
<!-- Try to read the host page's demonstration value. -->
<script>
  console.log("Frame read:", localStorage.getItem("preview-secret"));
</script>

What does this test do?

The heading gives you a visible render result. The script asks the browser for the host's preview-secret value before printing the result to the Console.

  • Use your browser's menu to open Developer Tools.
  • Select the Console panel.

Before you render this HTML, do you think the frame can read the value stored by the host page?

  • Click Render preview to run the test.

You should see Frame storage test inside the iframe. In the Console, you should see Frame read: host-only-demo.

That result is the intended shortfall. The preview's visual boundary did not create an origin boundary.

Why could the frame read it?

An unrestricted srcdoc document inherits the host page's localhost origin. Its script can therefore reach the same localStorage data as the host.

Console message missing?

Confirm that Developer Tools is attached to http://localhost:3000. Check that the pasted HTML includes the complete opening and closing script tags.

Help me troubleshoot the iframe storage test.

Your preview works, but its script can reach the host's stored data. Next, you'll add a sandbox and rerun the same test to see that access fail.

Sandbox the Preview Frame

Your preview can already render pasted HTML inside an iframe. The previous test exposed the dangerous part: code inside the unsandboxed srcdoc frame can read the host page's localStorage value.

A browser sandbox can give the frame an opaque origin. In this step, you'll keep scripts running while cutting off access to host storage.

In this step, get ready to:
  • Protect the existing iframe with a scripts-only sandbox.
  • Repeat the localStorage attack.
  • Verify the origin boundary through a SecurityError.
Restrict the preview's origin

The sandbox attribute starts from a restricted frame. The allow-scripts token restores the capability this preview needs.

  • Copy the attack HTML currently in the preview input.
  • Switch back to the editor containing the host preview source from the previous step.
  • Find the opening iframe tag that receives the pasted HTML through srcdoc.
  • Add this attribute inside that opening tag:
sandbox="allow-scripts"

What does this attribute do?

  • The sandbox attribute applies browser restrictions to the iframe.
  • The allow-scripts token keeps scripts running inside the preview.
  • The missing same-origin permission gives the frame an opaque origin.
  • Host localStorage becomes inaccessible.
  • Host cookies become inaccessible.
  • Save the host preview source.
Test the isolated origin

The storage attack now tests whether the new boundary is active. A successful isolation test still executes the pasted script.

Before you render the attack again, do you expect the script to stop entirely or run until it reaches the storage read?

  • Switch back to the browser tab already serving http://localhost:3000.
  • Refresh the host page.
  • Return to Developer Tools from the previous test.
  • Return to its console view.
  • Paste the copied attack HTML into the preview input.
  • Use the same render action from the previous step.

You'll see a SecurityError when the script reaches localStorage. The error is the expected result.

That is the boundary working. Your preview can execute scripts without inheriting the host's storage access.

Still seeing the stored value?

  • Confirm that the attribute is on the iframe that receives srcdoc.
  • Save the host preview source again.
  • Refresh http://localhost:3000 again.
  • Reuse the same attack HTML that printed the stored value in the previous step.

Help me inspect my sandbox test.

✔️ Awesome, I've got everything!

Your sandboxed preview still runs pasted scripts. Host storage access now ends with a SecurityError.

ⓧ I'd like to double check the full code

Confirm that the existing opening iframe element includes this exact attribute:

sandbox="allow-scripts"

The iframe still receives pasted HTML through srcdoc. The scripts-only sandbox gives it an opaque origin.

Your HTML input, render action, and host storage demonstration remain unchanged.

Your frame now executes preview scripts without inheriting the host's storage access. Next, you'll test whether the preview can escape its frame.

Test Frame Escape Attempts

Your iframe sandbox now stops framed scripts from reading the host's storage. The next question is whether hostile HTML can escape its rectangle.

An iframe sandbox uses permission tokens to lift individual restrictions. You'll attack three blocked capabilities before granting one permission for a controlled comparison.

In this step, get ready to:
  • Attempt to replace the top-level page from inside the preview.
  • Attempt to open a popup from inside the preview.
  • Attempt to submit a form from inside the preview.
Test top-window navigation

Top navigation lets framed content replace the page that contains it. A hostile preview could use this capability to send someone away from the trusted host page.

This test targets about:blank. An unexpected escape would open an empty browser page without contacting an external site.

  • Return to the HTML input on the localhost page from earlier.
  • Replace the previous storage test with this top-navigation payload:
<button id="top-navigation-test">Try top navigation</button>
<pre id="result"></pre>

<script>
  // Try to replace the host page from inside the sandbox.
  const result = document.getElementById("result");
  document.getElementById("top-navigation-test").addEventListener("click", () => {
    try {
      top.location.href = "about:blank";
      result.textContent = "Top navigation was allowed.";
    } catch (error) {
      result.textContent = `Top navigation blocked: ${error.name}`;
    }
  });
</script>

What does this payload test?

  • The click handler tries to change the location of the top-level browser context.
  • The result area records whether the browser accepted the navigation.
  • The empty destination keeps this security test local.

Before you render it, do you think a click inside the frame can replace the host page?

  • Trigger the same render action you used earlier.
  • Click Try top navigation inside the preview.
  • Return to the Console panel from earlier.

The host page stays on localhost. The preview reports Top navigation blocked while the Console shows a sandbox-blocking message.

Did the host page disappear?

  • Return to the localhost host page using the browser address bar.
  • Inspect the preview iframe in Developer Tools.
  • Confirm its sandbox value contains only allow-scripts.

Ask for help if the frame still replaces the host: Why can my sandboxed srcdoc iframe still navigate the top-level page?

Test popups and form submission

Popups create another browsing context. Forms can send data or navigate to a submission destination.

The baseline sandbox grants neither capability. Testing both shows that script execution does not automatically grant every browser power.

  • Return to the host page's HTML input.
  • Replace the top-navigation payload with this popup and form payload:
<h2>Popup and form tests</h2>
<button id="popup-test">Try popup</button>
<form id="form-test" action="about:blank" method="get">
  <button type="submit">Try form submission</button>
</form>
<pre id="result"></pre>

<script>
  // Report whether the sandbox permits a new browsing context.
  const result = document.getElementById("result");
  document.getElementById("popup-test").addEventListener("click", () => {
    const popup = window.open("about:blank", "_blank");
    result.textContent = popup ? "Popup opened." : "Popup blocked.";
  });

  // Record the attempt before the browser applies its form restriction.
  document.getElementById("form-test").addEventListener("submit", () => {
    result.textContent += "\nForm submission attempted.";
  });
</script>

What does this payload test?

  • The popup button calls window.open() during a direct click.
  • The result reports whether the browser returned a popup reference.
  • The form handler records the submission attempt before the browser enforces the sandbox restriction.

Before you click either control, do you expect scripts being allowed to make popups or forms work too?

  • Trigger the host page's render action.
  • Click Try popup inside the preview.
  • Click Try form submission inside the preview.
  • Check the Console panel for sandbox-blocking messages.

The preview reports Popup blocked. No new tab opens.

The form attempt is recorded inside the preview. The frame stays on the test content while the Console reports that the sandbox blocked submission.

Did a popup or submission succeed?

  • Inspect the current preview iframe in the Elements panel.
  • Remove allow-popups if it is present.
  • Remove allow-forms if it is present.

Ask for help with an unexpected result: Why are popups or form submissions succeeding in my sandboxed iframe?

Grant one permission and restore the sandbox

A sandbox token removes one specific restriction. Temporarily granting popup permission gives you a controlled before-and-after comparison.

Finding the live attribute is a little fiddly because Developer Tools displays it inside the iframe element's markup. This experiment changes only the current page in your browser.

  • Return to the Elements panel in Developer Tools.
  • Click the element picker in the top-left corner of Developer Tools.
  • Click the preview frame on the host page.
  • Double-click the iframe's sandbox attribute value.
  • Replace allow-scripts with allow-scripts allow-popups.
  • Return to the host page's HTML input.
  • Trigger the render action with the popup and form payload still in the input.

Before you click the popup button again, what should change now that one restriction has been lifted?

  • Click Try popup inside the preview.

A new blank tab opens. The popup capability changed while form submission remained restricted.

What did one token change?

The allow-popups token permits new browsing contexts created by the sandboxed page. It does not grant form submission or top-level navigation.

This is least-privilege sandboxing in action. Each token should exist only when the preview genuinely needs that capability.

  • Close the blank tab opened by the preview.
  • Return to the localhost host page.
  • Refresh the host page to discard the temporary Developer Tools edit.
  • Return to the Elements panel.
  • Use the element picker to select the preview iframe again.
  • Confirm the iframe's sandbox value is back to allow-scripts.

The hardened configuration is restored. Your iframe once again grants scripts without granting escape capabilities.

Before you rerun the attacks, which three capabilities should remain blocked after the refresh?

  • Paste the popup and form payload from earlier into the host page's HTML input.
  • Trigger the render action.
  • Click Try popup.
  • Click Try form submission.

No new tab opens. The form remains inside the blocked preview while the Console records the sandbox restriction.

  • Replace the input with the top-navigation payload from earlier.
  • Trigger the render action.
  • Click Try top navigation.

The host stays on localhost. The preview reports that top navigation was blocked.

Is the popup still blocked after granting permission?

  • Confirm the live iframe shows allow-scripts allow-popups before the controlled popup test.
  • Trigger the popup from the visible button so the attempt follows a direct click.
  • Check whether your browser has separately blocked popups for localhost.

Ask for help with the comparison: Why is my popup test still blocked after adding allow-popups?

✔️ Awesome, I've got everything!

Your live iframe is restored to sandbox="allow-scripts". Top navigation remains blocked.

Popups and form submission remain blocked too. The temporary popup permission is gone.

ⓧ I'd like to double check the full code

This step used pasted test payloads plus a temporary live DOM edit. Your persistent host page remains unchanged.

  • Check that the host page is still served on http://localhost:3000.
  • Check that the iframe sandbox value is exactly allow-scripts.
  • Check that allow-same-origin is absent.
  • Check that allow-top-navigation is absent.
  • Check that allow-popups is absent.
  • Check that allow-forms is absent.
  • Confirm that the earlier localStorage test produced a security error.
  • Confirm that the restored sandbox blocks top navigation.
  • Confirm that the restored sandbox blocks popups.
  • Confirm that the restored sandbox blocks form submission.
  • Confirm that the temporary popup token opened one blank tab before you refreshed the page.

You have now proven that the preview cannot replace the host page or create a popup. It also cannot submit a form.

Next up, you'll test whether the sandbox can stop the preview from sending data over the network.

Block Network Exfiltration

Your sandbox now blocks the frame capabilities you tested earlier. Outbound network requests remain a separate capability.

A hostile preview can place stolen data inside a request URL. This step proves that leak in Developer Tools before you close it.

In this step, get ready to:
  • Send a fetch request from the sandboxed preview.
  • Send an image request carrying the same sample data.
  • Close the network path while preserving inline script behavior.
Prove the sandbox can still send data

A URL query string can carry data outside the preview. The receiving server learns that data as soon as the request arrives.

  • Switch back to the browser tab serving http://localhost:3000.
  • Replace the current preview input with this network test by pasting the code below:
<!-- Render a visible label for this network test -->
<h1>Network exfiltration test</h1>
<p>Inspect the requests in Developer Tools.</p>

<script>
  // Put sample data in a fetch request URL
  const sampleData = "preview-secret"
  fetch(`/?channel=fetch&data=${sampleData}`, { mode: "no-cors" })
    .then(() => console.log("Fetch request completed"))
    .catch(() => console.log("Fetch request blocked"))
</script>

<!-- Put the same sample data in an image request URL -->
<img src="/?channel=image&data=preview-secret" alt="Network request test">

What does this test do?

  • The sampleData value represents information that hostile HTML wants to send away.
  • The fetch() call places that value in a request URL.
  • The image element creates a second request without reading a response in JavaScript.

Before you render this payload, predict whether the sandbox will stop either request.

  • Select the Network tab in Developer Tools.
  • Clear the existing request entries.
  • Enter preview-secret in the request filter.
  • Use the render action from earlier.

You should see one fetch row with a successful HTTP response. You should also see one image row with a successful response.

That is the intended shortfall. Both requests left the preview despite its restricted sandbox.

Why doesn't the sandbox stop these requests?

The iframe sandbox removes selected browser capabilities from the preview. It does not define which network destinations the preview may contact.

The server receives the query string even when JavaScript cannot inspect the response. Preventing data from leaving requires a resource-loading policy.

Don't see both requests?

  • Confirm that the Network tab is recording requests.
  • Check that the request filter contains preview-secret.
  • Render the complete payload again after confirming the iframe still uses sandbox="allow-scripts".

Help me find the missing network requests.

Add the preview's network policy

A Content Security Policy gives the rendered document rules for loading resources. The default-src 'none' directive blocks every resource category that has no explicit allowance.

The placement is easy to miss. The policy must appear before the pasted HTML so it governs every request the preview attempts.

  • Switch back to the code editor tab containing the host preview page from earlier.
  • Find the logic that assigns the pasted HTML to the iframe's srcdoc content.
  • Prepend this policy element to every rendered preview document:
<!-- Block resource loads while keeping required inline scripts -->
<meta http-equiv="Content-Security-Policy"
      content="default-src 'none'; script-src 'unsafe-inline'">

What does this policy allow?

  • The default-src 'none' directive blocks resource types that lack a more specific rule.
  • The script-src 'unsafe-inline' directive keeps the preview's required inline script behavior available.
  • The missing connect-src rule falls back to default-src 'none' for fetch requests.
  • The missing img-src rule falls back to the same blocking policy for images.
  • Save the host preview page from earlier.
  • Refresh the browser tab at http://localhost:3000.

You should still see the HTML input with its render action. The preview iframe should remain visible.

Did the host page stop working?

  • Check that the policy element is prepended to the string assigned to srcdoc.
  • Remove the policy element from the host page's own head if you placed it there.
  • Keep the iframe sandbox value set to allow-scripts.

Help me fix the policy placement.

✔️ Awesome, I've got everything!

Great. The host page still loads with the sandbox and preview controls intact.

ⓧ I'd like to double check the full code

Your preview construction should preserve these three security properties.

  • The iframe sandbox value remains allow-scripts.
  • The policy element is added to the rendered preview document.
  • The policy appears before any pasted HTML.

The policy element should match this reference.

<!-- Block resource loads while keeping required inline scripts -->
<meta http-equiv="Content-Security-Policy"
      content="default-src 'none'; script-src 'unsafe-inline'">
Repeat the attack under CSP

A fair security check reuses the same hostile payload. Only the preview's policy has changed.

Before you render the payload again, predict what will happen to each network request.

  • Select the Network tab in Developer Tools.
  • Clear the existing request entries.
  • Enter preview-secret in the request filter.
  • Use the render action with the network test still in the input.

You should see the fetch request reported as blocked or failed. You should also see the image request reported as blocked or failed.

  • Select the Console tab in Developer Tools.

You should see policy violations for the blocked connections. You should also see Fetch request blocked from the payload's rejection handler.

That's the network hole closed. The preview can still execute its permitted inline script without sending the sample data away.

What did each layer stop?

The sandbox removes frame capabilities such as host access and top-level navigation. CSP controls the destinations that preview resources can contact.

The iframe keeps sandbox="allow-scripts" without permissions for same-origin access or escape routes. The rendered document now blocks network loading by default.

Are the requests still succeeding?

  • Move the policy element before every script or image in the generated preview document.
  • Check that the policy contains default-src 'none' exactly.
  • Check that the iframe still receives the updated srcdoc content after each render.

Help me block the remaining requests.

Your preview now blocks storage access, escape attempts, and network exfiltration. Next, you'll add another boundary by serving the preview from a separate origin.

Separate the Preview Origin

Your sandbox now blocks storage access. Your Content Security Policy stops network exfiltration from pasted HTML.

The preview renderer still lives on the host server. A flaw in that renderer would place untrusted content beside the application it is meant to protect.

A second server gives the renderer its own origin. The browser treats http://localhost:3000 plus http://localhost:3001 as distinct origins because their ports differ.

In this step, get ready to:
  • Serve the preview renderer on localhost port 3001.
  • Embed the separate renderer inside the host page on localhost port 3000.
  • Run the origin isolation regression check.
Serve the renderer on a second port

The new server places the renderer behind a separate browser boundary. The inner iframe keeps the restrictions you already proved.

  • Create the separate preview renderer page beside the host site by moving the existing sandboxed srcdoc renderer into it.
  • Save the preview renderer page.
  • Create a second terminal session beside the terminal running the host server.
  • Start the preview renderer with the same static server workflow from earlier configured for port 3001.
  • Open a new tab in the browser from earlier.
  • Enter http://localhost:3001 in the address bar.

You should see the separate preview renderer load. The host server remains available at http://localhost:3000.

Why does the port matter?

A browser origin includes the scheme. It also includes the hostname plus the port.

Ports 3000 plus 3001 create separate origins. The renderer now sits behind a browser-enforced boundary before the inner sandbox applies its restrictions.

Preview server not loading?

  • Confirm the second server points to the separate preview site.
  • Confirm the second server uses port 3001.
  • Restart the second server after correcting its location.

If the page still does not load, help me troubleshoot the preview server.

Embed the separate renderer

The host keeps the HTML input plus its render action. Its embedded preview now points to the renderer on the second server.

  • Switch back to the host preview page in the code editor from earlier.
  • Update the embedded preview source to http://localhost:3001.
  • Save the host preview page.
  • Return to the browser tab at http://localhost:3000.
  • Refresh the host page.

You should see the separate renderer embedded inside the host page. The main address bar still shows http://localhost:3000.

  • Switch back to the host preview page in the code editor.
  • Update the existing render action to send the pasted HTML to the separate renderer.
  • Save the host preview page.
  • Return to the host page in your browser.
  • Paste the harmless HTML sample from your earlier rendering check into the existing input.

Before you render the sample, where do you think its visible output belongs after this change?

  • Activate the existing render action.

You should see the sample inside the renderer's sandboxed srcdoc iframe. The host page remains on port 3000.

That boundary is now working. Your host still accepts pasted HTML while port 3001 handles the rendering context.

Which protections stay in place?

The inner iframe keeps sandbox="allow-scripts". No temporary permission token remains from the earlier experiment.

The rendered document keeps default-src 'none'. Only the required inline script behavior remains allowed.

Renderer loads without the pasted HTML?

  • Confirm the host embeds http://localhost:3001.
  • Confirm the existing render action targets the separate renderer.
  • Confirm the second server remains running.

If the renderer stays empty, help me trace the HTML transfer.

✔️ Awesome, I've got everything!

Your host now embeds the separately served renderer. Both local servers remain running for the isolation check.

ⓧ I'd like to double check the full code

Compare your two-page setup with this complete end state.

  • Confirm the host page is served at http://localhost:3000.
  • Confirm the preview renderer is served at http://localhost:3001.
  • Confirm the host accepts pasted HTML through its existing input.
  • Confirm the host embeds the separately served preview renderer.
  • Confirm the inner iframe uses only sandbox="allow-scripts".
  • Confirm the rendered document applies default-src 'none'.
  • Confirm only the required inline script behavior remains allowed.
  • Confirm no temporary sandbox permission token remains.
Verify the isolated origin

The renderer site now has its own origin on port 3001. The inner sandboxed document also receives an opaque origin because the iframe lacks the same-origin permission.

Before you check, which origin do you think the inner frame reports after moving the renderer?

  • Return to Developer Tools from the earlier policy test.
  • Select the Console context for the inner sandboxed srcdoc frame.
  • Inspect the origin reported by that frame.

You should see null as the reported origin. This confirms that the inner document has an opaque origin.

  • Rerun the earlier host localStorage access test inside the rendered preview.
  • Repeat the earlier top-navigation test.
  • Repeat the earlier popup test.
  • Repeat the earlier form-submission test.
  • Repeat the earlier fetch exfiltration test.
  • Repeat the earlier image exfiltration test.

The storage read still fails with a security exception. The host demonstration value remains unreachable.

Top navigation remains blocked. Popups remain blocked.

Form submission remains blocked. The network requests remain blocked with Content Security Policy violations visible in the console.

What does the null origin prove?

The separate port isolates the renderer site from the host site. The sandbox gives the rendered document an opaque origin inside that renderer.

Untrusted preview code cannot reach the host page. Required scripts can still run inside the restricted frame.

Frame origin is not null?

  • Confirm the selected Console context belongs to the inner srcdoc frame.
  • Confirm the iframe still uses only sandbox="allow-scripts".
  • Remove any temporary sandbox permission token left from the earlier experiment.

If the frame still reports another origin, help me find the lost sandbox restriction.

Your renderer now has a separate origin plus an opaque inner frame. Next up, you'll add a guarded resize channel that accepts valid messages while rejecting malformed sources.

Guard the Resize Channel

Your two-origin preview already blocks storage access, navigation, popups, forms, and network exfiltration. Those defenses keep hostile HTML away from the host page.

Automatic sizing creates the final trust boundary. A resize request crosses from the untrusted preview context into the host page through postMessage.

The sandboxed sender reports its origin as null. The host identifies the expected frame through event.source.

In this step, get ready to:
  • Build a host listener that trusts only the embedded preview frame.
  • Validate each resize payload before applying a clamped height.
  • Test the channel against all three message cases.
Build the host-side guard

Every message arriving at the host starts as untrusted input. The listener needs to reject an unexpected sender before it inspects or applies the payload.

How should the guard work?

  • The sender check compares event.source with the window belonging to the expected preview frame.
  • The payload check requires the single supported resize message type.
  • The height check requires a finite numeric value.
  • The clamp confines an accepted height to the configured minimum and maximum.
  • The final assignment applies the clamped height to the embedded frame.
  • Each failed guard logs the rejection before returning.
  • Return to the host page source in your editor.
  • Locate the existing iframe that embeds the preview renderer from http://localhost:3001.
  • Implement the guarded message listener beside the existing iframe setup by following the guard order above.
  • Save the host page source.

Before you test the sender gate, do you think a correctly shaped message from the host page itself can resize the preview?

  • Refresh the host page at http://localhost:3000.
  • Return to the Developer Tools console for the host page.
  • Send a resize-shaped message to the host window from the host page context using the supported type and a numeric height.

You'll see a wrong-source rejection in the console. The iframe height stays unchanged because the host window does not match the expected preview frame.

No wrong-source rejection?

Confirm that the console execution context belongs to the host page on port 3000. Check that the listener compares the sender before validating the payload.

Confirm that every rejected branch writes a clear log entry before returning.

Help me debug the sender guard

Send the supported resize request

The preview renderer on port 3001 is the only accepted sender. Its message carries one supported type plus the measured content height.

What should the renderer send?

  • The destination is the renderer's parent host window.
  • The message type matches the single value recognized by the host listener.
  • The height contains the preview's measured numeric height.
  • The renderer sends the message after the inner content is ready for measurement.
  • Return to the preview renderer source in your editor.
  • Implement the outgoing resize message by following the sender requirements above.
  • Save the preview renderer source.

Before you refresh, do you expect a tall preview to remain at its old height or resize to fit its content?

  • Refresh the host page at http://localhost:3000.
  • Render a tall HTML sample through the existing preview flow.
  • Inspect the embedded preview after the renderer sends its resize request.

You'll see the iframe height change to fit the preview within the configured limits. The console stays free of rejection logs for this valid request.

Preview height stays unchanged?

Confirm that the renderer sends its message after measuring the content. Check that its message type matches the host listener's supported type exactly.

Check that the host compares the sender with the window of the port 3001 iframe. A reference to the inner sandboxed frame represents a different source.

Help me debug the valid resize message

Challenge every guard

A trusted sender can still send malformed data. Payload validation prevents missing values or nonnumeric heights from changing the host layout.

Before you run the malformed test, do you think the frame can resize when the supported message type carries an invalid height?

  • Temporarily replace the renderer's numeric height with a text value while keeping the supported message type.
  • Save the preview renderer source.
  • Refresh the host page at http://localhost:3000.

You'll see a malformed-payload rejection in the console. The iframe height stays unchanged because the height is not a finite number.

  • Restore the renderer's measured numeric height.
  • Save the preview renderer source.

Before the final check, which message should resize the frame: the expected renderer's valid request, its malformed request, or the host page's wrong-source request?

  • Refresh the host page at http://localhost:3000.
  • Render the tall HTML sample through the preview flow again.
  • Confirm that the valid request changes the iframe height within the configured limits.
  • Review the console for the earlier malformed-payload rejection.
  • Review the console for the earlier wrong-source rejection.

Your final result has one narrow path to success. A valid message from the expected preview renderer resizes the frame.

Malformed messages cannot alter the height. Messages from any other window are logged and rejected.

A rejected message still changes the height?

Place every rejection before the height assignment. Confirm that each failed check returns immediately after logging its reason.

Check the clamp separately by sending valid numeric heights below the minimum or above the maximum. Each accepted value should stop at the matching limit.

Help me debug the guarded resize channel

That closes the final channel into your host page. The preview can request a safe size without gaining control over anything else.

Secret mission

Run a Preview Security Regression

Turn every attack from this project into a repeatable security regression drill. You will prove that blocked routes stay closed while the single approved resize route still works.

Clean Up Your Resources

Clean Up Your Resources

Both local preview servers have no ongoing cloud costs. Decide whether to keep them running, pause them for later, or remove both active processes.

Resources you used:

  • Host server process serving the Host preview page at http://localhost:3000.
  • Preview server process serving the Preview renderer page at http://localhost:3001.

Keep everything running

No action is needed. This option fits continued testing of the hardened preview.

  • Leave the host server running on port 3000.
  • Leave the preview server running on port 3001.
  • Keep the hardened preview files available for further security experiments.

Pause - I'll come back to this later

Stopping both server processes frees ports 3000 and 3001. Your hardened preview files remain available for later.

  • Switch back to the terminal running the host server at http://localhost:3000.
  • Press Ctrl+C to stop the host server.
  • Switch back to the terminal running the preview server at http://localhost:3001.
  • Press Ctrl+C to stop the preview server.
  • Refresh the Host preview page in your browser.
  • Refresh the Preview renderer page in your browser.

Neither page loads while its matching server is stopped. This confirms both local processes have ended.

  • Restart the host server later with the same command you used earlier.
  • Restart the preview server later with the same command you used earlier.

Delete - I don't want to use this again

This project has no cloud resources to delete. Removing its active footprint means stopping both local servers.

  • Switch back to the terminal running the host server at http://localhost:3000.
  • Press Ctrl+C to stop the host server.
  • Switch back to the terminal running the preview server at http://localhost:3001.
  • Press Ctrl+C to stop the preview server.
  • Refresh the Host preview page to verify that its server has stopped.
  • Refresh the Preview renderer page to verify that its server has stopped.

Both pages stop loading after the processes end. The hardened preview files remain on your computer as a reusable component.

Nice Work!

Nice Work!

You did it! Your hardened browser preview now renders hostile HTML inside a sandboxed iframe while keeping the host page out of reach.

You've learned how to:

  • Prove that a naive srcdoc preview can read host storage. Close that leak with a scripts-only iframe sandbox plus a separate origin.
  • Exercise top-navigation, popup, and form attacks. Confirm that the restored sandbox blocks each escape route after a controlled token experiment.
  • Block fetch and image exfiltration with a Content Security Policy. Accept only finite, clamped resize messages from the expected preview source through a guarded postMessage channel.
  • Secret Mission: Complete the optional challenge to extend your hardened preview and push your browser security skills further.

Ready to quiz yourself?