|
|
@@ -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))
|
|
|
+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
|
|
|
+ }
|
|
|
|
|
|
- // 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)
|
|
|
+ // 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)
|
|
|
|
|
|
- // 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))
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
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.
|