| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- package web
- import (
- "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. localapi proxy allowlist
- func TestServeAPI(t *testing.T) {
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- // Serve dummy localapi. Just returns "success".
- localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintf(w, "success")
- })}
- defer localapi.Close()
- go localapi.Serve(lal)
- s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
- tests := []struct {
- name string
- reqMethod string
- reqPath string
- reqContentType string
- wantResp string
- wantStatus int
- }{{
- name: "invalid_endpoint",
- reqMethod: httpm.POST,
- reqPath: "/not-an-endpoint",
- wantResp: "invalid endpoint",
- wantStatus: http.StatusNotFound,
- }, {
- name: "not_in_localapi_allowlist",
- reqMethod: httpm.POST,
- reqPath: "/local/v0/not-allowlisted",
- wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
- wantStatus: http.StatusForbidden,
- }, {
- name: "in_localapi_allowlist",
- reqMethod: httpm.POST,
- reqPath: "/local/v0/logout",
- wantResp: "success", // Successfully allowed to hit localapi.
- wantStatus: http.StatusOK,
- }, {
- name: "patch_bad_contenttype",
- reqMethod: httpm.PATCH,
- reqPath: "/local/v0/prefs",
- reqContentType: "multipart/form-data",
- wantResp: "invalid request",
- wantStatus: http.StatusBadRequest,
- }}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
- 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; tt.wantStatus != gotStatus {
- t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
- }
- body, err := io.ReadAll(res.Body)
- if err != nil {
- t.Fatal(err)
- }
- gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
- if tt.wantResp != gotResp {
- t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, 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: "[email protected]", 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{},
- }
- 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{AuthNeeded: tailscaleAuth, 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{AuthNeeded: tailscaleAuth, 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{CanManageNode: 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: "[email protected]", ID: tailcfg.UserID(1)}
- otherUser := &tailcfg.UserProfile{LoginName: "[email protected]", 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) {
- // Testing web.toPeerCapabilities
- toPeerCapsTests := []struct {
- name string
- whois *apitype.WhoIsResponse
- wantCaps peerCapabilities
- }{
- {
- name: "empty-whois",
- whois: nil,
- wantCaps: peerCapabilities{},
- },
- {
- name: "no-webui-caps",
- whois: &apitype.WhoIsResponse{
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
- },
- },
- wantCaps: peerCapabilities{},
- },
- {
- name: "one-webui-cap",
- whois: &apitype.WhoIsResponse{
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnet\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- capFeatureSSH: true,
- capFeatureSubnet: true,
- },
- },
- {
- name: "multiple-webui-cap",
- whois: &apitype.WhoIsResponse{
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnet\"]}",
- "{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- capFeatureSSH: true,
- capFeatureSubnet: true,
- capFeatureExitNode: true,
- capFeatureAll: true,
- },
- },
- {
- name: "case=insensitive-caps",
- whois: &apitype.WhoIsResponse{
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- capFeatureSSH: true,
- capFeatureSubnet: true,
- },
- },
- {
- name: "random-canEdit-contents-dont-error",
- whois: &apitype.WhoIsResponse{
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"unknown-feature\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- "unknown-feature": true,
- },
- },
- {
- name: "no-canEdit-section",
- whois: &apitype.WhoIsResponse{
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canDoSomething\":[\"*\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{},
- },
- }
- for _, tt := range toPeerCapsTests {
- t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
- got, err := toPeerCapabilities(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,
- capFeatureFunnel: false,
- capFeatureSSH: false,
- capFeatureSubnet: false,
- capFeatureExitNode: false,
- capFeatureAccount: false,
- },
- },
- {
- name: "some-caps",
- caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
- wantCanEdit: map[capFeature]bool{
- capFeatureAll: false,
- capFeatureFunnel: false,
- capFeatureSSH: true,
- capFeatureSubnet: false,
- capFeatureExitNode: false,
- capFeatureAccount: true,
- },
- },
- {
- name: "wildcard-in-caps",
- caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
- wantCanEdit: map[capFeature]bool{
- capFeatureAll: true,
- capFeatureFunnel: true,
- capFeatureSSH: true,
- capFeatureSubnet: true,
- capFeatureExitNode: 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
- 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")
- }
- }
|