fix: fix link handling in example preview iframe sandbox (#701)

* fix: fix link handling in example preview iframe sandbox

* fix: added missing single quote

* fix(srcdoc): security/correctness issues
- Use location.hash = href for hash-link navigation so hashchange
  events, CSS :target, history/back and named anchors work correctly
- Always pass 'noopener,noreferrer' to window.open() for _blank links
- Guard e.target with instanceof Element before calling .closest() to
  avoid TypeError on text node click targets

* fix(srcdoc): security/correctness issues
- Added a protocol allowlist
- Re-navigation to same hash
- Consistency with rest of shim IIFE using var instead of let and const

* fix(srcdoc): scroll to top on bare hash links (#)

Intercept href="#" clicks and scroll to top of page in the iframe sandbox to mimic native browser behaviour.

* test(web): update PreviewModal sandbox test assertions to include popup flags
This commit is contained in:
meshackm
2026-05-09 21:24:25 +08:00
committed by GitHub
parent 0264ad6150
commit 461a312002
3 changed files with 61 additions and 3 deletions
+1 -1
View File
@@ -417,7 +417,7 @@ export function PreviewModal({
<iframe
key={activeView?.id ?? 'view'}
title={`${title} ${activeView?.label ?? ''}`}
sandbox="allow-scripts"
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
srcDoc={srcDoc}
/>
</div>
+38
View File
@@ -169,6 +169,10 @@ function escapeAttr(value: string): string {
// becomes a static, unnavigable preview. We install a same-origin
// in-memory shim BEFORE any user script runs so those decks degrade
// gracefully (position just doesn't persist across reloads).
// allow-popups and allow-popups-to-escape-sandbox are needed for
// links with target="_blank" to work in the sandboxed preview.
// Empty hrefs and hash only hrefs will be intercepted and ignored.
// hrefs leading to an id on the page will be scrolled into view.
function injectSandboxShim(doc: string): string {
const shim = `<script data-od-sandbox-shim>(function(){
function makeStore(){
@@ -193,6 +197,40 @@ function injectSandboxShim(doc: string): string {
}
tryShim('localStorage');
tryShim('sessionStorage');
document.addEventListener('click', (e) => {
if (!e.target || !(e.target instanceof Element)) return;
var link = e.target.closest('a[href]');
if (!link) return;
var href = link.getAttribute('href');
if (href === null) return;
var isAnchor = href.startsWith('#') || href === '';
if (isAnchor) {
e.preventDefault();
if (href === '' || href === '#') {
window.scrollTo({ top: 0 });
history.replaceState(null, '', ' ');
} else {
var targetId = href.slice(1);
var target = targetId ? document.getElementById(targetId) : null;
if (target) {
target.scrollIntoView();
location.hash === href && history.replaceState(null, '', ' ');
location.hash = href;
}
}
} else if (link.getAttribute('target') === '_blank') {
e.preventDefault();
let safe = false;
try {
var url = new URL(href, location.href);
safe =
url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'mailto:';
} catch (_) {}
safe && window.open(href, '_blank', 'noopener,noreferrer');
}
});
})();</script>`;
if (/<head[^>]*>/i.test(doc))
return doc.replace(/<head[^>]*>/i, (m) => `${m}${shim}`);
@@ -20,7 +20,7 @@ describe('PreviewModal sandbox isolation', () => {
/>,
);
expect(markup).toContain('sandbox="allow-scripts"');
expect(markup).toContain('sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"');
expect(markup).not.toContain('allow-same-origin');
expect(markup).toContain('srcDoc=');
});
@@ -42,8 +42,28 @@ describe('PreviewModal sandbox isolation', () => {
/>,
);
expect(markup).toContain('sandbox="allow-scripts"');
expect(markup).toContain('sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"');
expect(markup).not.toContain('allow-same-origin');
expect(markup).toContain('od:slide');
});
it('includes popup flags in the sandbox attribute', () => {
const markup = renderToStaticMarkup(
<PreviewModal
title="Popup preview"
views={[
{
id: 'popup',
label: 'Popup',
html: '<button onclick="window.open(\'https://example.com\')">Open Popup</button>',
},
]}
exportTitleFor={() => 'popup-preview'}
onClose={() => {}}
/>,
);
expect(markup).toContain('allow-popups');
expect(markup).toContain('allow-popups-to-escape-sandbox');
});
});