client/web: add Sec-Fetch-Site CSRF protection (#16046)
RELNOTE=Fix CSRF errors in the client Web UI Replace gorilla/csrf with a Sec-Fetch-Site based CSRF protection middleware that falls back to comparing the Host & Origin headers if no SFS value is passed by the client. Add an -origin override to the web CLI that allows callers to specify the origin at which the web UI will be available if it is hosted behind a reverse proxy or within another application via CGI. Updates #14872 Updates #15065 Signed-off-by: Patrick O'Doherty <patrick@tailscale.com>
This commit is contained in:
committed by
GitHub
parent
3ee4c60ff0
commit
a05924a9e5
+78
-61
@@ -6,7 +6,6 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,14 +13,14 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/clientupdate"
|
||||
@@ -60,6 +59,12 @@ type Server struct {
|
||||
cgiMode bool
|
||||
pathPrefix string
|
||||
|
||||
// originOverride is the origin that the web UI is accessible from.
|
||||
// This value is used in the fallback CSRF checks when Sec-Fetch-Site is not
|
||||
// available. In this case the application will compare Host and Origin
|
||||
// header values to determine if the request is from the same origin.
|
||||
originOverride string
|
||||
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
assetsCleanup func() // called from Server.Shutdown
|
||||
@@ -150,6 +155,9 @@ type ServerOpts struct {
|
||||
// as completed.
|
||||
// This field is required for ManageServerMode mode.
|
||||
WaitAuthURL func(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
|
||||
|
||||
// OriginOverride specifies the origin that the web UI will be accessible from if hosted behind a reverse proxy or CGI.
|
||||
OriginOverride string
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
@@ -169,15 +177,16 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
opts.LocalClient = &local.Client{}
|
||||
}
|
||||
s = &Server{
|
||||
mode: opts.Mode,
|
||||
logf: opts.Logf,
|
||||
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
timeNow: opts.TimeNow,
|
||||
newAuthURL: opts.NewAuthURL,
|
||||
waitAuthURL: opts.WaitAuthURL,
|
||||
mode: opts.Mode,
|
||||
logf: opts.Logf,
|
||||
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
timeNow: opts.TimeNow,
|
||||
newAuthURL: opts.NewAuthURL,
|
||||
waitAuthURL: opts.WaitAuthURL,
|
||||
originOverride: opts.OriginOverride,
|
||||
}
|
||||
if opts.PathPrefix != "" {
|
||||
// Enforce that path prefix always has a single leading '/'
|
||||
@@ -205,7 +214,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
|
||||
var metric string
|
||||
s.apiHandler, metric = s.modeAPIHandler(s.mode)
|
||||
s.apiHandler = s.withCSRF(s.apiHandler)
|
||||
s.apiHandler = s.csrfProtect(s.apiHandler)
|
||||
|
||||
// Don't block startup on reporting metric.
|
||||
// Report in separate go routine with 5 second timeout.
|
||||
@@ -218,23 +227,64 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) withCSRF(h http.Handler) http.Handler {
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
|
||||
// ref https://github.com/tailscale/tailscale/pull/14822
|
||||
// signal to the CSRF middleware that the request is being served over
|
||||
// plaintext HTTP to skip TLS-only header checks.
|
||||
withSetPlaintext := func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = csrf.PlaintextHTTPRequest(r)
|
||||
func (s *Server) csrfProtect(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// CSRF is not required for GET, HEAD, or OPTIONS requests.
|
||||
if slices.Contains([]string{"GET", "HEAD", "OPTIONS"}, r.Method) {
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NB: the order of the withSetPlaintext and csrfProtect calls is important
|
||||
// to ensure that we signal to the CSRF middleware that the request is being
|
||||
// served over plaintext HTTP and not over TLS as it presumes by default.
|
||||
return withSetPlaintext(csrfProtect(h))
|
||||
// first attempt to use Sec-Fetch-Site header (sent by all modern
|
||||
// browsers to "potentially trustworthy" origins i.e. localhost or those
|
||||
// served over HTTPS)
|
||||
secFetchSite := r.Header.Get("Sec-Fetch-Site")
|
||||
if secFetchSite == "same-origin" {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
} else if secFetchSite != "" {
|
||||
http.Error(w, fmt.Sprintf("CSRF request denied with Sec-Fetch-Site %q", secFetchSite), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// if Sec-Fetch-Site is not available we presume we are operating over HTTP.
|
||||
// We fall back to comparing the Origin & Host headers.
|
||||
|
||||
// use the Host header to determine the expected origin
|
||||
// (use the override if set to allow for reverse proxying)
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
http.Error(w, "CSRF request denied with no Host header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if s.originOverride != "" {
|
||||
host = s.originOverride
|
||||
}
|
||||
|
||||
originHeader := r.Header.Get("Origin")
|
||||
if originHeader == "" {
|
||||
http.Error(w, "CSRF request denied with no Origin header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
parsedOrigin, err := url.Parse(originHeader)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("CSRF request denied with invalid Origin %q", r.Header.Get("Origin")), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
origin := parsedOrigin.Host
|
||||
if origin == "" {
|
||||
http.Error(w, "CSRF request denied with no host in the Origin header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if origin != host {
|
||||
http.Error(w, fmt.Sprintf("CSRF request denied with mismatched Origin %q and Host %q", origin, host), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) modeAPIHandler(mode ServerMode) (http.Handler, string) {
|
||||
@@ -452,7 +502,6 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
switch {
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
s.serveGetNodeData(w, r)
|
||||
@@ -575,7 +624,6 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
@@ -1276,37 +1324,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// csrfKey returns a key that can be used for CSRF protection.
|
||||
// If an error occurs during key creation, the error is logged and the active process terminated.
|
||||
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|
||||
// If an error occurs during key storage, the error is logged and the active process terminated.
|
||||
func (s *Server) csrfKey() []byte {
|
||||
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
|
||||
|
||||
// if running in CGI mode, try to read from disk, but ignore errors
|
||||
if s.cgiMode {
|
||||
key, _ := os.ReadFile(csrfFile)
|
||||
if len(key) == 32 {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
// create a new key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatalf("error generating CSRF key: %v", err)
|
||||
}
|
||||
|
||||
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
|
||||
if s.cgiMode {
|
||||
if err := os.WriteFile(csrfFile, key, 0600); err != nil {
|
||||
log.Fatalf("unable to store CSRF key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests,
|
||||
// then strips it before invoking h.
|
||||
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
|
||||
|
||||
Reference in New Issue
Block a user