Search code examples
javascripthtmliframebrowser

How to access iframe.contentWindow.document of a nested iframe inside a sandboxed iframe?


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:

  1. "Not allowed to load local resource: blob:...."
  2. "Uncaught DOMException: Failed to read a named property 'document' from 'Window': Blocked a frame with origin "null" from accessing a cross-origin frame"

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


Solution

  • 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.