Skip to content

Security: douglasjordan2/sudo-approve

Security

SECURITY.md

Security

sudo-approve runs arbitrary commands as root in response to a network request. Its safety comes entirely from the deployment model and the cryptographic checks below. Read this before deploying.

Deployment model (the trust boundary)

This is not designed to be exposed to the public internet. It is safe only when:

  1. The hub is reachable on your tailnet only, over HTTPS via tailscale servenever tailscale funnel, and never a public reverse proxy. Every device that can reach the hub is treated as semi-trusted: it can read the audit dashboard and trigger approval prompts, but it cannot approve anything without your passkey.
  2. Each executor listens on 127.0.0.1 only. It has no inbound network surface; it only makes outbound calls to the hub. Do not change listen_host to a non-loopback address.
  3. SUDO_API_KEY and SUDO_APPROVE_SECRET are set. The hub refuses to start without them (override only with SUDO_APPROVE_INSECURE=1 for local dev).
  4. The Ed25519 private key stays on the hub. Only the public key is distributed to executors. The hub runs non-root.
  5. local-token and config.toml file permissions are intact (600, owned by the right user). deploy-executor.sh sets these — don't loosen them.

What is cryptographically enforced

These are the load-bearing guarantees, verified in the source:

  • Approval requires your passkey. A command is signed into an executable directive only after a successful WebAuthn assertion with user verification (require_user_verification=True). rp_id/origin are pinned from config, challenges are server-side and single-use, and sign_count is tracked for cloned-authenticator detection.
  • The approval is bound to the exact command. The token minted after the passkey check is HMAC'd over a hash of the command, so it can only authorize signing that command — not whatever might later sit in the row.
  • Directives are Ed25519-signed and command-bound. The executor runs a command only if: the hub's signature verifies, host == itself, command == exactly what it submitted, the directive hasn't expired (300s), and the single-use nonce has not been seen before (checked under a file lock). Forgery, replay, and substitution are all rejected.
  • Privilege separation. The network-facing hub cannot execute anything; compromising it yields denial of service, not code execution. Only the loopback-only executor runs as root.
  • No SQL injection / no stored XSS. All queries are parameterized; command text is auto-escaped in the approve UI.

Threat model

Threat Mitigation
Attacker forges a directive Ed25519 signature over canonical JSON; executor verifies before trusting any field
Replay of an old approval Single-use nonce (file-locked check) + 300s expiry, enforced by the executor
Hub signs a different command than the one approved Auth token is bound to a hash of the command; approve uses a conditional WHERE status='pending' update
MITM on the tailnet WireGuard encryption (Tailscale) + directive signature
Leaked executor bearer token Can only submit a request; still needs your passkey, and the command runs only on that host
Compromised hub host Non-root, holds no executor; cannot execute — worst case is DoS
Stolen phone Passkey requires device unlock / user verification
Local non-root user who can read local-token Can submit a root command request; cannot approve it (no passkey). They could try to social-engineer you into approving — so treat that file as sensitive

Residual risks (accepted under the tailnet model — know them)

  • Tailnet peers can read the audit dashboard and trigger prompts. / lists recent commands and their status (command output is no longer rendered or loaded into the list view), and any peer can POST a request that pushes an approval prompt to your phone. This is acceptable only because the tailnet is your trust boundary; the passkey still gates execution. If your tailnet includes devices you don't control, put the hub behind additional auth or restrict access with Tailscale ACLs.
  • host binding is conventional, not enforced per-token. target_host is now required on every request and the executor rejects any host mismatch, so a directive can never be signed with a null/foreign host that the wrong machine would run. The hub still trusts the target_host field rather than deriving it from the executor's bearer token; the poll model means an executor only receives directives for approvals it created, so this isn't exploitable today — but if you add per-host tokens, also map each token to its host.
  • Approval approves the entire shell line. The executor runs the command via a shell (it's a sudo wrapper, by design). Read the whole command on your phone before approving — a; b is two commands in one approval.
  • Any tailnet peer can subscribe to push notifications. /api/push/subscribe (like /register) is unauthenticated within the tailnet, and subscribed devices receive each pending command's text and reason (never output, and never the ability to approve). Notification payloads transit Apple/Google push relays encrypted end-to-end (RFC 8291, aes128gcm) — the relays can't read them. Same posture as the dashboard: the tailnet is the trust boundary; use Tailscale ACLs if yours is shared.

Hardening checklist

  • Hub reachable via tailscale serve (tailnet HTTPS), not Funnel
  • SUDO_API_KEY and SUDO_APPROVE_SECRET set to fresh 32-byte random values
  • Hub runs as a non-root user (systemd user service)
  • Executor listen_host = 127.0.0.1 (default — leave it)
  • keys/ private keys (hub-ed25519, vapid-private.pem) are mode 600, hub-only, never committed
  • config.toml (600) and local-token (600) permissions intact
  • Restrict hub access further with Tailscale ACLs if your tailnet is shared

Hardening applied in the initial security review

Before first public release, an audit of the auth path led to these changes:

  • Hub fails closed when SUDO_API_KEY/SUDO_APPROVE_SECRET are unset.
  • Approve token is now bound to a hash of the command, and approval uses a conditional update to close a check-then-act race.
  • Executor's nonce replay check is now atomic (file lock), and the local X-Local-Token comparison is constant-time.
  • target_host is required on every request (no null-host directives), and the audit dashboard no longer loads or renders command output.

Reporting a vulnerability

Please report security issues privately rather than opening a public issue. Open a GitHub security advisory or email the maintainer. Include reproduction steps and the affected component (hub vs executor). You'll get an acknowledgement and a fix timeline.

There aren't any published security advisories