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>
This commit is contained in:
Nick Khyl
2025-12-19 12:22:19 -06:00
parent 90b4358113
commit 11fd9341bc
3 changed files with 41 additions and 20 deletions

View File

@@ -25,6 +25,12 @@ func GetConnIdentity(logf logger.Logf, c net.Conn) (ci *ConnIdentity, err error)
if !ok {
return nil, fmt.Errorf("not a WindowsClientConn: %T", c)
}
if _, err := wcc.Token(); err != nil {
// Failure to obtain a token means the client cannot be authenticated.
// We don't care about the exact error, but it typically means the client
// attempted to connect at the Anonymous impersonation level.
return nil, fmt.Errorf("authentication failed: %w", err)
}
ci.pid, err = wcc.ClientPID()
if err != nil {
return nil, err
@@ -175,14 +181,19 @@ func (ci *ConnIdentity) WindowsToken() (WindowsToken, error) {
return nil, fmt.Errorf("not a WindowsClientConn: %T", ci.conn)
}
token, err := wcc.Token()
if err != nil {
return nil, err
}
// We duplicate the token's handle so that the WindowsToken we return may have
// a lifetime independent from the original connection.
var h windows.Handle
var dupToken windows.Handle
if err := windows.DuplicateHandle(
windows.CurrentProcess(),
windows.Handle(wcc.Token()),
windows.Handle(token),
windows.CurrentProcess(),
&h,
&dupToken,
0,
false,
windows.DUPLICATE_SAME_ACCESS,
@@ -190,5 +201,5 @@ func (ci *ConnIdentity) WindowsToken() (WindowsToken, error) {
return nil, err
}
return newToken(windows.Token(h)), nil
return newToken(windows.Token(dupToken)), nil
}

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"net"
"runtime"
"sync"
"time"
"github.com/tailscale/go-winio"
@@ -49,7 +50,9 @@ func listen(path string) (net.Listener, error) {
// embedded net.Conn must be a go-winio PipeConn.
type WindowsClientConn struct {
winioPipeConn
token windows.Token
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
@@ -79,12 +82,25 @@ func (conn *WindowsClientConn) ClientPID() (int, error) {
return int(pid), nil
}
// Token returns the Windows access token of the client user.
func (conn *WindowsClientConn) Token() windows.Token {
return conn.token
// 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 connection retains ownership of the returned token handle;
// the caller must not close it.
func (conn *WindowsClientConn) Token() (windows.Token, error) {
conn.tokenOnce.Do(func() {
conn.token, conn.tokenErr = clientUserAccessToken(conn.winioPipeConn)
})
return conn.token, conn.tokenErr
}
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
@@ -110,17 +126,7 @@ func (lw *winIOPipeListener) Accept() (net.Conn, error) {
conn.Close()
return nil, fmt.Errorf("unexpected type %T from winio.ListenPipe listener (itself a %T)", conn, lw.Listener)
}
token, err := clientUserAccessToken(pipeConn)
if err != nil {
conn.Close()
return nil, err
}
return &WindowsClientConn{
winioPipeConn: pipeConn,
token: token,
}, nil
return &WindowsClientConn{winioPipeConn: pipeConn}, nil
}
func clientUserAccessToken(pc winioPipeConn) (windows.Token, error) {

View File

@@ -58,7 +58,11 @@ func TestExpectedWindowsTypes(t *testing.T) {
if wcc.winioPipeConn.Fd() == 0 {
t.Error("accepted conn had unexpected zero fd")
}
if wcc.token == 0 {
tok, err := wcc.Token()
if err != nil {
t.Errorf("failed to retrieve client token: %v", err)
}
if tok == 0 {
t.Error("accepted conn had unexpected zero token")
}