fix: harden Shell Target recovery and storage
This commit is contained in:
+73
-113
@@ -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" +
|
||||
|
||||
Reference in New Issue
Block a user