| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- // Copyright (c) Tailscale Inc & contributors
- // SPDX-License-Identifier: BSD-3-Clause
- // Package safeweb provides a wrapper around an http.Server that applies
- // basic web application security defenses by default. The wrapper can be
- // used in place of an http.Server. A safeweb.Server adds mitigations for
- // Cross-Site Request Forgery (CSRF) attacks, and annotates requests with
- // appropriate Cross-Origin Resource Sharing (CORS), Content-Security-Policy,
- // X-Content-Type-Options, and Referer-Policy headers.
- //
- // To use safeweb, the application must separate its "browser" routes from "API"
- // routes, with each on its own http.ServeMux. When serving requests, the
- // server will first check the browser mux, and if no matching route is found it
- // will defer to the API mux.
- //
- // # Browser Routes
- //
- // All routes in the browser mux enforce CSRF protection using the gorilla/csrf
- // package. The application must template the CSRF token into its forms using
- // the [TemplateField] and [TemplateTag] APIs. Applications that are served in a
- // secure context (over HTTPS) should also set the SecureContext field to true
- // to ensure that the the CSRF cookies are marked as Secure.
- //
- // In addition, browser routes will also have the following applied:
- // - Content-Security-Policy header that disallows inline scripts, framing, and third party resources.
- // - X-Content-Type-Options header on responses set to "nosniff" to prevent MIME type sniffing attacks.
- // - Referer-Policy header set to "same-origin" to prevent leaking referrer information to third parties.
- //
- // By default the Content-Security-Policy header will disallow inline styles.
- // This can be overridden by setting the CSPAllowInlineStyles field to true in
- // the safeweb.Config struct.
- //
- // # API routes
- //
- // safeweb inspects the Content-Type header of incoming requests to the API mux
- // and prohibits the use of `application/x-www-form-urlencoded` values. If the
- // application provides a list of allowed origins and methods in its
- // configuration safeweb will set the appropriate CORS headers on pre-flight
- // OPTIONS requests served by the API mux.
- //
- // # HTTP Redirects
- //
- // The [RedirectHTTP] method returns a handler that redirects all incoming HTTP
- // requests to HTTPS at the same path on the provided fully qualified domain
- // name (FQDN).
- //
- // # Example usage
- //
- // h := http.NewServeMux()
- // h.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- // fmt.Fprint(w, "Hello, world!")
- // })
- // s, err := safeweb.NewServer(safeweb.Config{
- // BrowserMux: h,
- // })
- // if err != nil {
- // log.Fatalf("failed to create server: %v", err)
- // }
- // ln, err := net.Listen("tcp", ":8080")
- // if err != nil {
- // log.Fatalf("failed to listen: %v", err)
- // }
- // defer ln.Close()
- // if err := s.Serve(ln); err != nil && err != http.ErrServerClosed {
- // log.Fatalf("failed to serve: %v", err)
- // }
- //
- // [TemplateField]: https://pkg.go.dev/github.com/gorilla/csrf#TemplateField
- // [TemplateTag]: https://pkg.go.dev/github.com/gorilla/csrf#TemplateTag
- package safeweb
- import (
- "cmp"
- "context"
- crand "crypto/rand"
- "fmt"
- "log"
- "maps"
- "net"
- "net/http"
- "net/url"
- "path"
- "slices"
- "strings"
- "github.com/gorilla/csrf"
- )
- // CSP is the value of a Content-Security-Policy header. Keys are CSP
- // directives (like "default-src") and values are source expressions (like
- // "'self'" or "https://tailscale.com"). A nil slice value is allowed for some
- // directives like "upgrade-insecure-requests" that don't expect a list of
- // source definitions.
- type CSP map[string][]string
- // DefaultCSP is the recommended CSP to use when not loading resources from
- // other domains and not embedding the current website. If you need to tweak
- // the CSP, it is recommended to extend DefaultCSP instead of writing your own
- // from scratch.
- func DefaultCSP() CSP {
- return CSP{
- "default-src": {"self"}, // origin is the only valid source for all content types
- "frame-ancestors": {"none"}, // disallow framing of the page
- "form-action": {"self"}, // disallow form submissions to other origins
- "base-uri": {"self"}, // disallow base URIs from other origins
- // TODO(awly): consider upgrade-insecure-requests in SecureContext
- // instead, as this is deprecated.
- "block-all-mixed-content": nil, // disallow mixed content when serving over HTTPS
- }
- }
- // Set sets the values for a given directive. Empty values are allowed, if the
- // directive doesn't expect any (like "upgrade-insecure-requests").
- func (csp CSP) Set(directive string, values ...string) {
- csp[directive] = values
- }
- // Add adds a source expression to an existing directive.
- func (csp CSP) Add(directive, value string) {
- csp[directive] = append(csp[directive], value)
- }
- // Del deletes a directive and all its values.
- func (csp CSP) Del(directive string) {
- delete(csp, directive)
- }
- func (csp CSP) String() string {
- keys := slices.Collect(maps.Keys(csp))
- slices.Sort(keys)
- var s strings.Builder
- for _, k := range keys {
- s.WriteString(k)
- for _, v := range csp[k] {
- // Special values like 'self', 'none', 'unsafe-inline', etc., must
- // be quoted. Do it implicitly as a convenience here.
- if !strings.Contains(v, ".") && len(v) > 1 && v[0] != '\'' && v[len(v)-1] != '\'' {
- v = "'" + v + "'"
- }
- s.WriteString(" " + v)
- }
- s.WriteString("; ")
- }
- return strings.TrimSpace(s.String())
- }
- // The default Strict-Transport-Security header. This header tells the browser
- // to exclusively use HTTPS for all requests to the origin for the next year.
- var DefaultStrictTransportSecurityOptions = "max-age=31536000"
- // Config contains the configuration for a safeweb server.
- type Config struct {
- // SecureContext specifies whether the Server is running in a secure (HTTPS) context.
- // Setting this to true will cause the Server to set the Secure flag on CSRF cookies.
- SecureContext bool
- // BrowserMux is the HTTP handler for any routes in your application that
- // should only be served to browsers in a primary origin context. These
- // requests will be subject to CSRF protection and will have
- // browser-specific headers in their responses.
- BrowserMux *http.ServeMux
- // APIMux is the HTTP handler for any routes in your application that
- // should only be served to non-browser clients or to browsers in a
- // cross-origin resource sharing context.
- APIMux *http.ServeMux
- // AccessControlAllowOrigin specifies the Access-Control-Allow-Origin header sent in response to pre-flight OPTIONS requests.
- // Provide a list of origins, e.g. ["https://foobar.com", "https://foobar.net"] or the wildcard value ["*"].
- // No headers will be sent if no origins are provided.
- AccessControlAllowOrigin []string
- // AccessControlAllowMethods specifies the Access-Control-Allow-Methods header sent in response to pre-flight OPTIONS requests.
- // Provide a list of methods, e.g. ["GET", "POST", "PUT", "DELETE"].
- // No headers will be sent if no methods are provided.
- AccessControlAllowMethods []string
- // CSRFSecret is the secret used to sign CSRF tokens. It must be 32 bytes long.
- // This should be considered a sensitive value and should be kept secret.
- // If this is not provided, the Server will generate a random CSRF secret on
- // startup.
- CSRFSecret []byte
- // CSP is the Content-Security-Policy header to return with BrowserMux
- // responses.
- CSP CSP
- // CSPAllowInlineStyles specifies whether to include `style-src:
- // unsafe-inline` in the Content-Security-Policy header to permit the use of
- // inline CSS.
- CSPAllowInlineStyles bool
- // CookiesSameSiteLax specifies whether to use SameSite=Lax in cookies. The
- // default is to set SameSite=Strict.
- CookiesSameSiteLax bool
- // StrictTransportSecurityOptions specifies optional directives for the
- // Strict-Transport-Security header sent in response to requests made to the
- // BrowserMux when SecureContext is true.
- // If empty, it defaults to max-age of 1 year.
- StrictTransportSecurityOptions string
- // HTTPServer, if specified, is the underlying http.Server that safeweb will
- // use to serve requests. If nil, a new http.Server will be created.
- // Do not use the Handler field of http.Server, as it will be ignored.
- // Instead, set your handlers using APIMux and BrowserMux.
- HTTPServer *http.Server
- }
- func (c *Config) setDefaults() error {
- if c.BrowserMux == nil {
- c.BrowserMux = &http.ServeMux{}
- }
- if c.APIMux == nil {
- c.APIMux = &http.ServeMux{}
- }
- if c.CSRFSecret == nil || len(c.CSRFSecret) == 0 {
- c.CSRFSecret = make([]byte, 32)
- if _, err := crand.Read(c.CSRFSecret); err != nil {
- return fmt.Errorf("failed to generate CSRF secret: %w", err)
- }
- }
- if c.CSP == nil {
- c.CSP = DefaultCSP()
- }
- return nil
- }
- // Server is a safeweb server.
- type Server struct {
- Config
- h *http.Server
- csp string
- csrfProtect func(http.Handler) http.Handler
- }
- // NewServer creates a safeweb server with the provided configuration. It will
- // validate the configuration to ensure that it is complete and return an error
- // if not.
- func NewServer(config Config) (*Server, error) {
- // ensure that CORS configuration is complete
- corsMethods := len(config.AccessControlAllowMethods) > 0
- corsHosts := len(config.AccessControlAllowOrigin) > 0
- if corsMethods != corsHosts {
- return nil, fmt.Errorf("must provide both AccessControlAllowOrigin and AccessControlAllowMethods or neither")
- }
- // fill in any missing fields
- if err := config.setDefaults(); err != nil {
- return nil, fmt.Errorf("failed to set defaults: %w", err)
- }
- sameSite := csrf.SameSiteStrictMode
- if config.CookiesSameSiteLax {
- sameSite = csrf.SameSiteLaxMode
- }
- if config.CSPAllowInlineStyles {
- if _, ok := config.CSP["style-src"]; ok {
- config.CSP.Add("style-src", "unsafe-inline")
- } else {
- config.CSP.Set("style-src", "self", "unsafe-inline")
- }
- }
- s := &Server{
- Config: config,
- csp: config.CSP.String(),
- // only set Secure flag on CSRF cookies if we are in a secure context
- // as otherwise the browser will reject the cookie
- csrfProtect: csrf.Protect(config.CSRFSecret, csrf.Secure(config.SecureContext), csrf.SameSite(sameSite)),
- }
- s.h = cmp.Or(config.HTTPServer, &http.Server{})
- if s.h.Handler != nil {
- return nil, fmt.Errorf("use safeweb.Config.APIMux and safeweb.Config.BrowserMux instead of http.Server.Handler")
- }
- s.h.Handler = s
- return s, nil
- }
- type handlerType int
- const (
- unknownHandler handlerType = iota
- apiHandler
- browserHandler
- )
- func (h handlerType) String() string {
- switch h {
- case browserHandler:
- return "browser"
- case apiHandler:
- return "api"
- default:
- return "unknown"
- }
- }
- // checkHandlerType returns either apiHandler or browserHandler, depending on
- // whether apiPattern or browserPattern is more specific (i.e. which pattern
- // contains more pathname components). If they are equally specific, it returns
- // unknownHandler.
- func checkHandlerType(apiPattern, browserPattern string) handlerType {
- apiPattern, browserPattern = path.Clean(apiPattern), path.Clean(browserPattern)
- c := cmp.Compare(strings.Count(apiPattern, "/"), strings.Count(browserPattern, "/"))
- if apiPattern == "/" || browserPattern == "/" {
- c = cmp.Compare(len(apiPattern), len(browserPattern))
- }
- switch {
- case c > 0:
- return apiHandler
- case c < 0:
- return browserHandler
- default:
- return unknownHandler
- }
- }
- func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- // if we are not in a secure context, signal to the CSRF middleware that
- // TLS-only header checks should be skipped
- if !s.Config.SecureContext {
- r = csrf.PlaintextHTTPRequest(r)
- }
- _, bp := s.BrowserMux.Handler(r)
- _, ap := s.APIMux.Handler(r)
- switch {
- case bp == "" && ap != "": // APIMux match
- s.serveAPI(w, r)
- case bp != "" && ap == "": // BrowserMux match
- s.serveBrowser(w, r)
- case bp == "" && ap == "": // neither match
- http.NotFound(w, r)
- case bp != "" && ap != "":
- // Both muxes match the path. Route to the more-specific handler (as
- // determined by the number of components in the path). If it somehow
- // happens that both patterns are equally specific, something strange
- // has happened; say so.
- //
- // NOTE: checkHandlerType does not know about what the serve* handlers
- // will do — including, possibly, redirecting to more specific patterns.
- // If you have a less-specific pattern that redirects to something more
- // specific, this logic will not do what you wanted.
- handler := checkHandlerType(ap, bp)
- switch handler {
- case apiHandler:
- s.serveAPI(w, r)
- case browserHandler:
- s.serveBrowser(w, r)
- default:
- s := http.StatusInternalServerError
- log.Printf("conflicting mux paths in safeweb: request %q matches browser mux pattern %q and API mux pattern %q; returning %d", r.URL.Path, bp, ap, s)
- http.Error(w, "multiple handlers match this request", s)
- }
- }
- }
- func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
- // disallow x-www-form-urlencoded requests to the API
- if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" {
- http.Error(w, "invalid content type", http.StatusBadRequest)
- return
- }
- // set CORS headers for pre-flight OPTIONS requests if any were configured
- if r.Method == "OPTIONS" && len(s.AccessControlAllowOrigin) > 0 {
- w.Header().Set("Access-Control-Allow-Origin", strings.Join(s.AccessControlAllowOrigin, ", "))
- w.Header().Set("Access-Control-Allow-Methods", strings.Join(s.AccessControlAllowMethods, ", "))
- }
- s.APIMux.ServeHTTP(w, r)
- }
- func (s *Server) serveBrowser(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Security-Policy", s.csp)
- w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Referer-Policy", "same-origin")
- w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
- if s.SecureContext {
- w.Header().Set("Strict-Transport-Security", cmp.Or(s.StrictTransportSecurityOptions, DefaultStrictTransportSecurityOptions))
- }
- s.csrfProtect(s.BrowserMux).ServeHTTP(w, r)
- }
- // ServeRedirectHTTP serves a single HTTP handler on the provided listener that
- // redirects all incoming HTTP requests to the HTTPS address of the provided
- // fully qualified domain name (FQDN). Callers are responsible for closing the
- // listener.
- func (s *Server) ServeRedirectHTTP(ln net.Listener, fqdn string) error {
- return http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- new := url.URL{
- Scheme: "https",
- Host: fqdn,
- Path: r.URL.Path,
- RawQuery: r.URL.RawQuery,
- }
- http.Redirect(w, r, new.String(), http.StatusMovedPermanently)
- }))
- }
- // Serve starts the server and listens on the provided listener. It will block
- // until the server is closed. The caller is responsible for closing the
- // listener.
- func (s *Server) Serve(ln net.Listener) error {
- return s.h.Serve(ln)
- }
- // ListenAndServe listens on the TCP network address addr and then calls Serve
- // to handle requests on incoming connections. If addr == "", ":http" is used.
- func (s *Server) ListenAndServe(addr string) error {
- if addr == "" {
- addr = ":http"
- }
- lst, err := net.Listen("tcp", addr)
- if err != nil {
- return err
- }
- return s.Serve(lst)
- }
- // Close closes all client connections and stops accepting new ones.
- func (s *Server) Close() error {
- return s.h.Close()
- }
- // Shutdown gracefully shuts down the server without interrupting any active
- // connections. It has the same semantics as[http.Server.Shutdown].
- func (s *Server) Shutdown(ctx context.Context) error { return s.h.Shutdown(ctx) }
|