cors

package
v0.6.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 4 Imported by: 0

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

func Split

func Split(list string) []string

Split reads a comma-separated list of origins the way a deployment writes one into an environment variable: each entry trimmed, empty ones dropped, so an unset variable is no origins rather than one empty origin.

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) Allows

func (p Policy) Allows(origin string) bool

Allows reports whether an Origin header value is one this policy accepts.

func (Policy) Enabled

func (p Policy) Enabled() bool

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

func (p Policy) Wrap(next http.Handler) http.Handler

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL