Description
Background
Cross Site Request Forgery (CSRF) is a confused deputy attack where the attacker causes the browser to send a request to a target using the ambient authority of the user’s cookies or network position (although the latter is being addressed by Private Network Access). For example, attacker.example
can serve the following HTML to a target
<form action="https://example.com/send-money" method="post">
<input type="hidden" name="to" value="filippo" />
<input type="hidden" name="amount" value="1000000" />
</form>
and the browser will send a POST request to https://example.com/send-money
using the target’s cookies.
Essentially all applications that use cookies for authentication need to protect against CSRF. Importantly, this is not about protecting against an attacker that can make arbitrary requests (as an attacker doesn't know the user's cookies), but about working with browsers to identify authenticated requests initiated from untrusted sources.
Unlike Cross-Origin Resource Sharing (CORS), which is about sharing responses across origins, CSRF is about accepting state-changing requests, even if the attacker will not see the response. Defending against leaks is significantly more complex and nuanced, especially in the age of Spectre.
Why do browsers allow these requests in the first place? At least in part because disabling these third-party cookies breaks important Single-Sign On (SSO) flows. Any solution we implement will need to support a bypass mechanism, but these endpoints are rare exceptions.
Same site vs same site vs same origin
https://app.example.com
, https://marketing.example.com
, and even http://app.example.com
(depending on the definition) are all same-site but not same-origin.
It’s tempting to declare the goal as ensuring requests are simply from the same site, but different origins in the same site can actually sit at very different trust levels: for example it might be much easier to get XSS into an old marketing blog than in the admin panel.
The starkest difference in trust though is between an HTTPS and an HTTP origin, since a network attacker can serve anything it wants on the latter. This is sometimes referred to as the MitM CSRF bypass, but really it’s just a special case of a schemelessly same-site cross-origin CSRF attack.
Some parts of the Web platform apply a schemeful definition of same-site, where https://app.example.com
and http://app.example.com
are not same-site:
- Cookies in general apply the schemeless definition (HTTP = HTTPS). There is a proposal to address this, Origin-Bound-Cookies (and specifically its lack of opt-out for scheme binding, which subsumes the earlier Scheme-Bound Cookies proposal), which however hasn't shipped yet.
- The SameSite cookie attribute used to apply the schemeless definition (HTTP = HTTPS). Chrome changed that with Schemeful Same-Site in 2020, but Firefox and Safari never implemented it.
- Sec-Fetch-Site (and the HTML and Fetch specifications in general) applies the schemeful definition (HTTP ≠ HTTPS).
Using HTTP Strict Transport Security (HSTS), if possible, is a potential mitigation for HTTP→HTTPS issues.
Countermeasures
There are a number of potential countermeasures to CSRF, some of which have been available only for a few years.
Double submit or synchronized tokens
The “classic” countermeasure is a CSRF token, a large random value submitted in the request (e.g. as a hidden <input>
) and compared against a value stored in a cookie (double-submit) or in a stateful server-side session (synchronized tokens).
Normally, double-submit is not a same-origin countermeasure, because same-site origins can set cookies on each other by “cookie tossing”. This can be mitigated with the __Host-
cookie prefix, or by binding the token to the session/user with signed metadata. The former makes it impossible for the attacker to set the cookie, the latter ensures the attacker doesn't know a valid value to set it to.
Note that signing the cookies or tokens is unnecessary and ineffectual, unless it is binding the token to a user: an attacker that’s cookie tossing can otherwise obtain a valid signed pair by logging into the website and then use that for the attack.
This countermeasure turns a cross-origin forgery problem into a cross-origin leak problem: if the attacker can obtain a token from a cross-origin response, it can forge a valid request.
The token in the HTML body should be masked as a countermeasure against the BREACH compression attack.
The primary issue with CSRF tokens is that they require developers to instrument all their forms and other POST requests.
Origin header
Browsers send the source of a request in the Origin header, so CSRF can be mitigated by rejecting non-safe requests from other origins.
The main issue is knowing the application’s own origin. One option obviously is asking the developer to configure it, but that’s friction and might not always be easy (such as for open source projects and proxied setups).
The closest readily available approximation of the application’s own origin is the Host header. This has two issues:
- it may be different from the browser origin if a reverse proxy is involved;
- it does not include the scheme, so there is no way to know if an
http://
Origin is a cross-origin HTTP→HTTPS request or a same-origin HTTP request.
Some older (pre-2020) browsers didn’t send the Origin header for POST requests.
The value can be null
in a variety of cases, such as due to Referrer-Policy: no-referrer
or following cross-origin redirects. null
must be treated as an indication of a cross-origin request.
Some privacy extensions remove the Origin header instead of setting it to null
. This should be considered a security vulnerability introduced by the extension, since it removes any reliable indication of a browser cross-origin request.
SameSite cookies
If authentication cookies are explicitly set with the SameSite attribute Lax or Strict, they will not be sent with non-safe cross-site requests.
This is, by design, not a cross-origin protection, and it can’t be fixed with the __Host-
prefix (or Secure attribute), since that’s about who can set and read cookies, not about where the requests originate. (This difference is reflected in the difference between Scheme-Bound Cookies and Schemeful Same-Site.) The risk of same-site HTTP origins is still present, too, in browsers that don't implement Schemeful Same-Site.
Note that the rollout of SameSite Lax by default has mostly failed due to widespread breakage, especially in SSO flows.
Some browsers now default to Lax-allowing-unsafe, while others default(ed) to None for the first two minutes after the cookie was set. These defaults are not effective CSRF countermeasures.
Non-simple requests
Although CORS is not designed to protect against CSRF, “non-simple requests” which for example set headers that a simple <form>
couldn’t set are preflighted by an OPTIONS request.
An application could choose to allow only non-simple requests, but that is fairly limiting precisely because “simple requests” includes all the ones produced by <form>
.
Fetch metadata
To provide a reliable cross-origin signal to websites, browsers introduced Fetch metadata. In particular, the Sec-Fetch-Site header is set to cross-site
/same-site
/same-origin
/none
and is now the recommended method to mitigate CSRF. (none
means the request was directly user-initiated, e.g. a bookmark.)
The header has been available in all major browsers since 2023 (and earlier for all but Safari).
One limitation is that it is only sent to “trustworthy origins”, i.e. HTTPS and localhost. Note that this is not about the scheme of the initiator origin, but of the target, so it is sent for HTTP→HTTPS requests, but not for HTTPS→HTTP or HTTP→HTTP requests.
Existing libraries
I found two widely used Go libraries for protecting against CSRF.
github.com/gorilla/csrf
github.com/gorilla/csrf is primarily double-submit based, but then implements Origin header checks if the application is hosted on HTTPS, to protect against HTTP→HTTPS requests.
Until v1.7.3 (three weeks ago), HTTPS detection was broken, so only the same-site token checks were ever performed (GHSA-rq77-p4h8-4crw, https://attack.csrf.patrickod.com, see also #73151). In v1.7.3, the application is assumed to always be on HTTPS unless manually flagged otherwise, and the Origin header is checked against the Host header. This caused a number of new false positives for HTTP hosted applications (gorilla/csrf#188, gorilla/csrf#187, tailscale/tailscale#14872, tailscale/tailscale#15065).
The Handler allows GET, HEAD, OPTIONS, and TRACE.
If Origin is missing, the library checks that Referer has either no hostname or an allowed hostname. If both Origin and Referer are missing, the request is rejected (presumably rejecting most non-browser requests). These checks are effectively new in v1.7.3.
The checks can be bypassed on a per-Request basis with func UnsafeSkipCheck(*http.Request) *http.Request
.
Note that in v1.7.3 the double-submit tokens are redundant and strictly weaker than the Origin checks. The library also asks the developer to manage a secret key to sign the cookie with the token. I discussed this with other experts and my conclusion is that the signature and secret key are ineffectual.
It is not clear how actively maintained the library is. I reported a bypass of the new same-origin protections three weeks ago (GHSA-rm6j-cg4g-v2xx) and have not heard back despite reaching out to the maintainers directly. https://attack.csrf.patrickod.com suggests four months passed between the HTTPS detection issue report and the next release.
github.com/justinas/nosurf
github.com/justinas/nosurf is similarly based on double-submit.
It also tries to do extra checks for HTTPS targets, but appears to have the same issue as GHSA-rq77-p4h8-4crw, since it checks r.URL.Scheme
which is always empty. (I apologize for dropping effectively a 0-day, but this was just disclosed on a similar library, and anyway the intended security goal is not articulated in the docs AFAICT.) The check would be rejecting all requests if it weren’t broken, because it compares the Referer hostname with r.URL.Host
which is empty.
It also allows GET, HEAD, OPTIONS, and TRACE.
The checks can be rejected based on the path (including globs and regexes) or by callback.
The library seems unmaintained. The latest commit is a CI fix a year ago, the previous commit a critical security vulnerability fix in 2020.
Proposal details
I propose we add a handler to net/http which rejects cross-origin non-safe browser requests using primarily Fetch metadata, which require no extra effort from the developer.
If the Sec-Fetch-Site header is present and is not same-origin
or none
, we block all non-GET/HEAD/OPTIONS requests. This already secures all major up-to-date browsers for sites hosted on trustworthy origins.
We could stop there and fail open, but I suggest that as a fallback, if Sec-Fetch-Site is missing but Origin is present, we reject non-GET/HEAD/OPTIONS requests where the Origin header’s hostname doesn’t match the Host header. This will have a few false positives (missing Sec-Fetch-Site and a reverse proxy changes the Host) but eliminate most false negatives, and in particular it will protect HTTP sites. The only remaining false negatives will be HTTP→HTTPS requests sent by old browsers, which can be mitigated with HSTS (or by updating the probably anyway insecure browser).
We allow requests with neither Sec-Fetch-Site nor Origin, as they are not from browsers, and can’t be affected by CSRF.
// CrossOriginForgeryHandler rejects with a 403 Forbidden any non-safe browser
// requests that were initiated from a different origin. It protects against
// [Cross-Site Request Forgery (CSRF)].
//
// Cross-origin requests are detected with the [Sec-Fetch-Site] header,
// available in all browsers since 2023, or by comparing the hostname of the
// [Origin] header with the Host header.
//
// The GET, HEAD, and OPTIONS methods are [safe methods] and are always allowed.
// It's important that applications do not perform any state changing actions
// due to requests with safe methods.
//
// Requests without Sec-Fetch-Site or Origin headers are assumed to be either
// same-origin or non-browser requests, and are allowed.
//
// [Sec-Fetch-Site]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site
// [Origin]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
// [Cross-Site Request Forgery (CSRF)]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF
// [safe methods]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
type CrossOriginForgeryHandler struct {
// Handler is invoked for same-origin or non-browser requests.
Handler Handler
// ErrorHandler is invoked for cross-origin requests.
// If nil, a 403 Forbidden response is returned.
ErrorHandler Handler
// BypassOrigins is a list of origins that are allowed to send cross-origin
// requests. The values in this list must be fully-formed origins, including
// the scheme, and are compared verbatim to the [Origin] header.
//
// More complex bypass rules cam be implemented with [UnsafeAllowCrossOrigin].
//
// [Origin]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
BypassOrigins []string
}
// UnsafeAllowCrossOrigin disables [CrossOriginForgeryHandler] for the request.
// It is generally only useful when implementing single sign-on (SSO) flows.
func UnsafeAllowCrossOrigin(r *http.Request) *http.Request
func ExampleUnsafeAllowCrossOrigin() {
// This example shows how to use UnsafeAllowCrossOrigin to disable
// CrossOriginForgeryHandler for certain paths.
mux := NewServeMux()
// ...
csrfHandler := CrossOriginForgeryHandler{Handler: mux}
h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sso/redirect" {
// Disable CrossOriginForgeryHandler for this path.
r = UnsafeAllowCrossOrigin(r)
}
csrfHandler.ServeHTTP(w, r)
})
http.ListenAndServe(":8080", h)
}
Thanks to @empijei for helping with the design and the analysis, and to @patrickod for setting this in motion and testing the solution. /cc @golang/security
Metadata
Metadata
Assignees
Labels
Type
Projects
Status