C.W.K.
Stream
Lesson 05 of 06 · published

Iframes and CSP — Loading External Content Safely in the Panel

~13 min · side-panel, csp, iframe, frame-src, web-accessible-resources

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"The side panel is just an extension HTML page, and the same MV3 content security policy that governs the popup governs it. Lesson 5 is the rules and the safe escape valves — what you can render directly, what needs an iframe, and how to broaden frame-src without opening a hole."

The Default Extension-Pages CSP

MV3 ships with a strict default Content Security Policy for any HTML page inside the extension (popup, side panel, options page, full-tab page):

script-src 'self'; object-src 'self';

What this allows:

  • JavaScript files bundled in the extension (<script src="panel.js">).
  • Stylesheets bundled in the extension.
  • Images, fonts, and most other passive resources from anywhere.
  • Fetch / XHR to any URL.

What it forbids:

  • Inline scripts (<script>...</script>) and inline event handlers (onclick="foo()").
  • eval, new Function, setTimeout-with-string-argument — all banned at runtime.
  • Loading <script src="https://cdn.example.com/lib.js"> from anywhere outside the extension.
  • <iframe> pointing at any non-extension URL — blocked unless you broaden the CSP.

The remote-script ban is MV3's flagship security change. The iframe block is the one that bites first when you try to embed third-party content in a side panel.

Broadening the CSP — frame-src

To allow an iframe in your panel, add a content_security_policy.extension_pages entry to the manifest. The format is a single CSP string; you must restate the script-src and object-src baselines, then add your frame-src:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://www.youtube.com https://*.figma.com;"
  }
}

Now <iframe src="https://www.youtube.com/embed/..."> works inside panel.html. Other origins still get blocked. Keep the list as tight as you can — every domain in frame-src is a potential attack surface if that site is later compromised.

The Common Use Cases

  • Documentation or help content. Render your own docs hosted on a domain you control; cheaper than bundling and updating help text inside the extension.
  • Third-party widgets. Embed a Notion page, a Figma file, a YouTube tutorial. Each requires an explicit frame-src entry.
  • Authentication redirects. Some auth flows redirect through a third-party identity provider; the iframe pattern lets you handle the redirect inside the panel rather than spawning a new tab.
  • Sandboxed extension content. Iframes pointing at your own chrome-extension:// pages (with a different path) can wall off sensitive code — useful for displaying user-provided HTML/markdown without giving it script-of-self privileges.

Sandboxing Pages You Control

For the last case — rendering untrusted user content — combine an extension iframe with the sandbox manifest key:

{
  "sandbox": {
    "pages": ["sandbox.html"]
  }
}

sandbox.html now runs in a unique origin without chrome.* API access. The parent panel can postMessage to it and trust that any code inside cannot reach the user's clip storage. This is the pattern for displaying rich markdown or HTML clips where the source content is essentially arbitrary.

Web Accessible Resources — The Reverse Direction

The iframe story above is about the panel loading external content. The reverse — letting a host page embed an extension page — uses web_accessible_resources:

{
  "web_accessible_resources": [
    {
      "resources": ["injected.js", "badge.html"],
      "matches": ["https://*.github.com/*"]
    }
  ]
}

This declares which extension files the listed origins are allowed to load. Used by content scripts that inject <script> tags pointing at extension files (Lesson 3's legacy bridge pattern), and by extensions that let pages embed extension iframes.

Default CSP is tight on purpose. Add only what you need. Inline event handlers, eval, and arbitrary iframe origins are blocked by design — fix the underlying pattern, don't try to disable CSP.
Do not add 'unsafe-eval'. The Chrome Web Store will reject any extension that loosens CSP with 'unsafe-eval' or 'unsafe-inline'. The rare legitimate reason (WebAssembly via eval-based loaders in old bundlers) almost always has a modern workaround. If you find yourself needing eval, your dependency picked the wrong build target — fix that.

Code

manifest.json — extension_pages CSP with frame-src, plus sandbox page·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.7.0",
  "action": { "default_popup": "popup.html" },
  "background": { "service_worker": "background.js" },
  "side_panel": { "default_path": "panel.html" },
  "permissions": ["storage", "tabs", "scripting", "activeTab", "sidePanel"],
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://docs.example.com;"
  },
  "sandbox": {
    "pages": ["sandbox.html"]
  },
  "content_scripts": [
    { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }
  ],
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }
}
panel.html — third-party iframe + sandboxed extension iframe·html
<!-- panel.html — iframe a help doc + sandboxed clip renderer -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="panel.css" />
  </head>
  <body>
    <h1>ClipDeck</h1>
    <details>
      <summary>Help</summary>
      <!-- Allowed because docs.example.com is in frame-src -->
      <iframe src="https://docs.example.com/clipdeck-help" width="100%" height="200"></iframe>
    </details>

    <div id="list"></div>

    <!-- Sandboxed renderer for untrusted-shape clips (rich HTML/markdown) -->
    <iframe id="clipRenderer" src="sandbox.html" style="display:none;width:100%;border:0;"></iframe>

    <script src="panel.js"></script>
  </body>
</html>
panel.js — postMessage to the sandboxed renderer·javascript
// panel.js — message the sandboxed iframe to render a clip
function renderClipInSandbox(clip) {
  const iframe = document.getElementById("clipRenderer");
  iframe.style.display = "block";
  iframe.contentWindow.postMessage(
    { source: "clipdeck-panel", type: "renderClip", clip },
    "*"
  );
}

window.addEventListener("message", (event) => {
  // Only trust messages from our own sandbox iframe
  if (event.source !== document.getElementById("clipRenderer").contentWindow) return;
  if (event.data?.source !== "clipdeck-sandbox") return;
  if (event.data.type === "rendered" && event.data.heightPx) {
    document.getElementById("clipRenderer").style.height = `${event.data.heightPx}px`;
  }
});

// In sandbox.html / sandbox.js, the inner side parses the clip,
// renders it as DOM (NOT innerHTML — use DOMPurify or your own escape),
// measures the rendered height, and postMessage's back:
//   parent.postMessage({ source: 'clipdeck-sandbox', type: 'rendered', heightPx: ... }, '*');

External links

Exercise

Update clipdeck/manifest.json with the first code block (version 0.7.0, content_security_policy.extension_pages broadened to allow https://docs.example.com in frame-src; sandbox.pages declared). Add the iframe markup from the second code block to clipdeck/panel.html. Create a placeholder sandbox.html with <h1 id="out">sandbox alive</h1><script>parent.postMessage({source:'clipdeck-sandbox', type:'rendered', heightPx:40}, '*');</script>. Reload the extension and open the side panel — the docs iframe will show a placeholder error page (docs.example.com is not a real host), the sandbox iframe loads and posts back. Open the panel's DevTools, network tab, and confirm the docs.example.com request was permitted; CSP errors would have been logged otherwise. To prove CSP works, change the iframe src to https://example.com/anything (not in frame-src) and reload — the iframe will be blocked with a clear CSP violation in the console.
Hint
If the panel DevTools console reports Refused to frame ... because it violates the following Content Security Policy directive: frame-src 'self' even after your manifest update, two common causes: (1) you didn't reload the extension after editing manifest, or (2) the manifest JSON is syntactically broken so Chrome silently fell back to defaults (check chrome://extensions → Errors). If sandbox.html does not load at all, confirm it's listed under sandbox.pages AND is a real file in your extension directory. The sandbox postMessage will fail silently if your sandbox.js tries to access chrome.* — sandboxed pages have no chrome.* API access, that's the point.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.