fix: harden Shell Target recovery and storage

This commit is contained in:
proxyctl coordinator
2026-07-17 03:11:33 +08:00
parent 12e857932b
commit 5e0de9b766
9 changed files with 527 additions and 150 deletions
+73 -113
View File
@@ -3,7 +3,6 @@ package shell
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -16,6 +15,7 @@ import (
"strings"
"proxyctl/internal/profile"
"proxyctl/internal/securefs"
"proxyctl/internal/target"
)
@@ -37,6 +37,7 @@ type Configuration struct {
OwnFiles bool
UID int
GID int
TrustedRoots []string
}
// ResolveConfiguration keeps user Shell effects with the invoking user under sudo.
@@ -69,11 +70,16 @@ func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuratio
case "zsh":
startupFile = filepath.Join(owner.HomeDir, ".zshrc")
}
stateRoot := owner.HomeDir
if !pathWithin(stateDirectory, owner.HomeDir) {
stateRoot = stateDirectory
}
return Configuration{
Shell: shellPath, StartupFile: startupFile,
EnvironmentFile: filepath.Join(configDirectory, "shell.env"),
RecoveryFile: filepath.Join(stateDirectory, "proxyctl", "shell.json"),
OwnFiles: true, UID: owner.UID, GID: owner.GID,
TrustedRoots: []string{owner.HomeDir, profileConfiguration.TrustedRoot, stateRoot},
}, nil
}
@@ -110,110 +116,34 @@ type FileSystem interface {
Remove(string) error
}
type OSFileSystem struct{}
type OSFileSystem struct{ TrustedRoots []string }
func (OSFileSystem) ReadFile(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", path)
}
return os.ReadFile(path)
func NewOSFileSystem(configuration Configuration) OSFileSystem {
return OSFileSystem{TrustedRoots: append([]string(nil), configuration.TrustedRoots...)}
}
func (OSFileSystem) Remove(path string) error { return os.Remove(path) }
func (OSFileSystem) FileMode(path string) (os.FileMode, error) {
info, err := os.Lstat(path)
if err != nil {
return 0, err
func (f OSFileSystem) store(path string, owner *Ownership) securefs.Store {
root := ""
if len(f.TrustedRoots) == 0 {
root = string(filepath.Separator)
}
if !info.Mode().IsRegular() {
return 0, fmt.Errorf("%s is not a regular file", path)
for _, candidate := range f.TrustedRoots {
if pathWithin(path, candidate) && (root == "" || len(filepath.Clean(candidate)) > len(filepath.Clean(root))) {
root = candidate
}
}
return info.Mode().Perm(), nil
var secureOwner *securefs.Owner
if owner != nil {
secureOwner = &securefs.Owner{UID: owner.UID, GID: owner.GID}
}
return securefs.Store{TrustedRoot: root, Owner: secureOwner}
}
func (OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error {
if !filepath.IsAbs(path) {
return errors.New("Shell Target path must be absolute")
}
directory := filepath.Dir(path)
var missing []string
for current := directory; ; current = filepath.Dir(current) {
if _, err := os.Stat(current); err == nil {
break
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
missing = append(missing, current)
if filepath.Dir(current) == current {
break
}
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
for i := len(missing) - 1; i >= 0; i-- {
if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) {
if err := os.Chown(missing[i], owner.UID, owner.GID); err != nil {
return err
}
}
if err := os.Chmod(missing[i], 0o700); err != nil {
return err
}
}
var temporary string
for range 100 {
bytes := make([]byte, 8)
if _, err := rand.Read(bytes); err != nil {
return err
}
temporary = filepath.Join(directory, ".proxyctl-"+hex.EncodeToString(bytes))
file, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if errors.Is(err, fs.ErrExist) {
continue
}
if err != nil {
return err
}
ok := false
defer func() {
if !ok {
_ = os.Remove(temporary)
}
}()
if owner != nil && (owner.UID != os.Geteuid() || owner.GID != os.Getegid()) {
if err = file.Chown(owner.UID, owner.GID); err != nil {
_ = file.Close()
return err
}
}
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err = os.Rename(temporary, path); err != nil {
return err
}
ok = true
dir, err := os.Open(directory)
if err != nil {
return err
}
err = dir.Sync()
closeErr := dir.Close()
if err != nil {
return err
}
return closeErr
}
return errors.New("allocate temporary Shell Target file")
func (f OSFileSystem) ReadFile(path string) ([]byte, error) { return f.store(path, nil).ReadFile(path) }
func (f OSFileSystem) Remove(path string) error { return f.store(path, nil).Remove(path) }
func (f OSFileSystem) FileMode(path string) (os.FileMode, error) {
return f.store(path, nil).FileMode(path)
}
func (f OSFileSystem) AtomicWrite(path string, data []byte, mode os.FileMode, owner *Ownership) error {
return f.store(path, owner).AtomicWrite(path, data, mode)
}
type recovery struct {
@@ -230,7 +160,8 @@ type Adapter struct {
func New(configuration Configuration, files FileSystem, environment Environment) *Adapter {
if files == nil {
files = OSFileSystem{}
configured := NewOSFileSystem(configuration)
files = configured
}
if environment == nil {
environment = MapEnvironment{}
@@ -326,15 +257,18 @@ func (a *Adapter) enable(active profile.Profile) error {
}
generated := renderEnabled(normalized)
metadata.Fingerprint = fingerprint(generated)
if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil {
return fmt.Errorf("write Shell environment: %w", err)
}
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return err
}
// The true original must be durable before the managed environment becomes
// visible. A retry then reuses this metadata instead of snapshotting its own
// partially applied output as External state.
if err := a.writeOwned(a.configuration.RecoveryFile, append(data, '\n')); err != nil {
return fmt.Errorf("write Shell recovery metadata after environment changed: %w", err)
return fmt.Errorf("persist Shell recovery metadata before changing environment: %w", err)
}
if err := a.writeOwned(a.configuration.EnvironmentFile, generated); err != nil {
return fmt.Errorf("write Shell environment after recovery metadata persisted: %w", err)
}
return nil
}
@@ -413,30 +347,46 @@ func renderRestored(original map[string]string) []byte {
func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" }
func fingerprint(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func recognizesProxy(data []byte) bool {
text := string(data)
supported := make(map[string]bool, len(proxyVariables))
for _, name := range proxyVariables {
if strings.Contains(text, "export "+name+"=") || strings.Contains(text, name+"=") {
return true
supported[name] = true
}
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "export") {
if len(line) == len("export") || (line[len("export")] != ' ' && line[len("export")] != '\t') {
continue
}
line = strings.TrimSpace(line[len("export"):])
}
name, _, found := strings.Cut(line, "=")
if !found || strings.TrimSpace(name) != name || !supported[name] {
continue
}
return true
}
return false
}
type redactedError struct {
message string
cause error
class error
}
func (e *redactedError) Error() string { return e.message }
func (e *redactedError) Unwrap() error { return e.cause }
func (e *redactedError) Error() string { return e.message }
func (e *redactedError) Is(target error) bool { return e.class != nil && errors.Is(e.class, target) }
func redactError(err error, active *profile.Profile) error {
if err == nil {
return nil
}
message := profile.RedactText(err.Error())
class := classifySafeError(err)
if active == nil {
return &redactedError{message: message, cause: err}
return &redactedError{message: message, class: class}
}
for _, raw := range []string{active.SingleURL, active.HTTPURL, active.HTTPSURL, active.AllURL} {
if raw == "" {
@@ -449,7 +399,16 @@ func redactError(err error, active *profile.Profile) error {
}
}
}
return &redactedError{message: message, cause: err}
return &redactedError{message: message, class: class}
}
func classifySafeError(err error) error {
for _, safe := range []error{os.ErrPermission, fs.ErrNotExist, fs.ErrExist, context.Canceled, context.DeadlineExceeded} {
if errors.Is(err, safe) {
return safe
}
}
return nil
}
func (a *Adapter) Install(_ context.Context, executable string) error {
@@ -465,6 +424,7 @@ func (a *Adapter) Install(_ context.Context, executable string) error {
cleaned += "\n"
}
block := integrationStart + "\n" +
"[ -r " + shellQuote(a.configuration.EnvironmentFile) + " ] && . " + shellQuote(a.configuration.EnvironmentFile) + "\n" +
"proxyctl() {\n" +
" command " + shellQuote(executable) + " \"$@\"\n" +
" proxyctl_status=$?\n" +