@ -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.