Skip to content

proposal: net/http: add CrossOriginForgeryHandler #73626

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
FiloSottile opened this issue May 7, 2025 · 29 comments
Open

proposal: net/http: add CrossOriginForgeryHandler #73626

FiloSottile opened this issue May 7, 2025 · 29 comments
Labels
LibraryProposal Issues describing a requested change to the Go standard library or x/ libraries, but not to a tool Proposal
Milestone

Comments

@FiloSottile
Copy link
Contributor

FiloSottile commented May 7, 2025

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:

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:

  1. it may be different from the browser origin if a reverse proxy is involved;
  2. 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.

https://github.com/justinas/nosurf/blob/e5c9c1fe2d4f69668ff78f872abf3b396a08673a/handler.go#L148-L168

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

@gopherbot gopherbot added this to the Proposal milestone May 7, 2025
@seankhliao
Copy link
Member

I take it the api looks like this?

func CrossOriginForgeryHandler(next http.Handler) http.Handler

@FiloSottile
Copy link
Contributor Author

I've added a concrete API (oops). It's a struct to accomodate the ErrorHandler and BypassOrigins parameter, with the option to add more. I don't really have a strong opinion on other ways to pass parameters.

@gabyhelp gabyhelp added the LibraryProposal Issues describing a requested change to the Go standard library or x/ libraries, but not to a tool label May 7, 2025
@FiloSottile
Copy link
Contributor Author

Added a section surveying existing implementations, which helps motivate why we need a standard library solution.

@earthboundkid
Copy link
Contributor

It would be nice if ErrorHandler got the error so it could be logged.

@FiloSottile
Copy link
Contributor Author

It would be nice if ErrorHandler got the error so it could be logged.

gorilla/csrf has func FailureReason(*http.Request) error and we could do something similar, but I wonder how many useful bits that error would carry. I think it's just one: either Sec-Fetch-Site was present and not same-origin/none, or it was missing and Origin didn't match Host. Is it really that useful to tell them apart?

Also note that ErrorHandler has access to r.Headers, so if you're curious about % of requests without Sec-Fetch-Site, it's arguably easier to look at the headers than it is to look at error logs.

@earthboundkid
Copy link
Contributor

I was thinking ErrorHandler could be func(ResponseWriter, *Request, error) instead of a Handler, but I take the point that all you really care about is how often it's being triggered.

@renthraysk
Copy link

Could just have a func that returns nil or an error. Just need to ensure it is idempotent.

func CheckCrossOrigin(w http.ResponseWriter, r *http.Request) error 

So if need to care in the ErrorHandler just call the func again.

tomcz added a commit to digitalocean-labs/goldfish that referenced this issue May 9, 2025
@jub0bs
Copy link

jub0bs commented May 11, 2025

Thanks for putting this proposal together, @FiloSottile!

There's this widespread misconception that the changes to the SameSite attribute back in 2020 addressed all CSRF risks. If anything, the addition of a defence against CSRF in the standard library would help disabuse Gophers of this idea.

Below are some general comments about your Background section; as this comment was getting quite long, I'll post another comment about the proposal itself.

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.

It's worth clarifying that CSRF goes beyond cookies. The attacker may abuse the victim's position within a less public network in order to target a server directly inaccessible to the attacker. Private Network Access is a mitigation against this category of CSRF attacks, but it's not fully implemented in all major browsers.


Same site vs same origin

Thanks for stressing this crucial, yet often overlooked, distinction. Shameless plug, but I wrote a blog post on this topic a few years ago.


but really it’s just a special case of a same-site cross-origin CSRF attack.

In the modern sense of same-site (i.e. schemelessly same-site), which all major browsers understand, a request from http://example.com to https://example.com is considered cross-site, though.


same-site origins can set cookies on each other to an extent by “cookie tossing”.

Another shameless plug: for readers interested in an example of cookie tossing (which I reported years ago and, last time I checked, still had not been fixed), see this video.


if the attacker can obtain a token from a cross-origin response, it can forge a valid request.

For an example of exactly this, see this post.


[...] precisely because “simple requests” means the ones produced by <form>.

Close, but not quite: some HEAD requests are simple, but HTML forms can't send HEAD requests.


The risk of same-site HTTP origins is still present, too, and needs to be mitigated with HTTP Strict Transport Security (HSTS).

Not if "same-site" is understood as "schemelessly same-site"; see my comment further up.


the Sec-Fetch-Site header [...] is now the recommended method to mitigate CSRF

Citation needed 😄


github.com/gorilla/csrf [...]

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.

FWIW, I've had a similar experience. I think it's fair to consider that library unmaintained.

@jub0bs
Copy link

jub0bs commented May 11, 2025

// origin. It protects against [Cross Site Request Forgery (CSRF)].

Hyphenate "cross site". 😇


// The handler sets the Vary header to "Sec-Fetch-Site Origin" to ensure
// that caches do not cache the response across origins.

Two minor problems with this passage of the doc comment:

  • Vary is a list-based header; as such, the elements listed in its value should be separated by a comma (possibly surrounded by optional whitespace).
  • The handler should add (rather than set) a Vary header; earlier middleware may well have already populated the response with a Vary header listing different header names, and clobbering that header could open the door to cache-poisoning attacks.

I suggest

// The middleware adds a Vary header with the value "Sec-Fetch-Site, Origin" to ensure
// that caching intermediaries do not get poisoned.

// Non-browser requests and GET/HEAD/OPTIONS requests are not affected.

Perhaps a good opportunity for the doc to remind server developers of the concept of safe methods.


type CrossOriginForgeryHandler struct {
	// Handler is invoked for same-origin or non-browser requests.
	Handler Handler

One disadvantage of this approach is that you cannot reuse a CrossOriginForgeryHandler for different handlers. Instead, I suggest renaming the type, dropping its Handler field, and equipping it with a Wrap(Handler) Handler method:

type CrossOriginForgeryMiddleware struct {
 	// fields omitted
}

func (m *CrossOriginForgeryMiddleware) Wrap(h Handler) Handler { /* ... */ }

In this connection, see


	// 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
}

Maybe it's just me, but I feel that those fields would be better left non-exported and the type should be instantiated via some factory function / "constructor":

type CrossOriginForgeryMiddleware struct {
	// contains filtered or unexported fields
}

func NewCrossOriginForgeryMiddleware(errorHandler Handler, bypassOrigins []string) (*CrossOriginForgeryMiddleware, error) {
	// ...
}

This approach presents several advantages:

  • You could validate the list of bypass origins. In my experience, many developers either lack familiarity with the concept of Web origin or are prone to mistakes when specifying origins.
  • Via a modicum of defensive copying, you could make the type concurrency-safe, e.g. even when changes are made to the bypassOrigins parameter while the middleware is processing requests.
  • You would have the option to later make the type reconfigurable even while it's processing requests.

// 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

How UnsafeAllowCrossOrigin would modify the request is unclear to me. Can you clarify?


		if r.URL.Path == "/sso/redirect" {

Not a big fan of this approach, to be honest. This seems like a routing concern that would be better addressed outside any handler. Wouldn't it be better to apply the middleware to all but the "/sso/redirect" path at the router level?

@FiloSottile
Copy link
Contributor Author

FiloSottile commented May 11, 2025

Thank you for commenting @jub0bs, you were one of the folks I most hoped would take a look!

It's worth clarifying that CSRF goes beyond cookies. The attacker may abuse the victim's position within a less public network in order to target a server directly inaccessible to the attacker. Private Network Access is a mitigation against this category of CSRF attacks, but it's not fully implemented in all major browsers.

Added to the intro.

In the modern sense of same-site (i.e. schemelessly same-site), which all major browsers understand, a request from http://example.com to https://example.com is considered cross-site, though.

Thank you for making me learn about the distinction. Unfortunately, it looks like the situation is murky.

Sec-Fetch-Site applies the schemeful definition (HTTP ≠ HTTPS). We don't really care, since we will only allow same-origin.

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, so they will still send SameSite=Lax/Strict cookies from HTTP initiators to HTTPS origins.

Finally, for the purposes of cookie tossing, which is the generic sense of "same-site" I was using there, the schemeless definition (HTTP = HTTPS) applies, because HTTP origins can set (prefix-less) cookies on same-site HTTPS origins. There is a proposal to address this, Origin-Bound-Cookies (and specifically its lack of opt-out for scheme binding, which subsumes Scheme-Bound Cookies), which however hasn't shipped yet.

Does this sound right? Edit: I updated the Background, let me know if I got anything wrong.

[...] precisely because “simple requests” means the ones produced by <form>.

Close, but not quite: some HEAD requests are simple, but HTML forms can't send HEAD requests.

Updated the text, thank you.

The risk of same-site HTTP origins is still present, too, and needs to be mitigated with HTTP Strict Transport Security (HSTS).

Not if "same-site" is understood as "schemelessly same-site"; see my comment further up.

See above, I believe that is only the case in Chromium engines so far, unfortunately.

the Sec-Fetch-Site header [...] is now the recommended method to mitigate CSRF

Citation needed 😄

Well, https://web.dev/articles/fetch-metadata is a recommendation :)

@FiloSottile
Copy link
Contributor Author

// origin. It protects against [Cross Site Request Forgery (CSRF)].

Hyphenate "cross site". 😇

Done!

// The handler sets the Vary header to "Sec-Fetch-Site Origin" to ensure
// that caches do not cache the response across origins.

Two minor problems with this passage of the doc comment:

D'oh. I remember thinking "I need to check the docs for Vary" and then not doing it.

However, I am having doubts on whether we should do this at all.

  1. Cookie-personalized responses should carry Cache-Control: private anyway.
  2. RFC 9111 is adamant that non-safe methods MUST not be cached.

Given (2) in particular, I have removed the Vary part of the proposal.

This also makes the Handler read-only for allowed requests.

// Non-browser requests and GET/HEAD/OPTIONS requests are not affected.

Perhaps a good opportunity for the doc to remind server developers of the concept of safe methods.

Done.

[...] renaming the type, dropping its Handler field, and equipping it with a Wrap(Handler) Handler method

[...] those fields would be better left non-exported and the type should be instantiated via some factory function / "constructor"

I don't care that much about the color of the bikeshed, but if we realize we are missing an option, we can't add it to the constructor later. If we add a setter, then it leads to the question "what happens if you call Wrap and then SetFoo? does it apply retroactively?"

Validating the bypass origins is very compelling, though.

Here's an alternative API, taking a page out of context and log/slog.

type CrossOriginProtection struct {
	// contains filtered or unexported fields
}

func NewCrossOriginProtection() CrossOriginProtection

func (CrossOriginProtection) WithBypassOrigins([]string) (CrossOriginProtection, error)

func (CrossOriginProtection) WithErrorHandler(Handler) CrossOriginProtection

func (CrossOriginProtection) Handler(Handler) Handler

Then the most simple usage is just NewCrossOriginProtection().Handler(mux) but you can wrap all the handlers you like, and we can add options later. Still, it's clear that options don't apply to the previous values.

(Anyone has a better name or opinions on *CrossOriginProtection vs CrossOriginProtection?)

How UnsafeAllowCrossOrigin would modify the request is unclear to me. Can you clarify?

It would set a private context value on the Request, which would be detected by the Handler later.

  if r.URL.Path == "/sso/redirect" {

Not a big fan of this approach, to be honest. This seems like a routing concern that would be better addressed outside any handler. Wouldn't it be better to apply the middleware to all but the "/sso/redirect" path at the router level?

I thought about how this integrates with the router level and I agree this is a bit annoying, but that feels more error-prone: if another path is added later it would be unprotected by default, unless the developer remembers to apply Wrap.

@FiloSottile
Copy link
Contributor Author

With the CrossOriginProtection API, and given it's now a read-only check, we could add a manual Check method, now or later, as suggested above.

func (CrossOriginProtection) Check(*Request) error

Unrelatedly, /cc @jba who might have ideas on routing integration.

@renthraysk
Copy link

Yes, Check makes testing easier, as don't have to deal with http.Handlers.

If have a manual Check, might be worth it creating an interface, and then have a single http.Handler

type Checker interface {
	Check(r *http.Request) error
}

func Filter(check Checker, next, err http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := err
		if err := check.Check(r); err == nil {
			h = next
		}
		h.ServeHTTP(w, r)
	})
}

@jba
Copy link
Contributor

jba commented May 12, 2025

Unrelatedly, /cc @jba who might have ideas on routing integration.

I think @neild might have some thoughts.

@jub0bs
Copy link

jub0bs commented May 12, 2025

@FiloSottile

Thank you for commenting @jub0bs, you were one of the folks I most hoped would take a look!

I'm honoured! 🙇

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, so they will still send SameSite=Lax/Strict cookies from HTTP initiators to HTTPS origins.

Thank you for bringing this subtlety to my attention! I had incorrectly assumed that all browsers had caught up to the modern meaning of "site".

Well, https://web.dev/articles/fetch-metadata is a recommendation :)

Indeed. Thanks!

Does this sound right? Edit: I updated the Background, let me know if I got anything wrong.

I've got no further objections about the Background section. 😇

@jub0bs
Copy link

jub0bs commented May 12, 2025

@FiloSottile

[...] if we realize we are missing an option, we can't add it to the constructor later.

Indeed. With a factory function with parameters, we may paint ourselves into a corner. Another possibility is something like functional options (which @dsnet implemented a form of in encoding/json/v2), but the pattern remains controversial in the community.

If we add a setter, then it leads to the question "what happens if you call Wrap and then SetFoo? does it apply retroactively?"

A valid concern.

func (CrossOriginProtection) Handler(Handler) Handler

I would still prefer a name like Wrap, since that's what a func(http.Handler) http.Handler (a middleware, essentially) invokes in my mind, but I won't insist further; others can chime in about naming.

It would set a private context value on the Request, which would be detected by the Handler later.

Ok, that makes sense. Thanks for clarifying.

[...] that feels more error-prone: if another path is added later it would be unprotected by default, unless the developer remembers to apply Wrap.

Not necessarily. @earthboundkid once helped me realise that you can build your routes by composition and only selectively apply a middleware to some "roots"; I have something like the following in mind:

func ExampleNewCrossOriginProtection_Wrap() {
  mux := http.NewServeMux()
  mux.HandleFunc("/sso/redirect", handleSSO) // note: not protected against CSRF (by design)

  api := http.NewServeMux()
  api.HandleFunc("GET  /users", handleUsersGet)
  api.HandleFunc("POST /users", handleUsersPost)

  handler := http.NewCrossOriginProtection().Wrap(api)
  mux.Handle("/api/", http.StripPrefix("/api", handler))

  if err := http.ListenAndServe(":8080", mux); err != http.ErrServerClosed {
    log.Fatal(err)
  }
}

@markuswustenberg
Copy link

I don't care that much about the color of the bikeshed, but if we realize we are missing an option, we can't add it to the constructor later. If we add a setter, then it leads to the question "what happens if you call Wrap and then SetFoo? does it apply retroactively?"

I would very much like the color of the shed to be pink! :D

Jokes aside, I think the nicest pattern I've seen in the wild for middleware (with signature func(http.Handler) http.Handler) is a constructor function that takes an options struct, so something like:

type Middleware = func(http.Handler) http.Handler

type CrossOriginProtectionOpts struct{
	// …
}

func CrossOriginProtection(opts CrossOriginProtectionOpts) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Do the thing
			next.ServeHTTP(w, r)
		}
	}
}

It's perhaps a bit verbose, but very clear and future-proof.

I'm biased, because I use Chi extensively, and this way, the user-facing API would be something like:

func main() {
	mux := chi.NewRouter()
	mux.Use(http.CrossOriginProtection(CrossOriginProtectionOpts{
		// …
	}))
}

In that context, calling it Wrap wouldn't make sense. But I can see why it would in the std lib.

Thoughts?

@jub0bs
Copy link

jub0bs commented May 12, 2025

@markuswustenberg

type Middleware = func(http.Handler) http.Handler

func CrossOriginProtection(CrossOriginProtectionOpts) Middleware

This approach would preclude option validation.

@markuswustenberg
Copy link

This approach would preclude option validation.

Hmm. I might be misunderstanding you, but wouldn’t you do that here?

type Middleware = func(http.Handler) http.Handler

type CrossOriginProtectionOpts struct{
	// …
}

func CrossOriginProtection(opts CrossOriginProtectionOpts) Middleware {
 // Validate opts here 👈
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Do the thing
			next.ServeHTTP(w, r)
		}
	}
}

@FiloSottile
Copy link
Contributor Author

Not necessarily. @earthboundkid once helped me realise that you can build your routes by composition and only selectively apply a middleware to some "roots"; I have something like the following in mind:

That's a nice pattern if you stick to it, but if you do mux.Handle("/api/newEndpoint", ...) by mistake it won't be protected.

I would still prefer a name like Wrap, since that's what a func(http.Handler) http.Handler (a middleware, essentially) invokes in my mind

Calling it Wrap would be the first instance of "Wrap" in the net/http API surface, and there is a pattern in the stdlib of naming methods after what they return. I don't have a strong opinion, though.

func CrossOriginProtection(opts CrossOriginProtectionOpts) Middleware

I would call this a traditional "config" pattern. To validate the config it'll need to return (Middleware, error) and then you can't pass it to chi.(*Mux).Use. Also, you'd need to check the error for every Handler you wrap.

The With API from #73626 (comment) works with chi.(*Mux).Use:

mux := chi.NewRouter()
mux.Use(NewCrossOriginProtection().Handler)

Also reads nicely IMHO. I'm starting to really like the With API.

@jub0bs
Copy link

jub0bs commented May 12, 2025

@markuswustenberg

Hmm. I might be misunderstanding you, but wouldn’t you do that here?

You're right; you could validate the options. But how would you communicate failure (invalid options) to the caller of CrossOriginProtection, then? Panicking doesn't feel right. As Filippo wrote, you'd need the following signature:

func (CrossOriginProtectionOpts) (Middleware, error)

@neild
Copy link
Contributor

neild commented May 12, 2025

To summarize my understanding of the proposal at a high-level:

Provide an opt-in mechanism for enabling CSRF defense using the Sec-Fetch-Site and Origin headers. This will:

  1. Allow GET/HEAD/OPTIONS requests.
  2. Deny other requests when Sec-Fetch-Site is present and not same-origin or none.
  3. Deny other requests when Origin is present and is not exactly equal to Host.

This mechanism requires additional customization, such as permitting a specific list of Origin values.

It must be possible to disable CSRF protection for some requests.

That all seems fine to me. So far as I can tell, all of this can be easily implemented in a third-party package, and perhaps we should start with one.

The proposal requires that the CSRF handler reject some requests. What, exactly, does it do to reject a request?

A concern with any API, either in or out of std, is that this is all going to be necessarily opt-in. We've talked in the past about having a one-knob setting to make an http.Server more defensive, changing less-secure defaults to more-secure ones. Is this something that can/should be enabled by a setting like that? How would this work?

A few general API thoughts:

A function which returns an error is more general purpose than a handler with an error callback. For example:

type CrossOriginProtectionOpts struct { ... }

// Implement http.Handler.
func (c CrossOriginProtectionOpts) ServeHTTP(ResponseWriter, *Request)

// Users can call Check directly to implement their own error handling.
func (c CrossOriginProtectionOpts) Check(req *Request) error

I don't like using *http.Request to pass options through different handler layers, as in the proposed UnsafeAllowCrossOrigin. I also don't see how this is any less mistake-prone than composed muxes as @jub0bs proposes; in both cases, you need to apply pattern matching both before and after the CSRF handler.

A possible option here is to add pattern matching to the CSRF options:

// Allow permits all requests to the given pattern.
// Patterns use the same syntax as [ServeMux].
//
// Example:
//    csrf.Allow("/sso/")
func (c CrossOriginProtectionOpts) Allow(pattern string)

@earthboundkid
Copy link
Contributor

That all seems fine to me. So far as I can tell, all of this can be easily implemented in a third-party package, and perhaps we should start with one.

This makes sense to me. x/net/cors or some such. It would give more time for flexibility before it's fully baked. I don't foresee this middleware gaining a lot of options, but some of the questions around should it use an options struct or functional options depend in part on how the API changes over time and whether people will need more options.

The proposal requires that the CSRF handler reject some requests. What, exactly, does it do to reject a request?

ErrorHandler is called if present, otherwise it does some default, presumably just text saying Forbidden: Bad CORS request.

A possible option here is to add pattern matching to the CSRF options:

// Allow permits all requests to the given pattern.
// Patterns use the same syntax as [ServeMux].
//
// Example:
// csrf.Allow("/sso/")
func (c CrossOriginProtectionOpts) Allow(pattern string)

I like this. It makes it more foolproof to just wrap everything in CORS protection. Maybe a better name than Allow is DisableFor.

@FiloSottile
Copy link
Contributor Author

So far as I can tell, all of this can be easily implemented in a third-party package, and perhaps we should start with one.

I plan to prototype this as a third-party module, but I still think we should ship this in net/http anyway. There are two unmaintained modules out there imported by thousands (just in the open source sphere) with active vulnerabilities. Migrating to a standard library handler requires much less vetting and is an easier sell than filippo.io/csrf.

The proposal requires that the CSRF handler reject some requests. What, exactly, does it do to reject a request?

It doesn't pass them to the wrapped handler and instead returns a 403 Forbidden response.

A concern with any API, either in or out of std, is that this is all going to be necessarily opt-in. We've talked in the past about having a one-knob setting to make an http.Server more defensive, changing less-secure defaults to more-secure ones. Is this something that can/should be enabled by a setting like that? How would this work?

Yes, a future hardened http.Server could do this by default. However, there is empirical evidence that applications do opt-in (by importing the unmaintained modules), so I wouldn't hold this until we move forward with the broader approach.

It would also be harder to migrate from an existing module to hardened http.Server than to this.

A function which returns an error is more general purpose than a handler with an error callback.

I agree with adding a Check, see #73626 (comment). We should still have the Handler as it provides a better safe-by-default experience and migration path from existing modules.

I don't like using *http.Request to pass options through different handler layers, as in the proposed UnsafeAllowCrossOrigin. I also don't see how this is any less mistake-prone than composed muxes as @jub0bs proposes; in both cases, you need to apply pattern matching both before and after the CSRF handler.

The difference is that if next year a colleague adds a new endpoint, with UnsafeAllowCrossOrigin as used in the example it will be protected by default.

A possible option here is to add pattern matching to the CSRF options:

// Allow permits all requests to the given pattern.
// Patterns use the same syntax as [ServeMux].
//
// Example:
//    csrf.Allow("/sso/")
func (c CrossOriginProtectionOpts) Allow(pattern string)

This would definitely be more user-friendly, but I was worried about opening a can of worms. How do wildcards work? How easy is it to internally repurpose the pattern code?

@Mizommz

This comment has been minimized.

@neild
Copy link
Contributor

neild commented May 12, 2025

Yes, a future hardened http.Server could do this by default.

Mainly, I'm curious how we'd do this by default. If this was a Server configuration option (which I'm not suggesting, this is just a hypothetical), then we'd turn it on. How would we turn on a middleware handler by default? If there are handler configuration options, how does the user set them?

How do wildcards work? How easy is it to internally repurpose the pattern code?

I'm thinking something along the lines of:

type CSRFHandler struct {
  mux ServeMux
}

type csrfAllowHandler struct { Handler }

func (h *CSRFHandler) Allow(pattern string) {
  h.mux.Handle(pattern, csrfAllowHandler{})
}

func (h *CSRFHandler) Check(req *Request) error {
  if handler, _ := h.mux.Handler(req); handler != nil {
    return nil // always allow
  }
  // apply CSRF checks...
}

The actual implementation could be more elegant if this is in net/http, but I think the above would work well enough.

@FiloSottile
Copy link
Contributor Author

Yes, a future hardened http.Server could do this by default.

Mainly, I'm curious how we'd do this by default. If this was a Server configuration option (which I'm not suggesting, this is just a hypothetical), then we'd turn it on. How would we turn on a middleware handler by default? If there are handler configuration options, how does the user set them?

Not sure how we'd configure the http.Server, there are many possible answers and it feels a bit out of scope, but we don't even need to call this a middleware. It's about using a different Handler (the ErrorHandler or a default that 403's) if the request has some headers.

  if handler, _ := h.mux.Handler(req); handler != nil {

TIL about ServeMux.Handler! Nice. However, I think this will need to be

if _, pattern := h.mux.Handler(req); pattern != "" {

otherwise we'll catch the 404 handler.

This part might be a bit surprising, but maybe it will be more often than not what users expect: "If the path is not in its canonical form, the handler will be an internally-generated handler that redirects to the canonical path. [...] Handler also returns the registered pattern that matches the request or, in the case of internally-generated redirects, the path that will match after following the redirect."

@jub0bs
Copy link

jub0bs commented May 12, 2025

@earthboundkid

This makes sense to me. x/net/cors or some such.

[...] wrap everything in CORS protection

"cors" wouldn't be the right package name, though. This proposal has little to do with CORS, which is only a mechanism for servers to instruct browsers to relax the Same-Origin Policy for select clients. In particular, note that there are (potentially malicious) cross-origin requests that do not participate in the CORS protocol, such as one issued by a HTML form or by the following JS code:

fetch('//example.com', {mode: 'no-cors', credentials: 'include'})

@aclements aclements moved this to Incoming in Proposals May 13, 2025
@aclements aclements moved this from Incoming to Active in Proposals May 13, 2025
@aclements
Copy link
Member

This proposal has been added to the active column of the proposals project
and will now be reviewed at the weekly proposal review meetings.
— aclements for the proposal review group

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
LibraryProposal Issues describing a requested change to the Go standard library or x/ libraries, but not to a tool Proposal
Projects
Status: Active
Development

No branches or pull requests