ArticleSoftware

What a CORS origin list does not protect

Why a public bucket can list localhost among its allowed origins without that being a security flaw, and in which cases it would be.

Author
Julio Simón
Published
Reading time
6 min

From my terminal I can tell this site’s bucket that I am julio.works, and it believes me:

curl -I -H "Origin: https://julio.works" https://media.julio.works/
HTTP/2 404
vary: Origin
access-control-expose-headers: Content-Range,Content-Length,Accept-Ranges,ETag
access-control-allow-origin: https://julio.works

The 404 is beside the point, because there is no file at the root of the bucket. What matters is the last line: the bucket accepts an origin I typed by hand.

If the production domain can be faked with a single line, the CORS origin list cannot be an access control. And if it is not one, it is no problem either that the same list includes localhost:4321 and localhost:8787, which are the addresses I work with on my own machine. To see why, you have to know who writes that header and whom it protects.

Who writes the header

The Origin header is written by the browser, and only by the browser. When the code of a page requests a resource from another domain, the browser adds the origin of that page to the request, and the code can neither change it nor leave it out. The server answers with another header, Access-Control-Allow-Origin, and again it is the browser that reads it and decides whether or not to hand the response to the code that asked for it.

Outside a browser, nobody plays that part. curl, a script or any other program can write whatever they like in Origin, as in the request above, and they get the file all the same, with the header or without it. That is why the origin list does not decide who can download a file.

Whom it protects

What nobody can fake is the origin of a page open in someone else’s browser. A third-party site can include code that requests a file from my bucket, but the Origin header of that request is written by the visitor’s browser, and it carries that site’s real domain.

That is the case CORS exists for. It protects the visitor, by stopping an unauthorised page from reading, from their browser and with their session, a response that was meant for them. The server merely declares which pages it trusts, and the one that enforces that declaration is each visitor’s browser.

The simulator below walks through the four possible cases, with the headers that travel in each one.

Who requests the file

A julio.works page, open in a visitor’s browser.

Request

GET /clips/tecleo.m4a
Origin: https://julio.works

Bucket response

200 OK
Access-Control-Allow-Origin: https://julio.works
Read allowed

The browser wrote the header, checks that the response names that same origin and hands the file to the page’s code.

The CORS headers in each case are the ones this site's bucket returns today. The file path is an example.

The third case is localhost. Whoever serves a page on their localhost:4321 does get to read the file, but they get to on their own machine and with files they could already download anyway. To use that origin against someone else, they would have to get that person’s browser to open a page served from the localhost of that person’s own computer; and anyone able to run a server on somebody else’s machine no longer needs CORS for anything.

When it matters

The origin list matters when the response changes depending on who asks for it. That happens whenever credentials are involved, such as cookies, an open session or a client certificate, and the server also answers with Access-Control-Allow-Credentials.

In that situation, one origin too many in the list is a data leak. If a bank’s API accepted any origin with credentials, a third party’s page could read the balance of whoever visits it while they have the bank’s session open in another tab. The same mistake is made when the server copies the origin it receives into the response without checking it against any list.

In an API with credentials, localhost is not an innocent origin either, because it authorises any program that manages to serve a page on that port of the user’s machine.

A public bucket without credentials sits at the opposite end. There is nothing in the response that is not already public, so the origin list only decides which pages may handle those files from their code.

The case of this site

This is the bucket’s policy, with the local name of my machine replaced by a generic one.

{
  "rules": [
    {
      "allowed": {
        "origins": [
          "https://julio.works",
          "http://localhost:4321",
          "http://localhost:8787",
          "http://my-machine.local:4321"
        ],
        "methods": ["GET", "HEAD"],
        "headers": ["Range"]
      },
      "exposeHeaders": ["Content-Range", "Content-Length", "Accept-Ranges", "ETag"],
      "maxAgeSeconds": 86400
    }
  ]
}

It only allows reading, with GET and HEAD, and a single header, Range, which is the one the browser uses to ask for a stretch of a video instead of the whole file.

On this site there are two uses that need CORS and one that does not:

  • The key sound needs it. The clip is downloaded with fetch and decoded with decodeAudioData. That is code reading a response from another origin: the textbook case.
  • The audio analyser of the builds needs it. The clips play through a video or audio tag and, at the same time, their sound goes through a Web Audio analyser that draws the spectrum. For that to work, the tag has to request the file with CORS.
  • An ordinary video or image does not need it. A tag that only displays the file loads it from any page, whether or not it is on the list.

The second use has a catch. If the tag does not carry the crossorigin attribute, the browser plays the video normally, but what it hands the analyser is silence. No error shows up in the console and no request fails: the bars of the spectrum simply do not move.

<audio src="https://media.julio.works/clips/tecleo.m4a" crossorigin="anonymous" preload="none"></audio>

The third use explains why CORS does not stop another site from linking to your files. A third-party page can put a video from my bucket in a tag and it will play without trouble; what it cannot do is read it from its code.

How access is really restricted

If what you want is for a file to be downloaded only by those who should, CORS is not the tool for it and you have to turn to others:

  • Signed URLs with an expiry. The server generates an address that is only valid for a few minutes and for one specific file. It works the same inside and outside a browser, because it does not depend on any header that can be faked.
  • Rules on the Referer header. They serve to slow down the site that links to your files for no other reason than to save on hosting, but outside a browser they are faked just like Origin.
  • Authentication. When the file is private, the right thing is to require a session; and as soon as there is a session, the origin list becomes a piece of security, as seen above.

Removing localhost from the production policy, or using a separate bucket for development, may be a matter of tidiness, but it adds no protection.

The practical rule

In a public bucket, localhost can stay on the list, because it gives nobody an access they did not already have.

The same rule applies to any other origin list, and it comes down to one question: does the response carry credentials? If it does not, the list only sorts out which pages may use the resource from their code. If it does, every origin on that list is a page being trusted with the data of whoever visits it.

NewsletterNew Labs publications in your inbox

I send you an email each time I publish a new article or research piece. You can unsubscribe from any of them.

Controller: Julio José Simón Gil. Purpose: sending you new publications. More information in the privacy policy.

Back to Labs