Skip to content

Security Model

Sia edited this page May 31, 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

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).

Rate limit

Per-IP token bucket in front of /api/, /ws/, and /login paths. Admin sessions / Bearer tokens bypass. Disabled if security.rateLimit.enabled = false (handy behind nginx / Cloudflare). See Rate Limit for the threat model and metric integration.

WebAuthn (passkey)

webauthn4j 0.29.1 implements registration + assertion verification. Same-origin signature defeats the phishing class of attacks that TOTP remains vulnerable to. Both methods are available concurrently — the login page exposes "🔑 Passkey 로 로그인" next to the password form.

  • Registration ceremony at /webauthn (any authenticated user). The attestation object is persisted whole (base64url CBOR) so the assertion path can rebuild a CredentialRecord via the 4-arg CredentialRecordImpl(AttestationObject, ...) constructor.
  • Assertion ceremony at POST /api/webauthn/assert/{options,verify} — unauthenticated (it's the login itself). On success the server mints a fresh device row + Bearer token + vibe_session cookie.
  • Challenge state is in-memory (5-minute TTL). A restart cancels any in-flight ceremony — single-user dev server context.
  • server.webauthn.{rpId, rpName, origin} config must match the hostname users browse to. See WebAuthn (Passkey) for the full setup playbook.
  • Audit: auth.passkey.register, auth.passkey.login, auth.passkey.delete.

Two-factor authentication

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.

Single-admin model

vibe-coder-server is a single-operator tool. There are no user roles, no /users management UI, and no per-user Project ACL.

  • There is exactly one admin (created at /setup). Every authenticated session — cookie or Bearer — has full access to every endpoint and every project.
  • The only access boundary is authentication itself: password, 2FA, passkey, idle timeout, and brute-force / IP lockout.

Session idle timeout

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

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

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

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

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.

No VNC / emulator surface

There is no in-container emulator, no noVNC websockify gateway, and no /dev/kvm privileged-container path — so there is no raw VNC port to secure. To test on a real device use wireless ADB.

Whitelist file edit

/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

/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

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, 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