Sometimes Alice and Bob struggle to make sense of it all.

Getting to Know CORS Inside and Out

All the different ways to configure cross-origin requests

by Joe Honton

In this episode Devin learns about the browser's same-origin policy and the CORS protocol for handling safelisted request headers, unsafe request headers, allowing credentials, allowing methods, exposing headers, caching CORS preflight requests, and cross-origin security.

Just beyond the Thorny Divide, in the central part of Tangle, there's a small farm that specializes in the native flowering plants of the Plains area. It is the pride and joy of Alice, who has turned a tidy profit by selling directly to her customers via the Internet.

Alice attracted the eye of Bob, a kindred spirit who sells heirloom tomatoes to the good folks of Tangle. The two were a perfect match and soon decided to join hands in their bright journey forward.

Corslandia Farms was born.

As time went by, they decided to freshen things up by replacing their faded websites with new photographs of their colorful offerings. They asked Devin, their new DevOps guys (who recently replaced Ivana their former IT gal), to put the catalog images online.

Devin suggested that they use this opportunity to merge their websites together. Alice and Bob agreed and they registered the domain name corslandia.com.

They liked having common branding and marketing, but they still wanted to keep their online catalogs separate.

"No problem," exclaimed Devin, "you can have separate subdomains for each business and still share the base domain."

This sounded pretty good to them. They agreed on annuals.corslandia.com for the native plant business, and heirlooms.corslandia.com for the tomato business.

As Devin went about his work, he separated the branding images from the rest, and placed them on the base domain for shared access. Devin knew that this type of cross-domain sharing was allowed by HTML, since JavaScript wasn't involved.

Next, Devin set up order processing with separate shopping baskets for Alice's and Bob's websites. But since they both used the same merchant account, he decided to host the credit card gateway on the base domain. He refactored the existing code base, changing only the website's form handler. This is what the new HTML looked like on both websites, and everything worked exactly as before:

<form action='https://corslandia.com/order_handler' method='POST'>

But Devin wanted to improve the user interface to be more interactive, so he switched from HTML forms to AJAX. To do this, he used the new fetch API, with settings like this:

fetch('https://corslandia.com/order_handler', {
method: 'POST',
mode: 'cors',
credentials: 'omit',
headers: {'content-type': 'application/x-www-form-urlencoded'},
body: wwwFormData
});

The same-origin policy

And here is where his adventure took a sharp turn. The browser's same-origin policy intercepted this new way of doing things. He was a bit surprised that the HTML form submission had worked, while the identical JavaScript form submission had failed. He learned something new: the same-origin policy is enforced only for dynamic JavaScript requests.

Devin knew that he could solve this problem by configuring CORS to grant cross-domain POST permission to the two new websites:

hostname  corslandia.com
request {
cross-origin {
`*` *origin='annuals.corslandia.com,heirlooms.corslandia.com' \
*methods='POST'
}
}

(Devin is using the RWSERVE HTTP/2 Server because he's a pure JavaScript guy, so the configuration settings are for that server. If he had been using PHP with Apache, or Java with Tomcat, the syntax would of course be different.)

With this in place, the order form once again worked. Still, Devin wanted to understand what was going on, and this is what he discovered.

First, the browser determined that the request was going from Alice's domain to the base domain. Then the browser included an extra header in the request origin: annuals.corslandia.com to alert the server to this fact. The reconfigured corslandia.com server examined its cross-origin settings and completed the request. It responded with access-control-allow-origin: https://annuals.corslandia.com as a way of confirming to the browser that everything was copacetic.

Devin had accomplished his first simple-CORS exchange.

Safelisted request headers

A simple exchange like this may occur with GET, HEAD and POST methods — but only when the request is limited to using these safelisted headers:

  • accept
  • accept-language
  • content-language
  • content-type: application/x-www-form-urlencoded
  • content-type: multipart/form-data
  • content-type: text/plain

The browser will not fulfill a request using the simple-CORS protocol when a script uses a PUT, PATCH, or DELETE method. Furthermore, it will only use simple-CORS when every request header is in the safelist.

All other requests go to the other side of CORS . . .

Allowing unsafe request headers

Devin decided to stop sending the fetch request using content-type: application/x-www-form-urlencoded. He felt it was more natural to use content-type: application/json since the ordering process was no longer using HTML forms. He made changes so that fetch looked like this:

fetch('https://corslandia.com/order_handler', {
method: 'POST',
mode: 'cors',
credentials: 'omit',
headers: {'content-type': 'application/json'},
body: jsonData
});

When he adjusted the fetch parameters to do this, he noticed a very different behavior. Even though the fetch parameters specified POST, the browser sent an OPTIONS. That request was accompanied by an origin header, plus an access-control-request-headers: content-type.

The browser was initiating a CORS preflight request.

Preflighting is necessary to prevent the browser from even trying the request if the destination domain's server is not going to allow it. It is the browser that determines the need for a preflight request. This type of CORS request occurs with a double round-trip exchange between the browser and the destination server.

In these cases the protocol proceeds with these steps:

  1. The browser begins by sending an OPTIONS preflight request with an origin and one or more access-control-request-* headers;
  2. The server responds with access-control-allow-* headers, informing the browser that it is going to allow the actual request;
  3. The browser examines the preflight response to look for the expected access-control-allow-* headers. If present, the browser sends the actual request method, using the same headers as the preflight request;
  4. The server carries out the actual request and answers with the actual response.

Only a few old-school content-types are on the CORS safelisted headers list, and application/json is not one of them. Devin adjusted the server's configuration to inform the browser to include the content-type header with the POST:

hostname  corslandia.com
request {
cross-origin {
`*` *origin='annuals.corslandia.com,heirlooms.corslandia.com' \
*methods='POST' \
*headers='content-type'
}
}

After doing this, the server responded with the additional header access-control-allow-headers: content-type, and he was back in business.

Allowing credentials

Devin did a quick walk-through with Alice to show her how the new order processing was working. She was generally pleased. Still, she suggested it would be better for her returning customers if some of the fields could be pre-filled. Also, since some of her customers were also Bob's customers, they should both be able to access this same data.

"No problem," Devin responded, and got right to work on it.

The data that he needed was already available in cookies stored under the base domain. He decided that it would be OK to allow Alice's and Bob's websites to have access to the base domain's cookie values since everybody trusted each other.

But the CORS protocol is reluctant to divulge too much to anyone — even when the participants say that they're willing to accept other risks. The CORS specification lists three types of "credentials" which are heavily guarded:

  1. HTTP cookies
  2. HTTP authentication headers
  3. TLS client certificates

Because of these rules, Devin had to change his script's fetch parameters to include credentials in the response. With that change he would be able to get scripted access to any corslandia.com cookies that might be returned. After his change, the fetch request now looked like this:

fetch('https://corslandia.com/order_handler', {
method: 'POST',
mode: 'cors',
credentials: 'include',
headers: {'content-type': 'application/json'},
body: jsonData
});

At the same time, on the server side, he had to once again reconfigure the cross-origin settings to have the server honor these requests. The server configuration looked like this:

hostname  corslandia.com
request {
cross-origin {
`*` *origin='annuals.corslandia.com,heirlooms.corslandia.com' \
*methods='POST' \
*headers='content-type'
*credentials=true
}
}

After making these edits and reissuing the request, the server's OPTIONS included a new header: access-control-allow-credentials. This is the server's way of informing the browser to go ahead with the actual request.

When the server receives the actual POST, it processes it as usual. Then, it appends access-control-allow-credentials as confirmation that it is OK to divulge credentials (any set-cookie headers), that might be in the response. If the browser doesn't see this in the server's response, it masks any credentials that it received.

Allowing methods

Devin was feeling pretty good about his CORS prowess. When Alice and Bob mentioned that they wanted to allow other websites to have direct access to their knowledgebase, he knew he was up to the challenge.

He got right to work on the design for the new API. He decided that there would be both a public service for the general public, and a restricted service for internal use only. He configured the new server like this:

hostname  api.corslandia.com
request {
cross-origin {
`/public` *origin='*' \
*methods='GET'
`/private` *origin='*.corslandia.com' \
*methods='GET,PUT,PATCH,DELETE'
}
}

With this setup OPTIONS requests were handled differently, depending on which website was making the request.

  • A GET request to retrieve data from https://api.corslandia.com/public by someone at gardeners-delight.com succeeds. This is because the server returns access-control-allow-origin:* and access-control-allow-methods:GET.
  • A PUT request to add a new record to https://api.corslandia.com/private from Alice's website succeeds. This is because the server returns access-control-allow-origin:*.corslandia.com and access-control-allow-methods:PUT.
  • A DELETE request to https://api.corslandia.com/private by someone at gardeners-delight.com fails. This is because the server responds to OPTIONS without no access-control-allow-* headers. The browser does not even attempt the actual request to delete.

Exposing headers

Devin walked into the office one morning, after a late night of reddit and memes, when Bob dropped by and mentioned that one of his old customers was unable to download the Spring Catalog.

"No problem," Devin replied, and began looking into it.

He found the place on Bob's website with the link to the catalog. Somehow, he couldn't quite figure out what was going on. He opened up a quick chat with Ivana (you remember Ivana, the IT gal).

"Oh that," said Ivana, "we put the catalog behind a little script which checked credentials and only downloaded it to authorized customers."

"Cool," said Devin, "No problem. I got it."

This script, was now hosted on the new website at heirlooms.corslandia.com. It attempts to download the catalog from the base domain corslandia.com. In the original script, Ivana used POST to send the customer credentials. Once verified, the server would start downloading the catalog with content-type: binary/octet-stream. For some unknown reason this wasn't working anymore. Devin smelled a CORS rat.

Devin opened the browser's inspector to see if he could figure out what was happening. The request returned fine, with these response headers:

access-control-allow-origin: heirlooms.corslandia.com
content-type: binary/octet-stream
content-disposition: attachment; filename=spring-catalog.pdf

Then he used the debugger to look at these values in detail. He discovered that the content-disposition variable was null, even though the network inspector showed that it had a value. What!?

After chasing the rabbit down the hole, he finally discovered that this was yet another relic of how CORS works. CORS has a safelist of response headers that scripts can have unfiltered access to. The list is limited to:

  • expires
  • pragma

"Ah ha! Content-disposition is not on the safelist," exclaimed Devin.

So he went to the server and adjusted the configuration to expose the content-disposition header like this:

hostname  corslandia.com
request {
cross-origin {
`*` *origin='heirlooms.corslandia.com' \
*expose='content-disposition'
}
}

Now the server responded with access-control-expose-headers:content-disposition. Lo and behold, the browser stopped filtering the content-disposition value! Everything was working again.

Caching

After a while, things began to quiet down, Alice and Bob were happy, and Devin settled in to the steady hum of enhancements and fixes.

One quiet morning Devin took the off chance to examine the server logs to see if there were any obvious performance improvements he could make. One thing struck him as a candidate: the number of OPTIONS requests was nearly identical to the number of POST requests. Wasn't there something that he could do about this crazy CORS double round-tripping?

Well, after checking Google, MDN and Stack Overflow, and not finding what he wanted, he finally gave in and decided to read the spec.

"Ooh, access-control-max-age, that's what I'm looking for," proclaimed Devin. He had discovered that there's a way to tell the browser to keep CORS preflight responses in its local cache.

"This could be a big boost for our API consumers who make lots of calls to retrieve data from our knowledgebase," thought Devin.

So he changed the API server's configuration file to look like this:

hostname  api.corslandia.com
request {
cross-origin {
`/public` *origin='*' \
*methods='GET'
*max-age=86400
`/private` *origin='*.corslandia.com' \
*methods='GET,PUT,PATCH,DELETE'
*max-age=86400
}
}

Now, the browser behaves differently. Initial requests go through the preflighting as usual, but receive access-control-max-age:86400. Subsequent requests within the next 24 hours skip the preflight and immediately issue the user's actual request.

Security

Devin was feeling pretty good about the work he had done, when one morning Bob walks in and introduces him to a new guy, Ken.

Ken was going to be the new Kubernetes guy for Corslandia Farms.

"Wow! Kubernetes. Cool," said Devin, with a worried sort of hesitation.

"I want you to show Ken all the great stuff you've been doing for us," Bob announced.

"First of all, let's take a look at security. We don't want any slip-ups there," proclaimed Ken, with the self-assurance of someone who's seen a few things in his time.

"No problem," replied Devin, even while a foreboding air filled the room.

After a deep dive that morning, Devin was beginning to relax, as all the hard work he had put into getting CORS to work, seemed to be passing muster.

After lunch Ken began to poke around at the API. "Hmm, this is interesting," he mused. "I can use curl to delete records from the knowledgebase without being signed in."

"Yikes!," Devin leaned in and looked over Ken's shoulder. "That's not cool."

Ken probed with a few more questions, before launching into what must have been a standard lecture: "CORS is not a security protocol. It doesn't enhance security at all. It's merely a way to relax the same-origin policy that browsers have put in place to safeguard users from weak websites and bad actors. Think of it like this: CORS weakens security -- use it sparingly.

"Curl is the real culprit here. Curl is not a browser and does not implement CORS in any way. Anybody with basic skills can use curl to issue harmful requests directly to your servers. Remember, any API request that modifies data needs to be protected by authorization tokens -- which is a whole nother matter."


A few weeks later, Ivana received a LinkedIn notification asking her to congratulate Devin on his new position as Director of Cybersecurity.


No minifig characters were harmed in the production of this Tangled Web Services episode.

Follow the adventures of Antoní, Bjørne, Clarissa, Devin, Ernesto, Ivana, Ken and the gang as Tangled Web Services boldly goes where tech has gone before.

Getting to Know CORS Inside and Out — All the different ways to configure cross-origin requests

🔗 🔎