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,
    },
})
FieldDefaultEffect
AllowedOriginsnoneThe exact origins permitted. The single entry "*" allows any
AllowOriginFuncnoneA dynamic decision, consulted only for an origin AllowedOrigins did not already allow
AllowedMethodsGET, HEAD, POST, PUT, PATCH, DELETE, OPTIONSThe methods a cross-origin request may use
AllowedHeadersContent-Type, Authorization, X-Request-IdThe request headers a client may send
ExposedHeadersnoneThe response headers a client may read. Browsers expose only a small safelist otherwise
AllowCredentialsoffPermits cookies and Authorization headers on cross-origin requests
MaxAge10 minutesHow 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, /openapi.json and any documentation page 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.