Web Performance Calendar

The speed geek's favorite time of year
2018 Edition
ABOUT THE AUTHOR
Philip Tellis

Philip Tellis (@bluesmoon is a geek who likes to make the computer do his work for him. He is the Principal RUM Distiller at Akamai and the author of the boomerang JavaScript library for measuring the Real User performance of websites.

Allan Wirth

Allan Wirth is a senior security researcher in the Adversarial Resilience group at Akamai who is working to make the internet a safer place.

On the 2012 edition of the performance advent calendar, I posted about the non-blocking script loader pattern that we developed at LogNormal/SOASTA to load our 3rd party JavaScript outside the critical path and make sure we were not a SPoF for our customers’ pages. Unlike the async and defer attributes, which still block the onload event, the non-blocking script loader pattern allows JavaScript to be loaded completely out of sequence, and will never block, even if the script never loads.

A lot has changed since 2012. Browsers now have new methods to load scripts without blocking the page, while security standards like CSP, as well as some XSS and CSRF checkers make the JavaScript iframe method problematic. The document.write method that we use inside the iframe is also problematic because Chrome now reports that it’s ill advised even though it’s happening in an anonymous iframe.

We’ve also noticed that creating an iframe in JavaScript can add about 80 ms of latency to a page, and for sites which normally completely load in under 1 second (yes, we’ve worked with some of them), this 10% increase is significant. Lastly, we’ve also heard from site owners that an iframe in the document’s HEAD section causes problems with SEO, and even though we’ve confirmed several times that this is not true, it turns out that developing a new solution is easier than explaining the intricacies of the old one.

So, today we’ll talk about a new loader that has none of the problems of the old loader, and also has the benefit of working well with CSP. The downside though is that we’ve dropped IE6 support. Ok, not really a downside.

Preload Hints

The first new standard we’ll use is preload hints. Specifically the link rel="preload" as="script" method and not the HTTP header. According to caniuse, this is supported by 75% of today’s browsers. IE & Firefox being the two notable exceptions. Of course, it isn’t sufficient to simply stick a link element into your document’s head. We need to attach load and error handlers, and also be able to fallback to other methods if preload isn’t supported, so we need to use JavaScript to inject the element:

var l = document.createElement("link");
if (l.relList && typeof l.relList.supports === "function" && l.relList.supports("preload") && ("as" in l)) {
    l.href = "";
    l.rel  = "preload";
    l.as   = "script";
    l.addEventListener("load", promote);
    l.addEventListener("error", fallback_loader);
    setTimeout(function() {
        if (!promoted) {
            fallback_loader();
        }
    }, LOADER_TIMEOUT);
    where.parentNode.appendChild(l);
}
else {
    fallback_loader();
}

The preload hint tells (supported) browsers to download the resource asynchronously, and without blocking any events, and to store it in the script cache in the expectation that a script node will, at some point in the near future, reference it. The trick to being completely non-blocking is to inject the script node only after we’re sure that the resource is in cache. This is when the node’s load event fires. It’s also why we cannot use an HTTP header, since you cannot attach JavaScript handlers to HTTP headers.

There are a few undefined items above, and most aren’t too important. fallback_loader is the method we’ll use in case preload hints aren’t available, which could just be the old iframe loader. where is a reference DOM node that tells us where to inject the link element. It could just be the current script node accessed via document.currentScript.

promote is a function we’ll use to change our link to a script once the file is in cache, and promoted is a boolean to make sure we stop once that’s done. We’ll go through these two now.

function promote() {
    var s;
    s = document.createElement("script");
    s.id = "a-unique-id-for-your-script";
    s.src = this.href;
    // async is not really needed since dynamic scripts are async by default
    // and the script is already in cache at this point, but some naïve
    // parsers will see a missing async attribute and think we're not async
    s.async = true;
    this.parentNode.appendChild(s);
    promoted = true;
}

Nothing too complex in here. The gist of it is the s.src = this.href line. Since promote() executes as the load handler for the link element, this refers to the link element itself, and this.href is the url we want. This is really cool because it means we could attach the same handler to several link elements for several different scripts. We could also inherit the id from the link element.

At the end of this function we set promoted to true, and we’ll also check its value in our fallback_loader() to avoid running both loaders.

IFrame Fallback

In the case that the browser does not support or fails to preload, the the loader falls back to a modified version of the non-blocking script loader pattern used in previous versions of the loader.

    var s, iframe = document.createElement("iframe");

    iframe.src = "about:blank";
    
    iframe.title = "";
    iframe.role = "presentation";
    
    s = (iframe.frameElement || iframe).style;
    s.width = 0; s.height = 0; s.border = 0; s.display = "none";
    
    where.parentNode.insertBefore(iframe, where);

This code creates an empty iframe and injects it into the first script node in the document. It sets the `title` to blank and the ARIA `role` to `presentation` to tell assistive technologies it can be ignored. A similar effect is achieved via CSS to hide the frame from visual display.

document.domain and IE

Once we have the the iframe, the parent needs to modify the contents of the iframe DOM. To do this, it needs to call document.open on the the child document.

    try {
      win = iframe.contentWindow;
      doc = win.document.open();
    }
    catch (e) {
      // document.domain has been changed and we're on an old version of IE, so we got an access denied.
      // Note: the only browsers that have this problem also do not have CSP support.
      
      // Get document.domain of the parent window
      dom = document.domain;
      
      // Set the src of the iframe to a JavaScript URL that will immediately set its document.domain to match the parent.
      // This lets us access the iframe document long enough to inject our script.
      // Our script may need to do more domain massaging later.
      iframe.src = "javascript:var d=document.open();d.domain='" + dom + "';void(0);";

      win = iframe.contentWindow;
      doc = win.document.open();
    }

In IE8, this is incorrectly denied if the parent frame has set document.domain. To work around this, the loader catches the security exception, and instead uses a javascript scheme uri to explicitly set document.domain in the child.

In all versions of IE and Edge, if the parent’s document.domain later changes, the parent will lose access to the child. To work around this, the first thing that our script does on load is grab a handle to window.parent and store it locally. If it cannot access window.parent it attempts to walk up the domain tree subdomains until it hits a match with the parent. This mitigates the race condition of the loader executing before some other script on the page changes document.domain.

Triggering onload

The crucial trick of the non-blocking iframe pattern is to get the iframe to load the script after its load event has triggered. This is so because the parent’s onload event only waits for the iframe’s onload event, and not for resources loaded in the iframe after its onload. To do this, an event handler needs to be bound to the `load` event in the child.

  var bootstrap = function() {
    var js = doc.createElement("script");
    js.src = "";
    js.id = "a-unique-id-for-your-iframed-script";
    doc.body.appendChild(js);
  };

  try {
    win._l = bootstrap;

    if (win.addEventListener) {
      win.addEventListener("load", win._l, false);
    }
    else if (win.attachEvent) {
      win.attachEvent("onload", win._l);
    }
  }
  catch(e) {
    // unsafe version for IE8 compatability
    // If document.domain has changed, we can't use win, but we can use doc
    doc._l = function() {
      if (dom) {
        this.domain = dom;
      }
      bootstrap();
    }
    doc.write('<bo' + 'dy onload="document._l();">');
  }
  doc.close();

The bootstrap function creates a script element in the child document to load the script, including a magic id to tell the script that it has been loaded by the iframe loader.

The script then tries to bind the event listener in the child window. In modern browsers, it can do this using win.addEventListener, and it uses win.attachEvent in IE8. Trying to access win in IE8 when document.domain has been changed results in an exception. The fallback is to re-reset document.domain and then call the event handler via an inline body onload handler injected through document.write. Luckily this code is only required on IE8, so if you don’t need to support IE8, you can drop this branch entirely.

An interesting hack in the document.write() call is separating the <body> tag into two strings. This is so that poorly behaved 3rd party parsers (we know who you are) don’t interpret it as an opening tag. (Yes, this is an actual issue we’ve encountered in the wild!)

Finally, the loader calls document.close() which triggers the attached event handler after control returns to the event loop.

CSP Compliance

CSP (Content Security Policy) is a technology for sites to specify an allowlist of resources (like scripts) that they can include. This is primarily used to mitigate XSS attacks, and has seen some increased attention from recent “Magecart” credit card skimming attacks. Ryan Barnett recently did a deep dive about how sites can use CSP to protect themselves against such attacks.

CSP Compliance for inline scripts works best if the script doesn’t change so you can add a hash of the script. This gets a little complicated when your script creates an iframe which has more scripts within it.

Of the browsers that need the iframe fallback, Firefox and IE 10 & 11 also support CSP, which means we need a CSP solution that works with both the preload method, that loads the script in the current frame, as well as the iframe method.

Previous versions of the loader always used javascript scheme URIs as the src for the iframe. This was to work around a bug in IE6 where about:blank would trigger insecure content warnings in secure contexts. CSP Considers the javascript scheme similar to inline event handlers – in order to use it with a CSP policy unsafe-inline (or unsafe-hashes in CSP3) would be required in the policy. By dropping IE6 support though, we could switch to an about:blank URL which has equivalent properties for our purposes but does not need whitelisting in the CSP policy for frame-src or script-src.

Previous versions of the loader also used document.write to bind the event handler. This is not required in most cases, but does introduce a problem for CSP. These anonymous iframes inherit the CSP policy of the parent, so to write a script into the child, that script would also need to be allowed by the CSP policy. Thankfully, it turns out that this technique is only relevant in the document.domain corner case on IE8 (no CSP support), so for any modern browser that does support CSP, we can use the addEventListener method.

Finally, with CSP it is also possible to block inline styles via style-src, even when set through JavaScript (although this is under discussion). Changing the loader to apply the iframe styling via the CSSOM mitigates any potential issues arising from style-src directives.
The cumulative effect of these changes is that the loader snippet can be secured by only two additions to the script-src directive – the hash of the loader snippet and the location of the script itself.

You can use report-uri.com’s handy tool to generate a hash of your script.

Full Code

The full code is included as a github gist. It also includes a dom walker for you iframed script that’s needed in case the parent iframe changes document.domain after your script loads and initialise:

The loader

For the loader, our CSP header would look like this:

Content-Security-Policy: script-src sha256-N17tpZTa695DVQJ0H+pRpxvMH/27hbTyxTdTugwOGvQ=

And the inline loader script node looks like this:

<script id="nb-loader-script">
(function(url) {

  // document.currentScript works on most browsers, but not all
  var where = document.currentScript || document.getElementById("nb-loader-script"),
      promoted = false,
      LOADER_TIMEOUT = 3000,
      IDPREFIX = "__nb-script";

  // function to promote a preload link node to an async script node
  function promote() {
    var s;
    s = document.createElement("script");
    s.id = IDPREFIX + "-async";
    
    s.src = url;

    where.parentNode.appendChild(s);
    promoted = true;
  }

  // function to load script in an iframe on browsers that don't support preload hints
  function iframe_loader() {
    promoted = true;
    var win, doc, dom, s, bootstrap, iframe = document.createElement("iframe");

    // IE6, which does not support CSP, treats about:blank as insecure content, so we'd have to use javascript:void(0) there
    // In browsers that do support CSP, javascript:void(0) is considered unsafe inline JavaScript, so we prefer about:blank
    iframe.src = "about:blank";
    
    // We set title and role appropriately to play nicely with screen readers and other assistive technologies
    iframe.title = "";
    iframe.role = "presentation";
    
    s = (iframe.frameElement || iframe).style;
    s.width = 0; s.height = 0; s.border = 0; s.display = "none";
    
    where.parentNode.insertBefore(iframe, where);
    try {
      win = iframe.contentWindow;
      doc = win.document.open();
    }
    catch (e) {
      // document.domain has been changed and we're on an old version of IE, so we got an access denied.
      // Note: the only browsers that have this problem also do not have CSP support.
      
      // Get document.domain of the parent window
      dom = document.domain;
      
      // Set the src of the iframe to a JavaScript URL that will immediately set its document.domain to match the parent.
      // This lets us access the iframe document long enough to inject our script.
      // Our script may need to do more domain massaging later.
      iframe.src = "javascript:var d=document.open();d.domain='" + dom + "';void(0);";
      win = iframe.contentWindow;
      doc = win.document.open();
    }

    bootstrap = function() {
      // This code runs inside the iframe
      var js = doc.createElement("script");
      js.id = IDPREFIX + "-iframe-async";
      js.src = url;
      doc.body.appendChild(js);
    };
    
    try {
      win._l = bootstrap

      if (win.addEventListener) {
        win.addEventListener("load", win._l, false);
      }
      else if (win.attachEvent) {
        win.attachEvent("onload", win._l);
      }
    }
    catch (f) {
      // unsafe version for IE8 compatability
      // If document.domain has changed, we can't use win, but we can use doc
      doc._l = function() {
        if (dom) {
          this.domain = dom;
        }
        bootstrap();
      }
      doc.write('<bo' + 'dy onload="document._l();">');
    }
    doc.close();
  }

  // We first check to see if the browser supports preload hints via a link element
  var l = document.createElement("link");

  if (l.relList && typeof l.relList.supports === "function" && l.relList.supports("preload") && ("as" in l)) {
    l.href = url;
    l.rel  = "preload";
    l.as   = "script";
    
    // If the link successfully preloads our script, we'll promote it to a script node.
    l.addEventListener("load", promote);
    
    // If the preload fails or times out, we'll fallback to the iframe loader
    l.addEventListener("error", iframe_loader);
    setTimeout(function() {
        if (!promoted) {
            iframe_loader();
        }
    }, LOADER_TIMEOUT);
    
    where.parentNode.appendChild(l);
  }
  else {
    // If preload hints aren't supported, then fallback to the iframe loader
    iframe_loader();
  }

})("https://your.script.url/goes/here.js");
</script>

The DOM Walker

Put this at the start of your script, and call it any time an access to the parent frame results in a security exception.

(function _check_doc_domain(domain) {
  // This snippet tries to walk document.domain in case the parent frame changed it after our iframe was created and the script was loaded

  /*eslint no-unused-vars:0*/
  var test;

  if (!window) {
    return;
  }

  // If domain is not passed in, then this is a global call
  // domain is only passed in if we call ourselves, so we
  // skip the frame check at that point
  if (typeof domain === "undefined") {
    // If we're running in the main window, then we don't need this
    if (window.parent === window || !document.getElementById("__nb-script-iframe-async")) {
      return;// true;  // nothing to do
    }

    try {
      // If document.domain is changed during page load (from www.blah.com to blah.com, for example),
      // window.parent.window.location.href throws "Permission Denied" in IE.
      // Resetting the inner domain to match the outer makes location accessible once again
      if (window.document.domain !== window.parent.window.document.domain) {
        window.document.domain = window.parent.window.document.domain;
      }
    }
    catch (err) {
      // We could log this, but nothing else to do
    }
  }

  domain = document.domain;

  if (domain.indexOf(".") === -1) {
    // we've reached the top level domain
    return;// false;  // not okay, but we did our best
  }

  // 1. Test without setting document.domain
  try {
    test = window.parent.document;
    return;// test !== undefined;  // all okay
  }
  // 2. Test with document.domain
  catch (err) {
    document.domain = domain;
  }
  try {
    test = window.parent.document;
    return;// test !== undefined;  // all okay
  }
  // 3. Strip off leading part and try again
  catch (err) {
    domain = domain.replace(/^[\w\-]+\./, "");
  }

  _check_doc_domain(domain);
})();