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 1 year 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"
"net"
"net/http"
"os/exec"
"os/user"
"runtime"
"strconv"
"strings"
"sync"
@ -30,6 +32,7 @@ import (
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/util/systemd"
"tailscale.com/version"
)
// Server is an IPN backend and its set of 0 or more active localhost
@ -371,6 +374,8 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool {
// This is useful because, on Windows, tailscaled itself always runs with
// elevated rights: we want to avoid privilege escalation for certain mutative operations.
func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
switch runtime.GOOS {
case "windows":
tok, err := ci.WindowsToken()
if err != nil {
if !errors.Is(err, ipnauth.ErrNotImplemented) {
@ -381,6 +386,40 @@ func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
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
}
}
// 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
// TODO: roll-up this Windows-specific check into either PermitWrite
// or a global admin escalation check.
if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) {
http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized)
if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
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 {
if goos != "windows" {
return false
func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) error {
if !slices.Contains([]string{"windows", "linux", "darwin"}, goos) {
return nil
}
if goos == "darwin" && !version.IsSandboxedMacOS() {
return nil
}
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) {

@ -161,14 +161,40 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
goos string
configIn *ipn.ServeConfig
h *Handler
want bool
wantErr bool
}{
{
name: "linux",
goos: "linux",
configIn: &ipn.ServeConfig{},
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",
@ -181,7 +207,7 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
},
},
h: &Handler{CallerIsLocalAdmin: false},
want: false,
wantErr: false,
},
{
name: "windows-path-handler-admin",
@ -194,7 +220,7 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
},
},
h: &Handler{CallerIsLocalAdmin: true},
want: false,
wantErr: false,
},
{
name: "windows-path-handler-not-admin",
@ -207,15 +233,16 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
},
},
h: &Handler{CallerIsLocalAdmin: false},
want: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldDenyServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h)
if got != tt.want {
t.Errorf("shouldDenyServeConfigForGOOSAndUserContext() got = %v, want %v", got, tt.want)
err := authorizeServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h)
gotErr := err != nil
if gotErr != tt.wantErr {
t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want error %v", err, tt.wantErr)
}
})
}

Loading…
Cancel
Save