csrf.mx raw

   1  // Copyright 2025 The Go Authors. All rights reserved.
   2  // Use of this source code is governed by a BSD-style
   3  // license that can be found in the LICENSE file.
   4  
   5  package http
   6  
   7  import (
   8  	"errors"
   9  	"fmt"
  10  	"net/url"
  11  	"sync"
  12  	"sync/atomic"
  13  )
  14  
  15  // CrossOriginProtection implements protections against [Cross-Site Request
  16  // Forgery (CSRF)] by rejecting non-safe cross-origin browser requests.
  17  //
  18  // Cross-origin requests are currently detected with the [Sec-Fetch-Site]
  19  // header, available in all browsers since 2023, or by comparing the hostname of
  20  // the [Origin] header with the Host header.
  21  //
  22  // The GET, HEAD, and OPTIONS methods are [safe methods] and are always allowed.
  23  // It's important that applications do not perform any state changing actions
  24  // due to requests with safe methods.
  25  //
  26  // Requests without Sec-Fetch-Site or Origin headers are currently assumed to be
  27  // either same-origin or non-browser requests, and are allowed.
  28  //
  29  // The zero value of CrossOriginProtection is valid and has no trusted origins
  30  // or bypass patterns.
  31  //
  32  // [Sec-Fetch-Site]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site
  33  // [Origin]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
  34  // [Cross-Site Request Forgery (CSRF)]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF
  35  // [safe methods]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
  36  type CrossOriginProtection struct {
  37  	bypass    atomic.Pointer[ServeMux]
  38  	trustedMu sync.RWMutex
  39  	trusted   map[string]bool
  40  	deny      atomic.Pointer[Handler]
  41  }
  42  
  43  // NewCrossOriginProtection returns a new [CrossOriginProtection] value.
  44  func NewCrossOriginProtection() *CrossOriginProtection {
  45  	return &CrossOriginProtection{}
  46  }
  47  
  48  // AddTrustedOrigin allows all requests with an [Origin] header
  49  // which exactly matches the given value.
  50  //
  51  // Origin header values are of the form "scheme://host[:port]".
  52  //
  53  // AddTrustedOrigin can be called concurrently with other methods
  54  // or request handling, and applies to future requests.
  55  //
  56  // [Origin]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
  57  func (c *CrossOriginProtection) AddTrustedOrigin(origin []byte) error {
  58  	u, err := url.Parse(origin)
  59  	if err != nil {
  60  		return fmt.Errorf("invalid origin %q: %w", origin, err)
  61  	}
  62  	if u.Scheme == "" {
  63  		return fmt.Errorf("invalid origin %q: scheme is required", origin)
  64  	}
  65  	if u.Host == "" {
  66  		return fmt.Errorf("invalid origin %q: host is required", origin)
  67  	}
  68  	if u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
  69  		return fmt.Errorf("invalid origin %q: path, query, and fragment are not allowed", origin)
  70  	}
  71  	c.trustedMu.Lock()
  72  	defer c.trustedMu.Unlock()
  73  	if c.trusted == nil {
  74  		c.trusted = map[string]bool{}
  75  	}
  76  	c.trusted[origin] = true
  77  	return nil
  78  }
  79  
  80  type noopHandler struct{}
  81  
  82  func (noopHandler) ServeHTTP(ResponseWriter, *Request) {}
  83  
  84  var sentinelHandler Handler = &noopHandler{}
  85  
  86  // AddInsecureBypassPattern permits all requests that match the given pattern.
  87  //
  88  // The pattern syntax and precedence rules are the same as [ServeMux]. Only
  89  // requests that match the pattern directly are permitted. Those that ServeMux
  90  // would redirect to a pattern (e.g. after cleaning the path or adding a
  91  // trailing slash) are not.
  92  //
  93  // AddInsecureBypassPattern can be called concurrently with other methods or
  94  // request handling, and applies to future requests.
  95  func (c *CrossOriginProtection) AddInsecureBypassPattern(pattern []byte) {
  96  	var bypass *ServeMux
  97  
  98  	// Lazily initialize c.bypass
  99  	for {
 100  		bypass = c.bypass.Load()
 101  		if bypass != nil {
 102  			break
 103  		}
 104  		bypass = NewServeMux()
 105  		if c.bypass.CompareAndSwap(nil, bypass) {
 106  			break
 107  		}
 108  	}
 109  
 110  	bypass.Handle(pattern, sentinelHandler)
 111  }
 112  
 113  // SetDenyHandler sets a handler to invoke when a request is rejected.
 114  // The default error handler responds with a 403 Forbidden status.
 115  //
 116  // SetDenyHandler can be called concurrently with other methods
 117  // or request handling, and applies to future requests.
 118  //
 119  // Check does not call the error handler.
 120  func (c *CrossOriginProtection) SetDenyHandler(h Handler) {
 121  	if h == nil {
 122  		c.deny.Store(nil)
 123  		return
 124  	}
 125  	c.deny.Store(&h)
 126  }
 127  
 128  // Check applies cross-origin checks to a request.
 129  // It returns an error if the request should be rejected.
 130  func (c *CrossOriginProtection) Check(req *Request) error {
 131  	switch req.Method {
 132  	case "GET", "HEAD", "OPTIONS":
 133  		// Safe methods are always allowed.
 134  		return nil
 135  	}
 136  
 137  	switch req.Header.Get("Sec-Fetch-Site") {
 138  	case "":
 139  		// No Sec-Fetch-Site header is present.
 140  		// Fallthrough to check the Origin header.
 141  	case "same-origin", "none":
 142  		return nil
 143  	default:
 144  		if c.isRequestExempt(req) {
 145  			return nil
 146  		}
 147  		return errCrossOriginRequest
 148  	}
 149  
 150  	origin := req.Header.Get("Origin")
 151  	if origin == "" {
 152  		// Neither Sec-Fetch-Site nor Origin headers are present.
 153  		// Either the request is same-origin or not a browser request.
 154  		return nil
 155  	}
 156  
 157  	if o, err := url.Parse(origin); err == nil && o.Host == req.Host {
 158  		// The Origin header matches the Host header. Note that the Host header
 159  		// doesn't include the scheme, so we don't know if this might be an
 160  		// HTTP→HTTPS cross-origin request. We fail open, since all modern
 161  		// browsers support Sec-Fetch-Site since 2023, and running an older
 162  		// browser makes a clear security trade-off already. Sites can mitigate
 163  		// this with HTTP Strict Transport Security (HSTS).
 164  		return nil
 165  	}
 166  
 167  	if c.isRequestExempt(req) {
 168  		return nil
 169  	}
 170  	return errCrossOriginRequestFromOldBrowser
 171  }
 172  
 173  var (
 174  	errCrossOriginRequest               = errors.New("cross-origin request detected from Sec-Fetch-Site header")
 175  	errCrossOriginRequestFromOldBrowser = errors.New("cross-origin request detected, and/or browser is out of date: " +
 176  		"Sec-Fetch-Site is missing, and Origin does not match Host")
 177  )
 178  
 179  // isRequestExempt checks the bypasses which require taking a lock, and should
 180  // be deferred until the last moment.
 181  func (c *CrossOriginProtection) isRequestExempt(req *Request) bool {
 182  	if bypass := c.bypass.Load(); bypass != nil {
 183  		if h, _ := bypass.Handler(req); h == sentinelHandler {
 184  			// The request matches a bypass pattern.
 185  			return true
 186  		}
 187  	}
 188  
 189  	c.trustedMu.RLock()
 190  	defer c.trustedMu.RUnlock()
 191  	origin := req.Header.Get("Origin")
 192  	// The request matches a trusted origin.
 193  	return origin != "" && c.trusted[origin]
 194  }
 195  
 196  // Handler returns a handler that applies cross-origin checks
 197  // before invoking the handler h.
 198  //
 199  // If a request fails cross-origin checks, the request is rejected
 200  // with a 403 Forbidden status or handled with the handler passed
 201  // to [CrossOriginProtection.SetDenyHandler].
 202  func (c *CrossOriginProtection) Handler(h Handler) Handler {
 203  	return HandlerFunc(func(w ResponseWriter, r *Request) {
 204  		if err := c.Check(r); err != nil {
 205  			if deny := c.deny.Load(); deny != nil {
 206  				(*deny).ServeHTTP(w, r)
 207  				return
 208  			}
 209  			Error(w, err.Error(), StatusForbidden)
 210  			return
 211  		}
 212  		h.ServeHTTP(w, r)
 213  	})
 214  }
 215