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
11 changes: 9 additions & 2 deletions agent/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions agent/upload_security.go
Original file line number Diff line number Diff line change
@@ -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
}
87 changes: 87 additions & 0 deletions agent/upload_security_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading