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.
This is not designed to be exposed to the public internet. It is safe only when:
- The hub is reachable on your tailnet only, over HTTPS via
tailscale serve— nevertailscale 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. - Each executor listens on
127.0.0.1only. It has no inbound network surface; it only makes outbound calls to the hub. Do not changelisten_hostto a non-loopback address. SUDO_API_KEYandSUDO_APPROVE_SECRETare set. The hub refuses to start without them (override only withSUDO_APPROVE_INSECURE=1for local dev).- The Ed25519 private key stays on the hub. Only the public key is distributed to executors. The hub runs non-root.
local-tokenandconfig.tomlfile permissions are intact (600, owned by the right user).deploy-executor.shsets these — don't loosen them.
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/originare pinned from config, challenges are server-side and single-use, andsign_countis 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-usenoncehas 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 | 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 |
- 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. hostbinding is conventional, not enforced per-token.target_hostis 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 thetarget_hostfield 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; bis 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.
- Hub reachable via
tailscale serve(tailnet HTTPS), not Funnel -
SUDO_API_KEYandSUDO_APPROVE_SECRETset 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 mode600, hub-only, never committed -
config.toml(600) andlocal-token(600) permissions intact - Restrict hub access further with Tailscale ACLs if your tailnet is shared
Before first public release, an audit of the auth path led to these changes:
- Hub fails closed when
SUDO_API_KEY/SUDO_APPROVE_SECRETare 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-Tokencomparison is constant-time. target_hostis required on every request (no null-host directives), and the audit dashboard no longer loads or renders command output.
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.