-
Notifications
You must be signed in to change notification settings - Fork 1
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.
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 [...]
- BCrypt cost 12 (configurable in
server.yml). - Stored as hash only. The plaintext password is never persisted.
- Timing-safe
dummy verifyruns on missing users (prevents enumeration).
Two-layer brute-force defence:
-
Account lock: 10 consecutive
/api/auth/loginfailures 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).
TOTP (RFC 6238, HMAC-SHA1, 30 s period, 6 digits) — Google Authenticator
compatible, self-implemented (no external dependencies). Enable at
/2fa. When active:
- Client posts
username + passwordto/api/auth/login. - Server returns
401 totp_required(audited as a normal step, not a failure). - Client prompts user for Authenticator code, retries with
totpCodeadded. - Server verifies (window
±1for 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.
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.
-
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 SSRPOSTendpoints: 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.
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 gets403 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 gets403 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.
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 unauthorizedwith 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.
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.
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.
Two paths:
- Visit
/setupin a browser → fill the form. - Set
VIBECODER_ADMIN_USERNAMEandVIBECODER_ADMIN_PASSWORDenv vars before firstdocker 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.
Every disk read/write goes through PathSafety.normalizeAndCheck, which:
- Resolves
..and symlinks. - Checks the result is a descendant of
WorkspacePath.root(or.vibecoder/). - 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.
workspace:
uploadDeniedExtensions:
- exe
- bat
- cmd
- ps1
- shConfigurable in server.yml. The default list blocks Windows scripts +
shell scripts that could be accidentally executed by other tools.
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.
- All Claude / Gradle / Git / npm child processes run as the unprivileged
vibeuser (UID/GID matched to host viaPUID/PGID). -
vibehas NOPASSWDsudoinside 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/execor web terminal. - The
script -qPTY wrap used for the Claude semi-automatic OAuth exposes only a single one-line text input field (the OAuth code), not a full shell.
- 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()afterdestroy()waits 5 s. - Background tasks (build / install / clone) emit
Done(status=…)over WebSocket on completion, including TIMEOUT/CANCELED states. Clients can always tell.
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.
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 frombuild_webhook_secrets. -
X-Vibe-Secret— plaintext secret transmitted over the wire. Server computesSHA-256(secret)and compares to the stored hash with constant-time equality. -
X-Vibe-Signature— optionalHMAC-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.
WebhookNotifier accepts only three host families:
- Slack:
hooks.slack.com/* - Discord:
discord.com/api/webhooks/*ordiscordapp.com/api/webhooks/* - Telegram:
api.telegram.orgreached 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.
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 browserCombined 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.
/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/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.
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.
.env is in .gitignore so it never reaches git, but it sits on disk
in plaintext. Lock its permissions:
chmod 0600 ~/vibe-coder/.envIf 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.
Open a private security advisory on GitHub. Do not file public issues.