web_test.go 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. package web
  4. import (
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "net/http"
  11. "net/http/httptest"
  12. "net/netip"
  13. "net/url"
  14. "slices"
  15. "strings"
  16. "testing"
  17. "time"
  18. "github.com/google/go-cmp/cmp"
  19. "tailscale.com/client/tailscale"
  20. "tailscale.com/client/tailscale/apitype"
  21. "tailscale.com/ipn"
  22. "tailscale.com/ipn/ipnstate"
  23. "tailscale.com/net/memnet"
  24. "tailscale.com/tailcfg"
  25. "tailscale.com/types/views"
  26. "tailscale.com/util/httpm"
  27. )
  28. func TestQnapAuthnURL(t *testing.T) {
  29. query := url.Values{
  30. "qtoken": []string{"token"},
  31. }
  32. tests := []struct {
  33. name string
  34. in string
  35. want string
  36. }{
  37. {
  38. name: "localhost http",
  39. in: "http://localhost:8088/",
  40. want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
  41. },
  42. {
  43. name: "localhost https",
  44. in: "https://localhost:5000/",
  45. want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
  46. },
  47. {
  48. name: "IP http",
  49. in: "http://10.1.20.4:80/",
  50. want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
  51. },
  52. {
  53. name: "IP6 https",
  54. in: "https://[ff7d:0:1:2::1]/",
  55. want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
  56. },
  57. {
  58. name: "hostname https",
  59. in: "https://qnap.example.com/",
  60. want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
  61. },
  62. {
  63. name: "invalid URL",
  64. 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.",
  65. want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
  66. },
  67. {
  68. name: "err != nil",
  69. in: "http://192.168.0.%31/",
  70. want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
  71. },
  72. }
  73. for _, tt := range tests {
  74. t.Run(tt.name, func(t *testing.T) {
  75. u := qnapAuthnURL(tt.in, query)
  76. if u != tt.want {
  77. t.Errorf("expected url: %q, got: %q", tt.want, u)
  78. }
  79. })
  80. }
  81. }
  82. // TestServeAPI tests the web client api's handling of
  83. // 1. invalid endpoint errors
  84. // 2. localapi proxy allowlist
  85. func TestServeAPI(t *testing.T) {
  86. lal := memnet.Listen("local-tailscaled.sock:80")
  87. defer lal.Close()
  88. // Serve dummy localapi. Just returns "success".
  89. localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  90. fmt.Fprintf(w, "success")
  91. })}
  92. defer localapi.Close()
  93. go localapi.Serve(lal)
  94. s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
  95. tests := []struct {
  96. name string
  97. reqMethod string
  98. reqPath string
  99. reqContentType string
  100. wantResp string
  101. wantStatus int
  102. }{{
  103. name: "invalid_endpoint",
  104. reqMethod: httpm.POST,
  105. reqPath: "/not-an-endpoint",
  106. wantResp: "invalid endpoint",
  107. wantStatus: http.StatusNotFound,
  108. }, {
  109. name: "not_in_localapi_allowlist",
  110. reqMethod: httpm.POST,
  111. reqPath: "/local/v0/not-allowlisted",
  112. wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
  113. wantStatus: http.StatusForbidden,
  114. }, {
  115. name: "in_localapi_allowlist",
  116. reqMethod: httpm.POST,
  117. reqPath: "/local/v0/logout",
  118. wantResp: "success", // Successfully allowed to hit localapi.
  119. wantStatus: http.StatusOK,
  120. }, {
  121. name: "patch_bad_contenttype",
  122. reqMethod: httpm.PATCH,
  123. reqPath: "/local/v0/prefs",
  124. reqContentType: "multipart/form-data",
  125. wantResp: "invalid request",
  126. wantStatus: http.StatusBadRequest,
  127. }}
  128. for _, tt := range tests {
  129. t.Run(tt.name, func(t *testing.T) {
  130. r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
  131. if tt.reqContentType != "" {
  132. r.Header.Add("Content-Type", tt.reqContentType)
  133. }
  134. w := httptest.NewRecorder()
  135. s.serveAPI(w, r)
  136. res := w.Result()
  137. defer res.Body.Close()
  138. if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
  139. t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
  140. }
  141. body, err := io.ReadAll(res.Body)
  142. if err != nil {
  143. t.Fatal(err)
  144. }
  145. gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
  146. if tt.wantResp != gotResp {
  147. t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
  148. }
  149. })
  150. }
  151. }
  152. func TestGetTailscaleBrowserSession(t *testing.T) {
  153. userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
  154. userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)}
  155. userANodeIP := "100.100.100.101"
  156. userBNodeIP := "100.100.100.102"
  157. taggedNodeIP := "100.100.100.103"
  158. var selfNode *ipnstate.PeerStatus
  159. tags := views.SliceOf([]string{"tag:server"})
  160. tailnetNodes := map[string]*apitype.WhoIsResponse{
  161. userANodeIP: {
  162. Node: &tailcfg.Node{ID: 1, StableID: "1"},
  163. UserProfile: userA,
  164. },
  165. userBNodeIP: {
  166. Node: &tailcfg.Node{ID: 2, StableID: "2"},
  167. UserProfile: userB,
  168. },
  169. taggedNodeIP: {
  170. Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()},
  171. },
  172. }
  173. lal := memnet.Listen("local-tailscaled.sock:80")
  174. defer lal.Close()
  175. localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil)
  176. defer localapi.Close()
  177. go localapi.Serve(lal)
  178. s := &Server{
  179. timeNow: time.Now,
  180. lc: &tailscale.LocalClient{Dial: lal.Dial},
  181. }
  182. // Add some browser sessions to cache state.
  183. userASession := &browserSession{
  184. ID: "cookie1",
  185. SrcNode: 1,
  186. SrcUser: userA.ID,
  187. Created: time.Now(),
  188. Authenticated: false, // not yet authenticated
  189. }
  190. userBSession := &browserSession{
  191. ID: "cookie2",
  192. SrcNode: 2,
  193. SrcUser: userB.ID,
  194. Created: time.Now().Add(-2 * sessionCookieExpiry),
  195. Authenticated: true, // expired
  196. }
  197. userASessionAuthorized := &browserSession{
  198. ID: "cookie3",
  199. SrcNode: 1,
  200. SrcUser: userA.ID,
  201. Created: time.Now(),
  202. Authenticated: true, // authenticated and not expired
  203. }
  204. s.browserSessions.Store(userASession.ID, userASession)
  205. s.browserSessions.Store(userBSession.ID, userBSession)
  206. s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized)
  207. tests := []struct {
  208. name string
  209. selfNode *ipnstate.PeerStatus
  210. remoteAddr string
  211. cookie string
  212. wantSession *browserSession
  213. wantError error
  214. wantIsAuthorized bool // response from session.isAuthorized
  215. }{
  216. {
  217. name: "not-connected-over-tailscale",
  218. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
  219. remoteAddr: "77.77.77.77",
  220. wantSession: nil,
  221. wantError: errNotUsingTailscale,
  222. },
  223. {
  224. name: "no-session-user-self-node",
  225. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
  226. remoteAddr: userANodeIP,
  227. cookie: "not-a-cookie",
  228. wantSession: nil,
  229. wantError: errNoSession,
  230. },
  231. {
  232. name: "no-session-tagged-self-node",
  233. selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags},
  234. remoteAddr: userANodeIP,
  235. wantSession: nil,
  236. wantError: errNoSession,
  237. },
  238. {
  239. name: "not-owner",
  240. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
  241. remoteAddr: userBNodeIP,
  242. wantSession: nil,
  243. wantError: errNotOwner,
  244. },
  245. {
  246. name: "tagged-remote-source",
  247. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
  248. remoteAddr: taggedNodeIP,
  249. wantSession: nil,
  250. wantError: errTaggedRemoteSource,
  251. },
  252. {
  253. name: "tagged-local-source",
  254. selfNode: &ipnstate.PeerStatus{ID: "3"},
  255. remoteAddr: taggedNodeIP, // same node as selfNode
  256. wantSession: nil,
  257. wantError: errTaggedLocalSource,
  258. },
  259. {
  260. name: "not-tagged-local-source",
  261. selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID},
  262. remoteAddr: userANodeIP, // same node as selfNode
  263. cookie: userASession.ID,
  264. wantSession: userASession,
  265. wantError: nil, // should not error
  266. },
  267. {
  268. name: "has-session",
  269. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
  270. remoteAddr: userANodeIP,
  271. cookie: userASession.ID,
  272. wantSession: userASession,
  273. wantError: nil,
  274. },
  275. {
  276. name: "has-authorized-session",
  277. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
  278. remoteAddr: userANodeIP,
  279. cookie: userASessionAuthorized.ID,
  280. wantSession: userASessionAuthorized,
  281. wantError: nil,
  282. wantIsAuthorized: true,
  283. },
  284. {
  285. name: "session-associated-with-different-source",
  286. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
  287. remoteAddr: userBNodeIP,
  288. cookie: userASession.ID,
  289. wantSession: nil,
  290. wantError: errNoSession,
  291. },
  292. {
  293. name: "session-expired",
  294. selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
  295. remoteAddr: userBNodeIP,
  296. cookie: userBSession.ID,
  297. wantSession: nil,
  298. wantError: errNoSession,
  299. },
  300. }
  301. for _, tt := range tests {
  302. t.Run(tt.name, func(t *testing.T) {
  303. selfNode = tt.selfNode
  304. r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}}
  305. if tt.cookie != "" {
  306. r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
  307. }
  308. session, _, _, err := s.getSession(r)
  309. if !errors.Is(err, tt.wantError) {
  310. t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
  311. }
  312. if diff := cmp.Diff(session, tt.wantSession); diff != "" {
  313. t.Errorf("wrong session; (-got+want):%v", diff)
  314. }
  315. if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized {
  316. t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
  317. }
  318. })
  319. }
  320. }
  321. // TestAuthorizeRequest tests the s.authorizeRequest function.
  322. // 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
  323. func TestAuthorizeRequest(t *testing.T) {
  324. // Create self and remoteNode owned by same user.
  325. // See TestGetTailscaleBrowserSession for tests of
  326. // browser sessions w/ different users.
  327. user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
  328. self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
  329. remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
  330. remoteIP := "100.100.100.101"
  331. lal := memnet.Listen("local-tailscaled.sock:80")
  332. defer lal.Close()
  333. localapi := mockLocalAPI(t,
  334. map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
  335. func() *ipnstate.PeerStatus { return self },
  336. nil,
  337. nil,
  338. )
  339. defer localapi.Close()
  340. go localapi.Serve(lal)
  341. s := &Server{
  342. mode: ManageServerMode,
  343. lc: &tailscale.LocalClient{Dial: lal.Dial},
  344. timeNow: time.Now,
  345. }
  346. validCookie := "ts-cookie"
  347. s.browserSessions.Store(validCookie, &browserSession{
  348. ID: validCookie,
  349. SrcNode: remoteNode.Node.ID,
  350. SrcUser: user.ID,
  351. Created: time.Now(),
  352. Authenticated: true,
  353. })
  354. tests := []struct {
  355. reqPath string
  356. reqMethod string
  357. wantOkNotOverTailscale bool // simulates req over public internet
  358. wantOkWithoutSession bool // simulates req over TS without valid browser session
  359. wantOkWithSession bool // simulates req over TS with valid browser session
  360. }{{
  361. reqPath: "/api/data",
  362. reqMethod: httpm.GET,
  363. wantOkNotOverTailscale: false,
  364. wantOkWithoutSession: true,
  365. wantOkWithSession: true,
  366. }, {
  367. reqPath: "/api/data",
  368. reqMethod: httpm.POST,
  369. wantOkNotOverTailscale: false,
  370. wantOkWithoutSession: false,
  371. wantOkWithSession: true,
  372. }, {
  373. reqPath: "/api/somethingelse",
  374. reqMethod: httpm.GET,
  375. wantOkNotOverTailscale: false,
  376. wantOkWithoutSession: false,
  377. wantOkWithSession: true,
  378. }, {
  379. reqPath: "/assets/styles.css",
  380. wantOkNotOverTailscale: false,
  381. wantOkWithoutSession: true,
  382. wantOkWithSession: true,
  383. }}
  384. for _, tt := range tests {
  385. t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
  386. doAuthorize := func(remoteAddr string, cookie string) bool {
  387. r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
  388. r.RemoteAddr = remoteAddr
  389. if cookie != "" {
  390. r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
  391. }
  392. w := httptest.NewRecorder()
  393. return s.authorizeRequest(w, r)
  394. }
  395. // Do request from non-Tailscale IP.
  396. if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
  397. t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
  398. }
  399. // Do request from Tailscale IP w/o associated session.
  400. if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
  401. t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
  402. }
  403. // Do request from Tailscale IP w/ associated session.
  404. if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
  405. t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
  406. }
  407. })
  408. }
  409. }
  410. func TestServeAuth(t *testing.T) {
  411. user := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(1)}
  412. self := &ipnstate.PeerStatus{
  413. ID: "self",
  414. UserID: user.ID,
  415. TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
  416. }
  417. remoteIP := "100.100.100.101"
  418. remoteNode := &apitype.WhoIsResponse{
  419. Node: &tailcfg.Node{
  420. Name: "nodey",
  421. ID: 1,
  422. Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
  423. },
  424. UserProfile: user,
  425. }
  426. vi := &viewerIdentity{
  427. LoginName: user.LoginName,
  428. NodeName: remoteNode.Node.Name,
  429. NodeIP: remoteIP,
  430. ProfilePicURL: user.ProfilePicURL,
  431. Capabilities: peerCapabilities{},
  432. }
  433. testControlURL := &defaultControlURL
  434. lal := memnet.Listen("local-tailscaled.sock:80")
  435. defer lal.Close()
  436. localapi := mockLocalAPI(t,
  437. map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
  438. func() *ipnstate.PeerStatus { return self },
  439. func() *ipn.Prefs {
  440. return &ipn.Prefs{ControlURL: *testControlURL}
  441. },
  442. nil,
  443. )
  444. defer localapi.Close()
  445. go localapi.Serve(lal)
  446. timeNow := time.Now()
  447. oneHourAgo := timeNow.Add(-time.Hour)
  448. sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
  449. s := &Server{
  450. mode: ManageServerMode,
  451. lc: &tailscale.LocalClient{Dial: lal.Dial},
  452. timeNow: func() time.Time { return timeNow },
  453. newAuthURL: mockNewAuthURL,
  454. waitAuthURL: mockWaitAuthURL,
  455. }
  456. successCookie := "ts-cookie-success"
  457. s.browserSessions.Store(successCookie, &browserSession{
  458. ID: successCookie,
  459. SrcNode: remoteNode.Node.ID,
  460. SrcUser: user.ID,
  461. Created: oneHourAgo,
  462. AuthID: testAuthPathSuccess,
  463. AuthURL: *testControlURL + testAuthPathSuccess,
  464. })
  465. failureCookie := "ts-cookie-failure"
  466. s.browserSessions.Store(failureCookie, &browserSession{
  467. ID: failureCookie,
  468. SrcNode: remoteNode.Node.ID,
  469. SrcUser: user.ID,
  470. Created: oneHourAgo,
  471. AuthID: testAuthPathError,
  472. AuthURL: *testControlURL + testAuthPathError,
  473. })
  474. expiredCookie := "ts-cookie-expired"
  475. s.browserSessions.Store(expiredCookie, &browserSession{
  476. ID: expiredCookie,
  477. SrcNode: remoteNode.Node.ID,
  478. SrcUser: user.ID,
  479. Created: sixtyDaysAgo,
  480. AuthID: "/a/old-auth-url",
  481. AuthURL: *testControlURL + "/a/old-auth-url",
  482. })
  483. tests := []struct {
  484. name string
  485. controlURL string // if empty, defaultControlURL is used
  486. cookie string // cookie attached to request
  487. wantNewCookie bool // want new cookie generated during request
  488. wantSession *browserSession // session associated w/ cookie after request
  489. path string
  490. wantStatus int
  491. wantResp any
  492. }{
  493. {
  494. name: "no-session",
  495. path: "/api/auth",
  496. wantStatus: http.StatusOK,
  497. wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
  498. wantNewCookie: false,
  499. wantSession: nil,
  500. },
  501. {
  502. name: "new-session",
  503. path: "/api/auth/session/new",
  504. wantStatus: http.StatusOK,
  505. wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
  506. wantNewCookie: true,
  507. wantSession: &browserSession{
  508. ID: "GENERATED_ID", // gets swapped for newly created ID by test
  509. SrcNode: remoteNode.Node.ID,
  510. SrcUser: user.ID,
  511. Created: timeNow,
  512. AuthID: testAuthPath,
  513. AuthURL: *testControlURL + testAuthPath,
  514. Authenticated: false,
  515. },
  516. },
  517. {
  518. name: "query-existing-incomplete-session",
  519. path: "/api/auth",
  520. cookie: successCookie,
  521. wantStatus: http.StatusOK,
  522. wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
  523. wantSession: &browserSession{
  524. ID: successCookie,
  525. SrcNode: remoteNode.Node.ID,
  526. SrcUser: user.ID,
  527. Created: oneHourAgo,
  528. AuthID: testAuthPathSuccess,
  529. AuthURL: *testControlURL + testAuthPathSuccess,
  530. Authenticated: false,
  531. },
  532. },
  533. {
  534. name: "existing-session-used",
  535. path: "/api/auth/session/new", // should not create new session
  536. cookie: successCookie,
  537. wantStatus: http.StatusOK,
  538. wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess},
  539. wantSession: &browserSession{
  540. ID: successCookie,
  541. SrcNode: remoteNode.Node.ID,
  542. SrcUser: user.ID,
  543. Created: oneHourAgo,
  544. AuthID: testAuthPathSuccess,
  545. AuthURL: *testControlURL + testAuthPathSuccess,
  546. Authenticated: false,
  547. },
  548. },
  549. {
  550. name: "transition-to-successful-session",
  551. path: "/api/auth/session/wait",
  552. cookie: successCookie,
  553. wantStatus: http.StatusOK,
  554. wantResp: nil,
  555. wantSession: &browserSession{
  556. ID: successCookie,
  557. SrcNode: remoteNode.Node.ID,
  558. SrcUser: user.ID,
  559. Created: oneHourAgo,
  560. AuthID: testAuthPathSuccess,
  561. AuthURL: *testControlURL + testAuthPathSuccess,
  562. Authenticated: true,
  563. },
  564. },
  565. {
  566. name: "query-existing-complete-session",
  567. path: "/api/auth",
  568. cookie: successCookie,
  569. wantStatus: http.StatusOK,
  570. wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
  571. wantSession: &browserSession{
  572. ID: successCookie,
  573. SrcNode: remoteNode.Node.ID,
  574. SrcUser: user.ID,
  575. Created: oneHourAgo,
  576. AuthID: testAuthPathSuccess,
  577. AuthURL: *testControlURL + testAuthPathSuccess,
  578. Authenticated: true,
  579. },
  580. },
  581. {
  582. name: "transition-to-failed-session",
  583. path: "/api/auth/session/wait",
  584. cookie: failureCookie,
  585. wantStatus: http.StatusUnauthorized,
  586. wantResp: nil,
  587. wantSession: nil, // session deleted
  588. },
  589. {
  590. name: "failed-session-cleaned-up",
  591. path: "/api/auth/session/new",
  592. cookie: failureCookie,
  593. wantStatus: http.StatusOK,
  594. wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
  595. wantNewCookie: true,
  596. wantSession: &browserSession{
  597. ID: "GENERATED_ID",
  598. SrcNode: remoteNode.Node.ID,
  599. SrcUser: user.ID,
  600. Created: timeNow,
  601. AuthID: testAuthPath,
  602. AuthURL: *testControlURL + testAuthPath,
  603. Authenticated: false,
  604. },
  605. },
  606. {
  607. name: "expired-cookie-gets-new-session",
  608. path: "/api/auth/session/new",
  609. cookie: expiredCookie,
  610. wantStatus: http.StatusOK,
  611. wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
  612. wantNewCookie: true,
  613. wantSession: &browserSession{
  614. ID: "GENERATED_ID",
  615. SrcNode: remoteNode.Node.ID,
  616. SrcUser: user.ID,
  617. Created: timeNow,
  618. AuthID: testAuthPath,
  619. AuthURL: *testControlURL + testAuthPath,
  620. Authenticated: false,
  621. },
  622. },
  623. {
  624. name: "control-server-no-check-mode",
  625. controlURL: "http://alternate-server.com/",
  626. path: "/api/auth/session/new",
  627. wantStatus: http.StatusOK,
  628. wantResp: &newSessionAuthResponse{},
  629. wantNewCookie: true,
  630. wantSession: &browserSession{
  631. ID: "GENERATED_ID", // gets swapped for newly created ID by test
  632. SrcNode: remoteNode.Node.ID,
  633. SrcUser: user.ID,
  634. Created: timeNow,
  635. Authenticated: true,
  636. },
  637. },
  638. }
  639. for _, tt := range tests {
  640. t.Run(tt.name, func(t *testing.T) {
  641. if tt.controlURL != "" {
  642. testControlURL = &tt.controlURL
  643. } else {
  644. testControlURL = &defaultControlURL
  645. }
  646. r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
  647. r.RemoteAddr = remoteIP
  648. r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
  649. w := httptest.NewRecorder()
  650. s.serve(w, r)
  651. res := w.Result()
  652. defer res.Body.Close()
  653. // Validate response status/data.
  654. if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
  655. t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
  656. }
  657. var gotResp string
  658. if res.StatusCode == http.StatusOK {
  659. body, err := io.ReadAll(res.Body)
  660. if err != nil {
  661. t.Fatal(err)
  662. }
  663. gotResp = strings.Trim(string(body), "\n")
  664. }
  665. var wantResp string
  666. if tt.wantResp != nil {
  667. b, _ := json.Marshal(tt.wantResp)
  668. wantResp = string(b)
  669. }
  670. if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
  671. t.Errorf("wrong response; (-got+want):%v", diff)
  672. }
  673. // Validate cookie creation.
  674. sessionID := tt.cookie
  675. var gotCookie bool
  676. for _, c := range w.Result().Cookies() {
  677. if c.Name == sessionCookieName {
  678. gotCookie = true
  679. sessionID = c.Value
  680. break
  681. }
  682. }
  683. if gotCookie != tt.wantNewCookie {
  684. t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
  685. }
  686. // Validate browser session contents.
  687. var gotSesson *browserSession
  688. if s, ok := s.browserSessions.Load(sessionID); ok {
  689. gotSesson = s.(*browserSession)
  690. }
  691. if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
  692. // If requested, swap in the generated session ID before
  693. // comparing got/want.
  694. tt.wantSession.ID = sessionID
  695. }
  696. if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
  697. t.Errorf("wrong session; (-got+want):%v", diff)
  698. }
  699. })
  700. }
  701. }
  702. // TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function.
  703. // For each given test case, we assert that the local API received a request to log the expected metric.
  704. func TestServeAPIAuthMetricLogging(t *testing.T) {
  705. user := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(1)}
  706. otherUser := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(2)}
  707. self := &ipnstate.PeerStatus{
  708. ID: "self",
  709. UserID: user.ID,
  710. TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
  711. }
  712. remoteIP := "100.100.100.101"
  713. remoteNode := &apitype.WhoIsResponse{
  714. Node: &tailcfg.Node{
  715. Name: "remote-managed",
  716. ID: 1,
  717. Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
  718. },
  719. UserProfile: user,
  720. }
  721. remoteTaggedIP := "100.123.100.213"
  722. remoteTaggedNode := &apitype.WhoIsResponse{
  723. Node: &tailcfg.Node{
  724. Name: "remote-tagged",
  725. ID: 2,
  726. Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")},
  727. Tags: []string{"dev-machine"},
  728. },
  729. UserProfile: user,
  730. }
  731. localIP := "100.1.2.3"
  732. localNode := &apitype.WhoIsResponse{
  733. Node: &tailcfg.Node{
  734. Name: "local-managed",
  735. ID: 3,
  736. StableID: "self",
  737. Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")},
  738. },
  739. UserProfile: user,
  740. }
  741. localTaggedIP := "100.1.2.133"
  742. localTaggedNode := &apitype.WhoIsResponse{
  743. Node: &tailcfg.Node{
  744. Name: "local-tagged",
  745. ID: 4,
  746. StableID: "self",
  747. Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")},
  748. Tags: []string{"prod-machine"},
  749. },
  750. UserProfile: user,
  751. }
  752. otherIP := "100.100.2.3"
  753. otherNode := &apitype.WhoIsResponse{
  754. Node: &tailcfg.Node{
  755. Name: "other-node",
  756. ID: 5,
  757. Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")},
  758. },
  759. UserProfile: otherUser,
  760. }
  761. nonTailscaleIP := "10.100.2.3"
  762. testControlURL := &defaultControlURL
  763. var loggedMetrics []string
  764. lal := memnet.Listen("local-tailscaled.sock:80")
  765. defer lal.Close()
  766. localapi := mockLocalAPI(t,
  767. map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
  768. func() *ipnstate.PeerStatus { return self },
  769. func() *ipn.Prefs {
  770. return &ipn.Prefs{ControlURL: *testControlURL}
  771. },
  772. func(metricName string) {
  773. loggedMetrics = append(loggedMetrics, metricName)
  774. },
  775. )
  776. defer localapi.Close()
  777. go localapi.Serve(lal)
  778. timeNow := time.Now()
  779. oneHourAgo := timeNow.Add(-time.Hour)
  780. s := &Server{
  781. mode: ManageServerMode,
  782. lc: &tailscale.LocalClient{Dial: lal.Dial},
  783. timeNow: func() time.Time { return timeNow },
  784. newAuthURL: mockNewAuthURL,
  785. waitAuthURL: mockWaitAuthURL,
  786. }
  787. authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated"
  788. s.browserSessions.Store(authenticatedRemoteNodeCookie, &browserSession{
  789. ID: authenticatedRemoteNodeCookie,
  790. SrcNode: remoteNode.Node.ID,
  791. SrcUser: user.ID,
  792. Created: oneHourAgo,
  793. AuthID: testAuthPathSuccess,
  794. AuthURL: *testControlURL + testAuthPathSuccess,
  795. Authenticated: true,
  796. })
  797. authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated"
  798. s.browserSessions.Store(authenticatedLocalNodeCookie, &browserSession{
  799. ID: authenticatedLocalNodeCookie,
  800. SrcNode: localNode.Node.ID,
  801. SrcUser: user.ID,
  802. Created: oneHourAgo,
  803. AuthID: testAuthPathSuccess,
  804. AuthURL: *testControlURL + testAuthPathSuccess,
  805. Authenticated: true,
  806. })
  807. unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated"
  808. s.browserSessions.Store(unauthenticatedRemoteNodeCookie, &browserSession{
  809. ID: unauthenticatedRemoteNodeCookie,
  810. SrcNode: remoteNode.Node.ID,
  811. SrcUser: user.ID,
  812. Created: oneHourAgo,
  813. AuthID: testAuthPathSuccess,
  814. AuthURL: *testControlURL + testAuthPathSuccess,
  815. Authenticated: false,
  816. })
  817. unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated"
  818. s.browserSessions.Store(unauthenticatedLocalNodeCookie, &browserSession{
  819. ID: unauthenticatedLocalNodeCookie,
  820. SrcNode: localNode.Node.ID,
  821. SrcUser: user.ID,
  822. Created: oneHourAgo,
  823. AuthID: testAuthPathSuccess,
  824. AuthURL: *testControlURL + testAuthPathSuccess,
  825. Authenticated: false,
  826. })
  827. tests := []struct {
  828. name string
  829. cookie string // cookie attached to request
  830. remoteAddr string // remote address to hit
  831. wantLoggedMetric string // expected metric to be logged
  832. }{
  833. {
  834. name: "managing-remote",
  835. cookie: authenticatedRemoteNodeCookie,
  836. remoteAddr: remoteIP,
  837. wantLoggedMetric: "web_client_managing_remote",
  838. },
  839. {
  840. name: "managing-local",
  841. cookie: authenticatedLocalNodeCookie,
  842. remoteAddr: localIP,
  843. wantLoggedMetric: "web_client_managing_local",
  844. },
  845. {
  846. name: "viewing-not-owner",
  847. cookie: authenticatedRemoteNodeCookie,
  848. remoteAddr: otherIP,
  849. wantLoggedMetric: "web_client_viewing_not_owner",
  850. },
  851. {
  852. name: "viewing-local-tagged",
  853. cookie: authenticatedLocalNodeCookie,
  854. remoteAddr: localTaggedIP,
  855. wantLoggedMetric: "web_client_viewing_local_tag",
  856. },
  857. {
  858. name: "viewing-remote-tagged",
  859. cookie: authenticatedRemoteNodeCookie,
  860. remoteAddr: remoteTaggedIP,
  861. wantLoggedMetric: "web_client_viewing_remote_tag",
  862. },
  863. {
  864. name: "viewing-local-non-tailscale",
  865. cookie: authenticatedLocalNodeCookie,
  866. remoteAddr: nonTailscaleIP,
  867. wantLoggedMetric: "web_client_viewing_local",
  868. },
  869. {
  870. name: "viewing-local-unauthenticated",
  871. cookie: unauthenticatedLocalNodeCookie,
  872. remoteAddr: localIP,
  873. wantLoggedMetric: "web_client_viewing_local",
  874. },
  875. {
  876. name: "viewing-remote-unauthenticated",
  877. cookie: unauthenticatedRemoteNodeCookie,
  878. remoteAddr: remoteIP,
  879. wantLoggedMetric: "web_client_viewing_remote",
  880. },
  881. }
  882. for _, tt := range tests {
  883. t.Run(tt.name, func(t *testing.T) {
  884. testControlURL = &defaultControlURL
  885. r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil)
  886. r.RemoteAddr = tt.remoteAddr
  887. r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
  888. w := httptest.NewRecorder()
  889. s.serveAPIAuth(w, r)
  890. if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) {
  891. t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics)
  892. }
  893. loggedMetrics = []string{}
  894. res := w.Result()
  895. defer res.Body.Close()
  896. })
  897. }
  898. }
  899. // TestPathPrefix tests that the provided path prefix is normalized correctly.
  900. // If a leading '/' is missing, one should be added.
  901. // If multiple leading '/' are present, they should be collapsed to one.
  902. // Additionally verify that this prevents open redirects when enforcing the path prefix.
  903. func TestPathPrefix(t *testing.T) {
  904. tests := []struct {
  905. name string
  906. prefix string
  907. wantPrefix string
  908. wantLocation string
  909. }{
  910. {
  911. name: "no-leading-slash",
  912. prefix: "javascript:alert(1)",
  913. wantPrefix: "/javascript:alert(1)",
  914. wantLocation: "/javascript:alert(1)/",
  915. },
  916. {
  917. name: "2-slashes",
  918. prefix: "//evil.example.com/goat",
  919. // We must also get the trailing slash added:
  920. wantPrefix: "/evil.example.com/goat",
  921. wantLocation: "/evil.example.com/goat/",
  922. },
  923. {
  924. name: "absolute-url",
  925. prefix: "http://evil.example.com",
  926. // We must also get the trailing slash added:
  927. wantPrefix: "/http:/evil.example.com",
  928. wantLocation: "/http:/evil.example.com/",
  929. },
  930. {
  931. name: "double-dot",
  932. prefix: "/../.././etc/passwd",
  933. // We must also get the trailing slash added:
  934. wantPrefix: "/etc/passwd",
  935. wantLocation: "/etc/passwd/",
  936. },
  937. }
  938. for _, tt := range tests {
  939. t.Run(tt.name, func(t *testing.T) {
  940. options := ServerOpts{
  941. Mode: LoginServerMode,
  942. PathPrefix: tt.prefix,
  943. CGIMode: true,
  944. }
  945. s, err := NewServer(options)
  946. if err != nil {
  947. t.Error(err)
  948. }
  949. // verify provided prefix was normalized correctly
  950. if s.pathPrefix != tt.wantPrefix {
  951. t.Errorf("prefix was not normalized correctly; want=%q, got=%q", tt.wantPrefix, s.pathPrefix)
  952. }
  953. s.logf = t.Logf
  954. r := httptest.NewRequest(httpm.GET, "http://localhost/", nil)
  955. w := httptest.NewRecorder()
  956. s.ServeHTTP(w, r)
  957. res := w.Result()
  958. defer res.Body.Close()
  959. location := w.Header().Get("Location")
  960. if location != tt.wantLocation {
  961. t.Errorf("request got wrong location; want=%q, got=%q", tt.wantLocation, location)
  962. }
  963. })
  964. }
  965. }
  966. func TestRequireTailscaleIP(t *testing.T) {
  967. self := &ipnstate.PeerStatus{
  968. TailscaleIPs: []netip.Addr{
  969. netip.MustParseAddr("100.1.2.3"),
  970. netip.MustParseAddr("fd7a:115c::1234"),
  971. },
  972. }
  973. lal := memnet.Listen("local-tailscaled.sock:80")
  974. defer lal.Close()
  975. localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil)
  976. defer localapi.Close()
  977. go localapi.Serve(lal)
  978. s := &Server{
  979. mode: ManageServerMode,
  980. lc: &tailscale.LocalClient{Dial: lal.Dial},
  981. timeNow: time.Now,
  982. logf: t.Logf,
  983. }
  984. tests := []struct {
  985. name string
  986. target string
  987. wantHandled bool
  988. wantLocation string
  989. }{
  990. {
  991. name: "localhost",
  992. target: "http://localhost/",
  993. wantHandled: true,
  994. wantLocation: "http://100.1.2.3:5252/",
  995. },
  996. {
  997. name: "ipv4-no-port",
  998. target: "http://100.1.2.3/",
  999. wantHandled: true,
  1000. wantLocation: "http://100.1.2.3:5252/",
  1001. },
  1002. {
  1003. name: "ipv4-correct-port",
  1004. target: "http://100.1.2.3:5252/",
  1005. wantHandled: false,
  1006. },
  1007. {
  1008. name: "ipv6-no-port",
  1009. target: "http://[fd7a:115c::1234]/",
  1010. wantHandled: true,
  1011. wantLocation: "http://100.1.2.3:5252/",
  1012. },
  1013. {
  1014. name: "ipv6-correct-port",
  1015. target: "http://[fd7a:115c::1234]:5252/",
  1016. wantHandled: false,
  1017. },
  1018. {
  1019. name: "quad-100",
  1020. target: "http://100.100.100.100/",
  1021. wantHandled: false,
  1022. },
  1023. {
  1024. name: "ipv6-service-addr",
  1025. target: "http://[fd7a:115c:a1e0::53]/",
  1026. wantHandled: false,
  1027. },
  1028. }
  1029. for _, tt := range tests {
  1030. t.Run(tt.name, func(t *testing.T) {
  1031. s.logf = t.Logf
  1032. r := httptest.NewRequest(httpm.GET, tt.target, nil)
  1033. w := httptest.NewRecorder()
  1034. handled := s.requireTailscaleIP(w, r)
  1035. if handled != tt.wantHandled {
  1036. t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
  1037. }
  1038. location := w.Header().Get("Location")
  1039. if location != tt.wantLocation {
  1040. t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
  1041. }
  1042. })
  1043. }
  1044. }
  1045. func TestPeerCapabilities(t *testing.T) {
  1046. // Testing web.toPeerCapabilities
  1047. toPeerCapsTests := []struct {
  1048. name string
  1049. whois *apitype.WhoIsResponse
  1050. wantCaps peerCapabilities
  1051. }{
  1052. {
  1053. name: "empty-whois",
  1054. whois: nil,
  1055. wantCaps: peerCapabilities{},
  1056. },
  1057. {
  1058. name: "no-webui-caps",
  1059. whois: &apitype.WhoIsResponse{
  1060. CapMap: tailcfg.PeerCapMap{
  1061. tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
  1062. },
  1063. },
  1064. wantCaps: peerCapabilities{},
  1065. },
  1066. {
  1067. name: "one-webui-cap",
  1068. whois: &apitype.WhoIsResponse{
  1069. CapMap: tailcfg.PeerCapMap{
  1070. tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
  1071. "{\"canEdit\":[\"ssh\",\"subnet\"]}",
  1072. },
  1073. },
  1074. },
  1075. wantCaps: peerCapabilities{
  1076. capFeatureSSH: true,
  1077. capFeatureSubnet: true,
  1078. },
  1079. },
  1080. {
  1081. name: "multiple-webui-cap",
  1082. whois: &apitype.WhoIsResponse{
  1083. CapMap: tailcfg.PeerCapMap{
  1084. tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
  1085. "{\"canEdit\":[\"ssh\",\"subnet\"]}",
  1086. "{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
  1087. },
  1088. },
  1089. },
  1090. wantCaps: peerCapabilities{
  1091. capFeatureSSH: true,
  1092. capFeatureSubnet: true,
  1093. capFeatureExitNode: true,
  1094. capFeatureAll: true,
  1095. },
  1096. },
  1097. {
  1098. name: "case=insensitive-caps",
  1099. whois: &apitype.WhoIsResponse{
  1100. CapMap: tailcfg.PeerCapMap{
  1101. tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
  1102. "{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
  1103. },
  1104. },
  1105. },
  1106. wantCaps: peerCapabilities{
  1107. capFeatureSSH: true,
  1108. capFeatureSubnet: true,
  1109. },
  1110. },
  1111. {
  1112. name: "random-canEdit-contents-dont-error",
  1113. whois: &apitype.WhoIsResponse{
  1114. CapMap: tailcfg.PeerCapMap{
  1115. tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
  1116. "{\"canEdit\":[\"unknown-feature\"]}",
  1117. },
  1118. },
  1119. },
  1120. wantCaps: peerCapabilities{
  1121. "unknown-feature": true,
  1122. },
  1123. },
  1124. {
  1125. name: "no-canEdit-section",
  1126. whois: &apitype.WhoIsResponse{
  1127. CapMap: tailcfg.PeerCapMap{
  1128. tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
  1129. "{\"canDoSomething\":[\"*\"]}",
  1130. },
  1131. },
  1132. },
  1133. wantCaps: peerCapabilities{},
  1134. },
  1135. }
  1136. for _, tt := range toPeerCapsTests {
  1137. t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
  1138. got, err := toPeerCapabilities(tt.whois)
  1139. if err != nil {
  1140. t.Fatalf("unexpected: %v", err)
  1141. }
  1142. if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
  1143. t.Errorf("wrong caps; (-got+want):%v", diff)
  1144. }
  1145. })
  1146. }
  1147. // Testing web.peerCapabilities.canEdit
  1148. canEditTests := []struct {
  1149. name string
  1150. caps peerCapabilities
  1151. wantCanEdit map[capFeature]bool
  1152. }{
  1153. {
  1154. name: "empty-caps",
  1155. caps: nil,
  1156. wantCanEdit: map[capFeature]bool{
  1157. capFeatureAll: false,
  1158. capFeatureFunnel: false,
  1159. capFeatureSSH: false,
  1160. capFeatureSubnet: false,
  1161. capFeatureExitNode: false,
  1162. capFeatureAccount: false,
  1163. },
  1164. },
  1165. {
  1166. name: "some-caps",
  1167. caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
  1168. wantCanEdit: map[capFeature]bool{
  1169. capFeatureAll: false,
  1170. capFeatureFunnel: false,
  1171. capFeatureSSH: true,
  1172. capFeatureSubnet: false,
  1173. capFeatureExitNode: false,
  1174. capFeatureAccount: true,
  1175. },
  1176. },
  1177. {
  1178. name: "wildcard-in-caps",
  1179. caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
  1180. wantCanEdit: map[capFeature]bool{
  1181. capFeatureAll: true,
  1182. capFeatureFunnel: true,
  1183. capFeatureSSH: true,
  1184. capFeatureSubnet: true,
  1185. capFeatureExitNode: true,
  1186. capFeatureAccount: true,
  1187. },
  1188. },
  1189. }
  1190. for _, tt := range canEditTests {
  1191. t.Run("canEdit-"+tt.name, func(t *testing.T) {
  1192. for f, want := range tt.wantCanEdit {
  1193. if got := tt.caps.canEdit(f); got != want {
  1194. t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
  1195. }
  1196. }
  1197. })
  1198. }
  1199. }
  1200. var (
  1201. defaultControlURL = "https://controlplane.tailscale.com"
  1202. testAuthPath = "/a/12345"
  1203. testAuthPathSuccess = "/a/will-succeed"
  1204. testAuthPathError = "/a/will-error"
  1205. )
  1206. // mockLocalAPI constructs a test localapi handler that can be used
  1207. // to simulate localapi responses without a functioning tailnet.
  1208. //
  1209. // self accepts a function that resolves to a self node status,
  1210. // so that tests may swap out the /localapi/v0/status response
  1211. // as desired.
  1212. func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server {
  1213. return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1214. switch r.URL.Path {
  1215. case "/localapi/v0/whois":
  1216. addr := r.URL.Query().Get("addr")
  1217. if addr == "" {
  1218. t.Fatalf("/whois call missing \"addr\" query")
  1219. }
  1220. if node := whoIs[addr]; node != nil {
  1221. writeJSON(w, &node)
  1222. return
  1223. }
  1224. http.Error(w, "not a node", http.StatusUnauthorized)
  1225. return
  1226. case "/localapi/v0/status":
  1227. writeJSON(w, ipnstate.Status{Self: self()})
  1228. return
  1229. case "/localapi/v0/prefs":
  1230. writeJSON(w, prefs())
  1231. return
  1232. case "/localapi/v0/upload-client-metrics":
  1233. type metricName struct {
  1234. Name string `json:"name"`
  1235. }
  1236. var metricNames []metricName
  1237. if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil {
  1238. http.Error(w, "invalid JSON body", http.StatusBadRequest)
  1239. return
  1240. }
  1241. metricCapture(metricNames[0].Name)
  1242. writeJSON(w, struct{}{})
  1243. return
  1244. default:
  1245. t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
  1246. }
  1247. })}
  1248. }
  1249. func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
  1250. // Create new dummy auth URL.
  1251. return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil
  1252. }
  1253. func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
  1254. switch id {
  1255. case testAuthPathSuccess: // successful auth URL
  1256. return &tailcfg.WebClientAuthResponse{Complete: true}, nil
  1257. case testAuthPathError: // error auth URL
  1258. return nil, errors.New("authenticated as wrong user")
  1259. default:
  1260. return nil, errors.New("unknown id")
  1261. }
  1262. }