Tailshell is a self-hosted web terminal: it gives authenticated users an interactive shell on the host (via ttyd + tmux), persists user state in MySQL, and is reverse-proxied by nginx. This document records the disclosure process and the threat model.
For the operational hardening checklist that production deployments are expected to follow (HTTPS via Tailscale Serve, cookie flags, CORS posture, secret handling, rate-limit tuning), see docs/SECURITY.md.
Do not file a public GitHub issue or pull request for security problems. Instead:
- Email the security contact at security@visorcraft.com.
- Include reproduction steps, the Tailshell git commit (
git rev-parse HEAD), the deployment topology (single-user vs. multi-user, Tailscale Serve vs. direct, MFA on/off), and any relevant nginx / API logs with secrets redacted. - We aim to acknowledge within 72 hours and to publish a patch + advisory within two weeks of confirming a fix.
Please give us a 30-day coordinated-disclosure window before going public, unless the vulnerability is already actively exploited.
The latest master branch is supported with security fixes. Older
tagged releases receive fixes only when the patch is trivial to
backport. Always run with the latest API + UI + nginx images pulled
together — running mismatched versions across the three layers is
unsupported.
Tailshell ships zero telemetry. No analytics, no crash reports, no ping-home behavior. The only outbound traffic Tailshell originates on its own is:
- Container image pulls during
docker compose up(from your configured registry). - Tailscale Serve / Tailscale Funnel traffic, when the operator wires it up.
The application itself does not phone home. Operator-side observability (audit logs, structured logs, Prometheus metrics) is local-only by default and lives under the host's filesystem.
- JWTs are issued with short-lived access tokens (~15 minutes) and rotating refresh tokens (~7 days), each revocable server-side.
- Cookies are
HttpOnlywithSameSite=Strict. Production deployments must setTAILSHELL_COOKIE_SECURE=trueso cookies are also markedSecure. - CSRF protection is enforced on mutating routes when cookie auth is
used. The UI sends
X-CSRF-Token; the API rejects mutations that don't carry it. - Passwords are hashed with bcrypt at cost factor 12 and validated against the strong password policy.
- Login attempts are rate-limited (default: 5 attempts per 15 minutes, per IP + per username).
- Admins can enable TOTP-based MFA (and should). MFA is enforced per role.
- Roles:
admin,user,editor,readonly,auditor. Endpoint access is enforced per role; routes that mutate global state are admin-only.
- ttyd runs as an unprivileged user-level systemd service. The
wrapper refuses to start as root unless
TAILSHELL_ALLOW_ROOT_TTYD=trueis set explicitly. - Terminal access is gated by role and by an additional
terminal_tokencookie that scopes WebSocket connections to a single authenticated session. - tmux sessions are namespaced per user so workspace isolation does not depend on shell discipline.
- The reverse proxy refuses upgrade requests without a valid
terminal_token.
- MySQL is reached over the internal Docker network only;
docker-compose.ymlbinds the port to127.0.0.1. - Database access goes through Knex with parameterized queries.
- Audit log entries (auth events + CRUD on workspaces, prompts, invitations, password resets) are written into the audit table with the actor, action, target, source IP, and timestamp.
- Refresh-token rotation, MFA challenges, and password-reset tokens are stored hashed; the plaintext is never persisted.
.envis in.gitignore. The.env.examplefile lists every variable Tailshell reads.- Production deployments should prefer Docker secrets or an
external secret manager over
.envfiles. The API and MySQL containers support*_FILEvariants for sensitive values (JWT_SECRET_FILE,MYSQL_PASSWORD_FILE, etc.). - TLS certificates live under
nginx/certs/and are mounted read-only into the nginx container.
Tailshell does not sandbox the shells it spawns. A user with a terminal session can run any command their Unix account is allowed to run, including outbound network traffic. This is intentional — the whole point of the product is to be a real shell — but operators should configure their host's outbound firewall, Tailscale ACLs, or egress proxy according to their threat model.
CORS is disabled by default. The expected production layout is same-origin: the UI and API are served from the same hostname (via nginx) so no preflight requests are needed.
If you intentionally serve the UI from a separate origin, set
CORS_ORIGIN to an exact allowlist (never *). Credentialed CORS
requires CORS_CREDENTIALS=true and a non-wildcard origin.
| Threat | Mitigation |
|---|---|
| Stolen access cookie replayed from another host | SameSite=Strict cookies, short access-token TTL, Secure flag in HTTPS deployments, IP-bound refresh sessions. |
| Brute-force login | Per-IP + per-username login rate limit; admins can require MFA. |
| CSRF against a mutating route | Every mutating endpoint requires X-CSRF-Token matched to the csrf_token cookie when cookie auth is used. |
| Terminal hijack via WebSocket upgrade | The proxy refuses upgrades without a valid terminal_token cookie; ttyd is bound to a tmux session that belongs to the authenticated user. |
| Privilege escalation via the ttyd service | ttyd runs as an unprivileged user-level service; the wrapper refuses root unless explicitly opted in. |
| SQL injection | All queries go through Knex's parameterized builder; no string concatenation in handlers. |
| Refresh-token theft | Refresh tokens are hashed at rest, rotated on use, and revocable server-side. The session table tracks revocations. |
| Password leak through logs | The API logger is configured to drop request bodies on /login, /reset-password, /change-password, and the MFA endpoints. |
| Audit-log tampering by a compromised admin | Audit entries are append-only at the application layer; rotating offsite backups (see docs/BACKUP-OFFSITE.md) defeat in-place tampering. |
| Exposed Docker socket | Tailshell does not require host access to the Docker socket. Operators who mount it should know the implications. |
| Direct access to MySQL from the public internet | The compose file binds MySQL to 127.0.0.1; Tailscale-only deployments add a second layer of defense. |
| Stale invite token replay | Invitation tokens are single-use, scoped by email, and expire by default; the API rejects reuse with a structured error. |
npm auditis run during CI on every push.- Dependabot opens weekly PRs for transitive and direct npm updates; please review and merge them once CI is green.
- Container base images (
node,nginx,mysql) are pinned by tag in the compose files; bump them in a dedicated PR so the change is easy to revert.