From fa542426e52bbba37bd19ba457d0516010f27864 Mon Sep 17 00:00:00 2001 From: Harry Harpham Date: Thu, 21 May 2026 09:19:59 -0600 Subject: [PATCH] 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 --- ipn/localapi/localapi_test.go | 26 +++++- ipn/localapi/serve.go | 9 ++- ipn/serve.go | 40 +++++++++ ipn/serve_test.go | 147 ++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 5 deletions(-) diff --git a/ipn/localapi/localapi_test.go b/ipn/localapi/localapi_test.go index fc4ddbd92..1c1873a1d 100644 --- a/ipn/localapi/localapi_test.go +++ b/ipn/localapi/localapi_test.go @@ -535,7 +535,7 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { wantErr bool }{ { - name: "not-path-handler", + name: "not-path-or-unix-handler", configIn: &ipn.ServeConfig{ Web: map[ipn.HostPort]*ipn.WebServerConfig{ "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ @@ -570,6 +570,30 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { h: newHandler(false), 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 { diff --git a/ipn/localapi/serve.go b/ipn/localapi/serve.go index 1f677f7ab..5b5c9e964 100644 --- a/ipn/localapi/serve.go +++ b/ipn/localapi/serve.go @@ -53,7 +53,8 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { 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 // or a global admin escalation check. 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() { return nil } - if !configIn.HasPathHandler() { + if !configIn.HasPathHandler() && !configIn.IsServingUnixAny() { return nil } if h.Actor.IsLocalAdmin(h.b.OperatorUserID()) { @@ -97,9 +98,9 @@ func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeC } switch goos { 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": - 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: // We filter goos at the start of the func, this default case // should never happen. diff --git a/ipn/serve.go b/ipn/serve.go index 5a65658a5..40315f545 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -264,6 +264,46 @@ func (sc *ServeConfig) HasPathHandler() bool { 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 // TCPForward mode on any port. This is exclusive of Web/HTTPS serving. func (sc *ServeConfig) IsTCPForwardingAny() bool { diff --git a/ipn/serve_test.go b/ipn/serve_test.go index bf043ca39..b8a7aee79 100644 --- a/ipn/serve_test.go +++ b/ipn/serve_test.go @@ -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"` +}{}