diff options
| author | bndw <ben@bdw.to> | 2026-02-10 21:29:08 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-10 21:29:08 -0800 |
| commit | 47d4b3b6e4d68660e6e1e05fe2e1c0839f86e40e (patch) | |
| tree | af9b3274d2c4ef2bdcdfc1a074c52a52f8d523e3 /cmd/ship | |
| parent | 86a9dbce8b6c067c7e94bc6ba5a078b7d85eb9ca (diff) | |
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
Diffstat (limited to 'cmd/ship')
| -rw-r--r-- | cmd/ship/deploy.go | 3 | ||||
| -rw-r--r-- | cmd/ship/deploy_cmd.go | 3 | ||||
| -rw-r--r-- | cmd/ship/env/list.go | 3 | ||||
| -rw-r--r-- | cmd/ship/env/set.go | 3 | ||||
| -rw-r--r-- | cmd/ship/env/unset.go | 3 | ||||
| -rw-r--r-- | cmd/ship/host/init.go | 42 | ||||
| -rw-r--r-- | cmd/ship/logs.go | 3 | ||||
| -rw-r--r-- | cmd/ship/remove.go | 3 | ||||
| -rw-r--r-- | cmd/ship/restart.go | 3 | ||||
| -rw-r--r-- | cmd/ship/status.go | 3 | ||||
| -rw-r--r-- | cmd/ship/validate.go | 9 |
11 files changed, 71 insertions, 7 deletions
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 { | |||
| 140 | name = filepath.Base(binary) | 140 | name = filepath.Base(binary) |
| 141 | } | 141 | } |
| 142 | } | 142 | } |
| 143 | if err := validateName(name); err != nil { | ||
| 144 | return err | ||
| 145 | } | ||
| 143 | 146 | ||
| 144 | // Check if this is an update to an existing app/site | 147 | // Check if this is an update to an existing app/site |
| 145 | existingApp, _ := st.GetApp(host, name) | 148 | 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: | |||
| 24 | 24 | ||
| 25 | func runDeployGit(cmd *cobra.Command, args []string) error { | 25 | func runDeployGit(cmd *cobra.Command, args []string) error { |
| 26 | name := args[0] | 26 | name := args[0] |
| 27 | if err := validateName(name); err != nil { | ||
| 28 | return err | ||
| 29 | } | ||
| 27 | 30 | ||
| 28 | st, err := state.Load() | 31 | st, err := state.Load() |
| 29 | if err != nil { | 32 | 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{ | |||
| 17 | 17 | ||
| 18 | func runList(cmd *cobra.Command, args []string) error { | 18 | func runList(cmd *cobra.Command, args []string) error { |
| 19 | name := args[0] | 19 | name := args[0] |
| 20 | if err := state.ValidateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 20 | 23 | ||
| 21 | st, err := state.Load() | 24 | st, err := state.Load() |
| 22 | if err != nil { | 25 | 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() { | |||
| 25 | 25 | ||
| 26 | func runSet(cmd *cobra.Command, args []string) error { | 26 | func runSet(cmd *cobra.Command, args []string) error { |
| 27 | name := args[0] | 27 | name := args[0] |
| 28 | if err := state.ValidateName(name); err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 28 | envVars := args[1:] | 31 | envVars := args[1:] |
| 29 | 32 | ||
| 30 | st, err := state.Load() | 33 | 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{ | |||
| 18 | 18 | ||
| 19 | func runUnset(cmd *cobra.Command, args []string) error { | 19 | func runUnset(cmd *cobra.Command, args []string) error { |
| 20 | name := args[0] | 20 | name := args[0] |
| 21 | if err := state.ValidateName(name); err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 21 | keys := args[1:] | 24 | keys := args[1:] |
| 22 | 25 | ||
| 23 | st, err := state.Load() | 26 | 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 | |||
| 157 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { | 157 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap"); err != nil { |
| 158 | return fmt.Errorf("error installing git/fcgiwrap: %w", err) | 158 | return fmt.Errorf("error installing git/fcgiwrap: %w", err) |
| 159 | } | 159 | } |
| 160 | // Allow git-http-backend (runs as www-data) to access repos owned by git. | ||
| 161 | // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. | ||
| 162 | // www-data's home is /var/www; ensure it can write .gitconfig there. | ||
| 163 | client.RunSudo("chown www-data:www-data /var/www") | ||
| 164 | if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { | ||
| 165 | return fmt.Errorf("error setting git safe.directory: %w", err) | ||
| 166 | } | ||
| 160 | fmt.Println(" git and fcgiwrap installed") | 167 | fmt.Println(" git and fcgiwrap installed") |
| 161 | 168 | ||
| 162 | fmt.Println("-> Creating git user...") | 169 | fmt.Println("-> Creating git user...") |
| @@ -165,6 +172,14 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host | |||
| 165 | if _, err := client.RunSudo("usermod -aG docker git"); err != nil { | 172 | if _, err := client.RunSudo("usermod -aG docker git"); err != nil { |
| 166 | return fmt.Errorf("error adding git user to docker group: %w", err) | 173 | return fmt.Errorf("error adding git user to docker group: %w", err) |
| 167 | } | 174 | } |
| 175 | // www-data needs to read git repos for git-http-backend | ||
| 176 | if _, err := client.RunSudo("usermod -aG git www-data"); err != nil { | ||
| 177 | return fmt.Errorf("error adding www-data to git group: %w", err) | ||
| 178 | } | ||
| 179 | // caddy needs to connect to fcgiwrap socket (owned by www-data) | ||
| 180 | if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil { | ||
| 181 | return fmt.Errorf("error adding caddy to www-data group: %w", err) | ||
| 182 | } | ||
| 168 | fmt.Println(" git user created") | 183 | fmt.Println(" git user created") |
| 169 | 184 | ||
| 170 | fmt.Println("-> Copying SSH keys to git user...") | 185 | fmt.Println("-> Copying SSH keys to git user...") |
| @@ -192,7 +207,20 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host | |||
| 192 | fmt.Println(" /srv/git created") | 207 | fmt.Println(" /srv/git created") |
| 193 | 208 | ||
| 194 | fmt.Println("-> Writing sudoers for git user...") | 209 | fmt.Println("-> Writing sudoers for git user...") |
| 195 | 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 * | 210 | sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services. |
| 211 | # App names are validated to [a-z][a-z0-9-] before reaching this point. | ||
| 212 | git ALL=(ALL) NOPASSWD: \ | ||
| 213 | /bin/systemctl daemon-reload, \ | ||
| 214 | /bin/systemctl reload caddy, \ | ||
| 215 | /bin/systemctl restart [a-z]*, \ | ||
| 216 | /bin/systemctl enable [a-z]*, \ | ||
| 217 | /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ | ||
| 218 | /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 219 | /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 220 | /bin/mkdir -p /var/lib/*, \ | ||
| 221 | /bin/mkdir -p /var/www/*, \ | ||
| 222 | /bin/chown -R git\:git /var/lib/*, \ | ||
| 223 | /bin/chown git\:git /var/www/* | ||
| 196 | ` | 224 | ` |
| 197 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | 225 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { |
| 198 | return fmt.Errorf("error writing sudoers: %w", err) | 226 | return fmt.Errorf("error writing sudoers: %w", err) |
| @@ -205,7 +233,7 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host | |||
| 205 | fmt.Println("-> Writing vanity import template...") | 233 | fmt.Println("-> Writing vanity import template...") |
| 206 | vanityHTML := `<!DOCTYPE html> | 234 | vanityHTML := `<!DOCTYPE html> |
| 207 | <html><head> | 235 | <html><head> |
| 208 | {{$path := trimPrefix "/" .Req.URL.Path}} | 236 | {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} |
| 209 | {{$parts := splitList "/" $path}} | 237 | {{$parts := splitList "/" $path}} |
| 210 | {{$module := first $parts}} | 238 | {{$module := first $parts}} |
| 211 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | 239 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> |
| @@ -237,16 +265,16 @@ func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host | |||
| 237 | if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { | 265 | if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { |
| 238 | return fmt.Errorf("error enabling services: %w", err) | 266 | return fmt.Errorf("error enabling services: %w", err) |
| 239 | } | 267 | } |
| 240 | if _, err := client.RunSudo("systemctl start docker fcgiwrap"); err != nil { | 268 | if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil { |
| 241 | return fmt.Errorf("error starting services: %w", err) | 269 | return fmt.Errorf("error starting services: %w", err) |
| 242 | } | 270 | } |
| 243 | fmt.Println(" Docker and fcgiwrap started") | 271 | fmt.Println(" Docker and fcgiwrap started") |
| 244 | 272 | ||
| 245 | fmt.Println("-> Reloading Caddy...") | 273 | fmt.Println("-> Restarting Caddy...") |
| 246 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | 274 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { |
| 247 | return fmt.Errorf("error reloading Caddy: %w", err) | 275 | return fmt.Errorf("error restarting Caddy: %w", err) |
| 248 | } | 276 | } |
| 249 | fmt.Println(" Caddy reloaded") | 277 | fmt.Println(" Caddy restarted") |
| 250 | 278 | ||
| 251 | hostState.GitSetup = true | 279 | hostState.GitSetup = true |
| 252 | fmt.Println(" Git deployment setup complete") | 280 | 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() { | |||
| 22 | 22 | ||
| 23 | func runLogs(cmd *cobra.Command, args []string) error { | 23 | func runLogs(cmd *cobra.Command, args []string) error { |
| 24 | name := args[0] | 24 | name := args[0] |
| 25 | if err := validateName(name); err != nil { | ||
| 26 | return err | ||
| 27 | } | ||
| 25 | follow, _ := cmd.Flags().GetBool("follow") | 28 | follow, _ := cmd.Flags().GetBool("follow") |
| 26 | lines, _ := cmd.Flags().GetInt("lines") | 29 | lines, _ := cmd.Flags().GetInt("lines") |
| 27 | 30 | ||
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{ | |||
| 18 | 18 | ||
| 19 | func runRemove(cmd *cobra.Command, args []string) error { | 19 | func runRemove(cmd *cobra.Command, args []string) error { |
| 20 | name := args[0] | 20 | name := args[0] |
| 21 | if err := validateName(name); err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 21 | 24 | ||
| 22 | st, err := state.Load() | 25 | st, err := state.Load() |
| 23 | if err != nil { | 26 | 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{ | |||
| 17 | 17 | ||
| 18 | func runRestart(cmd *cobra.Command, args []string) error { | 18 | func runRestart(cmd *cobra.Command, args []string) error { |
| 19 | name := args[0] | 19 | name := args[0] |
| 20 | if err := validateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 20 | 23 | ||
| 21 | st, err := state.Load() | 24 | st, err := state.Load() |
| 22 | if err != nil { | 25 | 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{ | |||
| 17 | 17 | ||
| 18 | func runStatus(cmd *cobra.Command, args []string) error { | 18 | func runStatus(cmd *cobra.Command, args []string) error { |
| 19 | name := args[0] | 19 | name := args[0] |
| 20 | if err := validateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 20 | 23 | ||
| 21 | st, err := state.Load() | 24 | st, err := state.Load() |
| 22 | if err != nil { | 25 | 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 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import "github.com/bdw/ship/internal/state" | ||
| 4 | |||
| 5 | // validateName checks that an app/project name is safe for use in shell | ||
| 6 | // commands, file paths, systemd units, and DNS labels. | ||
| 7 | func validateName(name string) error { | ||
| 8 | return state.ValidateName(name) | ||
| 9 | } | ||
