d01a358f35
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.
129 lines
4.2 KiB
Go
129 lines
4.2 KiB
Go
// ABOUTME: Unit tests for sshfsc helpers, primarily the path-traversal guard
|
|
// ABOUTME: in verify_mount_dir and the ssh_config field allowlist regexes.
|
|
|
|
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestVerifyMountDir(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
base := filepath.Join(home, "Servers")
|
|
if err := os.MkdirAll(base, 0700); err != nil {
|
|
t.Fatalf("setup base: %v", err)
|
|
}
|
|
realBase, err := filepath.EvalSymlinks(base)
|
|
if err != nil {
|
|
t.Fatalf("resolve base: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
hostname string
|
|
wantErr string // substring; "" means no error
|
|
wantPath string // only checked when wantErr == ""
|
|
}{
|
|
{"normal alias", "server1", "", filepath.Join(realBase, "server1")},
|
|
{"trailing slash", "server1/", "", filepath.Join(realBase, "server1")},
|
|
{"dot in name", "db.prod", "", filepath.Join(realBase, "db.prod")},
|
|
{"parent traversal", "../etc", "escapes mount base", ""},
|
|
{"deep traversal", "../../tmp/x", "escapes mount base", ""},
|
|
{"empty string", "", "escapes mount base", ""},
|
|
{"single dot", ".", "escapes mount base", ""},
|
|
// nested path lands under base — accepted by guard. The hostname allowlist
|
|
// (rxHostUser) rejects slashes upstream; this test pins guard behaviour.
|
|
{"nested path stays under base", "sub/dir", "", filepath.Join(realBase, "sub/dir")},
|
|
// absolute-looking input does not escape because filepath.Join keeps it
|
|
// under base. Same upstream allowlist applies.
|
|
{"absolute-looking input", "/etc/passwd", "", filepath.Join(realBase, "etc/passwd")},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := verify_mount_dir(tc.hostname)
|
|
if tc.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("want error containing %q, got nil (path=%q)", tc.wantErr, got)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tc.wantPath {
|
|
t.Fatalf("path = %q, want %q", got, tc.wantPath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateSSHFieldRegexes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
value string
|
|
rx string
|
|
ok bool
|
|
}{
|
|
{"plain host", "myserver", "host", true},
|
|
{"host with dots", "db.prod.example.com", "host", true},
|
|
{"host with hyphen", "edge-01", "host", true},
|
|
{"host leading dash rejected", "-rogue", "host", false},
|
|
{"host with comma rejected", "a,b", "host", false},
|
|
{"host with space rejected", "a b", "host", false},
|
|
{"host with slash rejected", "a/b", "host", false},
|
|
{"host empty rejected", "", "host", false},
|
|
|
|
{"identityfile tilde", "~/.ssh/id_ed25519", "ifile", true},
|
|
{"identityfile absolute", "/home/dom/.ssh/id_rsa", "ifile", true},
|
|
{"identityfile letter start", "id_rsa", "ifile", true},
|
|
{"identityfile digit start", "0key", "ifile", true},
|
|
{"identityfile dash mid accepted", "~/.ssh/id-host", "ifile", true},
|
|
{"identityfile with comma rejected", "~/.ssh/id,allow_other", "ifile", false},
|
|
{"identityfile with space rejected", "~/.ssh/id rsa", "ifile", false},
|
|
{"identityfile leading dash rejected", "-Ffoo", "ifile", false},
|
|
{"identityfile leading dot rejected", ".ssh/id", "ifile", false},
|
|
{"identityfile leading colon rejected", ":weird", "ifile", false},
|
|
{"identityfile empty rejected", "", "ifile", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var rx = rxHostUser
|
|
if tc.rx == "ifile" {
|
|
rx = rxIdentityFile
|
|
}
|
|
err := validate_ssh_field("X", tc.value, rx)
|
|
if tc.ok && err != nil {
|
|
t.Fatalf("want accept, got error: %v", err)
|
|
}
|
|
if !tc.ok && err == nil {
|
|
t.Fatalf("want reject, got accept")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifyMountDirRejectsSymlink(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
base := filepath.Join(home, "Servers")
|
|
if err := os.MkdirAll(base, 0700); err != nil {
|
|
t.Fatalf("setup base: %v", err)
|
|
}
|
|
target := t.TempDir()
|
|
if err := os.Symlink(target, filepath.Join(base, "evil")); err != nil {
|
|
t.Fatalf("create symlink: %v", err)
|
|
}
|
|
_, err := verify_mount_dir("evil")
|
|
if err == nil || !strings.Contains(err.Error(), "is a symlink") {
|
|
t.Fatalf("want symlink rejection, got %v", err)
|
|
}
|
|
}
|