I want to create a sandboxed <iframe>
(frame1), so that it can still run scripts but can't access its parent. I also want frame1 to be able to create an inner sandboxed iframe (frame2) that can not run any JavaScript. And I want frame1 to still be able to manipulate frame2.
I know that by setting frame1's sandbox="allow-same-origin"
, I can access frame2 from it, but then it can also access its parent.
Trying a lot of combinations between different sandbox
values and ways to set the sources of the <iframe>
elements, I faced two main error messages:
Here is the code snippet I am using to test the various possibilities:
var inner = [0,0,0,0]
var outer = [0,0,0]
var focus = null
document.body.innerHTML = [
[0,0],[0,1],[0,2],
[1,0],[1,1],[1,2],
[2,0],[2,1],[2,2],
[3,0],[3,1],[3,2],
].map(([i,o]) => `<button>${i}-${o}</button>`).join('')
const container = document.createElement('div')
const buttons = [...document.body.children]
document.body.append(container)
buttons.map(btn => { btn.onclick = reload })
function reload () {
if (focus) focus.style = ''
const btn = focus = this
btn.style = 'background-color: pink;'
const text = btn.textContent
const [I,O] = text.split('-').map(Number)
inner = inner.map((x, i) => i === I)
outer = outer.map((x, o) => o === O)
container.replaceChildren()
spawn(container)
}
// -----------------------------------------------
async function spawn (element) {
const program = 'test'
const htmlsrc = `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head><body>333</body></html>`
const blobsrc = new Blob([htmlsrc], { type: "text/html" })
const hrefsrc = URL.createObjectURL(blobsrc)
const datauri = `data:text/html;charset=utf-8,${htmlsrc}`
const src_js = `
const iframe = document.createElement('iframe')
iframe.setAttribute('sandbox', 'allow-same-origin')
const html = \`<!DOCTYPE html>
<html><head><meta charset="utf-8"></head><body>333</body></html>\`
const blob = new Blob([html], { type: "text/html" })
const href = URL.createObjectURL(blob)
const href2 = "${hrefsrc}"
const href3 = \`${datauri}\`
console.log({ href: href })
console.log({ href2: href2 })
console.log({ href3: href3 })
console.log({ lhref: location.href })
// not allowed to load resource
if (${inner[0]}) iframe.setAttribute('src', href2)
// no cross origin access:
if (${inner[1]}) iframe.setAttribute('src', href)
if (${inner[2]}) iframe.setAttribute('srcdoc', html)
if (${inner[3]}) iframe.setAttribute('src', href3)
iframe.onload = () => {
console.log("readonly iframe loaded")
const innerDoc = iframe.contentWindow.document
console.log(innerDoc.body.innerHTML)
}
document.body.appendChild(iframe)
`
const string = src_js
const sandbox = 'allow-scripts'
const html = index_html(wrap(string, program))
const src = _2href(_2blob(html), `#${program}`)
const srcuri = `data:text/html;base64,${btoa(html)}`
if (outer[0]) {
const { global, data, port } = await iframer(element, { srcdoc:html, sandbox })
}
if (outer[1]) {
const { global, data, port } = await iframer(element, { src: srcuri, sandbox })
}
if (outer[2]) {
const { global, data, port } = await iframer(element, { src, sandbox })
}
}
// ----------------------------------------------------------------------
function _2blob (html) { return new Blob([html], { type: "text/html" }) }
function _2href (blob, query = '') { return URL.createObjectURL(blob) + query }
function index_html (source) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>${source}</body>
</html>`
}
function wrap (source_js, program) {
const filepath = `${program || '(anonymous)'}.js`
const bootloader_js = `;(async element => {
// document.currentScript.remove()
const source = element.textContent
element.remove()
console.log('load: ' + '${program}')
eval(source)
//# sourceURL=(iojs:bootloader)
//# ignoreList=(iojs:bootloader)
})(document.querySelector('[type="text"]'))`
source_js = `;(async () => {
console.log('run: ' + "${filepath}");${source_js}
//# sourceURL=${filepath}
//# ignoreList=${filepath}
})()`
return `
<script type="text">${source_js}</`+`script>
<script>${bootloader_js}</`+`script>
`
}
// ----------------------------------------------------------------------
function iframer (element, { src, srcdoc, sandbox = '', timeout } = {}) {
const el = document.createElement('div')
const sh = el.attachShadow({ mode: 'closed' })
sh.innerHTML = `<iframe sandbox="${sandbox}"></iframe>`
const [iframe] = sh.children
const { promise, resolve, reject } = Promise.withResolvers()
iframe.onload = onload
window.addEventListener('message', onmessage)
const id = timeout !== undefined ? setTimeout(ontimeout, timeout) : null
if (src) iframe.src = src
else if (srcdoc) iframe.srcdoc = srcdoc
element.append(el)
return promise
function ontimeout () {
window.removeEventListener('message', onmessage)
iframe.onload = undefined
reject(new Error('iframe timeout'))
}
function onload () {
clearTimeout(id)
iframe.onload = undefined
window.removeEventListener('message', onmessage)
resolve({ global: iframe.contentWindow, data: null, port: null })
}
function onmessage (event) {
const { source, data = null, ports: [port = null] } = event
if (source === iframe.contentWindow) {
clearTimeout(id)
iframe.onload = undefined
window.removeEventListener('message', onmessage)
const sorigin = sandbox.includes('allow-same-origin')
resolve({ global: sorigin ? iframe.contentWindow : null, data, port })
}
}
}
Everything I tried so far can be found in this codepen
For simplicity, I'll call the top document top, the first frame frame1, and the inner one frame2.
I am not 100% sure of what exactly happens, but just from the look of it, I'd say it's as if the sandbox
attribute on frame1 will make it have its own origin, however the sub-resources it will load will still match the origin of the top-most navigable. So frame2 and top are same-origin, but frame1 has its own opaque origin. And this would hold no matter how you load frame2.
The only hack around I can think of right now is to use a data:
URL1 to load frame1, and no sandbox
attribute on its <iframe>
. The scripts in frame1 will still not be able to access top, because data:
URLs are also opaque URLs. However srcdoc
's origin will be based on their opaque origin and not on top's one, so we can load frame2 from frame1 in a srcdoc
, with a sandbox="allow-same-origin"
so that frame1 can access frame2, but frame1 can not access top, and frame2 can not execute scripts.
But that means you need to fetch the content of both frame1 so that you can create a data:
URL version of it, and of frame2 so that you can set it as the srcdoc
. Doing so, remember that frame1 is now in an opaque origin, and thus to be able to fetch resources on your server, you will need to pass an Access-Control-Allow-Origin: *
header, which you may not want. So instead, you may have to fetch both documents from top.
This would give,
frame1.html
<h1>I am frame 1</h1>
<!-- Below src will be replaced by the top window -->
<iframe src="frame2.html" sandbox="allow-same-origin"></iframe>
<script>
const frame = document.querySelector("iframe");
onload = e => frame.contentDocument.body.append("frame 1 can access frame 2");
parent.document.body.append("Oops frame 1 can access parent"); // cross-origin, won't happen
</script>
frame2.html
<h1>I am frame 2</h1>
<script>
// Shouldn't happen
alert("OoPs, running script");
</script>
top.html
<iframe height=300 sandbox="allow-same-origin allow-scripts"></iframe>
<script type=module>
const frame1Content = await fetch("./frame1.html").then(resp => resp.text());
const frame2Content = await fetch("./frame2.html").then(resp => resp.text());
const frame = document.querySelector("iframe");
// We can't have `"` in the content since we use it for the attribute
const cleanFrame2Content = frame2Content.replace(/"/g, "\\\"");
// Replace the src attribute by the actual content of frame2.html
const cleanFrame1Content = frame1Content.replace(`src="frame2.html"`, `srcdoc="${cleanFrame2Content}"`);
frame.src = `data:text/html,${encodeURIComponent(cleanFrame1Content)}`
</script>
As a JSFiddle since StackSnippet's null origined iframes will completely block any sub-frames to access other frames.
1. Using another domain would also achieve the same, it's basically what services like JSFiddle do to protect their main document but still allow their user embed same-origin iframes.