You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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.

FieldDefaultEffect
TrustedOriginsnoneOrigins allowed to send state-changing requests from elsewhere, such as your frontend on another origin
InsecureBypassPatternsnoneServeMux 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.

RequestSec-Fetch-SiteOriginOutcome
GET, HEAD, OPTIONSanythinganythingAllowed: these must never change state
A form or fetch from your own pagessame-originyour originAllowed
Typed into the address bar, a bookmarknonenoneAllowed
From a sibling subdomainsame-sitehttps://other.example.comRefused: same site is not same origin
From another sitecross-sitehttps://evil.testRefused
From another site, Origin withheldcross-sitenoneRefused
An old browser, from another sitenonehttps://evil.testRefused
An old browser, a sandboxed framenonenullRefused
An old browser, from your own pagesnoneyour hostAllowed
curl, a mobile app, another servernonenoneAllowed: no browser attached a credential for it
From a trusted origincross-site or same-sitea TrustedOrigins entryAllowed
Matching a bypass patternanythinganythingAllowed

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 original Host through, which is also what AllowedHosts needs.
  • The fallback cannot see the scheme, so a request from http://example.com to https://example.com is let through by a browser that sends no Sec-Fetch-Site. Every current browser sends it over https, and Strict-Transport-Security, which SecurityHeaders sends 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.