ipn/localapi: require root or sudo+operator access for SetServeConfig (#10142)

For an operator user, require them to be able to `sudo tailscale` to use
`tailscale serve`. This is similar to the Windows elevated token check.

Updates tailscale/corp#15405

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
pull/10167/head
Andrew Lytvynov 7 months ago committed by GitHub
parent fc2d63bb8c
commit 9b158db2c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -11,7 +11,9 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"os/exec"
"os/user" "os/user"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -30,6 +32,7 @@ import (
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/util/systemd" "tailscale.com/util/systemd"
"tailscale.com/version"
) )
// Server is an IPN backend and its set of 0 or more active localhost // Server is an IPN backend and its set of 0 or more active localhost
@ -371,16 +374,52 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool {
// This is useful because, on Windows, tailscaled itself always runs with // This is useful because, on Windows, tailscaled itself always runs with
// elevated rights: we want to avoid privilege escalation for certain mutative operations. // elevated rights: we want to avoid privilege escalation for certain mutative operations.
func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool { func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
tok, err := ci.WindowsToken() switch runtime.GOOS {
if err != nil { case "windows":
if !errors.Is(err, ipnauth.ErrNotImplemented) { tok, err := ci.WindowsToken()
s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err) if err != nil {
if !errors.Is(err, ipnauth.ErrNotImplemented) {
s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err)
}
return false
}
defer tok.Close()
return tok.IsElevated()
case "darwin":
if version.IsSandboxedMacOS() {
return false
}
// This is a standalone tailscaled setup, use the same logic as on
// Linux.
fallthrough
case "linux":
uid, ok := ci.Creds().UserID()
if !ok {
return false
} }
// root is always admin.
if uid == "0" {
return true
}
// if non-root, must be operator AND able to execute "sudo tailscale".
operatorUID := s.mustBackend().OperatorUserID()
if operatorUID != "" && uid != operatorUID {
return false
}
u, err := user.LookupId(uid)
if err != nil {
return false
}
if err := exec.Command("sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil {
return false
}
return true
default:
return false return false
} }
defer tok.Close()
return tok.IsElevated()
} }
// addActiveHTTPRequest adds c to the server's list of active HTTP requests. // addActiveHTTPRequest adds c to the server's list of active HTTP requests.

@ -920,8 +920,8 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
// require a local admin when setting a path handler // require a local admin when setting a path handler
// TODO: roll-up this Windows-specific check into either PermitWrite // TODO: roll-up this Windows-specific check into either PermitWrite
// or a global admin escalation check. // or a global admin escalation check.
if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) { if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized) http.Error(w, err.Error(), http.StatusUnauthorized)
return return
} }
@ -940,14 +940,30 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
} }
} }
func shouldDenyServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) bool { func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) error {
if goos != "windows" { if !slices.Contains([]string{"windows", "linux", "darwin"}, goos) {
return false return nil
}
if goos == "darwin" && !version.IsSandboxedMacOS() {
return nil
} }
if !configIn.HasPathHandler() { if !configIn.HasPathHandler() {
return false return nil
}
if h.CallerIsLocalAdmin {
return nil
} }
return !h.CallerIsLocalAdmin switch goos {
case "windows":
return errors.New("must be a Windows local admin to serve a path")
case "linux", "darwin":
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
default:
// We filter goos at the start of the func, this default case
// should never happen.
panic("unreachable")
}
} }
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) { func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {

@ -161,14 +161,40 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
goos string goos string
configIn *ipn.ServeConfig configIn *ipn.ServeConfig
h *Handler h *Handler
want bool wantErr bool
}{ }{
{ {
name: "linux", name: "linux",
goos: "linux", goos: "linux",
configIn: &ipn.ServeConfig{}, configIn: &ipn.ServeConfig{},
h: &Handler{CallerIsLocalAdmin: false}, h: &Handler{CallerIsLocalAdmin: false},
want: false, wantErr: false,
},
{
name: "linux-path-handler-admin",
goos: "linux",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
h: &Handler{CallerIsLocalAdmin: true},
wantErr: false,
},
{
name: "linux-path-handler-not-admin",
goos: "linux",
configIn: &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
h: &Handler{CallerIsLocalAdmin: false},
wantErr: true,
}, },
{ {
name: "windows-not-path-handler", name: "windows-not-path-handler",
@ -180,8 +206,8 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
}}, }},
}, },
}, },
h: &Handler{CallerIsLocalAdmin: false}, h: &Handler{CallerIsLocalAdmin: false},
want: false, wantErr: false,
}, },
{ {
name: "windows-path-handler-admin", name: "windows-path-handler-admin",
@ -193,8 +219,8 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
}}, }},
}, },
}, },
h: &Handler{CallerIsLocalAdmin: true}, h: &Handler{CallerIsLocalAdmin: true},
want: false, wantErr: false,
}, },
{ {
name: "windows-path-handler-not-admin", name: "windows-path-handler-not-admin",
@ -206,16 +232,17 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
}}, }},
}, },
}, },
h: &Handler{CallerIsLocalAdmin: false}, h: &Handler{CallerIsLocalAdmin: false},
want: true, wantErr: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := shouldDenyServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h) err := authorizeServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h)
if got != tt.want { gotErr := err != nil
t.Errorf("shouldDenyServeConfigForGOOSAndUserContext() got = %v, want %v", got, tt.want) if gotErr != tt.wantErr {
t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want error %v", err, tt.wantErr)
} }
}) })
} }

Loading…
Cancel
Save