summaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorkari-ts <kari@tailscale.com>2025-03-19 11:28:04 -0700
committerkari-ts <kari@tailscale.com>2025-04-04 14:24:56 -0700
commit6d5c7b11913e09b061e863411ad488dc44a13870 (patch)
tree9e1789b5080ae4a92523611e49920dcb1102604b /client
parentca50599c95e0a4cb7b4aab179e866e202f10c0c4 (diff)
parent3a2c92f08eac8cd8f50356ff288e40a28636ee42 (diff)
downloadtailscale-kari/taildropsaf.tar.xz
tailscale-kari/taildropsaf.zip
-check if Context.getExternalFilesDirs works as is for private dir
Diffstat (limited to 'client')
-rw-r--r--client/tailscale/devices.go7
-rw-r--r--client/web/web.go56
-rw-r--r--client/web/web_test.go92
3 files changed, 131 insertions, 24 deletions
diff --git a/client/tailscale/devices.go b/client/tailscale/devices.go
index b79191d53..0664f9e63 100644
--- a/client/tailscale/devices.go
+++ b/client/tailscale/devices.go
@@ -79,6 +79,13 @@ type Device struct {
// Tailscale have attempted to collect this from the device but it has not
// opted in, PostureIdentity will have Disabled=true.
PostureIdentity *DevicePostureIdentity `json:"postureIdentity"`
+
+ // TailnetLockKey is the tailnet lock public key of the node as a hex string.
+ TailnetLockKey string `json:"tailnetLockKey,omitempty"`
+
+ // TailnetLockErr indicates an issue with the tailnet lock node-key signature
+ // on this device. This field is only populated when tailnet lock is enabled.
+ TailnetLockErr string `json:"tailnetLockError,omitempty"`
}
type DevicePostureIdentity struct {
diff --git a/client/web/web.go b/client/web/web.go
index 6203b4c18..6eccdadcf 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -203,15 +203,25 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
}
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
- var metric string // clientmetric to report on startup
+ var metric string
+ s.apiHandler, metric = s.modeAPIHandler(s.mode)
+ s.apiHandler = s.withCSRF(s.apiHandler)
- // Create handler for "/api" requests with CSRF protection.
- // We don't require secure cookies, since the web client is regularly used
- // on network appliances that are served on local non-https URLs.
- // The client is secured by limiting the interface it listens on,
- // or by authenticating requests before they reach the web client.
+ // Don't block startup on reporting metric.
+ // Report in separate go routine with 5 second timeout.
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ s.lc.IncrementCounter(ctx, metric, 1)
+ }()
+
+ 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 {
@@ -221,27 +231,24 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
})
}
- switch s.mode {
+ // 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) {
+ switch mode {
case LoginServerMode:
- s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveLoginAPI)))
- metric = "web_login_client_initialization"
+ return http.HandlerFunc(s.serveLoginAPI), "web_login_client_initialization"
case ReadOnlyServerMode:
- s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveLoginAPI)))
- metric = "web_readonly_client_initialization"
+ return http.HandlerFunc(s.serveLoginAPI), "web_readonly_client_initialization"
case ManageServerMode:
- s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveAPI)))
- metric = "web_client_initialization"
+ return http.HandlerFunc(s.serveAPI), "web_client_initialization"
+ default: // invalid mode
+ log.Fatalf("invalid mode: %v", mode)
}
-
- // Don't block startup on reporting metric.
- // Report in separate go routine with 5 second timeout.
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- s.lc.IncrementCounter(ctx, metric, 1)
- }()
-
- return s, nil
+ return nil, ""
}
func (s *Server) Shutdown() {
@@ -328,7 +335,8 @@ func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (han
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
)
// allow requests on quad-100 (or ipv6 equivalent)
- if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
+ host := strings.TrimSuffix(r.Host, ":80")
+ if host == ipv4ServiceHost || host == ipv6ServiceHost {
return false
}
diff --git a/client/web/web_test.go b/client/web/web_test.go
index b9242f6ac..334b403a6 100644
--- a/client/web/web_test.go
+++ b/client/web/web_test.go
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/http/cookiejar"
"net/http/httptest"
"net/netip"
"net/url"
@@ -20,6 +21,7 @@ import (
"time"
"github.com/google/go-cmp/cmp"
+ "github.com/gorilla/csrf"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
@@ -1175,6 +1177,16 @@ func TestRequireTailscaleIP(t *testing.T) {
target: "http://[fd7a:115c:a1e0::53]/",
wantHandled: false,
},
+ {
+ name: "quad-100:80",
+ target: "http://100.100.100.100:80/",
+ wantHandled: false,
+ },
+ {
+ name: "ipv6-service-addr:80",
+ target: "http://[fd7a:115c:a1e0::53]:80/",
+ wantHandled: false,
+ },
}
for _, tt := range tests {
@@ -1477,3 +1489,83 @@ func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg
return nil, errors.New("unknown id")
}
}
+
+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 := httptest.NewServer(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)
+ }
+}