From its inception, the Web has been a game of whackamole between people finding
security holes and exploits, and other people plugging these holes and adding
defensive security mechanisms.
One of the busiest arenas in this struggle is the interaction between code
running on one site (via JavaScript embedded in its page) and other sites;
you may have heard about acronyms like XSS, CSRF, SSRF, SOP and CORS – they
are all related to this dynamic and fascinating aspect of modern computer
security. This post talks specifically about CORS, and what you should know
if you’re writing servers in Go.
Same-origin policy
Our story starts with the Same-origin policy (SOP) –
a mechanism built into browsers that prevents arbitrary access from the
site you’re currently browsing to other sites. Suppose you’re browsing
https://catvideos.meow; while you’re doing so, your browser will execute JS
code from that site’s pages.
JS can – among other things – fetch
resources from other domains; this is commonly used for images, stats, ads, for
loading other JS modules from CDNs and so on.
But it’s also an
inherently unsafe operation, because what if someone injects malicious code
into catvideos.meow that sends requests to https://yourbank.com! Since the JS
of catvideos.meow is executed by your browser, this is akin to you opening a
new browser window and visiting https://yourbank.com, including providing any
log-in information and cookies that may already be saved in your browser’s
session. That doesn’t sound very safe!
This is what the SOP was designed to prevent; generally speaking, except for a
limited set of “safe” (but mostly there for historical reasons) use cases like
fetching images, embedding and submitting a limited set of forms, JS is not
allowed to make cross-origin requests.
A request is considered cross-origin if it’s made from origin A to origin B,
and any of the following differ between the origins: protocol, domain and
port (a default port is assumed per protocol, if not explicitly provided):
If the protocol, domain and port match, the request is valid – the path doesn’t
matter. Naturally, this is used all the time by JS loading other resources from
its own domain.
Local experiment to observe the SOP in action
Let’s try a simple experiment to see how this browser protection works;
this only requires a couple of small HTML files with a bit of
JS. Place two HTML files in the same directory; one should be named
page.html and its contents don’t matter. The other should be named
do-fetch.html, with these contents:
<html> <head> <title>Fetch another pagetitle> head> <body> <script> var url = 'http://127.0.0.1:8080/page.html' fetch(url) .then(response => { console.log(response.status); }) .catch(error => { console.log("ERROR:", error); }); script> body> html>
It attempts to load page.html from a URL (which points to a local machine’s
port) via the fetch() API.
First experiment: run a local static file server in the directory containing
these two HTML files. Feel free to use my static-server project, but any server will do
[1]:
$ go install https://github.com/eliben/static-server@latest $ ls do-fetch.html page.html $ static-server -port 8080 . 2023/09/03 06:02:10.111818 Serving directory "." on http://127.0.0.1:8080
This serves our two HTML files on local port 8080. Now we can point our
browser to http://127.0.0.1:8080/do-fetch.html and open the browser console.
There shouldn’t be errors, and we should see the printout 200, which is
the successful HTTP response from attempting to load page.html.
It succeeds because this is a same-origin fetch, from
http://127.0.0.1:8080 to itself.
Second experiment: while the static server on port 8080 is still running,
run another instance of the server, serving the same directory on a different
port – you’ll want to do this in a separate terminal:
$ ls do-fetch.html page.html $ static-server -port 9999 . 2023/09/03 06:12:19.742790 Serving directory "." on http://127.0.0.1:9999
Now, let’s point the browser to http://127.0.0.1:9999/do-fetch.html and open
the browser console again. The page won’t load, and instead you’ll see an
error similar to:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://127.0.0.1:8080/page.html. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).
This is the SOP in action. Here’s what’s going on:
- As far as the browser is concerned, a web page at origin
http://127.0.0.1:9999 is making a fetch call to origin
http://127.0.0.1:8080 (note that this destination is hard-coded in
the source of do-fetch.html). - Since the ports are different, these are considered to be
different origins, and the fetch is a cross-origin request. - By the default SOP, cross-origin requests are blocked.
Note that the browser also mentions a CORS header, which is a great segue to
our next topic.
CORS
So what is CORS, and how can it help us make requests to different origins?
The CORS acronym stands for Cross-Origin Resource Sharing, and this is a
good definition from MDN:
Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that
allows a server to indicate any origins (domain, scheme, or port) other than
its own from which a browser should permit loading resources.
CORS is a simple protocol between an HTTP server and a browser. When a page
attempts to make a cross-origin request, the browser attaches a special header
to the request with the name Origin; in this header, the browser specifies
the origin from which the request originates.
We can actually observe this if we look at the debug console of the browser in
more detail in our SOP experiment. In the Network tab, we can examine the
exact HTTP request made by the browser to fetch the page from
http://127.0.0.1:8080/page.html when do-fetch.html asked for it.
We should see something like:
GET /page.html HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0 Accept: */* Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: http://127.0.0.1:9999/ Origin: http://127.0.0.1:9999
The important line here is the last one: it tells the server which origin
the request is coming from.
We can also examine the server’s response, in which we’ll see that the server
does not include a special header named Access-Control-Allow-Origin. Since
this header is not in the response, the browser assumes that the server doesn’t
support CORS from the specified origin, and this results in the error we’ve seen
above.
To complete a successful cross-origin request, the server has to approve the
request explicitly by returning an Access-Control-Allow-Origin header. The
value of the header should be either the origin named in the request’s
Origin header, or the special value * which means “all origins
accepted”.
To see this in action, it’s time for another experiment; let’s write a simple
Go server that supports cross-origin requests.
A sample Go server with CORS support
Leaving static file serving behind, let’s move closer towards what CORS is
actually used for: protecting access to APIs from unknown origins. Here’s a
simple Go server that serves a very basic API endpoint at /api, returning a
hard-coded JSON value:
func apiHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintln(w, `{"message": "hello"}`) } func main() { port := ":8080" mux := http.NewServeMux() mux.HandleFunc("/api", apiHandler) http.ListenAndServe(port, mux) }
This server should be started locally; Here’s a somewhat modified HTML file with
JS making a CORS request to this endpoint, assuming the server runs on local
port 8080:
<html> <head> <title>Access API through CORStitle> head> <body> <script> var url = 'http://localhost:8080/api' fetch(url) .then(response => { if (response.ok) { return response.json(); } else { throw new Error('Failed to fetch data'); } }) .then(data => { document.writeln(data.message); }) .catch(error => { document.writeln("ERROR: ", error); }); script> body> html>
Assuming this code is saved locally in access-through-cors.html, we will
serve it with static-server on port 9999, as before:
$ static-server -port 9999 . 2023/09/03 08:01:22.413757 Serving directory "." on http://127.0.0.1:9999
When we open http://127.0.0.1:9999/access-through-cors.html in the browser,
we’ll see the CORS error again:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://127.0.0.1:8080/api. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing).
Indeed, our server doesn’t support CORS yet! This is an important point to
emphasize – a server oblivious to CORS means it doesn’t support it.
In other words, CORS is “opt-in”. Since our server doesn’t check for the
Origin header and doesn’t return the expected CORS headers back to the
client, the browser assumes that the cross-origin request is denied, and returns
an error to the HTML page [2].
Let’s fix that, and implement CORS in our server. It’s customary to do it
as middleware that wraps the HTTP handler. Here’s a simple approach:
var originAllowlist = []string{ "http://127.0.0.1:9999", "http://cats.com", "http://safe.frontend.net", } func checkCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if slices.Contains(originAllowlist, origin) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Add("Vary", "Origin") } next.ServeHTTP(w, r) }) }
checkCORS is standard Go middleware.
It wraps any HTTP handler and adds CORS logic on top; here’s how it works:
- It checks if the Origin header is present in the request (the header’s
Get method will return an empty string for a missing header). - If yes, it checks its value; if it’s in an allow-list of authorized origins,
the origin is parroted back to the client in the
Access-Control-Allow-Origin header of the response. - We also set Vary: Origin in the response to avoid problems with caching
proxies between the server and the client (see
this section in the fetch standard for more details) - If there is no Origin header, or the origin value is not in our
allow-list, the middleware doesn’t change the response headers in any way.
As we saw before, this is equivalent to saying “I don’t support cross-origin
requests from that origin”.
Obviously, the allow-list solution presented here is ad-hoc, and you are free
to implement your own. Some API endpoints want to be truly public and support
cross-origin requests from any domain. In such cases, one can just hard-code
Access-Control-Allow-Origin: * in all responses, without additional logic.
In this case the Vary header isn’t required either.
Now that we have the middleware in place, we have to hook it into our server;
let’s wrap the top-level router, so checkCORS applies to all endpoints we
may add to the server in the future:
func main() { port := ":8080" mux := http.NewServeMux() mux.HandleFunc("/api", apiHandler) http.ListenAndServe(port, checkCORS(mux)) }
If we kill the old server occupying port 8080 and run this one instead,
re-loading access-through-cors.html we’ll see different results: the
page shows “hello” and there are no errors in the console. The CORS request
succeeded! Let’s examine the response headers:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://127.0.0.1:9999