// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package web import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/netip" "net/url" "slices" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/memnet" "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/httpm" ) func TestQnapAuthnURL(t *testing.T) { query := url.Values{ "qtoken": []string{"token"}, } tests := []struct { name string in string want string }{ { name: "localhost http", in: "http://localhost:8088/", want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token", }, { name: "localhost https", in: "https://localhost:5000/", want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token", }, { name: "IP http", in: "http://10.1.20.4:80/", want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token", }, { name: "IP6 https", in: "https://[ff7d:0:1:2::1]/", want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token", }, { name: "hostname https", in: "https://qnap.example.com/", want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token", }, { name: "invalid URL", in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.", want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", }, { name: "err != nil", in: "http://192.168.0.%31/", want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u := qnapAuthnURL(tt.in, query) if u != tt.want { t.Errorf("expected url: %q, got: %q", tt.want, u) } }) } } // TestServeAPI tests the web client api's handling of // 1. invalid endpoint errors // 2. permissioning of api endpoints based on node capabilities func TestServeAPI(t *testing.T) { selfTags := views.SliceOf([]string{"tag:server"}) self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags} prefs := &ipn.Prefs{} remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} remoteIPWithAllCapabilities := "100.100.100.101" remoteIPWithNoCapabilities := "100.100.100.102" lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, map[string]*apitype.WhoIsResponse{ remoteIPWithAllCapabilities: { Node: &tailcfg.Node{StableID: "node1"}, UserProfile: remoteUser, CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}}, }, remoteIPWithNoCapabilities: { Node: &tailcfg.Node{StableID: "node2"}, UserProfile: remoteUser, }, }, func() *ipnstate.PeerStatus { return self }, func() *ipn.Prefs { return prefs }, nil, ) defer localapi.Close() go localapi.Serve(lal) s := &Server{ mode: ManageServerMode, lc: &tailscale.LocalClient{Dial: lal.Dial}, timeNow: time.Now, } type requestTest struct { remoteIP string wantResponse string wantStatus int } tests := []struct { reqPath string reqMethod string reqContentType string reqBody string tests []requestTest }{{ reqPath: "/not-an-endpoint", reqMethod: httpm.POST, tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "invalid endpoint", wantStatus: http.StatusNotFound, }, { remoteIP: remoteIPWithAllCapabilities, wantResponse: "invalid endpoint", wantStatus: http.StatusNotFound, }}, }, { reqPath: "/local/v0/not-an-endpoint", reqMethod: httpm.POST, tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "invalid endpoint", wantStatus: http.StatusNotFound, }, { remoteIP: remoteIPWithAllCapabilities, wantResponse: "invalid endpoint", wantStatus: http.StatusNotFound, }}, }, { reqPath: "/local/v0/logout", reqMethod: httpm.POST, tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "not allowed", // requesting node has insufficient permissions wantStatus: http.StatusUnauthorized, }, { remoteIP: remoteIPWithAllCapabilities, wantResponse: "success", // requesting node has sufficient permissions wantStatus: http.StatusOK, }}, }, { reqPath: "/exit-nodes", reqMethod: httpm.GET, tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "null", wantStatus: http.StatusOK, // allowed, no additional capabilities required }, { remoteIP: remoteIPWithAllCapabilities, wantResponse: "null", wantStatus: http.StatusOK, }}, }, { reqPath: "/routes", reqMethod: httpm.POST, reqBody: "{\"setExitNode\":true}", tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "not allowed", wantStatus: http.StatusUnauthorized, }, { remoteIP: remoteIPWithAllCapabilities, wantStatus: http.StatusOK, }}, }, { reqPath: "/local/v0/prefs", reqMethod: httpm.PATCH, reqBody: "{\"runSSHSet\":true}", reqContentType: "application/json", tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "not allowed", wantStatus: http.StatusUnauthorized, }, { remoteIP: remoteIPWithAllCapabilities, wantStatus: http.StatusOK, }}, }, { reqPath: "/local/v0/prefs", reqMethod: httpm.PATCH, reqContentType: "multipart/form-data", tests: []requestTest{{ remoteIP: remoteIPWithNoCapabilities, wantResponse: "invalid request", wantStatus: http.StatusBadRequest, }, { remoteIP: remoteIPWithAllCapabilities, wantResponse: "invalid request", wantStatus: http.StatusBadRequest, }}, }} for _, tt := range tests { for _, req := range tt.tests { t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) { var reqBody io.Reader if tt.reqBody != "" { reqBody = bytes.NewBuffer([]byte(tt.reqBody)) } r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody) r.RemoteAddr = req.remoteIP if tt.reqContentType != "" { r.Header.Add("Content-Type", tt.reqContentType) } w := httptest.NewRecorder() s.serveAPI(w, r) res := w.Result() defer res.Body.Close() if gotStatus := res.StatusCode; req.wantStatus != gotStatus { t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus) } body, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline if req.wantResponse != gotResp { t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp) } }) } } } 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{ID: 1, StableID: "1"}, UserProfile: userA, }, userBNodeIP: { Node: &tailcfg.Node{ID: 2, StableID: "2"}, UserProfile: userB, }, taggedNodeIP: { Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()}, }, } lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil) defer localapi.Close() go localapi.Serve(lal) s := &Server{ timeNow: time.Now, lc: &tailscale.LocalClient{Dial: lal.Dial}, } // Add some browser sessions to cache state. userASession := &browserSession{ ID: "cookie1", SrcNode: 1, SrcUser: userA.ID, Created: time.Now(), Authenticated: false, // not yet authenticated } userBSession := &browserSession{ ID: "cookie2", SrcNode: 2, SrcUser: userB.ID, Created: time.Now().Add(-2 * sessionCookieExpiry), Authenticated: true, // expired } userASessionAuthorized := &browserSession{ ID: "cookie3", SrcNode: 1, SrcUser: userA.ID, Created: time.Now(), Authenticated: true, // 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-remote-source", selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, remoteAddr: taggedNodeIP, wantSession: nil, wantError: errTaggedRemoteSource, }, { name: "tagged-local-source", selfNode: &ipnstate.PeerStatus{ID: "3"}, remoteAddr: taggedNodeIP, // same node as selfNode wantSession: nil, wantError: errTaggedLocalSource, }, { name: "not-tagged-local-source", selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID}, remoteAddr: userANodeIP, // same node as selfNode cookie: userASession.ID, wantSession: userASession, wantError: nil, // should not error }, { 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.getSession(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(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized { t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized) } }) } } // TestAuthorizeRequest tests the s.authorizeRequest function. // 2023-10-18: These tests currently cover tailscale auth mode (not platform auth). func TestAuthorizeRequest(t *testing.T) { // Create self and remoteNode owned by same user. // See TestGetTailscaleBrowserSession for tests of // browser sessions w/ different users. user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user} remoteIP := "100.100.100.101" lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, func() *ipnstate.PeerStatus { return self }, nil, nil, ) defer localapi.Close() go localapi.Serve(lal) s := &Server{ mode: ManageServerMode, lc: &tailscale.LocalClient{Dial: lal.Dial}, timeNow: time.Now, } validCookie := "ts-cookie" s.browserSessions.Store(validCookie, &browserSession{ ID: validCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: time.Now(), Authenticated: true, }) tests := []struct { reqPath string reqMethod string wantOkNotOverTailscale bool // simulates req over public internet wantOkWithoutSession bool // simulates req over TS without valid browser session wantOkWithSession bool // simulates req over TS with valid browser session }{{ reqPath: "/api/data", reqMethod: httpm.GET, wantOkNotOverTailscale: false, wantOkWithoutSession: true, wantOkWithSession: true, }, { reqPath: "/api/data", reqMethod: httpm.POST, wantOkNotOverTailscale: false, wantOkWithoutSession: false, wantOkWithSession: true, }, { reqPath: "/api/somethingelse", reqMethod: httpm.GET, wantOkNotOverTailscale: false, wantOkWithoutSession: false, wantOkWithSession: true, }, { reqPath: "/assets/styles.css", wantOkNotOverTailscale: false, wantOkWithoutSession: true, wantOkWithSession: true, }} for _, tt := range tests { t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) { doAuthorize := func(remoteAddr string, cookie string) bool { r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil) r.RemoteAddr = remoteAddr if cookie != "" { r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) } w := httptest.NewRecorder() return s.authorizeRequest(w, r) } // Do request from non-Tailscale IP. if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale { t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk) } // Do request from Tailscale IP w/o associated session. if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession { t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk) } // Do request from Tailscale IP w/ associated session. if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession { t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk) } }) } } func TestServeAuth(t *testing.T) { user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)} self := &ipnstate.PeerStatus{ ID: "self", UserID: user.ID, TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")}, } remoteIP := "100.100.100.101" remoteNode := &apitype.WhoIsResponse{ Node: &tailcfg.Node{ Name: "nodey", ID: 1, Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")}, }, UserProfile: user, } vi := &viewerIdentity{ LoginName: user.LoginName, NodeName: remoteNode.Node.Name, NodeIP: remoteIP, ProfilePicURL: user.ProfilePicURL, Capabilities: peerCapabilities{capFeatureAll: true}, } testControlURL := &defaultControlURL lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, func() *ipnstate.PeerStatus { return self }, func() *ipn.Prefs { return &ipn.Prefs{ControlURL: *testControlURL} }, nil, ) defer localapi.Close() go localapi.Serve(lal) timeNow := time.Now() oneHourAgo := timeNow.Add(-time.Hour) sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2) s := &Server{ mode: ManageServerMode, lc: &tailscale.LocalClient{Dial: lal.Dial}, timeNow: func() time.Time { return timeNow }, newAuthURL: mockNewAuthURL, waitAuthURL: mockWaitAuthURL, } successCookie := "ts-cookie-success" s.browserSessions.Store(successCookie, &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, }) failureCookie := "ts-cookie-failure" s.browserSessions.Store(failureCookie, &browserSession{ ID: failureCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathError, AuthURL: *testControlURL + testAuthPathError, }) expiredCookie := "ts-cookie-expired" s.browserSessions.Store(expiredCookie, &browserSession{ ID: expiredCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: sixtyDaysAgo, AuthID: "/a/old-auth-url", AuthURL: *testControlURL + "/a/old-auth-url", }) tests := []struct { name string controlURL string // if empty, defaultControlURL is used cookie string // cookie attached to request wantNewCookie bool // want new cookie generated during request wantSession *browserSession // session associated w/ cookie after request path string wantStatus int wantResp any }{ { name: "no-session", path: "/api/auth", wantStatus: http.StatusOK, wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, wantNewCookie: false, wantSession: nil, }, { name: "new-session", path: "/api/auth/session/new", wantStatus: http.StatusOK, wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", // gets swapped for newly created ID by test SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: timeNow, AuthID: testAuthPath, AuthURL: *testControlURL + testAuthPath, Authenticated: false, }, }, { name: "query-existing-incomplete-session", path: "/api/auth", cookie: successCookie, wantStatus: http.StatusOK, wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: false, }, }, { name: "existing-session-used", path: "/api/auth/session/new", // should not create new session cookie: successCookie, wantStatus: http.StatusOK, wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess}, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: false, }, }, { name: "transition-to-successful-session", path: "/api/auth/session/wait", cookie: successCookie, wantStatus: http.StatusOK, wantResp: nil, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: true, }, }, { name: "query-existing-complete-session", path: "/api/auth", cookie: successCookie, wantStatus: http.StatusOK, wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode}, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: true, }, }, { name: "transition-to-failed-session", path: "/api/auth/session/wait", cookie: failureCookie, wantStatus: http.StatusUnauthorized, wantResp: nil, wantSession: nil, // session deleted }, { name: "failed-session-cleaned-up", path: "/api/auth/session/new", cookie: failureCookie, wantStatus: http.StatusOK, wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: timeNow, AuthID: testAuthPath, AuthURL: *testControlURL + testAuthPath, Authenticated: false, }, }, { name: "expired-cookie-gets-new-session", path: "/api/auth/session/new", cookie: expiredCookie, wantStatus: http.StatusOK, wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: timeNow, AuthID: testAuthPath, AuthURL: *testControlURL + testAuthPath, Authenticated: false, }, }, { name: "control-server-no-check-mode", controlURL: "http://alternate-server.com/", path: "/api/auth/session/new", wantStatus: http.StatusOK, wantResp: &newSessionAuthResponse{}, wantNewCookie: true, wantSession: &browserSession{ ID: "GENERATED_ID", // gets swapped for newly created ID by test SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: timeNow, Authenticated: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.controlURL != "" { testControlURL = &tt.controlURL } else { testControlURL = &defaultControlURL } r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil) r.RemoteAddr = remoteIP r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) w := httptest.NewRecorder() s.serve(w, r) res := w.Result() defer res.Body.Close() // Validate response status/data. if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) } var gotResp string if res.StatusCode == http.StatusOK { body, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } gotResp = strings.Trim(string(body), "\n") } var wantResp string if tt.wantResp != nil { b, _ := json.Marshal(tt.wantResp) wantResp = string(b) } if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" { t.Errorf("wrong response; (-got+want):%v", diff) } // Validate cookie creation. sessionID := tt.cookie var gotCookie bool for _, c := range w.Result().Cookies() { if c.Name == sessionCookieName { gotCookie = true sessionID = c.Value break } } if gotCookie != tt.wantNewCookie { t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) } // Validate browser session contents. var gotSesson *browserSession if s, ok := s.browserSessions.Load(sessionID); ok { gotSesson = s.(*browserSession) } if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" { // If requested, swap in the generated session ID before // comparing got/want. tt.wantSession.ID = sessionID } if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" { t.Errorf("wrong session; (-got+want):%v", diff) } }) } } // TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function. // For each given test case, we assert that the local API received a request to log the expected metric. func TestServeAPIAuthMetricLogging(t *testing.T) { user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)} otherUser := &tailcfg.UserProfile{LoginName: "user2@example.com", ID: tailcfg.UserID(2)} self := &ipnstate.PeerStatus{ ID: "self", UserID: user.ID, TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")}, } remoteIP := "100.100.100.101" remoteNode := &apitype.WhoIsResponse{ Node: &tailcfg.Node{ Name: "remote-managed", ID: 1, Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")}, }, UserProfile: user, } remoteTaggedIP := "100.123.100.213" remoteTaggedNode := &apitype.WhoIsResponse{ Node: &tailcfg.Node{ Name: "remote-tagged", ID: 2, Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")}, Tags: []string{"dev-machine"}, }, UserProfile: user, } localIP := "100.1.2.3" localNode := &apitype.WhoIsResponse{ Node: &tailcfg.Node{ Name: "local-managed", ID: 3, StableID: "self", Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")}, }, UserProfile: user, } localTaggedIP := "100.1.2.133" localTaggedNode := &apitype.WhoIsResponse{ Node: &tailcfg.Node{ Name: "local-tagged", ID: 4, StableID: "self", Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")}, Tags: []string{"prod-machine"}, }, UserProfile: user, } otherIP := "100.100.2.3" otherNode := &apitype.WhoIsResponse{ Node: &tailcfg.Node{ Name: "other-node", ID: 5, Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")}, }, UserProfile: otherUser, } nonTailscaleIP := "10.100.2.3" testControlURL := &defaultControlURL var loggedMetrics []string lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode}, func() *ipnstate.PeerStatus { return self }, func() *ipn.Prefs { return &ipn.Prefs{ControlURL: *testControlURL} }, func(metricName string) { loggedMetrics = append(loggedMetrics, metricName) }, ) defer localapi.Close() go localapi.Serve(lal) timeNow := time.Now() oneHourAgo := timeNow.Add(-time.Hour) s := &Server{ mode: ManageServerMode, lc: &tailscale.LocalClient{Dial: lal.Dial}, timeNow: func() time.Time { return timeNow }, newAuthURL: mockNewAuthURL, waitAuthURL: mockWaitAuthURL, } authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated" s.browserSessions.Store(authenticatedRemoteNodeCookie, &browserSession{ ID: authenticatedRemoteNodeCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: true, }) authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated" s.browserSessions.Store(authenticatedLocalNodeCookie, &browserSession{ ID: authenticatedLocalNodeCookie, SrcNode: localNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: true, }) unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated" s.browserSessions.Store(unauthenticatedRemoteNodeCookie, &browserSession{ ID: unauthenticatedRemoteNodeCookie, SrcNode: remoteNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: false, }) unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated" s.browserSessions.Store(unauthenticatedLocalNodeCookie, &browserSession{ ID: unauthenticatedLocalNodeCookie, SrcNode: localNode.Node.ID, SrcUser: user.ID, Created: oneHourAgo, AuthID: testAuthPathSuccess, AuthURL: *testControlURL + testAuthPathSuccess, Authenticated: false, }) tests := []struct { name string cookie string // cookie attached to request remoteAddr string // remote address to hit wantLoggedMetric string // expected metric to be logged }{ { name: "managing-remote", cookie: authenticatedRemoteNodeCookie, remoteAddr: remoteIP, wantLoggedMetric: "web_client_managing_remote", }, { name: "managing-local", cookie: authenticatedLocalNodeCookie, remoteAddr: localIP, wantLoggedMetric: "web_client_managing_local", }, { name: "viewing-not-owner", cookie: authenticatedRemoteNodeCookie, remoteAddr: otherIP, wantLoggedMetric: "web_client_viewing_not_owner", }, { name: "viewing-local-tagged", cookie: authenticatedLocalNodeCookie, remoteAddr: localTaggedIP, wantLoggedMetric: "web_client_viewing_local_tag", }, { name: "viewing-remote-tagged", cookie: authenticatedRemoteNodeCookie, remoteAddr: remoteTaggedIP, wantLoggedMetric: "web_client_viewing_remote_tag", }, { name: "viewing-local-non-tailscale", cookie: authenticatedLocalNodeCookie, remoteAddr: nonTailscaleIP, wantLoggedMetric: "web_client_viewing_local", }, { name: "viewing-local-unauthenticated", cookie: unauthenticatedLocalNodeCookie, remoteAddr: localIP, wantLoggedMetric: "web_client_viewing_local", }, { name: "viewing-remote-unauthenticated", cookie: unauthenticatedRemoteNodeCookie, remoteAddr: remoteIP, wantLoggedMetric: "web_client_viewing_remote", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testControlURL = &defaultControlURL r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil) r.RemoteAddr = tt.remoteAddr r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) w := httptest.NewRecorder() s.serveAPIAuth(w, r) if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) { t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics) } loggedMetrics = []string{} res := w.Result() defer res.Body.Close() }) } } // TestPathPrefix tests that the provided path prefix is normalized correctly. // If a leading '/' is missing, one should be added. // If multiple leading '/' are present, they should be collapsed to one. // Additionally verify that this prevents open redirects when enforcing the path prefix. func TestPathPrefix(t *testing.T) { tests := []struct { name string prefix string wantPrefix string wantLocation string }{ { name: "no-leading-slash", prefix: "javascript:alert(1)", wantPrefix: "/javascript:alert(1)", wantLocation: "/javascript:alert(1)/", }, { name: "2-slashes", prefix: "//evil.example.com/goat", // We must also get the trailing slash added: wantPrefix: "/evil.example.com/goat", wantLocation: "/evil.example.com/goat/", }, { name: "absolute-url", prefix: "http://evil.example.com", // We must also get the trailing slash added: wantPrefix: "/http:/evil.example.com", wantLocation: "/http:/evil.example.com/", }, { name: "double-dot", prefix: "/../.././etc/passwd", // We must also get the trailing slash added: wantPrefix: "/etc/passwd", wantLocation: "/etc/passwd/", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { options := ServerOpts{ Mode: LoginServerMode, PathPrefix: tt.prefix, CGIMode: true, } s, err := NewServer(options) if err != nil { t.Error(err) } // verify provided prefix was normalized correctly if s.pathPrefix != tt.wantPrefix { t.Errorf("prefix was not normalized correctly; want=%q, got=%q", tt.wantPrefix, s.pathPrefix) } s.logf = t.Logf r := httptest.NewRequest(httpm.GET, "http://localhost/", nil) w := httptest.NewRecorder() s.ServeHTTP(w, r) res := w.Result() defer res.Body.Close() location := w.Header().Get("Location") if location != tt.wantLocation { t.Errorf("request got wrong location; want=%q, got=%q", tt.wantLocation, location) } }) } } func TestRequireTailscaleIP(t *testing.T) { self := &ipnstate.PeerStatus{ TailscaleIPs: []netip.Addr{ netip.MustParseAddr("100.1.2.3"), netip.MustParseAddr("fd7a:115c::1234"), }, } lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil) defer localapi.Close() go localapi.Serve(lal) s := &Server{ mode: ManageServerMode, lc: &tailscale.LocalClient{Dial: lal.Dial}, timeNow: time.Now, logf: t.Logf, } tests := []struct { name string target string wantHandled bool wantLocation string }{ { name: "localhost", target: "http://localhost/", wantHandled: true, wantLocation: "http://100.1.2.3:5252/", }, { name: "ipv4-no-port", target: "http://100.1.2.3/", wantHandled: true, wantLocation: "http://100.1.2.3:5252/", }, { name: "ipv4-correct-port", target: "http://100.1.2.3:5252/", wantHandled: false, }, { name: "ipv6-no-port", target: "http://[fd7a:115c::1234]/", wantHandled: true, wantLocation: "http://100.1.2.3:5252/", }, { name: "ipv6-correct-port", target: "http://[fd7a:115c::1234]:5252/", wantHandled: false, }, { name: "quad-100", target: "http://100.100.100.100/", wantHandled: false, }, { name: "ipv6-service-addr", target: "http://[fd7a:115c:a1e0::53]/", wantHandled: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s.logf = t.Logf r := httptest.NewRequest(httpm.GET, tt.target, nil) w := httptest.NewRecorder() handled := s.requireTailscaleIP(w, r) if handled != tt.wantHandled { t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled) } location := w.Header().Get("Location") if location != tt.wantLocation { t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location) } }) } } func TestPeerCapabilities(t *testing.T) { userOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{UserID: tailcfg.UserID(1)}} tags := views.SliceOf[string]([]string{"tag:server"}) tagOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{Tags: &tags}} // Testing web.toPeerCapabilities toPeerCapsTests := []struct { name string status *ipnstate.Status whois *apitype.WhoIsResponse wantCaps peerCapabilities }{ { name: "empty-whois", status: userOwnedStatus, whois: nil, wantCaps: peerCapabilities{}, }, { name: "user-owned-node-non-owner-caps-ignored", status: userOwnedStatus, whois: &apitype.WhoIsResponse{ UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)}, Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, wantCaps: peerCapabilities{}, }, { name: "user-owned-node-owner-caps-ignored", status: userOwnedStatus, whois: &apitype.WhoIsResponse{ UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)}, Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, wantCaps: peerCapabilities{capFeatureAll: true}, // should just have wildcard }, { name: "tag-owned-no-webui-caps", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{}, }, }, wantCaps: peerCapabilities{}, }, { name: "tag-owned-one-webui-cap", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, wantCaps: peerCapabilities{ capFeatureSSH: true, capFeatureSubnets: true, }, }, { name: "tag-owned-multiple-webui-cap", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"ssh\",\"subnets\"]}", "{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}", }, }, }, wantCaps: peerCapabilities{ capFeatureSSH: true, capFeatureSubnets: true, capFeatureExitNodes: true, capFeatureAll: true, }, }, { name: "tag-owned-case-insensitive-caps", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"SSH\",\"sUBnets\"]}", }, }, }, wantCaps: peerCapabilities{ capFeatureSSH: true, capFeatureSubnets: true, }, }, { name: "tag-owned-random-canEdit-contents-get-dropped", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"unknown-feature\"]}", }, }, }, wantCaps: peerCapabilities{}, }, { name: "tag-owned-no-canEdit-section", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canDoSomething\":[\"*\"]}", }, }, }, wantCaps: peerCapabilities{}, }, { name: "tagged-source-caps-ignored", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, wantCaps: peerCapabilities{}, }, } for _, tt := range toPeerCapsTests { t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) { got, err := toPeerCapabilities(tt.status, tt.whois) if err != nil { t.Fatalf("unexpected: %v", err) } if diff := cmp.Diff(got, tt.wantCaps); diff != "" { t.Errorf("wrong caps; (-got+want):%v", diff) } }) } // Testing web.peerCapabilities.canEdit canEditTests := []struct { name string caps peerCapabilities wantCanEdit map[capFeature]bool }{ { name: "empty-caps", caps: nil, wantCanEdit: map[capFeature]bool{ capFeatureAll: false, capFeatureSSH: false, capFeatureSubnets: false, capFeatureExitNodes: false, capFeatureAccount: false, }, }, { name: "some-caps", caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true}, wantCanEdit: map[capFeature]bool{ capFeatureAll: false, capFeatureSSH: true, capFeatureSubnets: false, capFeatureExitNodes: false, capFeatureAccount: true, }, }, { name: "wildcard-in-caps", caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true}, wantCanEdit: map[capFeature]bool{ capFeatureAll: true, capFeatureSSH: true, capFeatureSubnets: true, capFeatureExitNodes: true, capFeatureAccount: true, }, }, } for _, tt := range canEditTests { t.Run("canEdit-"+tt.name, func(t *testing.T) { for f, want := range tt.wantCanEdit { if got := tt.caps.canEdit(f); got != want { t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want) } } }) } } var ( defaultControlURL = "https://controlplane.tailscale.com" testAuthPath = "/a/12345" testAuthPathSuccess = "/a/will-succeed" testAuthPathError = "/a/will-error" ) // mockLocalAPI constructs a test localapi handler that can be used // to simulate localapi responses without a functioning tailnet. // // self accepts a function that resolves to a self node status, // so that tests may swap out the /localapi/v0/status response // as desired. func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server { return &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 := whoIs[addr]; node != nil { writeJSON(w, &node) return } http.Error(w, "not a node", http.StatusUnauthorized) return case "/localapi/v0/status": writeJSON(w, ipnstate.Status{Self: self()}) return case "/localapi/v0/prefs": writeJSON(w, prefs()) return case "/localapi/v0/upload-client-metrics": type metricName struct { Name string `json:"name"` } var metricNames []metricName if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } metricCapture(metricNames[0].Name) writeJSON(w, struct{}{}) return case "/localapi/v0/logout": fmt.Fprintf(w, "success") return default: t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) } })} } func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { // Create new dummy auth URL. return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil } func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { switch id { case testAuthPathSuccess: // successful auth URL return &tailcfg.WebClientAuthResponse{Complete: true}, nil case testAuthPathError: // error auth URL return nil, errors.New("authenticated as wrong user") default: return nil, errors.New("unknown id") } }