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>
This commit is contained in:
parent
fc2d63bb8c
commit
9b158db2c6
|
@ -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,6 +374,8 @@ 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 {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
tok, err := ci.WindowsToken()
|
tok, err := ci.WindowsToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, ipnauth.ErrNotImplemented) {
|
if !errors.Is(err, ipnauth.ErrNotImplemented) {
|
||||||
|
@ -381,6 +386,40 @@ func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
|
||||||
defer tok.Close()
|
defer tok.Close()
|
||||||
|
|
||||||
return tok.IsElevated()
|
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.
|
// 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
|
||||||
}
|
}
|
||||||
return !h.CallerIsLocalAdmin
|
if h.CallerIsLocalAdmin {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
@ -181,7 +207,7 @@ 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",
|
||||||
|
@ -194,7 +220,7 @@ 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",
|
||||||
|
@ -207,15 +233,16 @@ 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…
Reference in New Issue