web_test.go 43 KB

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