From 47d4b3b6e4d68660e6e1e05fe2e1c0839f86e40e Mon Sep 17 00:00:00 2001 From: bndw Date: Tue, 10 Feb 2026 21:29:08 -0800 Subject: Harden security: name validation, scoped sudoers, safe.directory - Add ValidateName() enforcing ^[a-z][a-z0-9-]{0,62}$ on all entry points - Tighten sudoers to restrict cp sources/destinations and chown targets - Scope git safe.directory to www-data user only (preserves CVE-2022-24765) - Add www-data to git group and caddy to www-data group for fcgiwrap - Fix vanity import template to use orig_uri placeholder - Restart (not reload) services after group changes - Add name validation to env subcommands and deploy_cmd --- cmd/ship/deploy.go | 3 +++ cmd/ship/deploy_cmd.go | 3 +++ cmd/ship/env/list.go | 3 +++ cmd/ship/env/set.go | 3 +++ cmd/ship/env/unset.go | 3 +++ cmd/ship/host/init.go | 42 +++++++++++++++++++++++++++++++++++------- cmd/ship/logs.go | 3 +++ cmd/ship/remove.go | 3 +++ cmd/ship/restart.go | 3 +++ cmd/ship/status.go | 3 +++ cmd/ship/validate.go | 9 +++++++++ 11 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 cmd/ship/validate.go (limited to 'cmd') diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go index 9ac754c..86d4878 100644 --- a/cmd/ship/deploy.go +++ b/cmd/ship/deploy.go @@ -140,6 +140,9 @@ func runDeploy(cmd *cobra.Command, args []string) error { name = filepath.Base(binary) } } + if err := validateName(name); err != nil { + return err + } // Check if this is an update to an existing app/site existingApp, _ := st.GetApp(host, name) diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go index ca5c54d..ba45c4f 100644 --- a/cmd/ship/deploy_cmd.go +++ b/cmd/ship/deploy_cmd.go @@ -24,6 +24,9 @@ Examples: func runDeployGit(cmd *cobra.Command, args []string) error { name := args[0] + if err := validateName(name); err != nil { + return err + } st, err := state.Load() if err != nil { diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go index ad76eb6..e94b83a 100644 --- a/cmd/ship/env/list.go +++ b/cmd/ship/env/list.go @@ -17,6 +17,9 @@ var listCmd = &cobra.Command{ func runList(cmd *cobra.Command, args []string) error { name := args[0] + if err := state.ValidateName(name); err != nil { + return err + } st, err := state.Load() if err != nil { diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go index e11d2c9..d4292f3 100644 --- a/cmd/ship/env/set.go +++ b/cmd/ship/env/set.go @@ -25,6 +25,9 @@ func init() { func runSet(cmd *cobra.Command, args []string) error { name := args[0] + if err := state.ValidateName(name); err != nil { + return err + } envVars := args[1:] st, err := state.Load() diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go index 7d9a141..8292f42 100644 --- a/cmd/ship/env/unset.go +++ b/cmd/ship/env/unset.go @@ -18,6 +18,9 @@ var unsetCmd = &cobra.Command{ func runUnset(cmd *cobra.Command, args []string) error { name := args[0] + if err := state.ValidateName(name); err != nil { + return err + } keys := args[1:] st, err := state.Load() diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go index e1792f5..0ec573c 100644 --- a/cmd/ship/host/init.go +++ b/cmd/ship/host/init.go @@ -157,6 +157,13 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { return fmt.Errorf("error installing git/fcgiwrap: %w", err) } + // Allow git-http-backend (runs as www-data) to access repos owned by git. + // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. + // www-data's home is /var/www; ensure it can write .gitconfig there. + client.RunSudo("chown www-data:www-data /var/www") + if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { + return fmt.Errorf("error setting git safe.directory: %w", err) + } fmt.Println(" git and fcgiwrap installed") fmt.Println("-> Creating git user...") @@ -165,6 +172,14 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host if _, err := client.RunSudo("usermod -aG docker git"); err != nil { return fmt.Errorf("error adding git user to docker group: %w", err) } + // www-data needs to read git repos for git-http-backend + if _, err := client.RunSudo("usermod -aG git www-data"); err != nil { + return fmt.Errorf("error adding www-data to git group: %w", err) + } + // caddy needs to connect to fcgiwrap socket (owned by www-data) + if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil { + return fmt.Errorf("error adding caddy to www-data group: %w", err) + } fmt.Println(" git user created") fmt.Println("-> Copying SSH keys to git user...") @@ -192,7 +207,20 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host fmt.Println(" /srv/git created") fmt.Println("-> Writing sudoers for git user...") - sudoersContent := `git ALL=(ALL) NOPASSWD: /bin/systemctl restart *, /bin/systemctl daemon-reload, /bin/systemctl reload caddy, /bin/systemctl enable *, /bin/cp * /etc/systemd/system/*, /bin/cp * /etc/caddy/sites-enabled/*, /bin/mkdir -p /var/lib/*, /bin/mkdir -p /var/www/*, /bin/chown * + sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services. +# App names are validated to [a-z][a-z0-9-] before reaching this point. +git ALL=(ALL) NOPASSWD: \ + /bin/systemctl daemon-reload, \ + /bin/systemctl reload caddy, \ + /bin/systemctl restart [a-z]*, \ + /bin/systemctl enable [a-z]*, \ + /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ + /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ + /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ + /bin/mkdir -p /var/lib/*, \ + /bin/mkdir -p /var/www/*, \ + /bin/chown -R git\:git /var/lib/*, \ + /bin/chown git\:git /var/www/* ` if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { return fmt.Errorf("error writing sudoers: %w", err) @@ -205,7 +233,7 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host fmt.Println("-> Writing vanity import template...") vanityHTML := ` -{{$path := trimPrefix "/" .Req.URL.Path}} +{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} {{$parts := splitList "/" $path}} {{$module := first $parts}} @@ -237,16 +265,16 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { return fmt.Errorf("error enabling services: %w", err) } - if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil { + if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil { return fmt.Errorf("error starting services: %w", err) } fmt.Println(" Docker and fcgiwrap started") - fmt.Println("-> Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return fmt.Errorf("error reloading Caddy: %w", err) + fmt.Println("-> Restarting Caddy...") + if _, err := client.RunSudo("systemctl restart caddy"); err != nil { + return fmt.Errorf("error restarting Caddy: %w", err) } - fmt.Println(" Caddy reloaded") + fmt.Println(" Caddy restarted") hostState.GitSetup = true fmt.Println(" Git deployment setup complete") diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go index 4d85fe1..4c58a9c 100644 --- a/cmd/ship/logs.go +++ b/cmd/ship/logs.go @@ -22,6 +22,9 @@ func init() { func runLogs(cmd *cobra.Command, args []string) error { name := args[0] + if err := validateName(name); err != nil { + return err + } follow, _ := cmd.Flags().GetBool("follow") lines, _ := cmd.Flags().GetInt("lines") diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go index 8380e87..b55d0c8 100644 --- a/cmd/ship/remove.go +++ b/cmd/ship/remove.go @@ -18,6 +18,9 @@ var removeCmd = &cobra.Command{ func runRemove(cmd *cobra.Command, args []string) error { name := args[0] + if err := validateName(name); err != nil { + return err + } st, err := state.Load() if err != nil { diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go index 404624a..c902adb 100644 --- a/cmd/ship/restart.go +++ b/cmd/ship/restart.go @@ -17,6 +17,9 @@ var restartCmd = &cobra.Command{ func runRestart(cmd *cobra.Command, args []string) error { name := args[0] + if err := validateName(name); err != nil { + return err + } st, err := state.Load() if err != nil { diff --git a/cmd/ship/status.go b/cmd/ship/status.go index 536ec8c..4774fad 100644 --- a/cmd/ship/status.go +++ b/cmd/ship/status.go @@ -17,6 +17,9 @@ var statusCmd = &cobra.Command{ func runStatus(cmd *cobra.Command, args []string) error { name := args[0] + if err := validateName(name); err != nil { + return err + } st, err := state.Load() if err != nil { diff --git a/cmd/ship/validate.go b/cmd/ship/validate.go new file mode 100644 index 0000000..00275af --- /dev/null +++ b/cmd/ship/validate.go @@ -0,0 +1,9 @@ +package main + +import "github.com/bdw/ship/internal/state" + +// validateName checks that an app/project name is safe for use in shell +// commands, file paths, systemd units, and DNS labels. +func validateName(name string) error { + return state.ValidateName(name) +} -- cgit v1.2.3