Skip to content

Security Model

wody edited this page May 24, 2026 · 13 revisions

Security Model

vibe-coder is a single-user LAN tool. The threat model assumes:

  • The host PC is trusted (you own it).
  • The LAN is trusted (your home/office network behind a firewall).
  • The operator is trusted (you).
  • Public internet attackers cannot reach the server (no port forwarding, no public IP exposure).

If any of those assumptions fail, you need a reverse proxy with TLS, a WAF, and a higher-grade auth layer — beyond the scope of vibe-coder MVP.

Authentication

Bearer token + session cookie

POST /api/auth/login returns a token. Clients can pass it as Authorization: Bearer <token> or as a vibe_session cookie. Both paths converge in installAuth (Ktor plugin).

client → POST /api/auth/login {username, password}
       → 200 {token, deviceId, serverName, username}
       ← stores token in DataStore / cookie

client → GET /api/projects
       Header: Authorization: Bearer <token>
       → 200 [...]

Password storage

  • BCrypt cost 12 (configurable in server.yml).
  • Stored as hash only. The plaintext password is never persisted.
  • Timing-safe dummy verify runs on missing users (prevents enumeration).

Lock-out (v0.12.4+)

Two-layer brute-force defence:

  • Account lock: 10 consecutive /api/auth/login failures lock the account for 15 minutes.
  • IP block: 30 failures from a single IP within a 24-hour window block that IP for 24 hours. Catches credential-stuffing across multiple accounts (the per-account counter would otherwise reset between targets).

Both counters are in-memory (AuthService.failures / ipFailures); server restart clears them. The tradeoff is intentional — a server restart is itself a security-relevant event the operator initiates.

Timing-safe dummy verify runs on missing users so response time matches a real BCrypt verification (~250 ms at cost 12).

Two-factor authentication (v0.26.0+)

TOTP (RFC 6238, HMAC-SHA1, 30 s period, 6 digits) — Google Authenticator compatible, self-implemented (no external dependencies). Enable at /2fa. When active:

  1. Client posts username + password to /api/auth/login.
  2. Server returns 401 totp_required (audited as a normal step, not a failure).
  3. Client prompts user for Authenticator code, retries with totpCode added.
  4. Server verifies (window ±1 for clock drift), then issues the token.

Wrong code returns 401 invalid_totp and does count against the brute-force counters. See Two-Factor Auth for the full enrollment flow + lost-device recovery.

Multi-user role guard (v0.37.0 / v0.40.0 / v0.45.0 / v0.47.0)

admin_users.role splits accounts into admin / member / viewer. The first admin (created at /setup) is always admin; new users created via /users default to member.

SSR helpers

  • requireAdminOrRedirect(sess) — used at /users, /audit, /settings (GET + POST), /backup, /backup/download, and (v0.47.0) /settings/email, /settings/webhook, /settings/cors, /settings/git-integrations, /settings/cache. Member or viewer get redirected to dashboard.
  • requireWriteAccessOrRedirect(sess) (v0.40.0+) — blocks viewers from destructive SSR POST endpoints: project create / delete, console new / slash, build enqueue, files upload, edit, git commit, agents save / delete.

/2fa is intentionally open to all roles (personal security setting). /emulator/vnc/* (v0.42.0+) is admin-only on both the HTTP proxy and the WebSocket handshake — the WS path verifies the vibe_session cookie → device.userId → user.isAdmin before forwarding any frame.

Lockout protection — the /users form refuses to demote or delete the last admin account, and refuses self-deletion regardless of admin count.

JSON API + WebSocket role guards (v0.45.0)

The Bearer token path gained matching enforcement so a viewer / member can't bypass SSR redirects by calling REST directly.

installAuth(..., userRepo = adminUserRepo) now resolves device.userId to the user's role on every request, exposed via DevicePrincipal.userRole plus isAdmin / canWrite helpers.

Two ApplicationCall extensions:

  • call.requireApiWrite() — viewer Bearer gets 403 viewer_readonly. Applied to mutating REST endpoints:
    • POST /api/projects (register), DELETE /api/projects/{id}
    • POST /api/projects/{id}/build/debug, POST /api/projects/{id}/builds/{buildId}/cancel
    • POST /api/projects/{id}/git/commit
    • POST /api/projects/{id}/files/upload, DELETE /api/projects/{id}/files/{fileId}
    • POST /api/projects/{id}/claude/console/{prompt, new, cancel}
    • POST /api/projects/{id}/actions/invoke
    • All sub-agent prompt / cancel / new (v0.44.0+)
    • All Web Push subscribe / delete (v0.46.0+)
  • call.requireApiAdmin() — non-admin Bearer gets 403 admin_only. Applied to server-level setup endpoints:
    • POST /api/env-setup/install-all, POST /api/env-setup/install/{componentId}
    • POST /api/env-setup/claude-auth/{upload, api-key, delete}, POST /api/env-setup/claude-login/{start, submit, cancel}
    • POST /api/env-setup/mcp/{install, unregister, upload/...}
    • POST /api/git/integrations (register / delete / ssh-keygen)

WebSocket — at handshake the server does the same device.userId → user.role lookup and stores a viewerOnly flag per session. Incoming UserPrompt and ActionInvoke frames check it and short-circuit with WsFrame.Error("viewer_readonly", ...). The connection stays open — viewers keep receiving live frames, they just can't write.

See Users & Roles for the full role model and lockout protections.

Session idle timeout (v0.26.0+)

security.sessionIdleTimeoutMinutes (default 30, 0 = unlimited). Both AuthPlugin (Bearer) and SSR requireSessionOrRedirect check device.lastSeenAt on every request. If older than the threshold:

  • The device row is deleted (token immediately invalid for everyone).
  • SSR redirects to /login?err=session_timeout.
  • JSON API returns 401 unauthorized with no body (Bearer flow has no user-visible message channel).
  • Audit row: auth.session.timeout.

Combined with 2FA this gives "phishing-resistant once enrolled, fresh 2FA prompt after each idle period" without a "remember me" cookie.

CSRF protection (v0.12.4+)

Every SSR POST carries a hidden _csrf input. The token is derived from the device cookie via HMAC-SHA256 with a server-side pepper (rotated on restart). REST API (Bearer header, not cookie) is exempt — Authorization headers can't be auto-attached by a cross-origin page.

Multipart uploads (/projects/{id}/files/upload, MCP secret file, etc.) carry _csrf in the query string since receiveParameters is unavailable.

See CsrfTokens.kt. Reject with HTTP 403 + audit log.

WebSocket Origin check (v0.12.4+)

WS handshake reads Origin and rejects mismatched hosts as a defense-in-depth against cross-site WebSocket hijacking — even though SameSite=Lax already prevents the cookie from attaching cross-site in the typical browser.

First-boot bootstrap

Two paths:

  1. Visit /setup in a browser → fill the form.
  2. Set VIBECODER_ADMIN_USERNAME and VIBECODER_ADMIN_PASSWORD env vars before first docker compose up -d. The entrypoint hands them to the server; if no admin exists yet, it auto-creates one.

After auto-bootstrap, change the password via /password so the plaintext doesn't linger in .env.

Path safety

Every disk read/write goes through PathSafety.normalizeAndCheck, which:

  1. Resolves .. and symlinks.
  2. Checks the result is a descendant of WorkspacePath.root (or .vibecoder/).
  3. Rejects with ApiException(400, "path_traversal", ...) if not.

Direct consequence: a malicious project name or file path cannot escape the workspace. Claude itself, when asked to "edit ../../../etc/passwd", hits the same check and reports failure to the user.

Upload blacklist

workspace:
  uploadDeniedExtensions:
    - exe
    - bat
    - cmd
    - ps1
    - sh

Configurable in server.yml. The default list blocks Windows scripts + shell scripts that could be accidentally executed by other tools.

WebSocket authentication

Auth happens via the first frame, not URL query:

{ "type": "auth", "token": "<token>" }

Sent within 5 s of connection, else server closes. This avoids leaking the token to access logs, proxies, or browser history.

Sandboxing

  • All Claude / Gradle / Git / npm child processes run as the unprivileged vibe user (UID/GID matched to host via PUID/PGID).
  • vibe has NOPASSWD sudo inside the container — but only inside the container. The container itself has no access to the host filesystem except the bind mounts in ./vibe-coder-data/.
  • No raw shell endpoint. There's no /api/exec or web terminal.
  • The script -q PTY wrap used for the Claude semi-automatic OAuth exposes only a single one-line text input field (the OAuth code), not a full shell.

Process lifecycle

  • All external commands have hard timeouts (5 min default for short ops, 10 min for git clone, 30 min for builds).
  • Cancellation calls Process.destroyForcibly() after destroy() waits 5 s.
  • Background tasks (build / install / clone) emit Done(status=…) over WebSocket on completion, including TIMEOUT/CANCELED states. Clients can always tell.

Network exposure

The container listens on 0.0.0.0:17880 inside its network. Compose maps it to ${VIBE_PORT:-17880} on the host. Do NOT forward this port to the public internet. If you need remote access:

  • VPN into your LAN (Tailscale / WireGuard).
  • Or expose only through a reverse proxy with HTTPS termination + auth.

External build-trigger webhook auth (v0.33.0+)

POST /api/webhooks/build/{projectId} is admin-auth-free to allow external systems (GitHub Actions, CI, monitoring) to fire builds. It requires three headers instead:

  • X-Vibe-Secret-Id — picks the right row from build_webhook_secrets.
  • X-Vibe-Secret — plaintext secret transmitted over the wire. Server computes SHA-256(secret) and compares to the stored hash with constant-time equality.
  • X-Vibe-Signature — optional HMAC-SHA256(secret, body) hex. When present and the body is non-empty, the server also verifies. Gives body integrity even when TLS terminates upstream.

The secret value is shown once on /projects/{id}/automation right after creation (yellow card, persisted only as a SHA-256 hash). Lost secrets must be rotated by deleting + recreating.

This design transmits the plaintext secret on every call, which means a reverse proxy with HTTPS is mandatory when exposing the webhook outside LAN. The roadmap tracks a stricter HMAC-only mode where the server stores the plaintext encrypted with a server pepper.

Webhook SSRF defense (v0.27.0+)

WebhookNotifier accepts only three host families:

  • Slack: hooks.slack.com/*
  • Discord: discord.com/api/webhooks/* or discordapp.com/api/webhooks/*
  • Telegram: api.telegram.org reached via a bot token matching ^\d+:[A-Za-z0-9_-]+$

HttpClient is built with Redirect.NEVER so a 30x response can't bounce the request to an internal host. There is no way to point a webhook at an arbitrary URL from the /settings/webhook form — by design. If you need a custom collector, build an adapter that subscribes to the audit log instead.

noVNC mirror (:full image, v0.25.0+)

The :full variant exposes the in-container emulator's screen via websockify on port 6080. The port is not wrapped by vibe-coder auth — it's a raw VNC gateway intended for LAN-only access or SSH tunnels:

ssh -L 6080:localhost:6080 user@vibe-host
# then http://localhost:6080/vnc.html in your local browser

Combined with /dev/kvm passthrough this also gives the container a direct path to a host kernel interface — single-operator trusted host only. See Android Emulator for the full setup.

A server-side reverse proxy with cookie auth is on the v0.26+ roadmap to let this work safely on a public-IP host.

Whitelist file edit (v0.32.0+)

/projects/{id}/env-files exposes only a hard-coded whitelist (7 files including local.properties, .env, build.gradle.kts, etc.). Any other path returns invalid file:. This prevents the form from being turned into a path-traversal lever — a user who can edit the form markup still can't write outside the whitelist because the server validates rel against the ENV_FILES_WHITELIST constant.

Backup contents (v0.34.0+)

/backup/download streams a tar.gz that includes Claude OAuth credentials (they live under vibe-coder-data/claude/ which is inside the workspace tree). The credentials are already encrypted by Claude CLI at rest, but treat the resulting tar as as sensitive as the credentials themselves — don't commit it to git, don't put it in a shared bucket without server-side encryption.

PostgreSQL data dir is excluded (page-tear risk on live dir copy); use the pg_dump command rendered on the page instead.

Audit log (v0.15.0+)

Every IAM-level action (logins, password changes, 2FA enable/disable, session timeout, project create/delete, build queue/cancel, MCP install, settings save, git token lifecycle, git commit, console new/cancel, emulator AVD lifecycle, Play/TestFlight upload trigger, agent save/delete, schedule create/delete, webhook secret create/delete, external webhook build trigger) is recorded into audit_log with timestamp, user, IP, result, and an optional JSON detail. View at /audit (left nav: 감사 로그).

See the Audit Log page for the schema, recorded actions, URL filter recipes, and rotation guidance. Failure to write an audit entry never blocks the user's request — it logs to stderr only.

Secrets in .env

.env is in .gitignore so it never reaches git, but it sits on disk in plaintext. Lock its permissions:

chmod 0600 ~/vibe-coder/.env

If you set VIBECODER_ADMIN_PASSWORD here for auto-bootstrap, rotate it via /password after first login, then either remove it from .env or accept the risk that someone with file read access can see it.

Reporting security issues

Open a private security advisory on GitHub. Do not file public issues.

Clone this wiki locally