diff --git a/agent/diagnostics.go b/agent/diagnostics.go index e9736c5..983c32d 100644 --- a/agent/diagnostics.go +++ b/agent/diagnostics.go @@ -222,8 +222,15 @@ func (s *Session) DetectAuthWall() (*AuthWallResult, error) { return &authResult, nil } -// UploadFile triggers a file upload on a file input element. +// UploadFile triggers a file upload on a file input element. The path is +// validated first so an untrusted (page-injected) instruction can't exfiltrate +// local secrets via the upload — see validateUploadPath. func (s *Session) UploadFile(selector, filePath string) error { + resolved, err := validateUploadPath(filePath) + if err != nil { + return err + } + s.mu.Lock() defer s.mu.Unlock() @@ -244,7 +251,7 @@ func (s *Session) UploadFile(selector, filePath string) error { // Get the backend node ID for the file input _, err = s.page.Call("DOM.setFileInputFiles", map[string]any{ - "files": []string{filePath}, + "files": []string{resolved}, "objectId": objectID, }) if err != nil { diff --git a/agent/upload_security.go b/agent/upload_security.go new file mode 100644 index 0000000..1a40219 --- /dev/null +++ b/agent/upload_security.go @@ -0,0 +1,75 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// validateUploadPath resolves filePath and refuses to upload something that +// doesn't exist, isn't a regular file, or names well-known credential material. +// A file upload driven by (possibly page-injected) instructions must not be +// coaxed into exfiltrating local secrets like an SSH key. It returns the +// resolved absolute path to hand to the browser. +func validateUploadPath(filePath string) (string, error) { + if strings.TrimSpace(filePath) == "" { + return "", fmt.Errorf("upload_file: empty file path") + } + abs, err := filepath.Abs(filePath) + if err != nil { + return "", fmt.Errorf("upload_file: %w", err) + } + // Resolve symlinks so a link can't redirect the upload at a secret. + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", fmt.Errorf("upload_file: cannot access %q: %w", filePath, err) + } + info, err := os.Stat(resolved) + if err != nil { + return "", fmt.Errorf("upload_file: %w", err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("upload_file: %q is not a regular file", filePath) + } + if reason, blocked := sensitiveUploadReason(resolved); blocked { + return "", fmt.Errorf("upload_file: refusing to upload %q (%s)", filePath, reason) + } + return resolved, nil +} + +// sensitiveUploadReason reports whether an absolute path names well-known +// credential material that must never be uploaded, and why. Matching is on the +// resolved path's directory segments and base name, case-insensitively. It is +// intentionally a focused denylist of unambiguous secrets (private keys, cloud +// credentials, system secret files) — things never legitimately uploaded via a +// browser form. +func sensitiveUploadReason(absPath string) (string, bool) { + lower := strings.ToLower(filepath.ToSlash(absPath)) + base := filepath.Base(lower) + segments := strings.Split(lower, "/") + + sensitiveDirs := map[string]struct{}{ + ".ssh": {}, ".gnupg": {}, ".aws": {}, ".azure": {}, + ".kube": {}, ".docker": {}, "gcloud": {}, + } + for _, seg := range segments { + if _, ok := sensitiveDirs[seg]; ok { + return "credential directory", true + } + } + + sensitiveBases := map[string]struct{}{ + "id_rsa": {}, "id_dsa": {}, "id_ecdsa": {}, "id_ed25519": {}, + ".netrc": {}, ".pgpass": {}, + } + if _, ok := sensitiveBases[base]; ok { + return "credential file", true + } + + switch lower { + case "/etc/shadow", "/etc/gshadow", "/etc/sudoers", "/etc/master.passwd": + return "system secret file", true + } + return "", false +} diff --git a/agent/upload_security_test.go b/agent/upload_security_test.go new file mode 100644 index 0000000..fd3fc41 --- /dev/null +++ b/agent/upload_security_test.go @@ -0,0 +1,87 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSensitiveUploadReason(t *testing.T) { + blocked := []string{ + "/home/user/.ssh/id_rsa", + "/Users/x/.aws/credentials", + "/home/user/.config/gcloud/application_default_credentials.json", + "/home/user/.gnupg/secring.gpg", + "/tmp/id_ed25519", + "/home/user/.netrc", + "/etc/shadow", + "/etc/sudoers", + } + for _, p := range blocked { + if _, ok := sensitiveUploadReason(p); !ok { + t.Errorf("sensitiveUploadReason(%q) = allowed, want blocked", p) + } + } + + allowed := []string{ + "/home/user/Documents/resume.pdf", + "/Users/x/Downloads/photo.png", + "/var/data/report.csv", + "/tmp/upload.txt", + } + for _, p := range allowed { + if reason, ok := sensitiveUploadReason(p); ok { + t.Errorf("sensitiveUploadReason(%q) = blocked (%s), want allowed", p, reason) + } + } +} + +func TestValidateUploadPath(t *testing.T) { + dir := t.TempDir() + + // A normal regular file resolves and is allowed. + ok := filepath.Join(dir, "upload.txt") + if err := os.WriteFile(ok, []byte("hi"), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := validateUploadPath(ok) + if err != nil { + t.Fatalf("validateUploadPath(regular file) error: %v", err) + } + if filepath.Base(resolved) != "upload.txt" { + t.Errorf("resolved base = %q, want upload.txt", filepath.Base(resolved)) + } + + // Empty, missing, and directory paths are rejected. + if _, err := validateUploadPath(""); err == nil { + t.Error("empty path should error") + } + if _, err := validateUploadPath(filepath.Join(dir, "nope.txt")); err == nil { + t.Error("missing file should error") + } + if _, err := validateUploadPath(dir); err == nil { + t.Error("directory should error (not a regular file)") + } + + // A real file under a credential directory is refused. + sshDir := filepath.Join(dir, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + key := filepath.Join(sshDir, "some_key") + if err := os.WriteFile(key, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := validateUploadPath(key); err == nil { + t.Error("file under .ssh should be refused") + } + + // A real file whose base name is a private key is refused. + idrsa := filepath.Join(dir, "id_rsa") + if err := os.WriteFile(idrsa, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := validateUploadPath(idrsa); err == nil { + t.Error("file named id_rsa should be refused") + } +}