Cross-Origin Requests
A browser attaches cookies to a request whichever page made it. A page on another site can submit a form to your application, or make a fetch, and the browser sends the user's session cookie with it: the request arrives authenticated, and nothing in the cookie says the user did not ask for it. That is cross-site request forgery, and a session alone cannot tell the difference.
AppOptions.CrossOriginProtection can. It refuses a request other than GET, HEAD or
OPTIONS that a browser says came from another origin, with 403, before anything of the
application runs. It is net/http's own http.CrossOriginProtection, which reads the headers every
browser has sent since 2023, so there is no token to put in a form, no cookie to pair it with and
nothing to forget on one route.
Turning it on
app := muzak.New(muzak.AppOptions{
Title: settings.AppName,
Sessions: &muzak.SessionOptions{Secrets: settings.SessionSecrets},
CrossOriginProtection: &muzak.CrossOriginOptions{},
})
The zero value is the whole protection, and it refuses nothing a page on your own origin does.
Sessions cannot be configured without it: a session cookie is exactly the ambient credential
forgery exploits, so that combination is a build error rather than a warning nobody reads. An
application that authenticates with cookies of its own wants it just as much, and that includes
an API key that APIKeyVerifier reads from a cookie (see
Authentication): the browser attaches it to a forged request as
readily as a session.
| Field | Default | Effect |
|---|---|---|
TrustedOrigins | none | Origins allowed to send state-changing requests from elsewhere, such as your frontend on another origin |
InsecureBypassPatterns | none | ServeMux patterns exempt from the check, such as a webhook that verifies a signature |
What is refused
A browser says where a request came from in Sec-Fetch-Site. A browser too old to send it is
judged by whether its Origin names the host the request was sent to.
| Request | Sec-Fetch-Site | Origin | Outcome |
|---|---|---|---|
GET, HEAD, OPTIONS | anything | anything | Allowed: these must never change state |
| A form or fetch from your own pages | same-origin | your origin | Allowed |
| Typed into the address bar, a bookmark | none | none | Allowed |
| From a sibling subdomain | same-site | https://other.example.com | Refused: same site is not same origin |
| From another site | cross-site | https://evil.test | Refused |
From another site, Origin withheld | cross-site | none | Refused |
| An old browser, from another site | none | https://evil.test | Refused |
| An old browser, a sandboxed frame | none | null | Refused |
| An old browser, from your own pages | none | your host | Allowed |
| curl, a mobile app, another server | none | none | Allowed: no browser attached a credential for it |
| From a trusted origin | cross-site or same-site | a TrustedOrigins entry | Allowed |
| Matching a bypass pattern | anything | anything | Allowed |
A same-site request is refused deliberately. SameSite=Lax cookies are sent with it, so a
compromised or user-content subdomain is exactly where a forgery against the main application
comes from.
A form served by your own application, as in Forms and HTML,
posts as same-origin and passes, with no token in it.
Trusted origins
CrossOriginProtection: &muzak.CrossOriginOptions{
TrustedOrigins: []string{"https://app.example.com", "http://localhost:5173"},
},
An entry is compared exactly with the Origin header, which a browser writes as
scheme://host[:port] in lower case without the scheme's default port. Entries are checked with
the same rules as CORS origins, and one that could never match is a build
error naming the spelling to use: a trailing slash or other path, upper case, a default port such
as :443, or no scheme. A host outside ASCII is refused too, since a browser sends its punycode
(xn--) spelling. A pattern such as https://*.example.com and * are refused, since this
list matches whole origins only, and null is refused outright, because a sandboxed frame or a
file: page sends it, so any page can.
Bypass patterns
CrossOriginProtection: &muzak.CrossOriginOptions{
InsecureBypassPatterns: []string{
"POST /webhooks/{provider}", // verified by its signature, not a cookie
"POST /payments/return", // a payment provider posts its form here
},
},
A bypassed route accepts a forged request from any page, so list only one that authenticates the
request some other way, or that carries nothing worth forging. Patterns use
http.ServeMux syntax and match a request only as
ServeMux would match it directly: a path it would first clean or redirect, such as
/webhooks/../items, is checked as usual. A pattern ServeMux refuses, two that conflict, and
one whose method is GET, HEAD or OPTIONS, which are never refused and so bypass nothing, are
build errors.
A pattern that ends in / matches everything beneath it, as it does in ServeMux.
"POST /webhooks/" exempts every path under /webhooks/, so such a pattern is warned of when the
application is built; end it in {$}, as "POST /webhooks/{$}", to match that one path. A
pattern whose path is / alone, such as "POST /", with or without a method or a host, is a
build error, since it would turn the protection off for everything rather than exempt a route.
Name each path in full, or end the pattern with {$}, as in "POST /webhooks/{$}", to match
that one path alone.
The refusal
A refusal is rendered by the application's error renderer like any other error:
{
"error": {
"code": "cross_origin_request",
"message": "Cross-origin requests that change state are not accepted.",
"status": 403
},
"request_id": "01a122b9-5eaf-7b37-93ff-ee7cc407d0a4"
}
With problem details it is a Problem whose code is
cross_origin_request and whose type is <TypeBase>cross_origin_request, or about:blank when
no TypeBase is set. The code is muzak.CodeCrossOriginRequest, the message is
translatable at muzak.security.cross_origin, and the response carries
Vary: Origin, Sec-Fetch-Site and Cache-Control: no-store. Each refusal is logged at warn level
with the method, the path, the request identifier and the two headers it was decided by, cut to a
bounded length and escaped, so a forged header cannot forge a log line.
Where it sits in the chain
RequestID -> tracing and Observer -> SecurityHeaders -> Recovery -> health probes
-> Locale -> AccessLog -> AllowedHosts and RedirectHTTPS -> CORS -> cross-origin check
-> your middleware -> /docs and /openapi.json -> routes and mounts
Tracing, the observer, health probes, AllowedHosts and RedirectHTTPS are in the chain only
when they are configured. The check runs before anything that belongs to the application: no
middleware installed with Use, no guard, provider, binder or handler, no mounted handler and no
documentation sees a refused request. It runs inside CORS, so a refusal sent to an origin CORS
allows carries the Access-Control-Allow-Origin that lets the page's script read why it was
refused. It runs inside the access log, so a refusal is logged as a response like any other.
Health probes are answered before it, and are GETs in any case.
With CORS
CORS and this check answer different questions. CORS decides which origins may read a response; this decides which may change state. An origin CORS allows is not trusted here by that alone, because letting another origin read your API and letting it act as your users are different decisions. A frontend served from its own origin, calling your API with credentials, is listed in both:
CORS: muzak.CORSOptions{
AllowedOrigins: []string{"https://app.example.com"},
AllowCredentials: true,
},
CrossOriginProtection: &muzak.CrossOriginOptions{
TrustedOrigins: []string{"https://app.example.com"},
},
A preflight is an OPTIONS, so it always passes this check and is answered by CORS. The request
it announced is then checked as any other.
With WebSockets
A WebSocket handshake is a GET, so this check never refuses one. Cross-site WebSocket hijacking
is the WebSocket check's to stop: WSOptions.AllowedOrigins refuses a handshake from another
origin until it is listed. See The origin check.
Safe methods must stay safe
GET, HEAD and OPTIONS are never refused, because a page on another site can trigger them
by a link or an image without any script at all, and refusing them would break navigation. That
only protects you if those methods change nothing. A GET /logout or a GET /delete?id=7 is a
forgery waiting to happen, whatever this check does. Put every state change behind POST,
PUT, PATCH or DELETE.
Behind a proxy, and over plain HTTP
Browsers send Sec-Fetch-Site only to a secure origin: https, or localhost. Over plain HTTP
to any other host, and from a browser older than 2023, the check falls back to comparing the
host in Origin with the request's Host. Two consequences follow:
- A proxy that rewrites
Host, to an internal name say, makes that fallback refuse your own pages. Pass the originalHostthrough, which is also whatAllowedHostsneeds. - The fallback cannot see the scheme, so a request from
http://example.comtohttps://example.comis let through by a browser that sends noSec-Fetch-Site. Every current browser sends it over https, andStrict-Transport-Security, whichSecurityHeaderssends over TLS, keeps the plain HTTP page from existing in the first place.
Testing
func TestFormsFromOtherSitesAreRefused(t *testing.T) {
client := testclient.New(t, buildApp())
client.Post("/logout/",
testclient.Header("Origin", "https://evil.test"),
testclient.Header("Sec-Fetch-Site", "cross-site"),
).AssertStatus(http.StatusForbidden).AssertErrorCode(muzak.CodeCrossOriginRequest)
// The test client sends neither header, like any non-browser client.
client.Post("/logout/").AssertStatus(http.StatusOK)
}
Where to go next
Sessions covers the cookie this protects, CORS covers reading responses from another origin, Problem Details covers the error format a refusal can take, and Safe Defaults covers the rest of what is denied until it is configured.