client/web: restrict full management client behind browser sessions

Adds `getTailscaleBrowserSession` to pull the user's session out of
api requests, and `serveTailscaleAuth` to provide the "/api/auth"
endpoint for browser to request auth status and new sessions.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/9677/head
Sonia Appasamy 8 months ago committed by Sonia Appasamy
parent 7868393200
commit 3befc0ef02

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

@ -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)
}
})
}
}

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

Loading…
Cancel
Save