ipn,ipn/localapi: require local admin to serve Unix domain sockets
This resolves a local privilege escalation (LPE). Prior to this change, a non-admin user could utilize serve to access local Unix sockets they otherwise should not be able to access. For example, tailscale serve --http 80 unix:/var/run/docker.sock would give the user access to the Docker socket (usually root only). This works because tailscaled has root access and implements the proxy to the socket (see also: 'the confused deputy problem'). We resolve the problem by refusing to serve Unix targets altogether unless instructed to by a root user. Thanks to Tim Sageser (dtrsecurity) for this report. Fixes tailscale/corp#41998 Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
@@ -535,7 +535,7 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "not-path-handler",
|
name: "not-path-or-unix-handler",
|
||||||
configIn: &ipn.ServeConfig{
|
configIn: &ipn.ServeConfig{
|
||||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
@@ -570,6 +570,30 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
|
|||||||
h: newHandler(false),
|
h: newHandler(false),
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unix-handler-admin",
|
||||||
|
configIn: &ipn.ServeConfig{
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
|
"/": {Proxy: "unix:/var/run/foo.sock"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h: newHandler(true),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unix-handler-not-admin",
|
||||||
|
configIn: &ipn.ServeConfig{
|
||||||
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||||
|
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||||
|
"/": {Proxy: "unix:/var/run/foo.sock"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h: newHandler(false),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// require a local admin when setting a path handler
|
// require a local admin when setting a path handler or serving a Unix
|
||||||
|
// domain socket
|
||||||
// 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 err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
|
if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
|
||||||
@@ -89,7 +90,7 @@ func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeC
|
|||||||
if goos == "darwin" && version.IsSandboxedMacOS() {
|
if goos == "darwin" && version.IsSandboxedMacOS() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !configIn.HasPathHandler() {
|
if !configIn.HasPathHandler() && !configIn.IsServingUnixAny() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if h.Actor.IsLocalAdmin(h.b.OperatorUserID()) {
|
if h.Actor.IsLocalAdmin(h.b.OperatorUserID()) {
|
||||||
@@ -97,9 +98,9 @@ func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeC
|
|||||||
}
|
}
|
||||||
switch goos {
|
switch goos {
|
||||||
case "windows":
|
case "windows":
|
||||||
return errors.New("must be a Windows local admin to serve a path")
|
return errors.New("must be a Windows local admin to serve a path or Unix socket")
|
||||||
case "linux", "darwin", "illumos", "solaris":
|
case "linux", "darwin", "illumos", "solaris":
|
||||||
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
|
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path or Unix socket")
|
||||||
default:
|
default:
|
||||||
// We filter goos at the start of the func, this default case
|
// We filter goos at the start of the func, this default case
|
||||||
// should never happen.
|
// should never happen.
|
||||||
|
|||||||
@@ -264,6 +264,46 @@ func (sc *ServeConfig) HasPathHandler() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsServingUnixAny reports whether ServeConfig is serving Unix targets on any
|
||||||
|
// port or web handler.
|
||||||
|
func (sc *ServeConfig) IsServingUnixAny() bool {
|
||||||
|
if sc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, fgSrvCfg := range sc.Foreground {
|
||||||
|
if fgSrvCfg.IsServingUnixAny() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ph := range sc.TCP {
|
||||||
|
if strings.HasPrefix(ph.TCPForward, "unix:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, web := range sc.Web {
|
||||||
|
for _, h := range web.Handlers {
|
||||||
|
if strings.HasPrefix(h.Proxy, "unix:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, svcCfg := range sc.Services {
|
||||||
|
for _, ph := range svcCfg.TCP {
|
||||||
|
if strings.HasPrefix(ph.TCPForward, "unix:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, web := range svcCfg.Web {
|
||||||
|
for _, h := range web.Handlers {
|
||||||
|
if strings.HasPrefix(h.Proxy, "unix:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// IsTCPForwardingAny reports whether ServeConfig is currently forwarding in
|
// IsTCPForwardingAny reports whether ServeConfig is currently forwarding in
|
||||||
// TCPForward mode on any port. This is exclusive of Web/HTTPS serving.
|
// TCPForward mode on any port. This is exclusive of Web/HTTPS serving.
|
||||||
func (sc *ServeConfig) IsTCPForwardingAny() bool {
|
func (sc *ServeConfig) IsTCPForwardingAny() bool {
|
||||||
|
|||||||
@@ -417,3 +417,150 @@ func TestIsFunnelOn(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsServingUnixAny(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sc *ServeConfig
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
sc: &ServeConfig{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
sc: &ServeConfig{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
TCP: map[uint16]*TCPPortHandler{
|
||||||
|
80: {TCPForward: "localhost:8080"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp_to_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
TCP: map[uint16]*TCPPortHandler{
|
||||||
|
80: {TCPForward: "unix:/var/run/foo.sock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "web_to_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
Web: map[HostPort]*WebServerConfig{
|
||||||
|
"foo.test.ts.net:80": {
|
||||||
|
Handlers: map[string]*HTTPHandler{
|
||||||
|
"/": {Proxy: "unix:/var/run/foo.sock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "foreground_tcp_to_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
Foreground: map[string]*ServeConfig{
|
||||||
|
"abc": {
|
||||||
|
TCP: map[uint16]*TCPPortHandler{
|
||||||
|
80: {TCPForward: "unix:/var/run/foo.sock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "foreground_web_to_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
Foreground: map[string]*ServeConfig{
|
||||||
|
"abc": {
|
||||||
|
Web: map[HostPort]*WebServerConfig{
|
||||||
|
"foo.test.ts.net:80": {
|
||||||
|
Handlers: map[string]*HTTPHandler{
|
||||||
|
"/": {Proxy: "unix:/var/run/foo.sock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "services_tcp_to_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
Services: map[tailcfg.ServiceName]*ServiceConfig{
|
||||||
|
"svc:foo": {
|
||||||
|
TCP: map[uint16]*TCPPortHandler{
|
||||||
|
80: {TCPForward: "unix:/var/run/foo.sock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "services_web_to_unix",
|
||||||
|
sc: &ServeConfig{
|
||||||
|
Services: map[tailcfg.ServiceName]*ServiceConfig{
|
||||||
|
"svc:foo": {
|
||||||
|
Web: map[HostPort]*WebServerConfig{
|
||||||
|
"foo.test.ts.net:80": {
|
||||||
|
Handlers: map[string]*HTTPHandler{
|
||||||
|
"/": {Proxy: "unix:/var/run/foo.sock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.sc.IsServingUnixAny(); got != tt.want {
|
||||||
|
t.Errorf("ServeConfig.IsServingUnixAny() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The structural checks below guard against new vulnerabilities in serving Unix
|
||||||
|
// socket targets. If [ServeConfig] or [ServiceConfig] is updated, these checks
|
||||||
|
// will fail, forcing you to consider updating [ServeConfig.IsServingUnixAny].
|
||||||
|
//
|
||||||
|
// If your updates have created a new way to serve Unix sockets, please update
|
||||||
|
// IsServingUnixAny accordingly. Otherwise, you can just add your new field
|
||||||
|
// below and move on.
|
||||||
|
//
|
||||||
|
// IsServingUnixAny helps avoid a class of vulnerabilities in which tailscaled
|
||||||
|
// (by means of tailscale serve) gives non-root users access to Unix sockets
|
||||||
|
// they otherwise would not have access to (e.g. /var/run/docker.sock). As of
|
||||||
|
// 2026-06-03, serving Unix sockets at all requires root permissions, and
|
||||||
|
// IsServingUnixAny is how tailscaled knows when to enforce this restriction.
|
||||||
|
//
|
||||||
|
// See https://github.com/tailscale/corp/issues/41998
|
||||||
|
var _ ServeConfig = struct {
|
||||||
|
TCP map[uint16]*TCPPortHandler `json:",omitempty"`
|
||||||
|
Web map[HostPort]*WebServerConfig `json:",omitempty"`
|
||||||
|
Services map[tailcfg.ServiceName]*ServiceConfig `json:",omitempty"`
|
||||||
|
AllowFunnel map[HostPort]bool `json:",omitempty"`
|
||||||
|
Foreground map[string]*ServeConfig `json:",omitempty"`
|
||||||
|
ETag string `json:"-"`
|
||||||
|
}{}
|
||||||
|
var _ ServiceConfig = struct {
|
||||||
|
TCP map[uint16]*TCPPortHandler `json:",omitempty"`
|
||||||
|
Web map[HostPort]*WebServerConfig `json:",omitempty"`
|
||||||
|
Tun bool `json:",omitempty"`
|
||||||
|
}{}
|
||||||
|
|||||||
Reference in New Issue
Block a user