Files
tailscale/safesocket/pipe_windows.go
T
Nick Khyl 2917ea8d0e ipn/ipnauth, safesocket: defer named pipe client's token retrieval until ipnserver needs it
An error returned by net.Listener.Accept() causes the owning http.Server to shut down.
With the deprecation of net.Error.Temporary(), there's no way for the http.Server to test
whether the returned error is temporary / retryable or not (see golang/go#66252).

Because of that, errors returned by (*safesocket.winIOPipeListener).Accept() cause the LocalAPI
server (aka ipnserver.Server) to shut down, and tailscaled process to exit.

While this might be acceptable in the case of non-recoverable errors, such as programmer errors,
we shouldn't shut down the entire tailscaled process for client- or connection-specific errors,
such as when we couldn't obtain the client's access token because the client attempts to connect
at the Anonymous impersonation level. Instead, the LocalAPI server should gracefully handle
these errors by denying access and returning a 401 Unauthorized to the client.

In tailscale/tscert#15, we fixed a known bug where Caddy and other apps using tscert would attempt
to connect at the Anonymous impersonation level and fail. However, we should also fix this on the tailscaled
side to prevent a potential DoS, where a local app could deliberately open the Tailscale LocalAPI named pipe
at the Anonymous impersonation level and cause tailscaled to exit.

In this PR, we defer token retrieval until (*WindowsClientConn).Token() is called and propagate the returned token
or error via ipnauth.GetConnIdentity() to ipnserver, which handles it the same way as other ipnauth-related errors.

Fixes #18212
Fixes tailscale/tscert#13

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2025-12-23 14:04:45 -06:00

202 lines
6.3 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package safesocket
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go pipe_windows.go
import (
"context"
"fmt"
"net"
"runtime"
"sync"
"time"
"github.com/tailscale/go-winio"
"golang.org/x/sys/windows"
)
func connect(ctx context.Context, path string) (net.Conn, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
// We use the identification impersonation level so that tailscaled may
// obtain information about our token for access control purposes.
return winio.DialPipeAccessImpLevel(ctx, path, windows.GENERIC_READ|windows.GENERIC_WRITE, winio.PipeImpLevelIdentification)
}
// windowsSDDL is the Security Descriptor set on the namedpipe.
// It provides read/write access to all users and the local system.
// It is a var for testing, do not change this value.
var windowsSDDL = "O:BAG:BAD:PAI(A;OICI;GWGR;;;BU)(A;OICI;GWGR;;;SY)"
func listen(path string) (net.Listener, error) {
lc, err := winio.ListenPipe(
path,
&winio.PipeConfig{
SecurityDescriptor: windowsSDDL,
InputBufferSize: 256 * 1024,
OutputBufferSize: 256 * 1024,
},
)
if err != nil {
return nil, fmt.Errorf("namedpipe.Listen: %w", err)
}
return &winIOPipeListener{Listener: lc}, nil
}
// WindowsClientConn is an implementation of net.Conn that permits retrieval of
// the Windows access token associated with the connection's client. The
// embedded net.Conn must be a go-winio PipeConn.
type WindowsClientConn struct {
winioPipeConn
tokenOnce sync.Once
token windows.Token // or zero, if we couldn't obtain the client's token
tokenErr error
}
// winioPipeConn is a subset of the interface implemented by the go-winio's
// unexported *win32pipe type, as returned by go-winio's ListenPipe
// net.Listener's Accept method. This type is used in places where we really are
// assuming that specific unexported type and its Fd method.
type winioPipeConn interface {
net.Conn
// Fd returns the Windows handle associated with the connection.
Fd() uintptr
}
func resolvePipeHandle(pc winioPipeConn) windows.Handle {
return windows.Handle(pc.Fd())
}
func (conn *WindowsClientConn) handle() windows.Handle {
return resolvePipeHandle(conn.winioPipeConn)
}
// ClientPID returns the pid of conn's client, or else an error.
func (conn *WindowsClientConn) ClientPID() (int, error) {
var pid uint32
if err := getNamedPipeClientProcessId(conn.handle(), &pid); err != nil {
return -1, fmt.Errorf("GetNamedPipeClientProcessId: %w", err)
}
return int(pid), nil
}
// CheckToken returns an error if the client user's access token could not be retrieved,
// for example when the client opens the pipe with an anonymous impersonation level.
//
// Deprecated: use [WindowsClientConn.Token] instead.
func (conn *WindowsClientConn) CheckToken() error {
_, err := conn.getToken()
return err
}
// getToken returns the Windows access token of the client user,
// or an error if the token could not be retrieved, for example
// when the client opens the pipe with an anonymous impersonation level.
//
// The connection retains ownership of the returned token handle;
// the caller must not close it.
//
// TODO(nickkhyl): Remove this, along with [WindowsClientConn.CheckToken],
// once [ipnauth.ConnIdentity] is removed in favor of [ipnauth.Actor].
func (conn *WindowsClientConn) getToken() (windows.Token, error) {
conn.tokenOnce.Do(func() {
conn.token, conn.tokenErr = clientUserAccessToken(conn.winioPipeConn)
})
return conn.token, conn.tokenErr
}
// Token returns the Windows access token of the client user,
// or an error if the token could not be retrieved, for example
// when the client opens the pipe with an anonymous impersonation level.
//
// The caller is responsible for closing the returned token handle.
func (conn *WindowsClientConn) Token() (windows.Token, error) {
token, err := conn.getToken()
if err != nil {
return 0, err
}
var dupToken windows.Handle
if err := windows.DuplicateHandle(
windows.CurrentProcess(),
windows.Handle(token),
windows.CurrentProcess(),
&dupToken,
0,
false,
windows.DUPLICATE_SAME_ACCESS,
); err != nil {
return 0, err
}
return windows.Token(dupToken), nil
}
func (conn *WindowsClientConn) Close() error {
// Either wait for any pending [WindowsClientConn.Token] calls to complete,
// or ensure that the token will never be opened.
conn.tokenOnce.Do(func() {
conn.tokenErr = net.ErrClosed
})
if conn.token != 0 {
conn.token.Close()
conn.token = 0
}
return conn.winioPipeConn.Close()
}
// winIOPipeListener is a net.Listener that wraps a go-winio PipeListener and
// returns net.Conn values of type *WindowsClientConn with the associated
// windows.Token.
type winIOPipeListener struct {
net.Listener // must be from winio.ListenPipe
}
func (lw *winIOPipeListener) Accept() (net.Conn, error) {
conn, err := lw.Listener.Accept()
if err != nil {
return nil, err
}
pipeConn, ok := conn.(winioPipeConn)
if !ok {
conn.Close()
return nil, fmt.Errorf("unexpected type %T from winio.ListenPipe listener (itself a %T)", conn, lw.Listener)
}
return &WindowsClientConn{winioPipeConn: pipeConn}, nil
}
func clientUserAccessToken(pc winioPipeConn) (windows.Token, error) {
h := resolvePipeHandle(pc)
if h == 0 {
return 0, fmt.Errorf("clientUserAccessToken failed to get handle from pipeConn %T", pc)
}
// Impersonation touches thread-local state, so we need to lock until the
// client access token has been extracted.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := impersonateNamedPipeClient(h); err != nil {
return 0, err
}
defer func() {
// Revert the current thread's impersonation.
if err := windows.RevertToSelf(); err != nil {
panic(fmt.Errorf("could not revert impersonation: %w", err))
}
}()
// Extract the client's access token from the thread-local state.
var token windows.Token
if err := windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, true, &token); err != nil {
return 0, err
}
return token, nil
}
//sys getNamedPipeClientProcessId(h windows.Handle, clientPid *uint32) (err error) [int32(failretval)==0] = kernel32.GetNamedPipeClientProcessId
//sys impersonateNamedPipeClient(h windows.Handle) (err error) [int32(failretval)==0] = advapi32.ImpersonateNamedPipeClient