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:
Harry Harpham
2026-06-03 09:45:02 -06:00
parent 40c98cd267
commit fa542426e5
4 changed files with 217 additions and 5 deletions
+25 -1
View File
@@ -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 {
+5 -4
View File
@@ -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.