Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions streamlocal.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"sync"
"slices"

gossh "golang.org/x/crypto/ssh"
)
Expand Down Expand Up @@ -361,6 +362,57 @@ func validateSocketPath(socketPath string, opts UnixForwardingOptions) (string,
return cleaned, nil
}

// validateAndResolveSocketPath validates socketPath lexically, then resolves
// its symlinks and re-checks that the real destination is still within
// opts.AllowedDirectories and not excluded by opts.DeniedPrefixes. It returns
// the resolved path to dial.
//
// The lexical check alone is insufficient because the kernel connects to the
// symlink target: a symlink inside an allowed directory could otherwise
// redirect the connection to a socket outside it (e.g. a link in the user's
// home dir pointing at /var/run/docker.sock). The allow/deny entries are
// resolved as well, so legitimately symlinked directories (e.g. Linux's
// /var/run -> /run, or macOS's /tmp -> /private/tmp) continue to match.
func validateAndResolveSocketPath(socketPath string, opts UnixForwardingOptions) (string, error) {
cleaned, err := validateSocketPath(socketPath, opts)
if err != nil {
return "", err
}
if opts.AllowAll {
return cleaned, nil
}

resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
return "", err
}
if resolved == cleaned {
// No symlinks were involved; the lexical check already
// authorized this exact path.
return cleaned, nil
}

opts.AllowedDirectories = resolvePrefixes(opts.AllowedDirectories)
opts.DeniedPrefixes = resolvePrefixes(opts.DeniedPrefixes)
if _, err := validateSocketPath(resolved, opts); err != nil {
return "", err
}
return resolved, nil
}

// resolvePrefixes returns prefixes with each entry's symlinks resolved.
// Entries that cannot be resolved (e.g. they do not exist) are passed
// through unchanged so they still participate in lexical matching.
func resolvePrefixes(prefixes []string) []string {
out := slices.Clone(prefixes)
for i, p := range prefixes {
if r, err := filepath.EvalSymlinks(p); err == nil {
out[i] = r
}
}
return out
}

// NewLocalUnixForwardingCallback returns a LocalUnixForwardingCallback that
// validates socket paths against the provided options before dialing.
// Path validation errors are reported to the SSH client as
Expand All @@ -372,18 +424,18 @@ func NewLocalUnixForwardingCallback(opts UnixForwardingOptions) LocalUnixForward
}
}
return func(ctx Context, socketPath string) (net.Conn, error) {
cleaned, err := validateSocketPath(socketPath, opts)
resolved, err := validateAndResolveSocketPath(socketPath, opts)
if err != nil {
return nil, err
}
if opts.PathValidator != nil {
if err := opts.PathValidator(ctx, cleaned); err != nil {
if err := opts.PathValidator(ctx, resolved); err != nil {
return nil, err
}
}

var d net.Dialer
return d.DialContext(ctx, "unix", cleaned)
return d.DialContext(ctx, "unix", resolved)
}
}

Expand Down Expand Up @@ -435,11 +487,11 @@ func NewReverseUnixForwardingCallback(opts UnixForwardingOptions) ReverseUnixFor

// Apply socket permission mask. Default 0177 (mode 0600),
// matching OpenSSH's StreamLocalBindMask.
mask := os.FileMode(0177)
mask := os.FileMode(0o177)
if opts.BindMask != nil {
mask = *opts.BindMask
}
mode := os.FileMode(0666) &^ mask
mode := os.FileMode(0o666) &^ mask
if err := os.Chmod(cleaned, mode); err != nil {
_ = ln.Close()
return nil, fmt.Errorf("failed to set permissions on socket %q: %w", cleaned, err)
Expand Down
78 changes: 75 additions & 3 deletions streamlocal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,9 @@ func TestValidateSocketPath(t *testing.T) {
path string
opts UnixForwardingOptions
wantErr bool
wantClean string // expected cleaned path on success
errSubstr string // substring expected in error message
wantType error // expected error type (ErrRejected)
wantClean string // expected cleaned path on success
errSubstr string // substring expected in error message
wantType error // expected error type (ErrRejected)
}{
// Basic validation (applies to all modes).
{
Expand Down Expand Up @@ -447,6 +447,78 @@ func TestValidateSocketPath(t *testing.T) {
}
}

func TestLocalUnixForwardingRejectsSymlinkEscape(t *testing.T) {
t.Parallel()

ctx, cancel := newContext(nil)
defer cancel()

// A socket outside the allowed directory that a restricted user must
// not be able to reach (stand-in for /var/run/docker.sock).
outsideDir := tempDirUnixSocket(t)
outsidePath := filepath.Join(outsideDir, "secret.sock")
outsideLn, err := net.Listen("unix", outsidePath)
if err != nil {
t.Fatalf("failed to listen on outside socket: %v", err)
}
defer outsideLn.Close() //nolint:errcheck

// The only directory the user is allowed to forward into, plus a
// symlink inside it pointing at the outside socket.
allowedDir := tempDirUnixSocket(t)
linkPath := filepath.Join(allowedDir, "link.sock")
if err := os.Symlink(outsidePath, linkPath); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}

cb := NewLocalUnixForwardingCallback(UnixForwardingOptions{
AllowedDirectories: []string{allowedDir},
})

// Directly forwarding to the outside socket is rejected lexically.
if _, err := cb(ctx, outsidePath); !errors.Is(err, ErrRejected) {
t.Fatalf("direct forward to outside socket: got %v; want ErrRejected", err)
}

// Forwarding via the symlink must also be rejected: the resolved
// destination escapes the allowed directory.
if _, err := cb(ctx, linkPath); !errors.Is(err, ErrRejected) {
t.Fatalf("forward via symlink escaping allowed dir: got %v; want ErrRejected", err)
}
}

func TestLocalUnixForwardingAllowsSymlinkWithinAllowedDir(t *testing.T) {
t.Parallel()

ctx, cancel := newContext(nil)
defer cancel()

// A symlink whose target is also inside the allowed directory is
// legitimate.
allowedDir := tempDirUnixSocket(t)
realPath := filepath.Join(allowedDir, "real.sock")
ln, err := net.Listen("unix", realPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
defer ln.Close() //nolint:errcheck

linkPath := filepath.Join(allowedDir, "link.sock")
if err := os.Symlink(realPath, linkPath); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}

cb := NewLocalUnixForwardingCallback(UnixForwardingOptions{
AllowedDirectories: []string{allowedDir},
})

conn, err := cb(ctx, linkPath)
_ = conn.Close()
if err != nil {
t.Fatalf("forward via symlink within allowed dir: unexpected error %v", err)
}
}

func TestRejectedMessage(t *testing.T) {
t.Parallel()

Expand Down