Simple CORS requests
Let’s now make some changes to our API to relax the same-origin policy, so that JavaScript can read the responses from our API endpoints.
To start with, the simplest way to achieve this is by setting the following header on all our API responses:
Access-Control-Allow-Origin: *
The Access-Control-Allow-Origin response header is used to indicate to a browser that it’s OK to share a response with a different origin. In this case, the header value is the wildcard * character, which means that it’s OK to share the response with any other origin.
Let’s go ahead and create a small enableCORS() middleware function in our API application which sets this header:
package main ... func (app *application) enableCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") next.ServeHTTP(w, r) }) }
And then update your cmd/api/routes.go file so that this middleware is used on all the application routes. Like so:
package main ... func (app *application) routes() http.Handler { router := httprouter.New() router.NotFound = http.HandlerFunc(app.notFoundResponse) router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler)) router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler)) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler)) router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler)) router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler)) router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) // Add the enableCORS() middleware. return app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))) }
It’s important to point out here that the enableCORS() middleware is deliberately positioned early in the middleware chain.
If we positioned it after our rate limiter, for example, any cross-origin requests that exceed the rate limit would not have the Access-Control-Allow-Origin header set. This means that they would be blocked by the client’s web browser due to the same-origin policy, rather than the client receiving a 429 Too Many Requests response like they should.
OK, let’s try this out.
Restart the API application and then try visiting http://localhost:9000 in your browser again. This time the cross-origin request should complete successfully, and you should see the JSON from our healthcheck handler presented on the webpage. Like so:
I also recommend taking a quick look at the request and response headers for the JavaScript fetch() request again. You should see that the Access-Control-Allow-Origin: * header has been set on the response, similar to this:
Restricting origins
Using a wildcard to allow cross-origin requests, like we are in the code above, can be useful in certain circumstances (like when you have a completely public API with no access control checks). But more often you’ll probably want to restrict CORS to a much smaller set of trusted origins.
To do this, you need to explicitly include the trusted origins in the Access-Control-Allow-Origin header instead of using a wildcard. For example, if you only want to allow CORS from the origin https://www.example.com you could send the following header in your responses:
Access-Control-Allow-Origin: https://www.example.com
If you only have one, fixed, origin that you want to allow requests from, then doing this is quite simple — you can just update your enableCORS() middleware to hard-code the necessary origin value.
But if you need to support multiple trusted origins, or you want the value to be configurable at runtime, then things get a bit more complex.
One of the problems is that — in practice — you can only specify exactly one origin in the Access-Control-Allow-Origin header. You can’t include a list of multiple origin values, separated by spaces or commas like you might expect.
To work around this limitation, you’ll need to update your enableCORS() middleware to check if the value of the Origin header matches one of your trusted origins. If it does, then you can reflect (or echo) that value back in the Access-Control-Allow-Origin response header.
Supporting multiple dynamic origins
Let’s update our API so that cross-origin requests are restricted to a list of trusted origins, configurable at runtime.
The first thing we’ll do is add a new -cors-trusted-origins command-line flag to our API application, which we can use to specify the list of trusted origins at runtime. We’ll set this up so that the origins must be separated by a space character — like so:
$ go run ./cmd/api -cors-trusted-origins="https://www.example.com https://staging.example.com"
In order to process this command-line flag, we can combine the flags.Func() and strings.Fields() functions to split the origin values into a []string slice ready for use.
If you’re following along, open your cmd/api/main.go file and add the following code:
package main import ( "context" "database/sql" "flag" "log/slog" "os" "strings" // New import "sync" "time" "greenlight.alexedwards.net/internal/data" "greenlight.alexedwards.net/internal/mailer" _ "github.com/lib/pq" ) const version = "1.0.0" type config struct { port int env string db struct { dsn string maxOpenConns int maxIdleConns int maxIdleTime time.Duration } limiter struct { enabled bool rps float64 burst int } smtp struct { host string port int username string password string sender string } // Add a cors struct and trustedOrigins field with the type []string. cors struct { trustedOrigins []string } } ... func main() { var cfg config flag.IntVar(&cfg.port, "port", 4000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("GREENLIGHT_DB_DSN"), "PostgreSQL DSN") flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time") flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter") flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second") flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst") flag.StringVar(&cfg.smtp.host, "smtp-host", "sandbox.smtp.mailtrap.io", "SMTP host") flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port") flag.StringVar(&cfg.smtp.username, "smtp-username", "a7420fc0883489", "SMTP username") flag.StringVar(&cfg.smtp.password, "smtp-password", "e75ffd0a3aa5ec", "SMTP password") flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.alexedwards.net>", "SMTP sender") // Use the flag.Func() function to process the -cors-trusted-origins command line // flag. In this we use the strings.Fields() function to split the flag value into a // slice based on whitespace characters and assign it to our config struct. // Importantly, if the -cors-trusted-origins flag is not present, contains the empty // string, or contains only whitespace, then strings.Fields() will return an empty // []string slice. flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error { cfg.cors.trustedOrigins = strings.Fields(val) return nil }) flag.Parse() ... } ...
Once that’s done, the next step is to update our enableCORS() middleware. Specifically, we want the middleware to check if the value of the request Origin header is an exact, case-sensitive, match for one of our trusted origins. If there is a match, then we should set an Access-Control-Allow-Origin response header which reflects (or echoes) back the value of the request’s Origin header.
Otherwise, we should allow the request to proceed as normal without setting an Access-Control-Allow-Origin response header. In turn, that means that any cross-origin responses will be blocked by a web browser, just like they were originally.
A side effect of this is that the response will be different depending on the origin that the request is coming from. Specifically, the value of the Access-Control-Allow-Origin header may be different in the response, or it may not even be included at all.
So because of this we should make sure to always set a Vary: Origin response header to inform any caches that the response may be different. This is actually really important, and it can be the cause of subtle bugs like this one if you forget to do it. As a rule of thumb:
If your code makes a decision about what to return based on the content of a request header, you should include that header name in your Vary response header — even if the request didn’t include that header.
OK, let’s update our enableCORS() middleware in line with the logic above, like so:
package main ... func (app *application) enableCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Add the "Vary: Origin" header. w.Header().Add("Vary", "Origin") // Get the value of the request's Origin header. origin := r.Header.Get("Origin") // Only run this if there's an Origin request header present. if origin != "" { // Loop through the list of trusted origins, checking to see if the request // origin exactly matches one of them. If there are no trusted origins, then // the loop won't be iterated. for i := range app.config.cors.trustedOrigins { if origin == app.config.cors.trustedOrigins[i] { // If there is a match, then set a "Access-Control-Allow-Origin" // response header with the request origin as the value and break // out of the loop. w.Header().Set("Access-Control-Allow-Origin", origin) break } } } // Call the next handler in the chain. next.ServeHTTP(w, r) }) }
And with those changes complete, we’re now ready to try this out again.
Restart your API, passing in http://localhost:9000 and http://localhost:9001 as trusted origins like so:
$ go run ./cmd/api -cors-trusted-origins="http://localhost:9000 http://localhost:9001" time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established" time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
And when you refresh http://localhost:9000 in your browser, you should find that the cross-origin request still works successfully.
If you want, you can also try running the cmd/examples/cors/simple application with :9001 as the server address, and you should find that the cross-origin request works from that too.
In contrast, try running the cmd/examples/cors/simple application with the address :9002.
$ go run ./cmd/examples/cors/simple --addr=":9002" 2021/04/17 18:24:22 starting server on :9002
This will give the webpage an origin of http://localhost:9002 — which isn’t one of our trusted origins — so when you visit http://localhost:9002 in your browser you should find that the cross-origin request is blocked. Like so:
Additional information
Partial origin matches
If you have a lot of trusted origins that you want to support, then you might be tempted to check for a partial match on the origin to see if it ‘starts with’ or ‘ends with’ a specific value, or matches a regular expression. If you do this, you must be careful to avoid any unintentional matches.
As a simple example, if http://example.com and http://www.example.com are your trusted origins, your first thought might check that the request Origin header ends with example.com. This would be a bad idea, as an attacker could register the domain name attackerexample.com and any requests from that origin would pass your check.
This is just one simple example — and the following blog posts discuss some of the other vulnerabilities that can arise when using partial match or regular expression checks:
Generally, it’s best to check the Origin request header against an explicit safelist of full-length trusted origins, like we have done in this chapter.
The null origin
It’s important to never include the value "null" as a trusted origin in your safelist. This is because the request header Origin: null can be forged by an attacker by sending a request from a sandboxed iframe.
Authentication and CORS
If your API endpoint requires credentials (cookies or HTTP basic authentication) you should also set an Access-Control-Allow-Credentials: true header in your responses. If you don’t set this header, then the web browser will prevent any cross-origin responses with credentials from being read by JavaScript.
Importantly, you must never use the wildcard Access-Control-Allow-Origin: * header in conjunction with Access-Control-Allow-Credentials: true, as this would allow any website to make a credentialed cross-origin request to your API.
Also, importantly, if you want credentials to be sent with a cross-origin request then you’ll need to explicitly specify this in your JavaScript. For example, with fetch() you should set the credentials value of the request to 'include'. Like so:
fetch("https://api.example.com", {credentials: 'include'}).then( ... );
Or if using XMLHTTPRequest you should set the withCredentials property to true. For example:
var xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.example.com'); xhr.withCredentials = true; xhr.send(null);