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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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.
@ -178,6 +186,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
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 {
func (s *Server) csrfProtect(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = csrf.PlaintextHTTPRequest(r)
// 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{}
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",
},
}
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)
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")
})
s := &Server{
originOverride: tt.originOverride,
}
withCSRF := s.csrfProtect(handler)
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)
}
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)
}
})
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)
}
client := ser.Client()
client.Jar = jar
// 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)
}
csrfToken := strings.TrimSpace(string(tokenBytes))
if csrfToken == "" {
t.Fatal("empty csrf token")
}
// 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)
}
}

View File

@ -144,8 +144,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from github.com/prometheus-community/pro-bing+
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
@ -1112,13 +1110,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/tailscale/web-client-prebuilt+
encoding from encoding/gob+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/csv from github.com/spf13/pflag
encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
@ -1140,7 +1137,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/maphash from go4.org/mem
html from html/template+
html/template from github.com/gorilla/csrf+
html/template from tailscale.com/util/eventbus
internal/abi from crypto/x509/internal/macos+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
@ -1172,7 +1169,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/runtime/math from internal/runtime/maps+
internal/runtime/sys from crypto/subtle+
L internal/runtime/syscall from runtime+
internal/saferio from debug/pe+
W internal/saferio from debug/pe
internal/singleflight from net
internal/stringslite from embed+
internal/sync from sync+

View File

@ -43,6 +43,7 @@ Tailscale, as opposed to a CLI or a native app.
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)")
webf.BoolVar(&webArgs.readonly, "readonly", false, "run web UI in read-only mode")
webf.StringVar(&webArgs.origin, "origin", "", "origin at which the web UI is served (if behind a reverse proxy or used with cgi)")
return webf
})(),
Exec: runWeb,
@ -53,6 +54,7 @@ var webArgs struct {
cgi bool
prefix string
readonly bool
origin string
}
func tlsConfigFromEnvironment() *tls.Config {
@ -115,6 +117,9 @@ func runWeb(ctx context.Context, args []string) error {
if webArgs.readonly {
opts.Mode = web.ReadOnlyServerMode
}
if webArgs.origin != "" {
opts.OriginOverride = webArgs.origin
}
webServer, err := web.NewServer(opts)
if err != nil {
log.Printf("tailscale.web: %v", err)

View File

@ -27,8 +27,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
DW github.com/google/uuid from tailscale.com/clientupdate+
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
@ -319,12 +317,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/peterbourgon/ff/v3+
encoding from encoding/gob+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
@ -338,7 +335,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
hash/crc32 from compress/gzip+
hash/maphash from go4.org/mem
html from html/template+
html/template from github.com/gorilla/csrf+
html/template from tailscale.com/util/eventbus
image from github.com/skip2/go-qrcode+
image/color from github.com/skip2/go-qrcode+
image/png from github.com/skip2/go-qrcode
@ -372,7 +369,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
internal/runtime/math from internal/runtime/maps+
internal/runtime/sys from crypto/subtle+
L internal/runtime/syscall from runtime+
internal/saferio from debug/pe+
W internal/saferio from debug/pe
internal/singleflight from net
internal/stringslite from embed+
internal/sync from sync+

View File

@ -123,8 +123,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
DW github.com/google/uuid from tailscale.com/clientupdate+
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
@ -590,12 +588,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/tailscale/web-client-prebuilt+
encoding from encoding/gob+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
@ -609,7 +606,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
hash/crc32 from compress/gzip+
hash/maphash from go4.org/mem
html from html/template+
html/template from github.com/gorilla/csrf+
html/template from tailscale.com/util/eventbus
internal/abi from crypto/x509/internal/macos+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
@ -640,7 +637,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
internal/runtime/math from internal/runtime/maps+
internal/runtime/sys from crypto/subtle+
L internal/runtime/syscall from runtime+
internal/saferio from debug/pe+
W internal/saferio from debug/pe
internal/singleflight from net
internal/stringslite from embed+
internal/sync from sync+

View File

@ -113,8 +113,6 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
DWI github.com/google/uuid from github.com/prometheus-community/pro-bing+
LDW github.com/gorilla/csrf from tailscale.com/client/web
LDW github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
@ -534,12 +532,11 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from github.com/tailscale/web-client-prebuilt+
encoding from encoding/gob+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
LDW encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
@ -553,7 +550,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
hash/crc32 from compress/gzip+
hash/maphash from go4.org/mem
html from html/template+
LDW html/template from github.com/gorilla/csrf+
LDW html/template from tailscale.com/util/eventbus
internal/abi from crypto/x509/internal/macos+
internal/asan from internal/runtime/maps+
internal/bisect from internal/godebug
@ -584,7 +581,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
internal/runtime/math from internal/runtime/maps+
internal/runtime/sys from crypto/subtle+
LA internal/runtime/syscall from runtime+
LDW internal/saferio from debug/pe+
W internal/saferio from debug/pe
internal/singleflight from net
internal/stringslite from embed+
internal/sync from sync+