diff --git a/client/web/web.go b/client/web/web.go index d62c7fd40..c76cd4955 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -8,6 +8,7 @@ import ( "context" "crypto/rand" "encoding/json" + "errors" "fmt" "io" "log" @@ -60,7 +61,10 @@ type Server struct { browserSessions sync.Map } -const tsWebCookieName = "TS-Web-Session" +const ( + sessionCookieName = "TS-Web-Session" + sessionCookieExpiry = time.Hour * 24 * 30 // 30 days +) // browserSession holds data about a user's browser session // on the full management web client. @@ -70,10 +74,37 @@ type browserSession struct { ID string SrcNode tailcfg.StableNodeID SrcUser tailcfg.UserID - AuthPath string // control server path for user to authenticate the session + AuthURL string // control server URL for user to authenticate the session Authenticated time.Time // when zero, authentication not complete } +// isAuthorized reports true if the given session is authorized +// to be used by its associated user to access the full management +// web client. +// +// isAuthorized is true only when s.Authenticated is non-zero +// (i.e. the user has authenticated the session) and the session +// is not expired. +// 2023-10-05: Sessions expire by default after 30 days. +func (s *browserSession) isAuthorized() bool { + switch { + case s == nil: + return false + case s.Authenticated.IsZero(): + return false // awaiting auth + case s.isExpired(): // TODO: add time field to server? + return false // expired + } + return true +} + +// isExpired reports true if s is expired. +// 2023-10-05: Sessions expire by default after 30 days. +// If s.Authenticated is zero, isExpired reports false. +func (s *browserSession) isExpired() bool { + return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server? +} + // ServerOpts contains options for constructing a new Server. type ServerOpts struct { DevMode bool @@ -206,13 +237,130 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { return } +var ( + errNoSession = errors.New("no-browser-session") + errNotUsingTailscale = errors.New("not-using-tailscale") + errTaggedSource = errors.New("tagged-source") + errNotOwner = errors.New("not-owner") +) + +// getTailscaleBrowserSession retrieves the browser session associated with +// the request, if one exists. +// +// An error is returned in any of the following cases: +// +// - (errNotUsingTailscale) The request was not made over tailscale. +// +// - (errNoSession) The request does not have a session. +// +// - (errTaggedSource) The source is a tagged node. Users must use their +// own user-owned devices to manage other nodes' web clients. +// +// - (errNotOwner) The source is not the owner of this client (if the +// client is user-owned). Only the owner is allowed to manage the +// node via the web client. +// +// If no error is returned, the browserSession is always non-nil. +// getTailscaleBrowserSession does not check whether the session has been +// authorized by the user. Callers can use browserSession.isAuthorized. +func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) { + whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) + switch { + case err != nil: + return nil, errNotUsingTailscale + case whoIs.Node.IsTagged(): + return nil, errTaggedSource + } + srcNode := whoIs.Node.StableID + srcUser := whoIs.UserProfile.ID + + status, err := s.lc.StatusWithoutPeers(r.Context()) + switch { + case err != nil: + return nil, err + case status.Self == nil: + return nil, errors.New("missing self node in tailscale status") + case !status.Self.IsTagged() && status.Self.UserID != srcUser: + return nil, errNotOwner + } + + cookie, err := r.Cookie(sessionCookieName) + if errors.Is(err, http.ErrNoCookie) { + return nil, errNoSession + } else if err != nil { + return nil, err + } + v, ok := s.browserSessions.Load(cookie.Value) + if !ok { + return nil, errNoSession + } + session := v.(*browserSession) + if session.SrcNode != srcNode || session.SrcUser != srcUser { + // In this case the browser cookie is associated with another tailscale node. + // Maybe the source browser's machine was logged out and then back in as a different node. + // Return errNoSession because there is no session for this user. + return nil, errNoSession + } else if session.isExpired() { + // Session expired, remove from session map and return errNoSession. + s.browserSessions.Delete(session.ID) + return nil, errNoSession + } + return session, nil +} + +type authResponse struct { + OK bool `json:"ok"` // true when user has valid auth session + AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take + Error string `json:"error,omitempty"` // filled when Ok is false +} + +func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { + var resp authResponse + + session, err := s.getTailscaleBrowserSession(r) + switch { + case err != nil && !errors.Is(err, errNoSession): + resp = authResponse{OK: false, Error: err.Error()} + case session == nil: + // TODO(tailscale/corp#14335): Create a new auth path from control, + // and store back to s.browserSessions and request cookie. + case !session.isAuthorized(): + // TODO(tailscale/corp#14335): Check on the session auth path status from control, + // and store back to s.browserSessions. + default: + resp = authResponse{OK: true} + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") +} + // serveAPI serves requests for the web client api. // It should only be called by Server.ServeHTTP, via Server.apiHandler, // which protects the handler using gorilla csrf. func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { - // TODO(sonia,2023-09-26): Currently the full web client is served - // directly from platform plugins, so uses platform native auth. - if ok := authorizePlatformRequest(w, r); !ok { + if s.tsDebugMode == "full" { + // tailscale/corp#14335: Only restrict to tailscale auth in debug "full" web client mode. + // TODO(sonia,will): Switch serveAPI over to always require TS auth when we're ready + // to remove the debug flags. + // For now, existing client uses platform auth (else case below). + + if r.URL.Path == "/api/auth" { + // Serve auth, which creates a new session for the user to authenticate, + // in the case that the request doesn't already have one. + s.serveTailscaleAuth(w, r) + return + } + // For all other endpoints, require a valid session to proceed. + session, err := s.getTailscaleBrowserSession(r) + if err != nil || !session.isAuthorized() { + http.Error(w, "no valid session", http.StatusUnauthorized) + return + } + } else if ok := authorizePlatformRequest(w, r); !ok { return } diff --git a/client/web/web_test.go b/client/web/web_test.go index 8289d5536..0c49d2109 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -4,6 +4,8 @@ package web import ( + "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,9 +13,15 @@ import ( "net/url" "strings" "testing" + "time" + "github.com/google/go-cmp/cmp" "tailscale.com/client/tailscale" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn/ipnstate" "tailscale.com/net/memnet" + "tailscale.com/tailcfg" + "tailscale.com/types/views" ) func TestQnapAuthnURL(t *testing.T) { @@ -129,3 +137,191 @@ func TestServeAPI(t *testing.T) { }) } } + +func TestGetTailscaleBrowserSession(t *testing.T) { + userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} + userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)} + + userANodeIP := "100.100.100.101" + userBNodeIP := "100.100.100.102" + taggedNodeIP := "100.100.100.103" + + var selfNode *ipnstate.PeerStatus + tags := views.SliceOf([]string{"tag:server"}) + tailnetNodes := map[string]*apitype.WhoIsResponse{ + userANodeIP: { + Node: &tailcfg.Node{StableID: "Node1"}, + UserProfile: userA, + }, + userBNodeIP: { + Node: &tailcfg.Node{StableID: "Node2"}, + UserProfile: userB, + }, + taggedNodeIP: { + Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()}, + }, + } + + lal := memnet.Listen("local-tailscaled.sock:80") + defer lal.Close() + // Serve a testing localapi handler so we can simulate + // whois responses without a functioning tailnet. + localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/localapi/v0/whois": + addr := r.URL.Query().Get("addr") + if addr == "" { + t.Fatalf("/whois call missing \"addr\" query") + } + if node := tailnetNodes[addr]; node != nil { + if err := json.NewEncoder(w).Encode(&node); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + } + http.Error(w, "not a node", http.StatusUnauthorized) + return + case "/localapi/v0/status": + status := ipnstate.Status{Self: selfNode} + if err := json.NewEncoder(w).Encode(status); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + default: + // Only the above two endpoints get triggered from getTailscaleBrowserSession. + // No need to mock any of the other localapi endpoint. + t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) + } + })} + defer localapi.Close() + go localapi.Serve(lal) + + s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}} + + // Add some browser sessions to cache state. + userASession := &browserSession{ + ID: "cookie1", + SrcNode: "Node1", + SrcUser: userA.ID, + Authenticated: time.Time{}, // not yet authenticated + } + userBSession := &browserSession{ + ID: "cookie2", + SrcNode: "Node2", + SrcUser: userB.ID, + Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired + } + userASessionAuthorized := &browserSession{ + ID: "cookie3", + SrcNode: "Node1", + SrcUser: userA.ID, + Authenticated: time.Now(), // authenticated and not expired + } + s.browserSessions.Store(userASession.ID, userASession) + s.browserSessions.Store(userBSession.ID, userBSession) + s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized) + + tests := []struct { + name string + selfNode *ipnstate.PeerStatus + remoteAddr string + cookie string + + wantSession *browserSession + wantError error + wantIsAuthorized bool // response from session.isAuthorized + }{ + { + name: "not-connected-over-tailscale", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: "77.77.77.77", + wantSession: nil, + wantError: errNotUsingTailscale, + }, + { + name: "no-session-user-self-node", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: "not-a-cookie", + wantSession: nil, + wantError: errNoSession, + }, + { + name: "no-session-tagged-self-node", + selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags}, + remoteAddr: userANodeIP, + wantSession: nil, + wantError: errNoSession, + }, + { + name: "not-owner", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userBNodeIP, + wantSession: nil, + wantError: errNotOwner, + }, + { + name: "tagged-source", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: taggedNodeIP, + wantSession: nil, + wantError: errTaggedSource, + }, + { + name: "has-session", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: userASession.ID, + wantSession: userASession, + wantError: nil, + }, + { + name: "has-authorized-session", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: userASessionAuthorized.ID, + wantSession: userASessionAuthorized, + wantError: nil, + wantIsAuthorized: true, + }, + { + name: "session-associated-with-different-source", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, + remoteAddr: userBNodeIP, + cookie: userASession.ID, + wantSession: nil, + wantError: errNoSession, + }, + { + name: "session-expired", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, + remoteAddr: userBNodeIP, + cookie: userBSession.ID, + wantSession: nil, + wantError: errNoSession, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selfNode = tt.selfNode + r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}} + if tt.cookie != "" { + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) + } + session, err := s.getTailscaleBrowserSession(r) + if !errors.Is(err, tt.wantError) { + t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) + } + if diff := cmp.Diff(session, tt.wantSession); diff != "" { + t.Errorf("wrong session; (-got+want):%v", diff) + } + if gotIsAuthorized := session.isAuthorized(); gotIsAuthorized != tt.wantIsAuthorized { + t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized) + } + }) + } +} diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index 03b41d6a9..07fcd08d1 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -299,6 +299,11 @@ func (ps *PeerStatus) HasCap(cap tailcfg.NodeCapability) bool { return ps.CapMap.Contains(cap) || slices.Contains(ps.Capabilities, cap) } +// IsTagged reports whether ps is tagged. +func (ps *PeerStatus) IsTagged() bool { + return ps.Tags != nil && ps.Tags.Len() > 0 +} + // StatusBuilder is a request to construct a Status. A new StatusBuilder is // passed to various subsystems which then call methods on it to populate state. // Call its Status method to return the final constructed Status.