Same Origin Policy: Demystified

Same Origin Policy: Demystified

Featured on Hashnode

The original article can be found here: Same Origin Policy on AppSec Monkey

What is the Same Origin Policy?

Same Origin Policy is a set of design principles that govern how web browser features are implemented.

Its purpose is to isolate browser windows (and tabs) from each other so that, for example, when you go to example.com, the website will not be able to read your emails from gmail.com, which you may have open in another tab.

What is an Origin?

The definition of an origin is simple. Two websites are of the same origin if their scheme (http://, https://, etc.), host (e.g., appsecmonkey.com), and port (e.g., 443) are the same. You can find the definition in RFC6545 - The Web Origin Concept.

figure-1.jpg

Implicit ports

If the port is not explicitly specified, it's implicitly 80 for http and 443 for https.

Examples

These URIs are considered to be of the same origin:

  • https://www.appsecmonkey.com/
  • https://www.appsecmonkey.com/blog/same-origin-policy/
  • https://www.appsecmonkey.com:443/blog/same-origin-policy/

And these are all of different origins:

  • http://www.appsecmonkey.com/
  • https://appsecmonkey.org/
  • https://www.appsecmonkey.com:8080/

What is allowed by the same-origin policy, and what is not?

In general, writing is allowed, and reading is denied. How exactly this applies depends on the browser feature, so let's see some examples.

JavaScript Window Access

There are many ways in which a website can get a handle to another window. However, you can restrict this by using a COOP (Cross-Origin Opener Policy) and the frame-ancestors directive of CSP (Content Security Policy).

These methods include:

  • Using window.open.
  • Creating a frame (like we're about to).
  • Using window.opener if the website is framed by another.
  • Received postMessage event.source.

This handle provides access to stripped-down versions of the window and location objects.

Let's run some experiments on a.local. We'll start by getting a window handle to b.local by creating a cross-origin frame like so:

var crossOriginFrame = document.createElement("iframe");
crossOriginFrame.src = "http://b.local";
document.body.appendChild(crossOriginFrame);
var handle = crossOriginFrame.contentWindow;

Now let's see what we can do with it.

❌ Not allowed: Read cross-origin content

console.log(handle.document.body.innerHTML);
❌ Uncaught DOMException: Blocked a frame with origin "http://a.local" from accessing a cross-origin frame.

❌ Not allowed: Write cross-origin content

handle.document.body.innerHTML = "<h1>Hacked</h1>";
❌ Uncaught DOMException: Blocked a frame with origin "http://a.local" from accessing a cross-origin frame.

✅ Allowed: Read the number of frames within the cross-origin window

console.log(handle.frames.length);
✅ 2

☠ Security Impact: Being able to count the frames enables the frame counting cross-site leak attack.

❌ Not allowed: Read cross-origin URI

console.log(handle.location.href)
❌ Uncaught DOMException: Blocked a frame with origin "http://a.local" from accessing a cross-origin frame.

✅ Allowed: Write cross-origin URI

handle.location.replace("https://www.example.com");

figure-2.png

☠ Security Impact: Websites that you frame on your website can get a window handle to it via the window.opener property. This means that if you load a malicious website in an iframe on your website, the frame can change the URI of your site into, e.g., a phishing page (clone of your page that, e.g., steals your users' passwords or makes them download something malicious). You can prevent this using sandboxed iframes.

✅ Allowed: Messaging to the window via postMessage

The postMessage method allows cross-origin windows to communicate with each other.

// on http://b.local/
window.addEventListener("message", (event) => {
    document.write("Got message: " + event.data)
}, false)

// on http://a.local/
handle.postMessage("hello", "http://b.local");

figure-3.png

❌ Not allowed: Read localStorage or sessionStorage

console.log(handle.localStorage);
❌ Uncaught DOMException: Blocked a frame with origin "http://a.local" from accessing a cross-origin frame.

console.log(handle.sessionStorage);
❌ Uncaught DOMException: Blocked a frame with origin "http://a.local" from accessing a cross-origin frame.

Resource Embedding and JavaScript Access

In general, embedding any resource (image, style, script, etc.) is allowed cross-origin, but JavaScript cannot directly access the resource. However, you can restrict this with a CORP (Cross-Origin Resource Policy)).

Furthermore, when embedding resources, the browser user's cookies for the embedded resource's site are sent along with the request. Effectively this allows websites to send credentialed (with cookies) cross-site GET and HEAD requests.

☠ Security Impact: The fact that browsers send cookies along with these requests enables CSRF (Cross-Site Request Forgery) attacks if your website allows performing actions (e.g., transfer money, change password, delete account) via GET requests (which it, of course, shouldn't).

Let's see a couple of examples of cross-site resources.

✅ Allowed: Displaying an image

<img id="cross-origin-image" src="http://b.local/monkey.png"/>

✅ Allowed: Create a canvas from the image

var crossOriginImage = document.getElementById("cross-origin-image");
var canvas = document.createElement("canvas");
canvas.width = crossOriginImage.width;
canvas.height = crossOriginImage.height;
canvas.getContext('2d').drawImage(crossOriginImage, 0, 0, crossOriginImage.width, crossOriginImage.height);
document.body.appendChild(canvas);

figure-4.png

❌ Not allowed: Read pixels from the canvas

canvas.getContext('2d').getImageData(1, 1, 1, 1).data;
❌ Uncaught DOMException: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data.

✅ Allowed: Loading a style

This is fine. The style will be rendered on the page.

<link rel="stylesheet" href="http://b.local/test.css"/

❌ Not allowed: Read the style contents

console.log(document.styleSheets[0].cssRules);
❌ Uncaught DOMException: Failed to read the 'cssRules' property from 'CSSStyleSheet': Cannot access rules
    at <anonymous>:1:25

✅ Allowed: Loading a script

<script id="cross-origin-script" src="http://b.local/test.js"></script>

Here is the content of test.js:

var x = 5;

❌ Not allowed: Read the script source

There is no way to get the source of the script.

✅ Allowed: Access data and functions provided by the script

The script had the x variable, remember? We can use it now on our page.

 console.log(x);
 5

This is essentially how JSONP worked (don't use it anymore, it was never a good idea, and these days we have better ways which you'll see in a minute).

☠ Security Impact: The fact that browsers allow access to the data/functions provided by cross-domain scripts enables XSSI (Cross-Site Script Inclusion) attacks if your website serves dynamic JavaScript files with authenticated user data in them. So don't do anything like that.

HTML forms

In the previous section, we looked at how embedding cross-origin resources allowed for malicious websites to send credentialed GET requests on the browser user's behalf. Now you will see how HTML forms make it possible to send credentialed POST requests.

☠ Security Impact: This behavior is the primary reason CSRF vulnerabilities are so prevalent. Luckily the situation is finally going to improve soon as SameSite Cookies are starting to be enabled by default.

✅ Allowed: Submit credentialed cross-origin urlencoded HTML form

Let's say we have the following form on a.local and the user has an active session on b.local:

<form method="POST" action="http://b.local/transferFunds">
    <input name="amount" type="text" value="10000"/>
    <input name="iban" type="text" value="HACKERBANK1337"/>
    <input type="submit" value="Send"/>
</form>

figure-6.png

When the user clicks the "Send" button, a HTTP request like this is sent to b.local:

POST /transferFunds HTTP/1.1
Host: b.local
Cookie: SESSIONID=s3cr3t
Content-Type: application/x-www-form-urlencoded
...
amount=10000&iban=HACKERBANK1337

And the unwitting web application would send the money, thinking the request came from the user.

✅ Allowed: Submit credentialed cross-origin multipart HTML form

A cross-origin multipart form can be submitted without problems. Just add the enctype parameter like so:

<form method="POST" action="http://b.local/transferFunds" enctype="multipart/form-data">
    <input name="amount" type="text" value="10000"/>
    <input name="iban" type="text" value="HACKERBANK1337"/>
    <input type="submit" value="Send"/>
</form>

❌ Not allowed: Submit credentialed cross-origin JSON HTML form

Specifying application/json for the enctype will not work. The browser will fallback to application/x-www-form-urlencoded.

<form method="POST" action="http://b.local/transferFunds" enctype="application/json">
    <input name="amount" type="text" value='{"amount": 1000, "iban": "HACKERBANK1337", "foo": "'/>
    <input name="amount" type="text" value='bar"}'/>
</form>

☠ Security Impact: If the application fails to validate the content type properly, it could interpret this kind of POST request as valid JSON. Also, there are some drafts about implementing enctype="json", although no browser currently does so. For these reasons, it's vital to implement CSRF protection for, e.g., REST APIs as well as traditional web applications if they use cookie-based session management.

XHR and Fetch requests

✅ Allowed: Sending credentialed cross-origin GET, HEAD, and POST requests with XHR

The following will work. You will get an error, but the request will be sent. You can verify with your browser's developer tools, or better yet, set up a proxy tool such as OWASP ZAP between your browser and the webserver to really see what's going on.

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('GET', 'http://b.local/');
xhr.send()

✅ Allowed: Sending credentialed cross-origin GET, HEAD, and POST requests with fetch

Using fetch will work just the same.

fetch('http://b.local/', {method: 'POST', credentials: 'include'});

❌ Not allowed: Inspect the XHR response

With either, XHR or fetch, you will not be able to read the response that you get.

❌ Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://b.local/. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).

I'll get to what the Access-Control-Allow-Origin thing is in a minute.

❌ Not allowed: Sending PUT, PATCH, DELETE, etc. requests

Only specific HTTP verbs are allowed by default (GET, POST, HEAD, and OPTIONS).

fetch('http://b.local/', {method: 'PUT', credentials: 'include'});
❌ Access to fetch at 'http://b.local/' from origin 'http://a.local' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

There are two interesting parts in this error. It's talking about a preflight request, and the request mode. I'll get to the prefight thing soon, but let's quickly look at request modes first.

Request modes

The request mode can be used by web applications to prevent accidentally leaking unnecessary data in a request by, e.g., setting the mode explicitly to same-origin.

It, however, cannot be used to bypass any security controls. For example, if we change the mode to 'no-cors' like described in the error message, the PUT request would still not be sent; it would just result in a different error.

fetch('http://b.local/', {method: 'PUT', credentials: 'include', mode: 'no-cors'});
❌ Uncaught (in promise) TypeError: Failed to execute 'fetch' on 'Window': 'PUT' is unsupported in no-cors mode.

❌ Not allowed: Sending JSON requests

Only the whitelisted content types are allowed. This won't work.

fetch('http://b.local/', {
    method: 'POST', credentials: 'include', headers: {
        'Content-Type': 'application/json'
    },
});
❌ Access to fetch at 'http://b.local/' from origin 'http://a.local' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Again with the preflight. Alright, now I'll tell you what these Access-Control-Allow- things are.

Cross-Origin Resource Sharing (CORS)

Cross-Origin Resource Sharing, or CORS for short, is a mechanism for a website to partially opt-out of the same-origin policy in a controlled way.

Access-Control-Allow-Origin

For example, if b.local wants a.local to be able to read its content via fetch/XHR responses, then by specifying the CORS headers in the HTTP response, it can do so.

Access-Control-Allow-Origin: http://a.local/

You can also use a wildcard as the origin, but then Access-Control-Allow-Credentials (below) cannot be true. Also, the wildcard cannot contain any other text, so *.appsecmonkey.com wouldn't work. It's all or nothing, a complete wildcard or an exact origin.

Access-Control-Allow-Credentials

By default, CORS doesn't allow credentialed requests (that include the browser user's cookies). After all, credentialed CORS requests effectively give the websites to whom the privilege is granted full read and write control of the browser user's data in the application.

If you still want to enable it, you can use the Access-Control-Allow-Credentials header like so:

Access-Control-Allow-Credentials: true

Access-Control-Allow-Headers

Then, if you want to allow JSON requests or other non-whitelisted headers/values, you can do so via the Access-Control-Allow-Headers header:

Access-Control-Allow-Headers: content-type

Access-Control-Allow-Methods

Finally, if you want to enable other HTTP verbs than GET, POST, HEAD, and OPTIONS, you have to use the Access-Control-Allow-Methods header:

Access-Control-Allow-Methods: GET, POST, HEAD, PUT, PATCH, DELETE

Preflight

GET, POST, and HEAD requests are always sent, but the website is forbidden access to the response data if the response doesn't contain appropriate CORS headers.

But how does the browser know whether it is allowed to send a PUT request or not? If the answer to the question "can I send a PUT request" is in response to the PUT request, doesn't this create a chicken and egg problem? That's a great question, and the answer is simple: we send two requests.

The browser sends an OPTIONS request first, and then looks at the response headers for that request. If PUT is allowed (in Access-Control-Allow-Methods), only then the actual PUT request is sent.

This first OPTIONS request is aptly named the preflight request.

CORS in action

Let's revisit one of the tests we made earlier, but this time, b.local returns the following HTTP response headers:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET, HEAD, POST, PUT
Access-Control-Allow-Origin: http://a.local

✅ Allowed: Sending credentialed cross-origin GET, HEAD, POST, and PUT requests with fetch and reading the response

Now we have complete control of the cross-origin page.

fetch('http://b.local', {method: 'PUT', credentials: 'include', headers: {
        'Content-Type': 'application/json'
    }}).then(function (response) {
    return response.text();
}).then(function (html) {
    // This is the HTML from our response as a text string
    console.log(html);
});

<!doctype html>
<html lang=en>

<head>
    <meta charset=utf-8>
    <title>b.local</title>
    ...

☠ Security Impact: If you specify CORS headers like this, you are giving the allowed origins complete control over your website, including any authenticated user data and functionality. The same-origin policy is there to protect you, so think carefully before opting out of it.

WebSockets

✅ Allowed: Opening a cross-origin WebSocket connection, reading from it, and writing to it

This may be surprising, but the same-origin policy does not restrain WebSockets.

☠ Security Impact: If the application using WebSockets doesn't validate the Origin header in the WebSocket handshake or implement some other CSRF protection mechanism, it will be possible for a malicious website to open a WebSocket connection and use it as the browser user.

Conclusion

The same-origin policy is at the root of the web browser security model. It's old, and it's not perfect. As such, developers must understand the risks and implement the proper defense measures in their applications.

Generally, writing is allowed (e.g., sending cross-origin POST requests), but reading is not (e.g., reading the response to those requests). This means that without CSRF protection, websites are in trouble.

Developers can partially relax the same-origin policy with the CORS (Cross-Origin Resource Sharing) headers, but they should do so with care and avoid CORS altogether if possible.

The same-origin policy can also be made tighter in some of the newer browsers via CORP (Cross-Origin Resource Policy) and COOP (Cross-Origin Opener Policy).

Finally, and somewhat surprisingly, WebSockets are not protected by the Same Origin Policy at all. This can have surprising and unpleasant effects if you're not careful when implementing them.

Get the web security checklist spreadsheet!

Subscribe ☝️ Subscribe to AppSec Monkey's email list, get our best content delivered straight to your inbox, and get our 2021 Web Application Security Checklist Spreadsheet for FREE as a welcome gift!

Don't stop here

If you like this article, check out the other application security guides we have on AppSec Monkey as well.

Thanks for reading.