- Published on
Same Origin Policy (SOP)
- Authors
- Name
- Teo Selenius
- Follow @TeoSelenius
Learn about what the Same Origin Policy (SOP) is, and what it means for you, as a web developer.
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., www.appsecmonkey.com), and port (e.g., 443) are the same. You can find the definition in RFC6545 - The Web Origin Concept.
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 http://a.local. We'll start by getting a window handle to http://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')
☠ 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')
❌ 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)
❌ 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 http://a.local and the user has an active session on http://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>
When the user clicks the "Send" button, a HTTP request like this is sent to http://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 to 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 http://b.local wants http://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
In fact, even if you only want to allow, e.g., POST requests, you are still required to return Access-Control-Allow-Methods
if there are any other factors that cause your request to be preflighted, which we'll talk about next.
Preflight
Simple requests with the whitelisted HTTP verbs, headers, and content-type are always sent. Still, the website is forbidden access to the response data if the response doesn't contain the appropriate Access-Control-Allow-Origin
header.
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, http://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.