Documentation
¶
Overview ¶
Package cors answers a browser's question about another origin: may a page served from there call this API, and what may it send and read.
It exists because a front end is not always served from the API's own origin. app.example.com and api.example.com are one deployment and, to a browser, two origins — and the browser will not let a page on the first read an answer from the second until the second has said it may. Every sign-in redirect works without this, because a top-level navigation has no origin to check; the first token refresh after it is an XHR, and without the headers this package writes it fails before it is sent.
The answer is a Policy wrapped around the handler, and it wraps the whole handler rather than a route: which origins may read this API is a decision about the API, not about any one endpoint, and a policy applied to some routes and not others is a policy with a hole in it that only a browser finds. rig's own probes are answered outside whatever is wrapped, so a readiness check every second does not pay for it.
What a policy has to say is mostly not the application's to know. A search is sent as QUERY, and a preflight that omits the method fails as a network error rather than a status — so the client's fallback to POST never fires, and search fails with nothing in any log. Every unsafe request carries an Idempotency-Key. The rate-limit headers, the replayed-write marker and the sync cursor all travel in response headers a browser hides until they are exposed. Those facts belong to the server that reads and writes the headers, which is why the lists here are plain slices for a generator to fill rather than defaults this package guesses.
One thing it will never write is Access-Control-Allow-Credentials. rig's credential is a bearer token in a header, so no cookie and no client certificate ever has to cross an origin, and an origin not on the list carries no ambient authority to abuse.
Example ¶
A front end on https://app.example.com calling an API that is not served from there. The policy wraps the whole handler; rig's probes are answered outside whatever is wrapped.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"time"
"github.com/simonjanss/rig/runtime/cors"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("QUERY /api/v1/notes", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("RateLimit-Remaining", "99")
fmt.Fprint(w, `[]`)
})
handler := cors.Policy{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "QUERY", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "Idempotency-Key"},
ExposedHeaders: []string{"RateLimit-Remaining", "Retry-After"},
MaxAge: 10 * time.Minute,
}.Wrap(mux)
// The browser asks first. The mux has no OPTIONS route, so the policy
// answers this itself.
preflight := httptest.NewRequest(http.MethodOptions, "/api/v1/notes", nil)
preflight.Header.Set("Origin", "https://app.example.com")
preflight.Header.Set("Access-Control-Request-Method", "QUERY")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, preflight)
fmt.Println(rec.Code, rec.Header().Get("Access-Control-Allow-Methods"))
// Then it sends the request, and may read the headers the policy exposed.
search := httptest.NewRequest("QUERY", "/api/v1/notes", nil)
search.Header.Set("Origin", "https://app.example.com")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, search)
fmt.Println(rec.Code, rec.Header().Get("Access-Control-Allow-Origin"), rec.Header().Get("Access-Control-Expose-Headers"))
// A page from anywhere else gets the answer and no permission to read it.
other := httptest.NewRequest("QUERY", "/api/v1/notes", nil)
other.Header.Set("Origin", "https://evil.example.com")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, other)
fmt.Printf("%d %q\n", rec.Code, rec.Header().Get("Access-Control-Allow-Origin"))
}
Output: 204 GET, POST, QUERY, OPTIONS 200 https://app.example.com RateLimit-Remaining, Retry-After 200 ""
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Policy ¶
type Policy struct {
// AllowedOrigins are compared exactly with the Origin a browser sends, after
// case, surrounding whitespace and a trailing slash have been forgiven — a
// trailing slash is how an origin arrives by accident, and "https://x/" is
// not a value any browser ever sends.
//
// One wildcard form is understood: "https://*.example.com" matches exactly
// one label in place of the star, so app.example.com is in and
// a.b.example.com and example.com are not. That is the deployment with a
// tenant per subdomain.
//
// An entry that is not scheme://host — a bare "*", a hostname with no
// scheme — is not an origin and is dropped: this package would rather
// refuse everybody than allow everybody by accident, and a list holding
// only such entries leaves the handler untouched.
AllowedOrigins []string
// AllowOrigin is asked after the list, and only when the list said no. It is
// for the origins that are rows rather than configuration — a custom domain
// per tenant, read from a table — which no list in a file can name. Nil asks
// nobody.
AllowOrigin func(origin string) bool
// AllowedMethods is what a preflight is told it may ask for.
AllowedMethods []string
// AllowedHeaders is what a caller may send beyond the ones every request may
// carry anyway.
AllowedHeaders []string
// ExposedHeaders is what a caller may read off a response. Without it a
// cross-origin page sees only the handful of headers the specification
// safelists, and nothing an API says about limits, revisions or cursors is
// among them.
ExposedHeaders []string
// MaxAge is how long a browser may keep a preflight's answer. Zero sends no
// Access-Control-Max-Age at all, which leaves the browser's own default.
MaxAge time.Duration
}
Policy is which origins may call from a browser, and the methods and headers the exchange may use.
The zero Policy allows nothing, and Policy.Wrap on one returns the handler it was given: no headers at all, rather than headers that allow nothing and read as a misconfiguration.
func (Policy) Enabled ¶
Enabled reports whether Policy.Wrap would add anything: at least one origin that could be allowed, by the list or by Policy.AllowOrigin.
func (Policy) Wrap ¶
Wrap answers cross-origin requests for the origins the policy names, and leaves every other request exactly as it found it.
A preflight — OPTIONS with an Access-Control-Request-Method — is answered here and never reaches next. The generated mux has no OPTIONS pattern for any of its paths, so anything below would refuse it, and a refused preflight is a real request that is never made. Any other request from an allowed origin is marked as readable and passed on.
Every response carries Vary: Origin, allowed or not. The answer depends on the header either way, and a cache that stored one origin's answer and served it to another is the failure nobody manages to reproduce.