mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-03 10:31:59 +00:00
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>
This commit is contained in:
committed by
Sonia Appasamy
parent
7868393200
commit
3befc0ef02
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user