Looker Blog : Data Matters

iFrame Sandbox Permissions Tutorial

Jim Rottinger, Software Engineer at Looker

May 3, 2019

Understanding iFrame Sandboxes and iFrame Security

Embedding third-party JavaScript in web applications is a tale as old as time. Whether it’s dropping a widget onto your web page or including custom content from a client in your cloud application, it’s something that many developers have encountered in their career. We all know about the iframe element in HTML, but how much do we really know about how it works? What are the security concerns associated with running code inside of an iframe and, furthermore, how can the HTML5 sandbox attribute on the frame alleviate these concerns?

The goal of this tutorial is to walk through the various security risks associated with running third-party JavaScript on your page and explain how sandboxed iframes can alleviate those issues by restricting the permissions it is allowed to run with.

In this post, we’ll demonstrate setting up a demo application from the ground up that will simulate running JavaScript coming from a different origin. What we should end up with is a sandboxed environment in which we can execute any arbitrary JavaScript and still sleep well at night, knowing our host application will be safe from harm.

With all of that in mind, the guided walkthrough will consist of the following parts:

  1. Setting up two node servers to simulate two different origins
  2. Embedding the content of our client page in an iframe on the host page and investigating what the client iframe is and is not allowed to do
  3. Applying the sandbox attribute to the iframe and exploring the various options for the sandbox.

Let’s get started!


Step 1: Setting up the Servers for our Demo Application

To simulate executing code from a different origin, we are going to set up two node servers — one which we’ll call the host and second which we will call the client. We can do this using node’s http library to listen to and serve from two different ports.

// server.js
const http = require('http');
const hostname = 'localhost';
const host_port = 8000;
const client_port = 8001;

const host_server = http.createServer((req, res) => {
 res.statusCode = 200;
 res.setHeader('Content-Type', 'text/plain');
 res.end('This is the Host Server\n');
}).listen(host_port, hostname, () => {
 console.log(`Server running at http://${hostname}:${host_port}/`);
});;

const client_server = http.createServer((req, res) => {
 res.statusCode = 200;
 res.setHeader('Content-Type', 'text/plain');
 res.end('This is the Client Server\n');
}).listen(client_port, hostname, () => {
 console.log(`Client running at http://${hostname}:${client_port}/`);
 });;

Save this JS file to whatever name you’d like - I called it server.js. Then, to start our server, we can simply run:

node server.js

This should start two different http servers, one on port 8000 and the second on port 8001. To test that it is working, you can individually visit your localhost on port 8000 and 80001, which should look like this:

iframe

Even though they are both running on localhost, the same-origin policy that browsers implement operate on a protocol-host-port tuple that will only be true if the protocol, the hostname, and port number all match up. In this case, the protocol and host are the same, but since the ports are different, these will be considered to be different origins.

Of course, just having a hard-coded response won’t get us very far. We’ll need to be able to serve both HTML and JS for this demo. To do this, I whipped up a function to serve assets from a given folder.

function serveAsset(rootPath, url, res) {
 // default root route to index.html in the folder
 if (url === '/') url = 'index.html';

 const filePath = path.join(__dirname, rootPath, url)
 const readStream = fileSystem.createReadStream(filePath)
   .on('error', function() {
     res.statusCode = 404;
     res.end();
   });

 if (/^.*\.js$/.test(url)) {
   res.setHeader('Content-Type', 'text/javascript');
 } else {
   res.setHeader('Content-Type', 'text/html');
 }
 readStream.pipe(res);
}

This function will take in a root folder path, a url, and the response object. If the file is not found, it will return 404. If it is found, it will set the header to be text/javascript or text/html, depending on the file suffix. To get this to work, we need to include two more dependencies at the top of the file:

const fileSystem = require('fs');
const path = require('path');

Fun fact - Since we’re just using built-in node libraries, we do not have to install anything via npm! Once you instantiate fileSystem, path, and our asset function, go ahead and update your servers as well to call serveAsset.

const host_server = http.createServer((req, res) => {
 serveAsset('host', req.url, res)
}).listen(host_port, hostname, () => {
 console.log(`Server running at http://${hostname}:${host_port}/`);
});;

const client_server = http.createServer((req, res) => {
 serveAsset('client', req.url, res)
}).listen(client_port, hostname, () => {
 console.log(`Client running at http://${hostname}:${client_port}/`);
});

These now look very similar. The only difference is that the host_server will look for its assets in the host folder and the client_server will look in the client folder. If we were to restart our server now, we would see the following error message:

iframe

This is because our serveAsset function is looking in either the host or client folder for an asset to serve, and we haven’t created them yet! Let’s create both of them, each with an index.html and a JS file.

» mkdir host; mkdir client; touch host/index.html; touch host/host.js; touch client/index.html; touch client/client.js

Our file structure should look like this:

[-]host
 index.html
 host.js
[-]client
 index.html
 client.js
server.js

Now, if we start our server and visit our localhost location, we no longer get the 404, which means our server found the file! — but it has no content yet. To get some content in, let’s start with something very simple. For the host, we simply have the HTML as:

<!-- index.html -->
<html>
 <head>
 </head>
 <body>
   <h1>Host Page</h1>
   <p>Host message container</p>
   <script type="text/javascript" src="host.js"></script>
 </body>
</html>

And the JavaScript as:

alert('hello from the host')

The client content is exactly the same, just with the word host changed to client. If we restart our server now, we should be able to go to both http://localhost:8000/ and http://localhost:8001/ and see our content in action! Each page should send an alert from the JS file and then render our html content to the page.

Each node server is serving an index.html file which includes a JavaScript file

Each node server is serving an index.html file which includes a JavaScript file


Step 2: Embedding the Client in the Host without Sandboxing and Investigating its Permissions

With our two servers running, we are now ready to begin testing some iframe scenarios. Before we do that, let’s add a second file to our host so that we can compare the permissions of an iframe from the same origin and an iframe from a different origin.

touch host/hosted-client.html; touch host/hosted-client.js

Fill in the content of these files with the same content as we used for our other html/JS pairs. We’ll call this one the “Hosted Client,” meaning an iframe client coming from the same origin as our host.

Once we do that, back in our host/index.html we can iframe both our same-origin client and our different-origin client.

<iframe width=400 height=300 src="http://localhost:8000/hosted-client.html"></iframe>
<iframe width=400 height=300 src="http://127.0.0.1:8001/index.html"></iframe>

Please note that we use localhost for the hosted-client and 127.0.0.1 for the other. This will become important in the cookies section below. Refreshing the host page, you should now see two iframes, each with the content of our individual HTML files.

If you have alerts in your JS files like I do, you should have seen that each individual file triggers an alert at the top level of the browser. Should it be able to do this? That question brings us to our first area of concern — pop-ups and dialog boxes.

Pop-ups and Dialog Boxes

JavaScript has three different functions that trigger a popup — alert, prompt, and confirm. Each of these open up a dialog box at the top of the browsing context, regardless of whether it comes from the top-level window or not. The scary thing about these dialogs, as can be found in the alert documentation, is:

“Dialog boxes are modal windows — they prevent the user from accessing the rest of the program’s interface until the dialog box is closed. For this reason, you should not overuse any function that creates a dialog box (or modal window).”

I am sure that most of us have, at some point in our lives, been forcefully redirected to a spammy site against your will that then bombarded you with these types of dialogs. Even when you try to close out of it, it just pops open another one. This annoying behavior completely blocks you out of using a site and sadly, it’s incredibly simple to reproduce. Try adding this to your client.js file:

(function unescapablePrompt() {
 if (window.confirm("Do you want to win $1000?!?!")) {
   /* Open some spammy webpage or redirect */
   unescapablePrompt()
 } else {
   unescapablePrompt()
 }
}());

Now, when you visit your host site, you get this:

iframe

These prompts make it impossible to ever interact with the page and you have to close the entire tab or kill the JavaScript execution to get rid of it. The worst part is that regardless of whether your embedded content is coming from a different host or the same origin, this behavior is exploitable.

You might think that you could just do something like this to get rid of it:

alert = prompt = confirm = function () { } // does not work!

The problem with this is that each iframe operates within its own nested browsing context, so overriding the functions at the host level will not affect the functions at the frame level.

Fortunately, sandboxing can come to our rescue here, which we will see later in this post. For now, let’s move on to whether or not the iframe can navigate the page away from the current one.


Step 3: Top-level Window Navigation and Opening New Tabs

Let's now examine how and if the iframes are able to change the url of the top-level window and if they’re able to open a new window.

There are two different methods that we want to test here.

  1. window.open for opening new windows and tabs, and
  2. window.location for navigating the page away from the current url.

Helpful Note: iframes can reference its top-level window using window.top. Similarly, it can reference its parent’s window with window.parent. In our case, they do the same thing.

Let’s remove all the code in client.js and replace it with:

window.top.location = 'http://localhost:8001'

This will attempt to redirect the top-level window to the client host. If we run this, we get the following error:

iframe

That’s great! It blocks the automatic redirect. But wait — what’s that last part? “… nor has it received a user gesture.”

What does that mean? Does it mean that a user-initiated gesture can navigate the window? Let’s try it.

// client.js
function clickNav () {
 window.top.location = 'http://localhost:8001'
 }
<!-- client/index.html -->
<a href="" onclick="clickNav()">Navigate me</a>

If we add this code to our JS and HTML respectively, it will add a link to the client page. When we click it, the page navigates!

iframe

As it turns out, a user-initiated action can navigate the top-level window, which is the basic idea behind clickjacking. A typical clickjacking attack will put transparent click boxes over a page and then “hijack” the click to redirect the page to a different url. window.open works the same way.

// client.js
function clickNav () {
 window.open('http://localhost:8001')
}
window.open('http://localhost:8001')

If we have this, the browser will block the pop-up request of the outer function call, however, when we have it in a click handler, it will open the window regardless of whether we blocked pop-ups or not.

The other part of the above error message saying the iframe “does not have the same origin as the window it is targeting” means our iframe is the same origin as the host and it has full access to redirect the page, click-action or not.

// hosted-client.js
window.top.location = 'http://localhost:8001'

If you run this, the browser will allow the redirect to happen since it is the same origin. This is not the case for window.open. Even if it is from the same origin, the browser will block the window.open unless you explicitly tell the browser to allow the pop-up.

Cookies and Browser Requests

The final thing we are going to look at is browser cookies. Before getting started, make sure your hosted-client iframe is pointed at localhost and your client.js iframe is pointed at 127.0.0.1.

<iframe width=400 height=300 src="http://localhost:8000/hosted-client.html"></iframe>
<iframe width=400 height=300 src="http://127.0.0.1:8001/index.html"></iframe>

We need to do this because cookies care about domain and ignore the port. Once you have checked this, let’s set a cookie on the host.

// host.js
document.cookie = "session_id=A38XJISDASDW120"

This is representing a session ID, something that is often included in requests. Let’s see if our iframes can access the cookies.

// in client.js
console.log(document.cookie) // ""

// in hosted-client.js
console.log(document.cookie) // session_id=A38XJISDASDW120

As you can see, the client cannot access the cookies since it is a different origin, but the hosted-client can. Let’s try to make a request using the fetch API.

// hosted-client.js
var myRequest = new Request('http://localhost:8000');
fetch(myRequest, {
 method: 'GET',
 credentials: "include"
}).then(function(response) {
 console.log(response)
});

When we do this, we get a 200 response.

Response {type: "basic", url: "http://localhost:8000/", redirected: false, status: 200, ok: true, ...}

That means the server accepted the request and gave us a response, but did it send through the cookies? We can check this on the server side. Let’s add a console log to our host server request handler.

const host_server = http.createServer((req, res) => {
 console.log(req.headers)
 serveAsset('host', req.url, res)
}).listen(host_port, hostname, () => {
 console.log(`Server running at http://${hostname}:${host_port}/`);
})

This will output the headers when we make a request to our host server. Make sure you restart the server after you add this line, and then reload your page and look for the request coming from the hosted-client. It looks like this:

{
 host: 'localhost:8000',
 connection: 'keep-alive',
 'user-agent': 'Mozilla/5.0 (Macintosh......',
 accept: '*/*',
 referer: 'http://localhost:8000/hosted-client.html',
 'accept-encoding': 'gzip, deflate, br',
 'accept-language': 'en-US,en;q=0.9',
 cookie: 'session_id=A38XJISDASDW120'
}

As you can see, it sends the cookies through. If the server were to use just the session ID to authenticate the request, then it would think this is a legitimate request.


Step 4: Applying the Sandbox Attribute to the iframe

So far, we’ve identified four areas of concern when working with iframes.

  1. They can exploit pop-up dialog boxes to prevent interaction with the website
  2. Navigating the top-level window through clickjacking even on different-origin iframes
  3. Navigating the top-level window when the origin is the same even without user interaction
  4. Same-origin iframes can make requests with cookies.

Now we’re going to begin making use of the sandbox attribute for iframes, introduced in HTML5. When added to an iframe, the sandboxed iframe restricts pretty much all scripts and browser behavior of any kind. It is not until we add the permissions in a space-separated list that we enable the exact permissions we want to set. To see its initial state, add the attribute as an empty string to both of our iframes.

<iframe sandbox="" width=400 height=300 src="http://localhost:8000/hosted-client.html"></iframe>
<iframe sandbox="" width=400 height=300 src="http://127.0.0.1:8001/index.html"></iframe>

When we sandbox the iframe, it blocks all scripts from executing

Sandboxed iframes with no permissions block all scripts from running

Sandboxed iframes with no permissions block all scripts from running

Getting this to work starts by allowing various permissions one at a time . The full list of string values can be found in the iframe documentation under the sandbox section. We will be starting with allow-scripts.

Allowing Scripts

To begin here, let’s clear out our client.js and hosted-client.js and start with a simple console log.

console.log("I executed!")

Without defining any permissions, our sandbox won’t allow a console log to run. We can get our script running by adding the allow-scripts permission to our iframe attribute.

sandbox="allow-scripts"

Once you do this, and refresh the page, you should see a console log from each of our pages.

Pop-ups and Modals

One of the concerns we learned about is that an iframe can pop up dialog boxes at the top of the browsing context and prevent the user from interacting with the page. To see if this is exploitable in a sandbox, let’s add an alert to our client script.

alert("hello from the client")

When we run that we get the following error:

"Ignored call to 'alert()'. The document is sandboxed, and the 'allow-modals' keyword is not set."

Even though we are allowing scripts to run, the sandbox still limits a lot of the browser behaviors. In order for the alert to work from the iframe, we would have to add the allow-modals property to the iframe.

sandbox="allow-scripts allow-modals"

Keep in mind that this is an all-or-nothing thing. We cannot allow some popups and block others. This is an okay restriction, in my opinion, and it crosses off our first iframe security concern.

Top-level Window Navigation and Opening New Tabs

Our second and third security concerns are related to navigating the page away from the original URL. We saw that a same-origin iframe could navigate the page without a user interaction, and that a different-origin iframe could do so with user interaction. Let’s try this in our sandbox.

// client.js
function clickNav () {
 window.top.location = 'http://localhost:8001'
}
window.top.location = 'http://localhost:8001'

This results in the following errors:

iframe

With this, we covered two cases at once. The initial command to change the location failed, as did the one on-click. By applying separate permissions to our iframe for each of these cases, we can allow any navigation with allow-top-navigation and user-activated navigation with allow-top-navigation-by-user-activation.

sandbox="allow-scripts allow-top-navigation-by-user-activation"

When we turn this on, the different-origin iframe can redirect the page upon user action. The case is the same for same-origin iframes, where you can explicitly set the navigation permissions, regardless of the origin.

Cookies and Browser Requests

The final concern to address is the ability to access cookies and make requests with same-origin iframes. Let’s try accessing the cookies with a sandboxed iframe.

// hosted-client.js
console.log(document.cookie)

Unlike last time, this results in the following error:

Uncaught DOMException: Failed to read the 'cookie' property from 'Document': The document is sandboxed and lacks the 'allow-same-origin' flag.

Similarly, when we try to make a request with the same request code as the previous section, we get a different error.

Failed to load http://localhost:8000/: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'null' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

The above error is the browser blocking our request because we no longer have the same origin. This is because the sandbox property sets the origin of the frame to null, meaning it will now be a cross-origin request, even though the iframe is hosted on the same domain.

Adding the allow-same-origin sandbox attribute will prevent both of these errors from occurring. However, you should be careful and make sure you have complete control over the content of the frame before using it. As noted in the Mozilla iframe documentation:

“When the embedded document has the same origin as the main page, it is strongly discouraged to use both allow-scripts and allow-same-origin at the same time, as that allows the embedded document to programmatically remove the sandbox attribute. Although it is accepted, this case is no more secure than not using the sandbox attribute.”

Generally speaking, if you find yourself needing both allow-scripts and allow-same-origin for your sandbox, you should ask yourself why you are iframing in the first place, and whether or not having the sandbox property is appropriate.

Putting It All Together: How We Use iframes at Looker

As an example of a practical use of this, here at Looker, we use iframes to allow customers to create and run their own custom visualizations within our application.

Without any one way to vet every single line of code our customers could write for their custom visualizations, we needed to create a secure execution environment to run the code in. A diagram of this environment can be seen below.

Looker explore page using an iframe to render custom data visualization

Looker explore page using an iframe to render custom data visualization

We leverage the postMessage API to pass the data in and to receive back any events or errors that the visualization produces. Given the restrictions of the sandboxed iframe, it is not able to make calls outside of its own frame, nor is it able to read or modify anything about the parent page. This let’s us rest assured that both our application and our customers’ data is safe and secure.

Wrapping Up

I hope you found this post helpful as you address security concerns related to sandboxed iframes. By walking through this tutorial, you should now have a better understanding of:

  • How sandboxed iframes without allow-modals permission prevent user interaction on the page
  • How sandboxed iframes without the allow-top-navigation or the allow-top-navigation-by-user-activation properties can alleviate same-origin iframes that can redirect the top-level page, as well as different-origin iframes with some user interaction.
  • Why sandboxed iframes without the allow-same-origin property prevent same-origin iframes from having access to the domain’s cookies and making requests as if they were the host.

If you have any questions about this tutorial, feel free to reach out on the Looker Community. And if you’re curious to learn more about our Looker team, check out our open positions.


Important Notes

Next Previous

Subscribe for the Latest Posts