"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. - Remote frames are not automatically made safe by the default extension CSP. Declare a tight
frame-srcallowlist when embedding is intentional, and addconnect-srcseparately for frontend probes or fetches.
The remote-script ban is MV3's flagship security change. Framing is a two-sided contract: the extension's CSP must allow the target, and the target response must allow ancestors.
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. Use the provider's supported extension/OAuth flow or a user-visible tab. Do not assume an identity provider permits framing; most deliberately refuse it.
- 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.
'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.frame-src is only half the contract. It lets the extension page attempt the iframe. The remote response can still refuse embedding with X-Frame-Options or its own CSP frame-ancestors. Use a host-controlled embed route; do not frame arbitrary login pages.