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
41 changes: 39 additions & 2 deletions streamlocal.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (
"net"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"slices"

gossh "golang.org/x/crypto/ssh"
)
Expand Down Expand Up @@ -400,6 +400,43 @@ func validateAndResolveSocketPath(socketPath string, opts UnixForwardingOptions)
return resolved, nil
}

// validateAndResolveListenPath validates socketPath, then resolves symlinks in its
// parent directory and re-checks that the real bind location is still within
// opts.AllowedDirectories and not excluded by opts.DeniedPrefixes. It returns the
// resolved path to bind.
//
// Unlike validateAndResolveSocketPath, the socket file itself does not yet have to exist.
func validateAndResolveListenPath(socketPath string, opts UnixForwardingOptions) (string, error) {
cleaned, err := validateSocketPath(socketPath, opts)
if err != nil {
return "", err
} else if opts.AllowAll {
return cleaned, nil
}

dir := filepath.Dir(cleaned)
resolvedDir, err := filepath.EvalSymlinks(dir) // evaluate on directory since socket may not exist yet
if err != nil {
return "", err
} else if resolvedDir == dir {
// No symlinks in the parent, the lexical check already validated this exact path
return cleaned, nil
}

resolved := filepath.Join(resolvedDir, filepath.Base(cleaned))
opts.AllowedDirectories = resolvePrefixes(opts.AllowedDirectories)
opts.DeniedPrefixes = resolvePrefixes(opts.DeniedPrefixes)
if _, err := validateSocketPath(resolved, opts); err != nil {
return "", err
}
// Technically a symlink wont be able to bind(), but for clarity and defense in depth check it
if info, err := os.Lstat(resolved); err == nil && info.Mode().Type() == os.ModeSymlink {
return "", &rejectionError{reason: fmt.Sprintf("socket path %q is a symlink", resolved)}
}

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.
Expand Down Expand Up @@ -455,7 +492,7 @@ func NewReverseUnixForwardingCallback(opts UnixForwardingOptions) ReverseUnixFor
}
}
return func(ctx Context, socketPath string) (net.Listener, error) {
cleaned, err := validateSocketPath(socketPath, opts)
cleaned, err := validateAndResolveListenPath(socketPath, opts)
if err != nil {
return nil, err
}
Expand Down
31 changes: 31 additions & 0 deletions streamlocal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,37 @@ func TestLocalUnixForwardingAllowsSymlinkWithinAllowedDir(t *testing.T) {
}
}

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

ctx, cancel := newContext(nil)
t.Cleanup(cancel)

// A directory outside the allowed area that a restricted user must not be able to bind sockets into
outsideDir := tempDirUnixSocket(t)

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

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

// Directly binding in the outside directory is rejected lexically
if _, err := cb(ctx, filepath.Join(outsideDir, "test.sock")); !errors.Is(err, ErrRejected) {
t.Fatalf("direct bind outside allowed dir: got %v; want ErrRejected", err)
}

// Binding via the symlinked parent is also rejected
if _, err := cb(ctx, filepath.Join(linkDir, "test.sock")); !errors.Is(err, ErrRejected) {
t.Fatalf("bind via symlink escaping allowed dir: got %v; want ErrRejected", err)
}
}

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

Expand Down