How to: CORS — Part 1: Same-Origin Policy

Marcin Sodkiewicz
7 min readJan 23, 2023

--

I often find that experienced developers do not understand CORS. These articles are for them — devs who have been in IT for a long time and at this point are afraid to ask how CORS works in detail. It should also be a good reading for anyone who wants to understand it.

What is CORS anyway?

Before we go any further, let’s spend some time to understand the issue that authors had to solve. So why was CORS introduced? First let’s dive into its… well — origins.

In this part I want to focus on Same-Origin Policy — in my opinion understanding this browser mechanism is crucial to understand WHY CORS got introduced. If you know everything about SOP, you can just jump to part 2 under https://medium.com/@sodkiewiczm/deal-with-cors-using-serverless-part-2-cors-ae8ffeaf37

Same-Origin Policy

is a critical, and quite old, security mechanism introduced by Netscape in 1995 — just 1 year after cookies were invented and in the same year that JavaScript was introduced. This was a real revolution as web became very dynamic and interactive. CSRF and XSS (Cross-Site Scripting name was coined in year 2000) were not known at the time, so browsers were vulnerable to these attacks. It was the Wild West back then.

Same-Origin Policy was introduced to limit attack vectors by restricting the way scripts and documents under one origin could interact across origins. Why? It was quite trivial to get data from other website using its cookies, inject code and even… access files from victim’s machine back in those days.

What is origin? Same protocol, host and port. In general it is meant to describe a single app. Subdomain is not the same origin!

Whole point here is to prevent scripts in your origin from accessing private data from other origins. Of course there are many exceptions to that rule — you know… just to make the web useful. It’s quite common that you use cross-domain GIFs, memes or load scripts and stylesheets on your website. So here is full list of SOP exceptions:

  • Forms can be sent
  • Images can be loaded cross-origin inside <img> tag
  • Scripts can be loaded inside <script> tag
  • Stylesheets
  • Media: video and audio
  • External resources through <object> and <embed>
  • Anything embedded by <iframe>. Sites can use the X-Frame-Options header to prevent cross-origin framing.
  • Some browsers allow fonts through @font-face

Right now let’s try to get familiar with SOP and try to abuse it a little.

How it works in practice?

To visualize how SOP works let’s take a look at simple request that bypasses SOP — for example with image. I can insert an image from another origin without any issues.

Zoom for more details

Could I do it the same way to load json!? For the sake of simplicity let’s assume that our 3rd party service code is the following lambda-based API:

exports.handler = async (event) => {
console.info(event)

return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(event)
}
}

JSON endpoint as <img> src attribute

So let’s replace src part of <img> tag and let’s see what happens.

Zoom to see full details

So as you can see on GIF above that we are getting response 200, but we don’t have access to response data which was blocked by browser. This way, if we’re sending a simple request, the response payload will not be available to the attacker, as the call will be made to a different origin. A browser query is equivalent to calling our API with fetch with query as shown below:

const options = {
method: 'GET',
headers: {Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'},
mode: 'no-cors'
};

fetch('https://hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws/data.json', options)
.then(response => response.json())
.then(response => console.log(response))
.catch(err => console.error(err));

In the server-side logs, we can see all the details of the request:

{
version: '2.0',
routeKey: '$default',
rawPath: '/data.json',
rawQueryString: '',
headers: {
'sec-fetch-mode': 'no-cors',
referer: 'https://sodadev.github.io/',
'x-amzn-tls-version': 'TLSv1.2',
'sec-fetch-site': 'cross-site',
'accept-language': 'en-US,en;q=0.9,pl;q=0.8',
'x-forwarded-proto': 'https',
'x-forwarded-port': '443',
'x-forwarded-for': '...',
pragma: 'no-cache',
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'x-amzn-tls-cipher-suite': 'ECDHE-RSA-AES128-GCM-SHA256',
'sec-ch-ua': '"Not?A_Brand";v="8", "Chromium";v="108", "Google Chrome";v="108"',
'sec-ch-ua-mobile': '?0',
'x-amzn-trace-id': 'Root=1-63c46801-35cbc5cc10241c972290a062',
'sec-ch-ua-platform': '"macOS"',
host: 'hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws',
'cache-control': 'no-cache',
'accept-encoding': 'gzip, deflate, br',
'sec-fetch-dest': 'image',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
},
requestContext: {
...
},
isBase64Encoded: false
}

on communication diagram it looks like this:

Trying to send credentials

So what happens if we do the same call, but we force the browser to send cookies by using “include” credentials?

First, open your test API page in a browser (in my case it’s https://hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws/data.json) and open DevTools. Now set cookies using this script:
document.cookie = `secretKey=SecretValue;expires=${new Date(“2030–01–01”).toUTCString()};path=/;SameSite=None;Secure=1`.

Now we are getting infamous CORS error, so attacker won’t be able to get any data from a response. But what about request that is sent?

Zoom to see details

Well… it contains Cookies header in it. Attacker doesn’t have access to the data yet, but a request has been made.. This is a mock-up system without auth, but even if we had it, this request could have been authorised. So as long as backend system is not messed up, and is not doing any operations and/or mutations on HTTP GET, you think you might feel safe. Communication diagram looks like this:

But wait…. There was one more operation allowed in SOP — sending forms. In the event that someone is able to inject a form and trick the user into clicking it or using the form’s auto-submit through one of many options, the POST request will be sent to the BE with the proper cookie. The attacker still won’t be able to read the response — but it will still be sent and can potentially cause harm. The only good solution to this problem is a CSRF token.

Example form that could be injected by the attacker

<form id="maliciousForm" action="https://hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws/data.json" method="POST">
<input name="maliciousKey" value="maliciousValue" style="display: none" />
<input type="submit" value="Submit" style="display: none"/>
</form>

<script type="text/javascript">
document.forms["maliciousForm"].submit();
</script>

It will send request to your backend with cookies and forms data in body as in this example payload:

{
version: '2.0',
routeKey: '$default',
rawPath: '/data.json',
rawQueryString: '',
cookies: [ 'secretKey=SecretValue' ],
headers: {
'content-length': '27',
referer: 'https://sodadev.github.io/',
'x-amzn-tls-version': 'TLSv1.2',
'sec-fetch-site': 'cross-site',
origin: 'https://sodadev.github.io',
'x-forwarded-port': '443',
'sec-fetch-user': '?1',
'x-amzn-tls-cipher-suite': 'ECDHE-RSA-AES128-GCM-SHA256',
'sec-ch-ua-mobile': '?0',
host: 'hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws',
'upgrade-insecure-requests': '1',
'content-type': 'application/x-www-form-urlencoded',
'cache-control': 'no-cache',
'sec-fetch-mode': 'navigate',
'accept-language': 'en-US,en;q=0.9,pl;q=0.8',
cookie: 'secretKet=secretValue; secretKey=SecretValue',
'x-forwarded-proto': 'https',
'x-forwarded-for': '...',
pragma: 'no-cache',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'sec-ch-ua': '"Not?A_Brand";v="8", "Chromium";v="108", "Google Chrome";v="108"',
'x-amzn-trace-id': 'Root=1-63c479d9-043e7a3c32e2000556cb4ba7',
'sec-ch-ua-platform': '"macOS"',
'accept-encoding': 'gzip, deflate, br',
'sec-fetch-dest': 'document',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
},
requestContext: {
accountId: 'anonymous',
apiId: 'hm5avt5kpq4bne66qdgodqugyq0upqri',
domainName: 'hm5avt5kpq4bne66qdgodqugyq0upqri.lambda-url.eu-west-1.on.aws',
domainPrefix: 'hm5avt5kpq4bne66qdgodqugyq0upqri',
http: {
method: 'POST',
path: '/data.json',
protocol: 'HTTP/1.1',
sourceIp: '...',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
},
requestId: '631d3d4e-8876-4d3c-8674-a2ada96d6a43',
routeKey: '$default',
stage: '$default',
time: '15/Jan/2023:22:10:33 +0000',
timeEpoch: 1673820633305
},
body: 'bWFsaWNpb3VzS2V5PW1hbGljaW91c1ZhbHVl',
isBase64Encoded: true
}

Same-Origin Policy Summary

SOP is the mechanism that blocks access to the data that was loaded from other origins. Okay… Great. But what if we need to access data from other origin and make web fun again? Well… we need another mechanism to make that communication work in a secure manner.

Yeah, you guessed it — this is where CORS comes into play.

Cross-Origin Resource Sharing was created to loosen the strict Same-Origin policy and at the same time improve our security posture. Just go now and find out all the details in part 2 that can be found under: https://medium.com/@sodkiewiczm/deal-with-cors-using-serverless-part-2-cors-ae8ffeaf37

For a deeper dive into the history of SOP, there is a whole series of videos by LiveOverflow on YouTube, like this true gem:

--

--