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:
Patrick O'Doherty
2025-05-22 12:26:02 -07:00
committed by GitHub
parent 3ee4c60ff0
commit a05924a9e5
8 changed files with 184 additions and 169 deletions

View File

@@ -249,7 +249,6 @@ export function useAPI() {
return api
}
let csrfToken: string
let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
@@ -298,12 +297,10 @@ export function apiFetch<T>(
headers: {
Accept: "application/json",
"Content-Type": contentType,
"X-CSRF-Token": csrfToken,
},
body: body,
})
.then((r) => {
updateCsrfToken(r)
if (!r.ok) {
return r.text().then((err) => {
throw new Error(err)
@@ -322,13 +319,6 @@ export function apiFetch<T>(
})
}
function updateCsrfToken(r: Response) {
const tok = r.headers.get("X-CSRF-Token")
if (tok) {
csrfToken = tok
}
}
export function setSynoToken(token?: string) {
synoToken = token
}

View File

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

View File

@@ -11,7 +11,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"net/url"
@@ -21,14 +20,12 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/gorilla/csrf"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
"tailscale.com/tstest/nettest"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
@@ -1492,81 +1489,99 @@ func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg
}
func TestCSRFProtect(t *testing.T) {
s := &Server{}
mux := http.NewServeMux()
mux.HandleFunc("GET /test/csrf-token", func(w http.ResponseWriter, r *http.Request) {
token := csrf.Token(r)
_, err := io.WriteString(w, token)
if err != nil {
t.Fatal(err)
}
})
mux.HandleFunc("POST /test/csrf-protected", func(w http.ResponseWriter, r *http.Request) {
_, err := io.WriteString(w, "ok")
if err != nil {
t.Fatal(err)
}
})
h := s.withCSRF(mux)
ser := nettest.NewHTTPServer(nettest.GetNetwork(t), h)
defer ser.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("unable to construct cookie jar: %v", err)
tests := []struct {
name string
method string
secFetchSite string
host string
origin string
originOverride string
wantError bool
}{
{
name: "GET requests with no header are allowed",
method: "GET",
},
{
name: "POST requests with same-origin are allowed",
method: "POST",
secFetchSite: "same-origin",
},
{
name: "POST requests with cross-site are not allowed",
method: "POST",
secFetchSite: "cross-site",
wantError: true,
},
{
name: "POST requests with unknown sec-fetch-site values are not allowed",
method: "POST",
secFetchSite: "new-unknown-value",
wantError: true,
},
{
name: "POST requests with none are not allowed",
method: "POST",
secFetchSite: "none",
wantError: true,
},
{
name: "POST requests with no sec-fetch-site header but matching host and origin are allowed",
method: "POST",
host: "example.com",
origin: "https://example.com",
},
{
name: "POST requests with no sec-fetch-site and non-matching host and origin are not allowed",
method: "POST",
host: "example.com",
origin: "https://example.net",
wantError: true,
},
{
name: "POST requests with no sec-fetch-site and and origin that matches the override are allowed",
method: "POST",
originOverride: "example.net",
host: "internal.example.foo", // Host can be changed by reverse proxies
origin: "http://example.net",
},
}
client := ser.Client()
client.Jar = jar
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "OK")
})
// make GET request to populate cookie jar
resp, err := client.Get(ser.URL + "/test/csrf-token")
if err != nil {
t.Fatalf("unable to make request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %v", resp.Status)
}
tokenBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("unable to read body: %v", err)
}
s := &Server{
originOverride: tt.originOverride,
}
withCSRF := s.csrfProtect(handler)
csrfToken := strings.TrimSpace(string(tokenBytes))
if csrfToken == "" {
t.Fatal("empty csrf token")
}
r := httptest.NewRequest(tt.method, "http://example.com/", nil)
if tt.secFetchSite != "" {
r.Header.Set("Sec-Fetch-Site", tt.secFetchSite)
}
if tt.host != "" {
r.Host = tt.host
}
if tt.origin != "" {
r.Header.Set("Origin", tt.origin)
}
// make a POST request without the CSRF header; ensure it fails
resp, err = client.Post(ser.URL+"/test/csrf-protected", "text/plain", nil)
if err != nil {
t.Fatalf("unable to make request: %v", err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("unexpected status: %v", resp.Status)
}
// make a POST request with the CSRF header; ensure it succeeds
req, err := http.NewRequest("POST", ser.URL+"/test/csrf-protected", nil)
if err != nil {
t.Fatalf("error building request: %v", err)
}
req.Header.Set("X-CSRF-Token", csrfToken)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("unable to make request: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %v", resp.Status)
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("unable to read body: %v", err)
}
if string(out) != "ok" {
t.Fatalf("unexpected body: %q", out)
w := httptest.NewRecorder()
withCSRF.ServeHTTP(w, r)
res := w.Result()
defer res.Body.Close()
if tt.wantError {
if res.StatusCode != http.StatusForbidden {
t.Errorf("expected status forbidden, got %v", res.StatusCode)
}
return
}
if res.StatusCode != http.StatusOK {
t.Errorf("expected status ok, got %v", res.StatusCode)
}
})
}
}