diff --git a/client/web/src/api.ts b/client/web/src/api.ts index 9414e2d5d..e780c7645 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -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( 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( }) } -function updateCsrfToken(r: Response) { - const tok = r.headers.get("X-CSRF-Token") - if (tok) { - csrfToken = tok - } -} - export function setSynoToken(token?: string) { synoToken = token } diff --git a/client/web/web.go b/client/web/web.go index 6eccdadcf..f3158cd1f 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -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. diff --git a/client/web/web_test.go b/client/web/web_test.go index 2a6bc787a..12dbb5c79 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -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) + } + }) } } diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 12fb5cf2e..782603df0 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -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+ diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index e209d388e..5e1821dd0 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -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) diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 03bf2f94c..8c3b404b1 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -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+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 6de0ddc39..d9a9cac65 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -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+ diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 662752554..3b705f680 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -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+