19

I am writing a jQuery Plugin that needs to be able to run against DOM elements within an iFrame. I'm just testing this locally right now (ie url is file://.../example.html) and in Chrome I keep hitting "SecurityError: Failed to read the 'contentDocument' property from 'HTMLIFrameElement': Blocked a frame with origin "null" from accessing a cross-origin frame." and in Safari I just get an empty document.

Given that both the parent file and the iFrame's file are coming off my local disk (in development) and will be coming off the same server (in production) I'd have thought that I'd not be subject to the cross-origin issues.

Is there a way I can convince the browser that my local files are actually of the same domain?

<aside>Interestingly in Safari, using the console directly, I can type $("iframe").get(0).contentDocument.find("ol") and it happily finds my list. In Chrome this same line throws the security error just as if it were being executed.</aside>

Update

Based on the suggestions below I have fired up a simple local web-server to test this and am now not getting the cross-origin error - yay - but neither am I getting any content.

My Javascript looks like

$(document).ready(function(){
  var myFrame = $("iframe"),
      myDocument = $(myFrame.get(0).contentDocument),
      myElements;
  myDocument.ready(function(){
    myElements = myDocument.find("ul, ol");
    console.debug("success - iFrame", myFrame, "document", myDocument, "elements", myElements);
  });
});

The myDocument.ready is there just to ensure that the iFrame's document is ready - in reality it makes no difference.

I always end up with myElements being empty. ([] in safari or jQuery.fn.init[0] in Chrome)

But if I manually type this into the console:

$($("iframe").get(0).contentDocument).find("ol, ul")

I get my lists as expected. This is now the case in both Safari and Chrome.

So my question becomes: why can't my script see the DOM elements but the same code when entered directly into the browser's console can happily see the DOM elements?

Dave Sag
  • 12,289
  • 8
  • 77
  • 118

2 Answers2

32

Chrome has default security restrictions that don't allow you to access other windows from the hard disk even though they are technically the same origin. There is a flag in Chrome that can relax that security restriction (command line argument on Windows is what I remember), though I wouldn't recommend running with that flag for more than a quick test. See this post or this article for info about the command line argument.

If you run the files off a web server (even if it's a local web server) instead of your hard drive, you won't have this issue.

Or, you could test in other browsers that aren't as restrictive.


Now that you've changed the question into something different, you have to wait for an iframe window to load before you can access the content in it and you can't use jQuery's .ready() on a different document (it doesn't work on another document).

$(document).ready(function() {
    // get the iframe in my documnet
    var iframe = document.getElementById("testFrame");
    // get the window associated with that iframe
    var iWindow = iframe.contentWindow;

    // wait for the window to load before accessing the content
    iWindow.addEventListener("load", function() {
        // get the document from the window
        var doc = iframe.contentDocument || iframe.contentWindow.document;

        // find the target in the iframe content
        var target = doc.getElementById("target");
        target.innerHTML = "Found It!";
    });
});

Test page here.


EDIT: Upon further research, I found that jQuery will do some of this work for you like this and the jQuery solution appears to work in all the major browsers:

$(document).ready(function() {
    $("#testFrame").load(function() {
        var doc = this.contentDocument || this.contentWindow.document;
        var target = doc.getElementById("target");
        target.innerHTML = "Found It!";
    });
});

Test page here.

In looking at the jQuery implementation for this, all it is really doing is setting up a load event listener on the iFrame itself.


If you want to know the nitty gritty details that went into debugging/solving the first method above:

In my trying to solve this issue, I discovered some pretty bizarre things (in Chrome) with iFrames. When you first look in the window of the iframe, there is a document and it says that its readyState === "complete" such that you think it's done loading, but it's lying. The actual document and actual <body> tag from the document that is being loaded via URL into the iframe is NOT actually there yet. I proved this by putting a custom attribute on the <body data-test="hello"> and checking for that custom attribute. Lo and behold. Even though document.readyState === "complete", that custom attribute is not there on the <body> tag. So, I conclude (at least in Chrome) that the iFrame initially has a dummy and empty document and body in it that are not the actual ones that will be in place once the URL is loaded into the iFrame. This makes this whole process of detecting when it's ready to be quite confusing (it cost me hours figuring this out). In fact, if I set an interval timer and poll iWindow.document.body.getAttribute("data-test"), I will see it show as undefined repeatedly and then finally it will show up with the correct value and all of this with document.readyState === "complete" which means it's completely lying.

I think what's going on is that the iFrame starts out with a dummy and empty document and body which is then replaced AFTER the content starts loading. On the other hand, the iFrame window is the real window. So, the only ways I've found to actually wait for the content to be loaded are to monitor the load event on the iFrame window as that doesn't seem to lie. If you knew there was some specific content you were waiting for, you could also poll until that content was available. But, even then you have to be careful because you cannot fetch the iframe.contentWindow.document too soon because it will be the wrong document if you fetch it too soon. The whole thing is pretty broken. I can't find any way to use DOMContentLoaded from outside the iFrame document itself because you have no way of knowing then the actual document object is in place to you can attach the event handler to it. So ... I settled for the load event on the iFrame window which does seem to work.


If you actually control the code in the iFrame, then you can trigger the event more easily from the iFrame itself, either by using jQuery with $(document).ready() in the iFrame code with it's own version of jQuery or by calling a function in the parent window from a script located after your target element (thus ensuring the target element is loaded and ready).


Further Edit

After a bunch more research and testing, here's a function that will tell you when an iFrame hits the DOMContentLoaded event rather than waiting for the load event (which can take longer with images and style sheets).

// This function ONLY works for iFrames of the same origin as their parent
function iFrameReady(iFrame, fn) {
    var timer;
    var fired = false;

    function ready() {
        if (!fired) {
            fired = true;
            clearTimeout(timer);
            fn.call(this);
        }
    }

    function readyState() {
        if (this.readyState === "complete") {
            ready.call(this);
        }
    }

    // cross platform event handler for compatibility with older IE versions
    function addEvent(elem, event, fn) {
        if (elem.addEventListener) {
            return elem.addEventListener(event, fn);
        } else {
            return elem.attachEvent("on" + event, function () {
                return fn.call(elem, window.event);
            });
        }
    }

    // use iFrame load as a backup - though the other events should occur first
    addEvent(iFrame, "load", function () {
        ready.call(iFrame.contentDocument || iFrame.contentWindow.document);
    });

    function checkLoaded() {
        var doc = iFrame.contentDocument || iFrame.contentWindow.document;
        // We can tell if there is a dummy document installed because the dummy document
        // will have an URL that starts with "about:".  The real document will not have that URL
        if (doc.URL.indexOf("about:") !== 0) {
            if (doc.readyState === "complete") {
                ready.call(doc);
            } else {
                // set event listener for DOMContentLoaded on the new document
                addEvent(doc, "DOMContentLoaded", ready);
                addEvent(doc, "readystatechange", readyState);
            }
        } else {
            // still same old original document, so keep looking for content or new document
            timer = setTimeout(checkLoaded, 1);
        }
    }
    checkLoaded();
}

This is simply called like this:

// call this when you know the iFrame has been loaded
// in jQuery, you would put this in a $(document).ready()
iFrameReady(document.getElementById("testFrame"), function() {
    var target = this.getElementById("target");
    target.innerHTML = "Found It!";
});
Community
  • 1
  • 1
jfriend00
  • 580,699
  • 78
  • 809
  • 825
  • Fair enough. Running on Mac and can't control what my users will be running (even in testing). – Dave Sag Jul 07 '14 at 06:23
  • Running a local web server did make Chrome and Safari's behaviour consistent but the problem is not resolved. see update to the question above. – Dave Sag Jul 07 '14 at 07:38
  • @DaveSag - I've added onto my answer to address your new issue. – jfriend00 Jul 07 '14 at 22:09
  • Thanks for that detailed and patient response. In my situation my iFrame is actually loaded within a Chrome Extension but the content scripts are running outside of that iFrame, so yes I do have access to the iFrame's content, but I was not able to find a way to execute code within that iFrame. I've since worked that out but it raises some more complex issues for me with inter-frame message passing. I'll dig into your answer more carefully today and let you know how I went. Again, thanks. – Dave Sag Jul 08 '14 at 00:29
  • 1
    @DaveSag - After further research, I developed an `iFrameReady()` function (now included in my answer) that will notify you when an iFrame hits `DOMContentLoaded` without waiting until all images and stylesheets are loaded (which is what the `load` event does for the iFrame. – jfriend00 Jul 08 '14 at 01:41
  • Many thanks. I implemented your suggestion but alas my `myElements` array is still ending up as `[]` - though, as mentioned in my question, if I execute the exact same code in the console the elements are there, and they of course show up in the browser. I also tried polling for the elements themselves but they never appear as far as the code is concerned. See https://github.com/davesag/listJuggler/blob/scriptless-iframe/example/iframeExternallyReferencedExample.html#L39 for my source-code; maybe I've missed something really obvious. – Dave Sag Jul 08 '14 at 07:16
  • @DaveSag - Do you have your web page working online somewhere where I can directly debug it myself running in a browser? I tried your concept in my own page and it seems to work, but apparently something else is an issue in your particular page. – jfriend00 Jul 08 '14 at 21:20
  • Thanks @jfriend00 - if you pull the source from the 'scriptless-iframe' branch at https://github.com/davesag/listJuggler/tree/scriptless-iframe there's a simple Node based web server included. Just `npm install`, then `grunt` to build the code from Coffeescript, and then `node webServer.js` to run it. The page is then accessible at `http://localhost:8080/example/iframeExternallyReferencedExample.html` – Dave Sag Jul 08 '14 at 23:00
  • @DaveSag - sorry, but I'm not going to install a bunch of stuff on my own system to try to reproduce your issue. I was hoping you could offer a simpler way to reproduce and debug it. – jfriend00 Jul 08 '14 at 23:07
  • Yeah fair enough. I'd hosted the examples up on GitHub - see http://davesag.github.io/listJuggler/example/iframeExternallyReferencedExample.html – Dave Sag Jul 09 '14 at 03:00
  • 1
    @DaveSag - Ahh yes, a problem is that in Chrome and Firefox you cannot fetch the document until AFTER `iFrameReady()` calls its callback. Your code was fetching `myFrame.contents()` before. This is all explained in my answer, but it's because you are getting a temporary document that is not going to end up being the real document and thus you never find any content in this temporary and empty document. `iFrameReady()` passes the actual document as `this` when it calls the callback so you should probably do your searching in the iFrame from within the callback with `$(this).find(...)`. – jfriend00 Jul 09 '14 at 03:17
  • 1
    @DaveSag - FYI, you also have an implementation error in your implementation of `iFrameReady()`. The variable `fired` is declared to be local to the `ready()` function when it should be one level higher. This allows the callback to be called multiple times for the same `iFrame` (which your implementation is doing) which is not how it should work. – jfriend00 Jul 09 '14 at 03:23
  • I want to vote up the face off this answer. Thank you so much for adding this. – iamsar Mar 31 '15 at 16:30
  • 1
    Whenever I see 1ms timeout in answer to question regarding events, I burn a little puppy using napalm. So be careful with that. – Tomáš Zato - Reinstate Monica May 08 '15 at 21:11
  • @TomášZato - generally I'd agree with you, but this is a case where iFrames in some browsers have a weird startup procedure where they start out with a blank "about:" document and when, in that state, you can't assign event handlers because those event handlers will get overwritten when it starts loading the actual document. So, the code "polls" until it seems the actual document start to load and then it installs the event handlers and stops polling. The short `setTimeout()` is a polling loop. If there was an event to use, that would be preferable for sure, but there does not appear to be. – jfriend00 May 08 '15 at 23:51
1

Given that both the parent file and the iFrame's file are coming off my local disk (in development) and will be coming off the same server (in production) I'd have thought that I'd not be subject to the cross-origin issues.

"Please open this perfectly harmless HTML document that I have attached to this email." There are good reasons for browsers to apply cross-domain security to local files.

Is there a way I can convince the browser that my local files are actually of the same domain?

Install a web server. Test through http://localhost. As a bonus, get all the other benefits of an HTTP server (such as being able to use relative URIs that start with a / and develop with server side code).

Quentin
  • 800,325
  • 104
  • 1,079
  • 1,205
  • Thanks - I've run up a local web server and now Chrome and Safari behave consistently, though the problem is not yet resolved. see update above. – Dave Sag Jul 07 '14 at 07:37
  • @DaveSag — That's a completely different issue. It's probably because the DOM of the parent document is ready a long time before the document in the frame loads. Use a more suitable event (like load). – Quentin Jul 07 '14 at 08:17