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 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); })();