CORS
Cross-origin resource sharing is configured rather than installed. The zero value denies every cross-origin request, and no CORS middleware is installed at all until a policy names an origin or supplies a decision function.
That is the only safe default. A permissive policy set by accident hands any web page on the internet the ability to read authenticated responses from the browser of anyone visiting it.
Configuring it
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
CORS: muzak.CORSOptions{
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Request-Id"},
ExposedHeaders: []string{"X-Request-Id", "RateLimit-Remaining"},
AllowCredentials: true,
MaxAge: 10 * time.Minute,
},
})
| Field | Default | Effect |
|---|---|---|
AllowedOrigins | none | The exact origins permitted. The single entry "*" allows any |
AllowOriginFunc | none | A dynamic decision, consulted only for an origin AllowedOrigins did not already allow |
AllowedMethods | GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS | The methods a cross-origin request may use |
AllowedHeaders | Content-Type, Authorization, X-Request-Id | The request headers a client may send |
ExposedHeaders | none | The response headers a client may read. Browsers expose only a small safelist otherwise |
AllowCredentials | off | Permits cookies and Authorization headers on cross-origin requests |
MaxAge | 10 minutes | How long a browser may cache the preflight result. Browsers cap it regardless |
Origins are matched exactly, scheme and port included. https://app.example.com and
https://app.example.com:8443 are two different origins, and so are the http and https
spellings of the same host.
The combination that is refused
A wildcard origin combined with credentials is refused as a configuration error rather than served.
// This does not start. muzak.ErrCORSWildcardCredentials is reported when the
// application is built.
muzak.CORSOptions{
AllowedOrigins: []string{"*"},
AllowCredentials: true,
}
Browsers reject that pairing anyway, so accepting it here would only hide the mistake until it reached a browser and then look like a bug in the framework.
Deciding dynamically
muzak.CORSOptions{
AllowOriginFunc: func(origin string) bool {
return strings.HasSuffix(origin, ".example.com")
},
AllowCredentials: true,
}
It is consulted only when AllowedOrigins does not already allow the origin, and it runs on
every cross-origin request, so it must be cheap and free of side effects.
Be careful with suffix matching. strings.HasSuffix(origin, "example.com") without the dot
also matches https://notexample.com. Prefer an exact list, or parse the origin and compare
the host.
Exposing response headers
A browser lets a script read only a small safelist of response headers unless the policy says otherwise. If your clients read the request identifier or the rate limit budget, name those headers:
ExposedHeaders: []string{
muzak.HeaderRequestID,
muzak.HeaderRateLimitLimit,
muzak.HeaderRateLimitRemaining,
muzak.HeaderRateLimitReset,
},
Where CORS sits in the chain
CORS runs after any middleware installed with App.Use and before the documentation routes
and the router, so it covers the API, /docs and /openapi.json alike.
RequestID → SecurityHeaders → Recovery → AccessLog → your middleware → CORS → /docs and /openapi.json → routes
A preflight OPTIONS is answered by the CORS middleware. Routes also answer OPTIONS
automatically with an Allow header when nothing else does, which is a different mechanism
for a different question: Allow says which methods a path serves, and the CORS headers say
which a cross-origin caller may use.
What CORS does not cover
A WebSocket handshake. It is not subject to the same-origin policy and is never
preflighted, which is what makes cross-site hijacking possible in the first place.
AppOptions.CORS has no bearing on it. The separate check is
WSOptions.AllowedOrigins, and a cross-origin handshake is refused until it names one. See
WebSockets.
An event stream is covered, because an EventSource is an ordinary request subject to
the same-origin policy and to CORS like any other. See
Server-Sent Events.
A server-to-server client. CORS is a browser mechanism. curl, a Go client and a
mobile app ignore it entirely, so it is not an access control: it decides what a browser
will let a page read, and nothing more. Authorization is a
guard or a provider.
Testing it
func TestCORSAllowsTheApp(t *testing.T) {
client := testclient.New(t, buildApp())
res := client.Options("/items/",
testclient.Header("Origin", "https://app.example.com"),
testclient.Header("Access-Control-Request-Method", "POST"))
res.AssertHeader("Access-Control-Allow-Origin", "https://app.example.com")
}
func TestCORSDeniesAnyoneElse(t *testing.T) {
client := testclient.New(t, buildApp())
res := client.Get("/items/", testclient.Header("Origin", "https://evil.example"))
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want no header at all", got)
}
}
Where to go next
Safe Defaults covers the rest of what is denied until it is configured, and TLS covers serving the origins above over HTTPS.