refactor: harden ssh_config handling, mount path, and CLI UX from audit findings
Update PKGBUILD version / update-pkgver (push) Successful in 3s

Three rounds of audit-driven hardening, fully documented in DECISIONS.md:

- argv hardening: validate HostName/User/IdentityFile via allowlist regexes,
  parse Port via strconv.Atoi, surface ssh_config parse errors instead of
  silently swallowing them. Switch -o kernel_cache to auto_cache for network-
  FS correctness, pin StrictHostKeyChecking=accept-new.
- LOW-severity cleanup: -v verbose flag (default output is just the mount
  path), run_editor returns errors and main exits 7 on failure, ABOUTME
  headers, golang.org/x/sys v0.43.0 (go 1.25.0).
- Defense-in-depth + UX: rxIdentityFile first-character anchor rejects
  leading "-"/"."/":"/etc., verify_mount_dir resolves base via EvalSymlinks
  and refuses pre-existing symlinks at the mount path, flag.Usage shows the
  positional <Host> argument, run_editor uses cmd.Start() so cold-start
  Sublime does not block the terminal.
- CI: empty-PKGVER guard in update-pkgver workflow.
- Tests: verify_mount_dir path-traversal + symlink-reject coverage,
  rxHostUser/rxIdentityFile boundary cases.
This commit is contained in:
2026-04-26 11:24:45 +02:00
parent 967d5d74cc
commit d01a358f35
7 changed files with 350 additions and 42 deletions
+124 -30
View File
@@ -1,3 +1,6 @@
// ABOUTME: CLI tool that mounts remote filesystems via sshfs based on
// ABOUTME: ~/.ssh/config entries, with optional editor launch on the mountpoint.
package main
import (
@@ -6,15 +9,32 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/kevinburke/ssh_config"
"github.com/moby/sys/mountinfo"
)
var eFlag = flag.Bool("e", false, "open mountpoint in your editor")
var (
rxHostUser = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)
rxIdentityFile = regexp.MustCompile(`^[A-Za-z0-9/~][A-Za-z0-9._@/:+=~%-]*$`)
)
var (
eFlag = flag.Bool("e", false, "open mountpoint in your editor")
vFlag = flag.Bool("v", false, "verbose: print resolved ssh_config fields")
)
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr,
"usage: sshfsc [flags] <Host>\n\n"+
"Mount a remote home directory via sshfs based on ~/.ssh/config entries.\n\n"+
"Flags:\n")
flag.PrintDefaults()
}
flag.Parse()
args := flag.Args()
@@ -23,17 +43,58 @@ func main() {
os.Exit(2)
}
hostname := ssh_config.Get(args[0], "HostName")
user := ssh_config.Get(args[0], "User")
port := ssh_config.Get(args[0], "Port")
ifile := ssh_config.Get(args[0], "IdentityFile")
if len(hostname) == 0 || len(user) == 0 || len(ifile) == 0 {
fmt.Fprintln(os.Stderr, "Hostname not found in ~/.ssh/config")
hostname, err := lookup_ssh_field(args[0], "HostName")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if len(port) == 0 {
port = "22"
user, err := lookup_ssh_field(args[0], "User")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
port, err := lookup_ssh_field(args[0], "Port")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
ifile, err := lookup_ssh_field(args[0], "IdentityFile")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if hostname == "" || user == "" || ifile == "" {
var missing []string
if hostname == "" {
missing = append(missing, "HostName")
}
if user == "" {
missing = append(missing, "User")
}
if ifile == "" {
missing = append(missing, "IdentityFile")
}
fmt.Fprintf(os.Stderr, "ssh config %q missing required field(s): %s\n",
args[0], strings.Join(missing, ", "))
os.Exit(3)
}
if err := validate_ssh_field("HostName", hostname, rxHostUser); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if err := validate_ssh_field("User", user, rxHostUser); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if err := validate_ssh_field("IdentityFile", ifile, rxIdentityFile); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if p, perr := strconv.Atoi(port); perr != nil || p < 1 || p > 65535 {
fmt.Fprintf(os.Stderr, "ssh config Port %q is not a valid port number\n", port)
os.Exit(3)
}
mount, err := verify_mount_dir(hostname)
@@ -42,12 +103,16 @@ func main() {
os.Exit(4)
}
fmt.Println("Hostname: ", hostname)
fmt.Println("User: ", user)
fmt.Println("Port: ", port)
fmt.Println("Ifile: ", ifile)
fmt.Println("Mount: ", mount)
fmt.Println("---")
if *vFlag {
fmt.Println("Hostname: ", hostname)
fmt.Println("User: ", user)
fmt.Println("Port: ", port)
fmt.Println("Ifile: ", ifile)
fmt.Println("Mount: ", mount)
fmt.Println("---")
} else {
fmt.Println("Mount: ", mount)
}
chkmount, chkmount_err := mountinfo.Mounted(mount)
if chkmount_err != nil {
@@ -62,19 +127,37 @@ func main() {
} else {
fmt.Println("!!! Already mounted")
}
run_editor(mount)
// run_editor fires on -e regardless of mount state: re-invoking with -e
// re-opens the editor on an already-mounted server.
if err := run_editor(mount); err != nil {
fmt.Fprintln(os.Stderr, "run_editor() failed:", err)
os.Exit(7)
}
}
func run_editor(mount string) {
if *eFlag {
cmd := exec.Command("subl", mount)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
fmt.Fprintln(os.Stderr, "run_editor() failed with", err)
}
func lookup_ssh_field(alias, field string) (string, error) {
v, err := ssh_config.GetStrict(alias, field)
if err != nil {
return "", fmt.Errorf("parse ~/.ssh/config: %w", err)
}
return v, nil
}
func validate_ssh_field(name, value string, allow *regexp.Regexp) error {
if !allow.MatchString(value) {
return fmt.Errorf("ssh config %s %q contains disallowed characters", name, value)
}
return nil
}
func run_editor(mount string) error {
if !*eFlag {
return nil
}
cmd := exec.Command("subl", mount)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Start()
}
func verify_mount_dir(hostname string) (string, error) {
@@ -83,9 +166,19 @@ func verify_mount_dir(hostname string) (string, error) {
return "", fmt.Errorf("resolve home dir: %w", err)
}
base := filepath.Join(homedir, "Servers")
mount := filepath.Clean(filepath.Join(base, hostname))
if !strings.HasPrefix(mount, base+string(os.PathSeparator)) {
return "", fmt.Errorf("hostname %q escapes mount base %q", hostname, base)
if err := os.MkdirAll(base, 0700); err != nil {
return "", fmt.Errorf("create base %q: %w", base, err)
}
realBase, err := filepath.EvalSymlinks(base)
if err != nil {
return "", fmt.Errorf("resolve base %q: %w", base, err)
}
mount := filepath.Clean(filepath.Join(realBase, hostname))
if !strings.HasPrefix(mount, realBase+string(os.PathSeparator)) {
return "", fmt.Errorf("hostname %q escapes mount base %q", hostname, realBase)
}
if info, err := os.Lstat(mount); err == nil && info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("mount path %q is a symlink", mount)
}
if err := os.MkdirAll(mount, 0700); err != nil {
return "", fmt.Errorf("create mount dir %q: %w", mount, err)
@@ -98,13 +191,14 @@ func mount_sshfs(hostname string, user string, ifile string, port string, mount
"-o", "IdentityFile="+ifile,
"-o", "idmap=user",
"-o", "cache=yes",
"-o", "kernel_cache",
"-o", "auto_cache",
"-o", "attr_timeout=60",
"-o", "entry_timeout=60",
"-o", "negative_timeout=20",
"-o", "Ciphers=aes128-gcm@openssh.com",
"-o", "Compression=no",
"-o", "reconnect",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
user+"@"+hostname+":", mount)