2023-08-08 23:58:45 +00:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
2023-11-16 22:53:46 +00:00
"context"
2023-10-05 18:48:45 +00:00
"encoding/json"
"errors"
2023-08-28 20:44:48 +00:00
"fmt"
"io"
"net/http"
"net/http/httptest"
2023-11-03 03:05:40 +00:00
"net/netip"
2023-08-08 23:58:45 +00:00
"net/url"
2023-12-08 20:15:57 +00:00
"slices"
2023-08-28 20:44:48 +00:00
"strings"
2023-08-08 23:58:45 +00:00
"testing"
2023-10-05 18:48:45 +00:00
"time"
2023-08-28 20:44:48 +00:00
2023-10-05 18:48:45 +00:00
"github.com/google/go-cmp/cmp"
2023-08-28 20:44:48 +00:00
"tailscale.com/client/tailscale"
2023-10-05 18:48:45 +00:00
"tailscale.com/client/tailscale/apitype"
2023-11-29 16:44:48 +00:00
"tailscale.com/ipn"
2023-10-05 18:48:45 +00:00
"tailscale.com/ipn/ipnstate"
2023-08-28 20:44:48 +00:00
"tailscale.com/net/memnet"
2023-10-05 18:48:45 +00:00
"tailscale.com/tailcfg"
"tailscale.com/types/views"
2023-10-18 15:48:20 +00:00
"tailscale.com/util/httpm"
2023-08-08 23:58:45 +00:00
)
func TestQnapAuthnURL ( t * testing . T ) {
query := url . Values {
"qtoken" : [ ] string { "token" } ,
}
tests := [ ] struct {
name string
in string
want string
} {
{
name : "localhost http" ,
in : "http://localhost:8088/" ,
want : "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "localhost https" ,
in : "https://localhost:5000/" ,
want : "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "IP http" ,
in : "http://10.1.20.4:80/" ,
want : "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "IP6 https" ,
in : "https://[ff7d:0:1:2::1]/" ,
want : "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "hostname https" ,
in : "https://qnap.example.com/" ,
want : "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "invalid URL" ,
in : "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path." ,
want : "http://localhost/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
{
name : "err != nil" ,
in : "http://192.168.0.%31/" ,
want : "http://localhost/cgi-bin/authLogin.cgi?qtoken=token" ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
u := qnapAuthnURL ( tt . in , query )
if u != tt . want {
t . Errorf ( "expected url: %q, got: %q" , tt . want , u )
}
} )
}
}
2023-08-28 20:44:48 +00:00
// TestServeAPI tests the web client api's handling of
// 1. invalid endpoint errors
// 2. localapi proxy allowlist
func TestServeAPI ( t * testing . T ) {
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
// Serve dummy localapi. Just returns "success".
2023-08-30 01:50:04 +00:00
localapi := & http . Server { Handler : http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
fmt . Fprintf ( w , "success" )
} ) }
defer localapi . Close ( )
go localapi . Serve ( lal )
2023-08-28 20:44:48 +00:00
s := & Server { lc : & tailscale . LocalClient { Dial : lal . Dial } }
tests := [ ] struct {
2023-12-08 18:25:01 +00:00
name string
reqMethod string
reqPath string
reqContentType string
wantResp string
wantStatus int
2023-08-28 20:44:48 +00:00
} { {
name : "invalid_endpoint" ,
2023-12-08 18:25:01 +00:00
reqMethod : httpm . POST ,
2023-08-28 20:44:48 +00:00
reqPath : "/not-an-endpoint" ,
wantResp : "invalid endpoint" ,
wantStatus : http . StatusNotFound ,
} , {
name : "not_in_localapi_allowlist" ,
2023-12-08 18:25:01 +00:00
reqMethod : httpm . POST ,
2023-08-28 20:44:48 +00:00
reqPath : "/local/v0/not-allowlisted" ,
wantResp : "/v0/not-allowlisted not allowed from localapi proxy" ,
wantStatus : http . StatusForbidden ,
} , {
name : "in_localapi_allowlist" ,
2023-12-08 18:25:01 +00:00
reqMethod : httpm . POST ,
2023-08-28 20:44:48 +00:00
reqPath : "/local/v0/logout" ,
wantResp : "success" , // Successfully allowed to hit localapi.
wantStatus : http . StatusOK ,
2023-12-08 18:25:01 +00:00
} , {
name : "patch_bad_contenttype" ,
reqMethod : httpm . PATCH ,
reqPath : "/local/v0/prefs" ,
reqContentType : "multipart/form-data" ,
wantResp : "invalid request" ,
wantStatus : http . StatusBadRequest ,
2023-08-28 20:44:48 +00:00
} }
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
2023-12-08 18:25:01 +00:00
r := httptest . NewRequest ( tt . reqMethod , "/api" + tt . reqPath , nil )
if tt . reqContentType != "" {
r . Header . Add ( "Content-Type" , tt . reqContentType )
}
2023-08-28 20:44:48 +00:00
w := httptest . NewRecorder ( )
s . serveAPI ( w , r )
res := w . Result ( )
defer res . Body . Close ( )
if gotStatus := res . StatusCode ; tt . wantStatus != gotStatus {
2023-10-18 15:48:20 +00:00
t . Errorf ( "wrong status; want=%v, got=%v" , tt . wantStatus , gotStatus )
2023-08-28 20:44:48 +00:00
}
body , err := io . ReadAll ( res . Body )
if err != nil {
t . Fatal ( err )
}
gotResp := strings . TrimSuffix ( string ( body ) , "\n" ) // trim trailing newline
if tt . wantResp != gotResp {
t . Errorf ( "wrong response; want=%q, got=%q" , tt . wantResp , gotResp )
}
} )
}
}
2023-10-05 18:48:45 +00:00
func TestGetTailscaleBrowserSession ( t * testing . T ) {
userA := & tailcfg . UserProfile { ID : tailcfg . UserID ( 1 ) }
userB := & tailcfg . UserProfile { ID : tailcfg . UserID ( 2 ) }
userANodeIP := "100.100.100.101"
userBNodeIP := "100.100.100.102"
taggedNodeIP := "100.100.100.103"
var selfNode * ipnstate . PeerStatus
tags := views . SliceOf ( [ ] string { "tag:server" } )
tailnetNodes := map [ string ] * apitype . WhoIsResponse {
userANodeIP : {
2023-10-24 19:15:10 +00:00
Node : & tailcfg . Node { ID : 1 , StableID : "1" } ,
2023-10-05 18:48:45 +00:00
UserProfile : userA ,
} ,
userBNodeIP : {
2023-10-24 19:15:10 +00:00
Node : & tailcfg . Node { ID : 2 , StableID : "2" } ,
2023-10-05 18:48:45 +00:00
UserProfile : userB ,
} ,
taggedNodeIP : {
2023-10-24 19:15:10 +00:00
Node : & tailcfg . Node { ID : 3 , StableID : "3" , Tags : tags . AsSlice ( ) } ,
2023-10-05 18:48:45 +00:00
} ,
}
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
2023-12-08 20:15:57 +00:00
localapi := mockLocalAPI ( t , tailnetNodes , func ( ) * ipnstate . PeerStatus { return selfNode } , nil , nil )
2023-10-05 18:48:45 +00:00
defer localapi . Close ( )
go localapi . Serve ( lal )
2023-10-23 16:36:21 +00:00
s := & Server {
timeNow : time . Now ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
}
2023-10-05 18:48:45 +00:00
// Add some browser sessions to cache state.
userASession := & browserSession {
ID : "cookie1" ,
2023-10-18 20:45:25 +00:00
SrcNode : 1 ,
2023-10-05 18:48:45 +00:00
SrcUser : userA . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) ,
Authenticated : false , // not yet authenticated
2023-10-05 18:48:45 +00:00
}
userBSession := & browserSession {
ID : "cookie2" ,
2023-10-18 20:45:25 +00:00
SrcNode : 2 ,
2023-10-05 18:48:45 +00:00
SrcUser : userB . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) . Add ( - 2 * sessionCookieExpiry ) ,
Authenticated : true , // expired
2023-10-05 18:48:45 +00:00
}
userASessionAuthorized := & browserSession {
ID : "cookie3" ,
2023-10-18 20:45:25 +00:00
SrcNode : 1 ,
2023-10-05 18:48:45 +00:00
SrcUser : userA . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) ,
Authenticated : true , // authenticated and not expired
2023-10-05 18:48:45 +00:00
}
s . browserSessions . Store ( userASession . ID , userASession )
s . browserSessions . Store ( userBSession . ID , userBSession )
s . browserSessions . Store ( userASessionAuthorized . ID , userASessionAuthorized )
tests := [ ] struct {
name string
selfNode * ipnstate . PeerStatus
remoteAddr string
cookie string
wantSession * browserSession
wantError error
wantIsAuthorized bool // response from session.isAuthorized
} {
{
name : "not-connected-over-tailscale" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : "77.77.77.77" ,
wantSession : nil ,
wantError : errNotUsingTailscale ,
} ,
{
name : "no-session-user-self-node" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userANodeIP ,
cookie : "not-a-cookie" ,
wantSession : nil ,
wantError : errNoSession ,
} ,
{
name : "no-session-tagged-self-node" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , Tags : & tags } ,
remoteAddr : userANodeIP ,
wantSession : nil ,
wantError : errNoSession ,
} ,
{
name : "not-owner" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userBNodeIP ,
wantSession : nil ,
wantError : errNotOwner ,
} ,
{
2023-10-24 19:15:10 +00:00
name : "tagged-remote-source" ,
2023-10-05 18:48:45 +00:00
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : taggedNodeIP ,
wantSession : nil ,
2023-10-24 19:15:10 +00:00
wantError : errTaggedRemoteSource ,
} ,
{
name : "tagged-local-source" ,
selfNode : & ipnstate . PeerStatus { ID : "3" } ,
remoteAddr : taggedNodeIP , // same node as selfNode
wantSession : nil ,
wantError : errTaggedLocalSource ,
} ,
{
name : "not-tagged-local-source" ,
selfNode : & ipnstate . PeerStatus { ID : "1" , UserID : userA . ID } ,
remoteAddr : userANodeIP , // same node as selfNode
cookie : userASession . ID ,
wantSession : userASession ,
wantError : nil , // should not error
2023-10-05 18:48:45 +00:00
} ,
{
name : "has-session" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userANodeIP ,
cookie : userASession . ID ,
wantSession : userASession ,
wantError : nil ,
} ,
{
name : "has-authorized-session" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userA . ID } ,
remoteAddr : userANodeIP ,
cookie : userASessionAuthorized . ID ,
wantSession : userASessionAuthorized ,
wantError : nil ,
wantIsAuthorized : true ,
} ,
{
name : "session-associated-with-different-source" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userB . ID } ,
remoteAddr : userBNodeIP ,
cookie : userASession . ID ,
wantSession : nil ,
wantError : errNoSession ,
} ,
{
name : "session-expired" ,
selfNode : & ipnstate . PeerStatus { ID : "self" , UserID : userB . ID } ,
remoteAddr : userBNodeIP ,
cookie : userBSession . ID ,
wantSession : nil ,
wantError : errNoSession ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
selfNode = tt . selfNode
r := & http . Request { RemoteAddr : tt . remoteAddr , Header : http . Header { } }
if tt . cookie != "" {
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : tt . cookie } )
}
2023-12-08 20:15:57 +00:00
session , _ , _ , err := s . getSession ( r )
2023-10-05 18:48:45 +00:00
if ! errors . Is ( err , tt . wantError ) {
t . Errorf ( "wrong error; want=%v, got=%v" , tt . wantError , err )
}
if diff := cmp . Diff ( session , tt . wantSession ) ; diff != "" {
t . Errorf ( "wrong session; (-got+want):%v" , diff )
}
2023-10-23 16:36:21 +00:00
if gotIsAuthorized := session . isAuthorized ( s . timeNow ( ) ) ; gotIsAuthorized != tt . wantIsAuthorized {
2023-10-05 18:48:45 +00:00
t . Errorf ( "wrong isAuthorized; want=%v, got=%v" , tt . wantIsAuthorized , gotIsAuthorized )
}
} )
}
}
2023-10-18 15:48:20 +00:00
// TestAuthorizeRequest tests the s.authorizeRequest function.
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
func TestAuthorizeRequest ( t * testing . T ) {
// Create self and remoteNode owned by same user.
// See TestGetTailscaleBrowserSession for tests of
// browser sessions w/ different users.
user := & tailcfg . UserProfile { ID : tailcfg . UserID ( 1 ) }
self := & ipnstate . PeerStatus { ID : "self" , UserID : user . ID }
remoteNode := & apitype . WhoIsResponse { Node : & tailcfg . Node { StableID : "node" } , UserProfile : user }
remoteIP := "100.100.100.101"
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
localapi := mockLocalAPI ( t ,
map [ string ] * apitype . WhoIsResponse { remoteIP : remoteNode } ,
func ( ) * ipnstate . PeerStatus { return self } ,
2023-11-29 16:44:48 +00:00
nil ,
2023-12-08 20:15:57 +00:00
nil ,
2023-10-18 15:48:20 +00:00
)
defer localapi . Close ( )
go localapi . Serve ( lal )
s := & Server {
2023-11-02 19:13:22 +00:00
mode : ManageServerMode ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
timeNow : time . Now ,
2023-10-18 15:48:20 +00:00
}
validCookie := "ts-cookie"
s . browserSessions . Store ( validCookie , & browserSession {
ID : validCookie ,
2023-10-18 20:45:25 +00:00
SrcNode : remoteNode . Node . ID ,
2023-10-18 15:48:20 +00:00
SrcUser : user . ID ,
2023-10-18 20:45:25 +00:00
Created : time . Now ( ) ,
Authenticated : true ,
2023-10-18 15:48:20 +00:00
} )
tests := [ ] struct {
reqPath string
reqMethod string
wantOkNotOverTailscale bool // simulates req over public internet
wantOkWithoutSession bool // simulates req over TS without valid browser session
wantOkWithSession bool // simulates req over TS with valid browser session
} { {
reqPath : "/api/data" ,
reqMethod : httpm . GET ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : true ,
wantOkWithSession : true ,
} , {
reqPath : "/api/data" ,
reqMethod : httpm . POST ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : false ,
wantOkWithSession : true ,
} , {
reqPath : "/api/somethingelse" ,
reqMethod : httpm . GET ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : false ,
wantOkWithSession : true ,
} , {
reqPath : "/assets/styles.css" ,
wantOkNotOverTailscale : false ,
wantOkWithoutSession : true ,
wantOkWithSession : true ,
} }
for _ , tt := range tests {
t . Run ( fmt . Sprintf ( "%s-%s" , tt . reqMethod , tt . reqPath ) , func ( t * testing . T ) {
doAuthorize := func ( remoteAddr string , cookie string ) bool {
r := httptest . NewRequest ( tt . reqMethod , tt . reqPath , nil )
r . RemoteAddr = remoteAddr
if cookie != "" {
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : cookie } )
}
w := httptest . NewRecorder ( )
return s . authorizeRequest ( w , r )
}
// Do request from non-Tailscale IP.
if gotOk := doAuthorize ( "123.456.789.999" , "" ) ; gotOk != tt . wantOkNotOverTailscale {
t . Errorf ( "wantOkNotOverTailscale; want=%v, got=%v" , tt . wantOkNotOverTailscale , gotOk )
}
// Do request from Tailscale IP w/o associated session.
if gotOk := doAuthorize ( remoteIP , "" ) ; gotOk != tt . wantOkWithoutSession {
t . Errorf ( "wantOkWithoutSession; want=%v, got=%v" , tt . wantOkWithoutSession , gotOk )
}
// Do request from Tailscale IP w/ associated session.
if gotOk := doAuthorize ( remoteIP , validCookie ) ; gotOk != tt . wantOkWithSession {
t . Errorf ( "wantOkWithSession; want=%v, got=%v" , tt . wantOkWithSession , gotOk )
}
} )
}
}
2023-11-03 17:38:01 +00:00
func TestServeAuth ( t * testing . T ) {
2023-11-09 21:19:22 +00:00
user := & tailcfg . UserProfile { LoginName : "user@example.com" , ID : tailcfg . UserID ( 1 ) }
2023-11-03 03:05:40 +00:00
self := & ipnstate . PeerStatus {
ID : "self" ,
UserID : user . ID ,
TailscaleIPs : [ ] netip . Addr { netip . MustParseAddr ( "100.1.2.3" ) } ,
}
2023-10-18 20:45:25 +00:00
remoteIP := "100.100.100.101"
2023-11-09 21:19:22 +00:00
remoteNode := & apitype . WhoIsResponse {
Node : & tailcfg . Node {
Name : "nodey" ,
ID : 1 ,
Addresses : [ ] netip . Prefix { netip . MustParsePrefix ( remoteIP + "/32" ) } ,
} ,
UserProfile : user ,
}
vi := & viewerIdentity {
LoginName : user . LoginName ,
NodeName : remoteNode . Node . Name ,
NodeIP : remoteIP ,
ProfilePicURL : user . ProfilePicURL ,
}
2023-10-18 20:45:25 +00:00
2023-11-29 16:44:48 +00:00
testControlURL := & defaultControlURL
2023-10-18 20:45:25 +00:00
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
localapi := mockLocalAPI ( t ,
map [ string ] * apitype . WhoIsResponse { remoteIP : remoteNode } ,
func ( ) * ipnstate . PeerStatus { return self } ,
2023-11-29 16:44:48 +00:00
func ( ) * ipn . Prefs {
return & ipn . Prefs { ControlURL : * testControlURL }
} ,
2023-12-08 20:15:57 +00:00
nil ,
2023-10-18 20:45:25 +00:00
)
defer localapi . Close ( )
go localapi . Serve ( lal )
2023-10-19 20:13:40 +00:00
timeNow := time . Now ( )
oneHourAgo := timeNow . Add ( - time . Hour )
sixtyDaysAgo := timeNow . Add ( - sessionCookieExpiry * 2 )
2023-10-18 20:45:25 +00:00
s := & Server {
2023-11-16 22:53:46 +00:00
mode : ManageServerMode ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
timeNow : func ( ) time . Time { return timeNow } ,
newAuthURL : mockNewAuthURL ,
waitAuthURL : mockWaitAuthURL ,
2023-10-18 20:45:25 +00:00
}
successCookie := "ts-cookie-success"
s . browserSessions . Store ( successCookie , & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
2023-10-19 20:13:40 +00:00
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPathSuccess ,
2023-10-18 20:45:25 +00:00
} )
failureCookie := "ts-cookie-failure"
s . browserSessions . Store ( failureCookie , & browserSession {
ID : failureCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
2023-10-19 20:13:40 +00:00
Created : oneHourAgo ,
AuthID : testAuthPathError ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPathError ,
2023-10-18 20:45:25 +00:00
} )
expiredCookie := "ts-cookie-expired"
s . browserSessions . Store ( expiredCookie , & browserSession {
ID : expiredCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
2023-10-19 20:13:40 +00:00
Created : sixtyDaysAgo ,
AuthID : "/a/old-auth-url" ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + "/a/old-auth-url" ,
2023-10-18 20:45:25 +00:00
} )
tests := [ ] struct {
2023-11-03 17:38:01 +00:00
name string
2023-11-29 16:44:48 +00:00
controlURL string // if empty, defaultControlURL is used
2023-11-03 17:38:01 +00:00
cookie string // cookie attached to request
wantNewCookie bool // want new cookie generated during request
wantSession * browserSession // session associated w/ cookie after request
path string
wantStatus int
wantResp any
2023-10-18 20:45:25 +00:00
} {
{
2023-11-03 17:38:01 +00:00
name : "no-session" ,
path : "/api/auth" ,
2023-10-18 20:45:25 +00:00
wantStatus : http . StatusOK ,
2023-11-09 21:19:22 +00:00
wantResp : & authResponse { AuthNeeded : tailscaleAuth , ViewerIdentity : vi } ,
2023-11-03 17:38:01 +00:00
wantNewCookie : false ,
wantSession : nil ,
} ,
{
name : "new-session" ,
path : "/api/auth/session/new" ,
wantStatus : http . StatusOK ,
2023-11-29 16:44:48 +00:00
wantResp : & newSessionAuthResponse { AuthURL : * testControlURL + testAuthPath } ,
2023-10-18 20:45:25 +00:00
wantNewCookie : true ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : "GENERATED_ID" , // gets swapped for newly created ID by test
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
AuthID : testAuthPath ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPath ,
2023-10-19 20:13:40 +00:00
Authenticated : false ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "query-existing-incomplete-session" ,
2023-11-03 17:38:01 +00:00
path : "/api/auth" ,
2023-10-18 20:45:25 +00:00
cookie : successCookie ,
wantStatus : http . StatusOK ,
2023-11-09 21:19:22 +00:00
wantResp : & authResponse { AuthNeeded : tailscaleAuth , ViewerIdentity : vi } ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPathSuccess ,
2023-10-19 20:13:40 +00:00
Authenticated : false ,
} ,
} ,
{
2023-11-03 17:38:01 +00:00
name : "existing-session-used" ,
path : "/api/auth/session/new" , // should not create new session
cookie : successCookie ,
2023-10-18 20:45:25 +00:00
wantStatus : http . StatusOK ,
2023-11-29 16:44:48 +00:00
wantResp : & newSessionAuthResponse { AuthURL : * testControlURL + testAuthPathSuccess } ,
2023-11-03 17:38:01 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPathSuccess ,
2023-11-03 17:38:01 +00:00
Authenticated : false ,
} ,
} ,
{
name : "transition-to-successful-session" ,
path : "/api/auth/session/wait" ,
cookie : successCookie ,
wantStatus : http . StatusOK ,
wantResp : nil ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPathSuccess ,
2023-10-19 20:13:40 +00:00
Authenticated : true ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "query-existing-complete-session" ,
2023-11-03 17:38:01 +00:00
path : "/api/auth" ,
2023-10-18 20:45:25 +00:00
cookie : successCookie ,
wantStatus : http . StatusOK ,
2023-11-09 21:19:22 +00:00
wantResp : & authResponse { CanManageNode : true , ViewerIdentity : vi } ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : successCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPathSuccess ,
2023-10-19 20:13:40 +00:00
Authenticated : true ,
} ,
} ,
{
name : "transition-to-failed-session" ,
2023-11-03 17:38:01 +00:00
path : "/api/auth/session/wait" ,
2023-10-19 20:13:40 +00:00
cookie : failureCookie ,
wantStatus : http . StatusUnauthorized ,
wantResp : nil ,
wantSession : nil , // session deleted
} ,
{
2023-10-18 20:45:25 +00:00
name : "failed-session-cleaned-up" ,
2023-11-03 17:38:01 +00:00
path : "/api/auth/session/new" ,
2023-10-18 20:45:25 +00:00
cookie : failureCookie ,
wantStatus : http . StatusOK ,
2023-11-29 16:44:48 +00:00
wantResp : & newSessionAuthResponse { AuthURL : * testControlURL + testAuthPath } ,
2023-10-18 20:45:25 +00:00
wantNewCookie : true ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : "GENERATED_ID" ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
AuthID : testAuthPath ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPath ,
2023-10-19 20:13:40 +00:00
Authenticated : false ,
} ,
} ,
{
2023-10-18 20:45:25 +00:00
name : "expired-cookie-gets-new-session" ,
2023-11-03 17:38:01 +00:00
path : "/api/auth/session/new" ,
2023-10-18 20:45:25 +00:00
cookie : expiredCookie ,
wantStatus : http . StatusOK ,
2023-11-29 16:44:48 +00:00
wantResp : & newSessionAuthResponse { AuthURL : * testControlURL + testAuthPath } ,
2023-10-18 20:45:25 +00:00
wantNewCookie : true ,
2023-10-19 20:13:40 +00:00
wantSession : & browserSession {
ID : "GENERATED_ID" ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
AuthID : testAuthPath ,
2023-11-29 16:44:48 +00:00
AuthURL : * testControlURL + testAuthPath ,
2023-10-19 20:13:40 +00:00
Authenticated : false ,
} ,
2023-10-18 20:45:25 +00:00
} ,
2023-11-29 16:44:48 +00:00
{
name : "control-server-no-check-mode" ,
controlURL : "http://alternate-server.com/" ,
path : "/api/auth/session/new" ,
wantStatus : http . StatusOK ,
wantResp : & newSessionAuthResponse { } ,
wantNewCookie : true ,
wantSession : & browserSession {
ID : "GENERATED_ID" , // gets swapped for newly created ID by test
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : timeNow ,
Authenticated : true ,
} ,
} ,
2023-10-18 20:45:25 +00:00
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
2023-11-29 16:44:48 +00:00
if tt . controlURL != "" {
testControlURL = & tt . controlURL
} else {
testControlURL = & defaultControlURL
}
2023-11-03 03:05:40 +00:00
r := httptest . NewRequest ( "GET" , "http://100.1.2.3:5252" + tt . path , nil )
2023-10-18 20:45:25 +00:00
r . RemoteAddr = remoteIP
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : tt . cookie } )
w := httptest . NewRecorder ( )
2023-11-03 17:38:01 +00:00
s . serve ( w , r )
2023-10-18 20:45:25 +00:00
res := w . Result ( )
defer res . Body . Close ( )
2023-10-19 20:13:40 +00:00
// Validate response status/data.
2023-10-18 20:45:25 +00:00
if gotStatus := res . StatusCode ; tt . wantStatus != gotStatus {
t . Errorf ( "wrong status; want=%v, got=%v" , tt . wantStatus , gotStatus )
}
2023-11-03 17:38:01 +00:00
var gotResp string
2023-10-18 20:45:25 +00:00
if res . StatusCode == http . StatusOK {
body , err := io . ReadAll ( res . Body )
if err != nil {
t . Fatal ( err )
}
2023-11-03 17:38:01 +00:00
gotResp = strings . Trim ( string ( body ) , "\n" )
}
var wantResp string
if tt . wantResp != nil {
b , _ := json . Marshal ( tt . wantResp )
wantResp = string ( b )
2023-10-18 20:45:25 +00:00
}
2023-11-03 17:38:01 +00:00
if diff := cmp . Diff ( gotResp , string ( wantResp ) ) ; diff != "" {
2023-10-19 20:13:40 +00:00
t . Errorf ( "wrong response; (-got+want):%v" , diff )
2023-10-18 20:45:25 +00:00
}
2023-10-19 20:13:40 +00:00
// Validate cookie creation.
sessionID := tt . cookie
2023-10-18 20:45:25 +00:00
var gotCookie bool
for _ , c := range w . Result ( ) . Cookies ( ) {
if c . Name == sessionCookieName {
gotCookie = true
2023-10-19 20:13:40 +00:00
sessionID = c . Value
2023-10-18 20:45:25 +00:00
break
}
}
if gotCookie != tt . wantNewCookie {
t . Errorf ( "wantNewCookie wrong; want=%v, got=%v" , tt . wantNewCookie , gotCookie )
}
2023-10-19 20:13:40 +00:00
// Validate browser session contents.
var gotSesson * browserSession
if s , ok := s . browserSessions . Load ( sessionID ) ; ok {
gotSesson = s . ( * browserSession )
}
if tt . wantSession != nil && tt . wantSession . ID == "GENERATED_ID" {
// If requested, swap in the generated session ID before
// comparing got/want.
tt . wantSession . ID = sessionID
}
if diff := cmp . Diff ( gotSesson , tt . wantSession ) ; diff != "" {
t . Errorf ( "wrong session; (-got+want):%v" , diff )
}
2023-10-18 20:45:25 +00:00
} )
}
}
2023-12-08 20:15:57 +00:00
// TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function.
// For each given test case, we assert that the local API received a request to log the expected metric.
func TestServeAPIAuthMetricLogging ( t * testing . T ) {
user := & tailcfg . UserProfile { LoginName : "user@example.com" , ID : tailcfg . UserID ( 1 ) }
otherUser := & tailcfg . UserProfile { LoginName : "user2@example.com" , ID : tailcfg . UserID ( 2 ) }
self := & ipnstate . PeerStatus {
ID : "self" ,
UserID : user . ID ,
TailscaleIPs : [ ] netip . Addr { netip . MustParseAddr ( "100.1.2.3" ) } ,
}
remoteIP := "100.100.100.101"
remoteNode := & apitype . WhoIsResponse {
Node : & tailcfg . Node {
Name : "remote-managed" ,
ID : 1 ,
Addresses : [ ] netip . Prefix { netip . MustParsePrefix ( remoteIP + "/32" ) } ,
} ,
UserProfile : user ,
}
remoteTaggedIP := "100.123.100.213"
remoteTaggedNode := & apitype . WhoIsResponse {
Node : & tailcfg . Node {
Name : "remote-tagged" ,
ID : 2 ,
Addresses : [ ] netip . Prefix { netip . MustParsePrefix ( remoteTaggedIP + "/32" ) } ,
Tags : [ ] string { "dev-machine" } ,
} ,
UserProfile : user ,
}
localIP := "100.1.2.3"
localNode := & apitype . WhoIsResponse {
Node : & tailcfg . Node {
Name : "local-managed" ,
ID : 3 ,
StableID : "self" ,
Addresses : [ ] netip . Prefix { netip . MustParsePrefix ( localIP + "/32" ) } ,
} ,
UserProfile : user ,
}
localTaggedIP := "100.1.2.133"
localTaggedNode := & apitype . WhoIsResponse {
Node : & tailcfg . Node {
Name : "local-tagged" ,
ID : 4 ,
StableID : "self" ,
Addresses : [ ] netip . Prefix { netip . MustParsePrefix ( localTaggedIP + "/32" ) } ,
Tags : [ ] string { "prod-machine" } ,
} ,
UserProfile : user ,
}
otherIP := "100.100.2.3"
otherNode := & apitype . WhoIsResponse {
Node : & tailcfg . Node {
Name : "other-node" ,
ID : 5 ,
Addresses : [ ] netip . Prefix { netip . MustParsePrefix ( otherIP + "/32" ) } ,
} ,
UserProfile : otherUser ,
}
2023-12-11 20:50:15 +00:00
nonTailscaleIP := "10.100.2.3"
2023-12-08 20:15:57 +00:00
testControlURL := & defaultControlURL
var loggedMetrics [ ] string
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
localapi := mockLocalAPI ( t ,
map [ string ] * apitype . WhoIsResponse { remoteIP : remoteNode , localIP : localNode , otherIP : otherNode , localTaggedIP : localTaggedNode , remoteTaggedIP : remoteTaggedNode } ,
func ( ) * ipnstate . PeerStatus { return self } ,
func ( ) * ipn . Prefs {
return & ipn . Prefs { ControlURL : * testControlURL }
} ,
func ( metricName string ) {
loggedMetrics = append ( loggedMetrics , metricName )
} ,
)
defer localapi . Close ( )
go localapi . Serve ( lal )
timeNow := time . Now ( )
oneHourAgo := timeNow . Add ( - time . Hour )
s := & Server {
mode : ManageServerMode ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
timeNow : func ( ) time . Time { return timeNow } ,
newAuthURL : mockNewAuthURL ,
waitAuthURL : mockWaitAuthURL ,
}
2023-12-11 20:50:15 +00:00
authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated"
s . browserSessions . Store ( authenticatedRemoteNodeCookie , & browserSession {
ID : authenticatedRemoteNodeCookie ,
2023-12-08 20:15:57 +00:00
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : * testControlURL + testAuthPathSuccess ,
Authenticated : true ,
} )
2023-12-11 20:50:15 +00:00
authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated"
s . browserSessions . Store ( authenticatedLocalNodeCookie , & browserSession {
ID : authenticatedLocalNodeCookie ,
2023-12-08 20:15:57 +00:00
SrcNode : localNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : * testControlURL + testAuthPathSuccess ,
Authenticated : true ,
} )
2023-12-11 20:50:15 +00:00
unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated"
s . browserSessions . Store ( unauthenticatedRemoteNodeCookie , & browserSession {
ID : unauthenticatedRemoteNodeCookie ,
SrcNode : remoteNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : * testControlURL + testAuthPathSuccess ,
Authenticated : false ,
} )
unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated"
s . browserSessions . Store ( unauthenticatedLocalNodeCookie , & browserSession {
ID : unauthenticatedLocalNodeCookie ,
SrcNode : localNode . Node . ID ,
SrcUser : user . ID ,
Created : oneHourAgo ,
AuthID : testAuthPathSuccess ,
AuthURL : * testControlURL + testAuthPathSuccess ,
Authenticated : false ,
} )
2023-12-08 20:15:57 +00:00
tests := [ ] struct {
name string
cookie string // cookie attached to request
remoteAddr string // remote address to hit
wantLoggedMetric string // expected metric to be logged
} {
{
name : "managing-remote" ,
2023-12-11 20:50:15 +00:00
cookie : authenticatedRemoteNodeCookie ,
2023-12-08 20:15:57 +00:00
remoteAddr : remoteIP ,
wantLoggedMetric : "web_client_managing_remote" ,
} ,
{
name : "managing-local" ,
2023-12-11 20:50:15 +00:00
cookie : authenticatedLocalNodeCookie ,
2023-12-08 20:15:57 +00:00
remoteAddr : localIP ,
wantLoggedMetric : "web_client_managing_local" ,
} ,
{
name : "viewing-not-owner" ,
2023-12-11 20:50:15 +00:00
cookie : authenticatedRemoteNodeCookie ,
2023-12-08 20:15:57 +00:00
remoteAddr : otherIP ,
wantLoggedMetric : "web_client_viewing_not_owner" ,
} ,
{
name : "viewing-local-tagged" ,
2023-12-11 20:50:15 +00:00
cookie : authenticatedLocalNodeCookie ,
2023-12-08 20:15:57 +00:00
remoteAddr : localTaggedIP ,
wantLoggedMetric : "web_client_viewing_local_tag" ,
} ,
{
name : "viewing-remote-tagged" ,
2023-12-11 20:50:15 +00:00
cookie : authenticatedRemoteNodeCookie ,
2023-12-08 20:15:57 +00:00
remoteAddr : remoteTaggedIP ,
wantLoggedMetric : "web_client_viewing_remote_tag" ,
} ,
2023-12-11 20:50:15 +00:00
{
name : "viewing-local-non-tailscale" ,
cookie : authenticatedLocalNodeCookie ,
remoteAddr : nonTailscaleIP ,
wantLoggedMetric : "web_client_viewing_local" ,
} ,
{
name : "viewing-local-unauthenticated" ,
cookie : unauthenticatedLocalNodeCookie ,
remoteAddr : localIP ,
wantLoggedMetric : "web_client_viewing_local" ,
} ,
{
name : "viewing-remote-unauthenticated" ,
cookie : unauthenticatedRemoteNodeCookie ,
remoteAddr : remoteIP ,
wantLoggedMetric : "web_client_viewing_remote" ,
} ,
2023-12-08 20:15:57 +00:00
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
testControlURL = & defaultControlURL
r := httptest . NewRequest ( "GET" , "http://100.1.2.3:5252/api/auth" , nil )
r . RemoteAddr = tt . remoteAddr
r . AddCookie ( & http . Cookie { Name : sessionCookieName , Value : tt . cookie } )
w := httptest . NewRecorder ( )
s . serveAPIAuth ( w , r )
if ! slices . Contains ( loggedMetrics , tt . wantLoggedMetric ) {
t . Errorf ( "expected logged metrics to contain: '%s' but was: '%v'" , tt . wantLoggedMetric , loggedMetrics )
}
loggedMetrics = [ ] string { }
res := w . Result ( )
defer res . Body . Close ( )
} )
}
}
2023-11-03 03:05:40 +00:00
func TestRequireTailscaleIP ( t * testing . T ) {
self := & ipnstate . PeerStatus {
TailscaleIPs : [ ] netip . Addr {
netip . MustParseAddr ( "100.1.2.3" ) ,
netip . MustParseAddr ( "fd7a:115c::1234" ) ,
} ,
}
lal := memnet . Listen ( "local-tailscaled.sock:80" )
defer lal . Close ( )
2023-12-08 20:15:57 +00:00
localapi := mockLocalAPI ( t , nil , func ( ) * ipnstate . PeerStatus { return self } , nil , nil )
2023-11-03 03:05:40 +00:00
defer localapi . Close ( )
go localapi . Serve ( lal )
s := & Server {
mode : ManageServerMode ,
lc : & tailscale . LocalClient { Dial : lal . Dial } ,
timeNow : time . Now ,
logf : t . Logf ,
}
tests := [ ] struct {
name string
target string
wantHandled bool
wantLocation string
} {
{
name : "localhost" ,
target : "http://localhost/" ,
wantHandled : true ,
wantLocation : "http://100.1.2.3:5252/" ,
} ,
{
name : "ipv4-no-port" ,
target : "http://100.1.2.3/" ,
wantHandled : true ,
wantLocation : "http://100.1.2.3:5252/" ,
} ,
{
name : "ipv4-correct-port" ,
target : "http://100.1.2.3:5252/" ,
wantHandled : false ,
} ,
{
name : "ipv6-no-port" ,
target : "http://[fd7a:115c::1234]/" ,
wantHandled : true ,
wantLocation : "http://100.1.2.3:5252/" ,
} ,
{
name : "ipv6-correct-port" ,
target : "http://[fd7a:115c::1234]:5252/" ,
wantHandled : false ,
} ,
{
name : "quad-100" ,
target : "http://100.100.100.100/" ,
wantHandled : false ,
} ,
{
name : "ipv6-service-addr" ,
target : "http://[fd7a:115c:a1e0::53]/" ,
wantHandled : false ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . target , func ( t * testing . T ) {
s . logf = t . Logf
r := httptest . NewRequest ( httpm . GET , tt . target , nil )
w := httptest . NewRecorder ( )
handled := s . requireTailscaleIP ( w , r )
if handled != tt . wantHandled {
t . Errorf ( "request(%q) was handled; want=%v, got=%v" , tt . target , tt . wantHandled , handled )
}
location := w . Header ( ) . Get ( "Location" )
if location != tt . wantLocation {
t . Errorf ( "request(%q) wrong location; want=%q, got=%q" , tt . target , tt . wantLocation , location )
}
} )
}
}
2023-10-18 20:45:25 +00:00
var (
2023-11-29 16:44:48 +00:00
defaultControlURL = "https://controlplane.tailscale.com"
2023-10-18 20:45:25 +00:00
testAuthPath = "/a/12345"
testAuthPathSuccess = "/a/will-succeed"
testAuthPathError = "/a/will-error"
)
2023-10-18 15:48:20 +00:00
// mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet.
//
// self accepts a function that resolves to a self node status,
// so that tests may swap out the /localapi/v0/status response
// as desired.
2023-12-08 20:15:57 +00:00
func mockLocalAPI ( t * testing . T , whoIs map [ string ] * apitype . WhoIsResponse , self func ( ) * ipnstate . PeerStatus , prefs func ( ) * ipn . Prefs , metricCapture func ( string ) ) * http . Server {
2023-10-18 15:48:20 +00:00
return & http . Server { Handler : http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
switch r . URL . Path {
case "/localapi/v0/whois" :
addr := r . URL . Query ( ) . Get ( "addr" )
if addr == "" {
t . Fatalf ( "/whois call missing \"addr\" query" )
}
if node := whoIs [ addr ] ; node != nil {
2023-11-03 17:38:01 +00:00
writeJSON ( w , & node )
2023-10-18 15:48:20 +00:00
return
}
http . Error ( w , "not a node" , http . StatusUnauthorized )
return
case "/localapi/v0/status" :
2023-11-03 17:38:01 +00:00
writeJSON ( w , ipnstate . Status { Self : self ( ) } )
2023-10-18 15:48:20 +00:00
return
2023-11-29 16:44:48 +00:00
case "/localapi/v0/prefs" :
writeJSON ( w , prefs ( ) )
return
2023-12-08 20:15:57 +00:00
case "/localapi/v0/upload-client-metrics" :
type metricName struct {
Name string ` json:"name" `
}
var metricNames [ ] metricName
if err := json . NewDecoder ( r . Body ) . Decode ( & metricNames ) ; err != nil {
http . Error ( w , "invalid JSON body" , http . StatusBadRequest )
return
}
metricCapture ( metricNames [ 0 ] . Name )
writeJSON ( w , struct { } { } )
return
2023-10-18 15:48:20 +00:00
default :
t . Fatalf ( "unhandled localapi test endpoint %q, add to localapi handler func in test" , r . URL . Path )
}
} ) }
}
2023-11-16 22:53:46 +00:00
func mockNewAuthURL ( _ context . Context , src tailcfg . NodeID ) ( * tailcfg . WebClientAuthResponse , error ) {
// Create new dummy auth URL.
2023-11-29 16:44:48 +00:00
return & tailcfg . WebClientAuthResponse { ID : testAuthPath , URL : defaultControlURL + testAuthPath } , nil
2023-11-16 22:53:46 +00:00
}
func mockWaitAuthURL ( _ context . Context , id string , src tailcfg . NodeID ) ( * tailcfg . WebClientAuthResponse , error ) {
switch id {
case testAuthPathSuccess : // successful auth URL
return & tailcfg . WebClientAuthResponse { Complete : true } , nil
case testAuthPathError : // error auth URL
return nil , errors . New ( "authenticated as wrong user" )
default :
return nil , errors . New ( "unknown id" )
}
}