Security Model & Known Gaps
Ship is a single-user VPS deployment tool. The threat model assumes:
- You control the VPS and have root SSH access
- You trust everyone who has SSH push access (their keys are copied to the git user)
- The VPS runs only your own apps
Mitigations in place
App name validation
All app/project names are validated against ^[a-z][a-z0-9-]{0,62}$ before being used in shell commands, file paths, systemd units, or DNS labels. This prevents command injection via crafted names.
Scoped sudoers
The git user's sudo rules are restricted to specific paths:
- systemctl restart/enable only for services matching [a-z]*
- cp only from .ship/ subdirectories to /etc/systemd/system/ and /etc/caddy/sites-enabled/
- mkdir only under /var/lib/ and /var/www/
- chown only for git:git under /var/lib/ and /var/www/
Scoped safe.directory
Git's safe.directory is set only for the www-data user (not system-wide), preserving CVE-2022-24765 protection for other users.
Accepted risks (by design)
SSH key access = root access
The git user is in the docker group, which is root-equivalent (can mount the host filesystem). Additionally, .ship/service files pushed via git are installed as systemd units. Anyone with SSH push access effectively has root. This is intentional for a single-user tool.
Git repo visibility
Repos are private by default (not cloneable over HTTPS). Use ship init --public to make a repo publicly cloneable. This is controlled by the git-daemon-export-ok marker file in each bare repo. Only public repos are accessible via go get or git clone over HTTPS. The cgit web interface respects the same model — it is configured with export-ok=git-daemon-export-ok, so only public repos are browsable.
User-controlled systemd units
The .ship/service file in each repo is copied to /etc/systemd/system/ on push. A malicious service file could run arbitrary commands as root. This is equivalent to the Docker access risk above.
Known gaps (not yet addressed)
SSH host key verification disabled
ssh.InsecureIgnoreHostKey() is used for all SSH connections, and StrictHostKeyChecking=no for scp/rsync. This makes connections vulnerable to MITM attacks on untrusted networks. A future improvement would use known_hosts verification.
Env files may have loose permissions
Environment files at /etc/ship/env/{name}.env are created via sudo tee and may be world-readable depending on umask. These files can contain secrets. The deploy flow does chmod 600 but ship init does not. A future improvement would ensure consistent restrictive permissions.
host init is not idempotent
Running ship host init twice will overwrite /etc/caddy/Caddyfile and the base domain Caddy config, destroying any manual edits. No guard checks whether setup has already been completed.
No rollback on failed docker build
The post-receive hook installs .ship/service and .ship/Caddyfile before running docker build. If the build fails, the configs are updated but the old image is still running, creating a mismatch. The old container keeps running (due to set -e), but a manual restart would use the new (mismatched) unit file.
ship deploy vs git push ownership mismatch
ship deploy runs commands as root (the SSH user), while git push triggers the hook as the git user. Files checked out by ship deploy become root-owned, which can prevent subsequent git push deploys from overwriting them.
No concurrent push protection
Simultaneous pushes can race on the checkout directory and docker build. For single-user usage this is unlikely but not impossible.
Port allocation is monotonic
Ports are never reclaimed when apps are removed. After ~57,000 create/remove cycles, ports would be exhausted. Not a practical concern.
