Repository navigation
security(desktop): authenticate to VNC server-side, close the VITE_* password leak (#16299) - #17069
Conversation
…password leak (#16299) VITE_DESKTOP_VNC_PASSWORD/VITE_TERMINAL_VNC_PASSWORD/VITE_PLAYWRIGHT_VNC_PASSWORD were compiled into every browser's bundle, since noVNC's client performed the RFB VNC-Authentication (DES challenge-response) itself using that value. Vite compiles every import.meta.env.VITE_* reference into the built bundle regardless of whether the surrounding code path is reachable at runtime. api.vnc_proxy's websocket_proxy now performs the RFB handshake itself (security/vnc_rfb_auth.py, api/vnc_handshake_bridge.py): answers the real VNC server's password challenge server-side, then offers the browser security-type "None" -- noVNC never sees or needs a password. A handshake failure on either leg closes the socket (1011) before any relay starts; there is no fallback to an unauthenticated or partially-authenticated connection. The DES/RFB algorithm was verified against three independent sources before writing this, not derived from memory: RFC 6143 (password truncate/pad to 8 bytes), LibVNCServer's d3des.c (confirms the classic per-byte bit-reversal quirk via its own "bytebit[] has been reversed" comment), and vncdotool's modern Python implementation (uses the identical cryptography.hazmat.decrepit.ciphers.algorithms.TripleDES primitive, cross-checked byte-for-byte and end-to-end against this implementation). cryptography's TripleDES(key*3) in ECB mode was independently verified against the canonical FIPS DES test vector. The password is provisioned once (generate-on-first-run, same shape as the rest of #16299) by the existing ansible vnc role, which already generates a real random password for the VNC server itself (roles/vnc/tasks/main.yml:182-192) -- previously used only for the local x11vnc/vncserver password files. Registers that same password in the canonical secrets system (POST /api/v2/secrets/system, the existing AUTOBOT_INTERNAL_API_KEY-authenticated route) rather than a parallel plaintext-file path, per the owner's secrets-management rule. The backend reads it back at connection time via SecretsCoordinator and fails closed when it's missing. One mechanism for both VNC types (desktop/browser), keyed by vnc_type rather than host, per the owner's "one PR, one agent, files a service touches" rule -- register-credentials.yml's existing vnc_type: "desktop" is the only value this role has ever been invoked with; a future browser-VM invocation only needs to set vnc_type. Filed as follow-ups, not fixed here: #17054 (the desktop WebSocket admits any signed-in user, not just admin/owner -- an access-control policy question, not this credential leak), #17055 (both VNC launch paths bind the RFB port on every interface, needs a decision on whether direct-client access is relied on), #17059 (docker-compose deployments have no VNC password provisioning at all -- a missing feature, separate from this leak fix). Refs #16299
|
Warning Review limit reachedNext included review available in 12 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (20)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
…ntext transit, protocol, no_log
Independent review found the registration task was gated on the LOCAL
vnc-secrets.env marker file, which password generation creates before the
registration task ever runs. A first attempt that fails for any reason --
most notably a fresh host where this role's registration can run before
the backend's secrets-table migration does -- is never retried on any
later run, permanently breaking VNC on that host until someone manually
POSTs the password by hand. Fixed by gating on an actual read-back of the
secrets vault instead (does "vnc-password-{{ vnc_type }}" already exist?),
with retry/wait logic on both the read-back and the registration POST for
a backend that may not be ready yet.
Also: the registration POST goes over plain HTTP (backend_port has no TLS
listener) -- safe for the co-located desktop host this role is actually
invoked for today, but the PR claims one mechanism for both VNC types, and
the browser VM is genuinely cross-host. Added an explicit refusal
(ansible.builtin.fail) for any vnc_type other than "desktop" rather than
silently sending the password in cleartext once "browser" gets wired.
security/vnc_rfb_auth.py: offer_no_auth_as_server accepted any RFB 3.7+
browser and always sent a SecurityResult after security-type None, but RFC
6143 Appendix A version-gates that message -- 3.8 sends one, 3.3/3.7 don't.
A real 3.7 client would desync. Rather than track two behaviors for a
client this proxy has no real reason to support (noVNC has spoken 3.8 for
years), a pre-3.8 offer is now refused outright. Regression test proves it.
The pre-existing "Read VNC password from secrets file" task lacked
no_log: true; harmless before this PR since nothing consumed its plaintext
stdout, but #16299 added the registration task as a real consumer. Added.
Also fixes a stale reference an independent reviewer found: AppConfig.js's
getVncUrl() read vncConfig.password, a field this PR already removed a few
lines above -- would have serialized a literal "password=undefined" into
the generated VNC URL. Unlike useChatStore.ts's getDesktopUrl (genuinely
DORMANT, no callers), getVncUrl is live (DesktopInterface.vue,
PopoutChromiumBrowser.vue) -- neither caller passes options.password, so
removing the param entirely is safe.
Refs #16299
) Round 2 review: the retry logic from the previous commit didn't fix the ordering problem, it made it worse. setup-user-backend.yml runs roles: (which includes the vnc role) BEFORE post_tasks: (which runs the backend's secrets-table migration). On a fresh host the vault table cannot exist while the vnc role's inline registration task runs, however long it retries -- the retries just delay a uri task failure that still aborts the whole play before post_tasks, including the migrations themselves, ever run. A fresh host could then never finish provisioning at all, not just lose VNC. Fixed by splitting the registration logic into its own tasks file (roles/vnc/tasks/register-vnc-password.yml -- named to avoid the repo's *secret.yml gitignore pattern) called from two places: - tasks/main.yml calls it best-effort (block/rescue, mirroring the existing "Register VNC credentials in SLM" pattern in this same file) for every OTHER playbook that includes this role on an already-provisioned, already-migrated host: deploy.yml, playbooks/deploy_role.yml, playbooks/enroll-node.yml, playbooks/provision-fleet-roles.yml -- none of these run migrations. - setup-user-backend.yml ALSO calls it directly from its own post_tasks, after the migration sequence and after confirming the backend is up (the existing health check) -- the one playbook where the roles:-phase attempt cannot succeed on a fresh host. register-vnc-password.yml's own vault-read-back check (not a local marker file) makes this second, later call safe regardless of whether the first one ran, skipped, or failed. Also wrapped in block/rescue: VNC registration failing must never fail the overall provisioning run. The non-desktop refusal (sending the password in cleartext to a genuinely remote host) stays OUTSIDE both best-effort blocks -- it is a real misconfiguration, not a migration-timing flake, and must hard-fail the play rather than degrade into a rescued debug message. Also: backend_port | default(8001) was a second, redundant hardcoded port literal. Removed the default -- roles/backend/defaults/main.yml's backend_port is the single source of truth, and an explicit assert now fails clearly if it's ever undefined instead of silently guessing. A new guard (repo_tests/vnc_ansible_secrets_registration_16299_test.py) encodes the play order itself, not just the presence of retry logic -- verified it actually catches a swapped order before relying on it. Refs #16299
…the expected one (#16299) Round 2's block/rescue around the VNC password registration call was wrong in the opposite direction from round 1's bug: rescuing every failure into a debug line is only correct for the ONE call site genuinely expected to fail (setup-user-backend.yml's roles:-phase call, which runs before this same play's own migration sequence). On every other playbook that includes this role (deploy.yml, playbooks/deploy_role.yml, playbooks/enroll-node.yml, playbooks/provision-fleet-roles.yml -- none run migrations, so the vault already exists) and in setup-user-backend.yml's OWN post_tasks call (after migrations complete), a failure is real: a wrong API key, the backend actually being down. Rescuing it there just makes VNC fail closed with nothing but a log line nobody reads. Fixed with an explicit vnc_defer_vault_registration role var (default false, defaults/main.yml). setup-user-backend.yml sets it true for the vnc role, so tasks/main.yml skips the registration call entirely there -- nothing to rescue, logged as deferred instead -- and calls register-vnc-password.yml again, unrescued, from its own post_tasks after migrations. Every other playbook leaves the flag false, so tasks/main.yml's call runs for real there too, unrescued: a genuine failure now fails the play, as it should. The non-desktop-cleartext refusal was already outside both rescue blocks and stays a hard, unconditional failure. Dropped the "register the secret by hand via POST ..." recovery language -- there is no manual recovery path; re-running the playbook through the builtin updater is the only supported one. Refs #16299
…he mandated DES-ECB bandit finding (#16299) .secrets.baseline: the whole-tree secret scan flagged vnc_handshake_bridge_test.py:118 (a fixed 16-byte test fixture, not a real credential) and register-vnc-password.yml:74 (the literal field name "vnc_password" naming the secret's TYPE, not a value) as unaudited. Added both as is_secret: false, computed via detect-secrets' own PotentialSecret hasher and cross-checked against a throwaway scan -- not touching any other file's existing entries. vnc_rfb_auth.py:81: bandit's B305 (ECB mode) fires on the DES-ECB cipher RFB VNC Authentication mandates by specification (RFC 6143 7.2.2, two independent 8-byte blocks under the same key) -- not a weakness to fix, the protocol itself. Suppressed with a specific nosec code and the protocol citation, cipher mode unchanged.
…dit's flagged line (#16299) check_nosec_format.py rejects the dash-separated form (bandit reads every following word as a test ID); the two-comment form is required. But once that comment pushed the line over 120 chars, black re-wrapped the statement across 3 lines, moving modes.ECB() (bandit's reported line) onto a different physical line than the nosec comment on .encryptor() -- bandit suppresses per-line, not per-logical-statement, so the finding came back unsuppressed despite the comment being present. Verified directly: suppressed=1 with everything on one line, suppressed=0 once black re-split it. Split the Cipher(...) construction into its own short statement instead, which both fits under 120 chars unwrapped and puts the nosec on the exact line bandit flags.
…-password guard (#16299) repo_tests/vnc_password_not_in_frontend_source_16299_test.py declares autobot-frontend/src/*.js|*.ts|*.vue globs the python filter doesn't cover (#15900's own uncovered-glob sweep). Recorded in GLOB_DECLARED_UNCOVERED so the dependency is visible rather than silently unrun when that tree changes.
Thinking Path
#16299 (guessable default credentials) names
VITE_DESKTOP_VNC_PASSWORD/VITE_TERMINAL_VNC_PASSWORD/VITE_PLAYWRIGHT_VNC_PASSWORDas compiled into every browser's bundle — Vite inlines everyimport.meta.env.VITE_*reference into the built output regardless of whether the surrounding code path is reachable at runtime, so this is a real leak even though the consuming code (AppConfig.js,useChatStore.ts) is marked DORMANT (#1130, preserved for #5136).Traced the actual architecture before designing a fix:
api.vnc_proxy'swebsocket_proxyis a pure byte-level relay with zero RFB-protocol awareness — the VNC password challenge-response currently happens client-side, in the browser's noVNC JS, using the leaked value. Owner ruling (ratified across several messages on #16299): the backend answers the real server's challenge itself and offers the browser security-type "None", so noVNC never sees or needs a password.Two real bugs found by exploring the actual deployment topology before assuming the naive design would work, not by trusting the initial framing:
/etc/autobot/vnc-secrets.env/ the x11vnc passwd file the ansiblevncrole already writes). Traced the actual topology (NetworkConstants.MAIN_MACHINE_IP/BACKEND_APIshow the backend and the "desktop" VNC type are co-located, but "browser" is a separate VM) and confirmed with the owner: a plaintext env-file read would be a parallel credential path, forbidden by the canonical-secrets-manager rule. Redesigned to register the password through the existingPOST /api/v2/secrets/systemroute (alreadyAUTOBOT_INTERNAL_API_KEY-authenticated) instead.vncansible role at all —tasks/register-credentials.yml's ownvnc_type: "desktop"is hardcoded, and no playbook applies this role for thebrowsernode_role today. Rather than fork the mechanism by host, parameterized the new registration task by avnc_typerole default (mirrorsapi.vnc_proxy.VNC_ENDPOINTS's own keys exactly) so the same task works for a future browser invocation with zero changes — one mechanism, no desktop/browser split, per the owner's "one PR, one agent, per file-set touched" rule. Also found and filed security(vnc): docker-compose deployments have no VNC password provisioning at all #17059: docker-compose deployments have no VNC password provisioning at all (a different, separate gap).The DES/RFB algorithm itself was verified against three independent sources before writing a line of it, not derived from memory:
src/common/d3des.c— its own comment documents thebytebit[]table "has been reversed" from standard DES, confirming the classic VNC per-byte bit-reversal quirk is real, not folklore.vncdotool's modern Python client (vncdotool/rfb.py) — uses the identicalcryptography.hazmat.decrepit.ciphers.algorithms.TripleDESprimitive already available in this repo's pinnedcryptography>=50.0.1. Itsreverse_bitswas cross-checked byte-for-byte against this implementation's across all 256 values, and the full password→key→response pipeline cross-checked end-to-end across 6 password shapes × 5 random challenges.cryptography'sTripleDES(key*3)in ECB mode was independently verified against the canonical FIPS DES test vector (key133457799BBCDFF1→ ciphertext85E813540F0AB405, exact match) before relying on it.What Changed
autobot-backend/security/vnc_rfb_auth.py(new): the RFB handshake state machine —authenticate_as_client(answers the real server's VNC-Auth challenge, refuses to proceed if the server doesn't offer VNC Auth at all) andoffer_no_auth_as_server(offers the browser security-type "None" only). Fails closed on any protocol mismatch; never includes the password in an exception message.autobot-backend/api/vnc_handshake_bridge.py(new): adaptswebsocket_proxy's two real connections (an aiohttp WebSocket to the real VNC server, a FastAPI WebSocket to the browser) to the handshake module's byte-stream interface, buffering across WS message boundaries (RFB byte boundaries don't align with WS frame boundaries) and surfacing any leftover bytes so the plain relay doesn't drop the start of the framebuffer protocol.get_vnc_passwordreads the secret viaSecretsCoordinatorat connection time — fails closed (raises, never falls back to unauthenticated) when nothing is registered.autobot-backend/api/vnc_proxy.py:websocket_proxynow calls the handshake before starting the relay; aVncAuthErrorcloses the socket (1011) before either forwarding task starts.autobot-slm-backend/ansible/roles/vnc/tasks/main.yml: after generating the VNC password (already existing,openssl rand-based, gated first-run-only), registers it viaPOST /api/v2/secrets/systemusing the internal API key already read fromslm-secrets.env. Flagged for reviewer confirmation: reaches the backend via{{ main_host }}:{{ backend_port }}, mirroringNetworkConstants.BACKEND_API's Python-side equivalent —backend_port's own comment elsewhere says "loopback only", true for the co-located desktop host, unconfirmed for the browser VM's cross-host case.autobot-slm-backend/ansible/roles/vnc/defaults/main.yml: newvnc_type: "desktop"default, naming the secret this role registers.AppConfig.js,ssot-config.ts,defaults.js,useChatStore.ts,ssot-config.spec.ts): everypasswordfield/env-var reference removed from the VNC config paths (all in code explicitly marked DORMANT — preserved for feat(browser): region-marking + AI-assisted scraping templates on InteractiveScreenshot #5136, not deleted).VNCConfiginterface updated to match.repo_tests/vnc_password_not_in_frontend_source_16299_test.py(new): a_reach-floored sweep of all 1,411 frontend.vue/.ts/.jssource files asserting none reference aVITE_*_VNC_PASSWORDenv var — a source-level guard (this environment has nonode_modulesto run an actual build) that is a strictly necessary precondition for the string never reaching a built bundle. Verified it actually fails on a reintroduced reference before relying on it.Out of scope, filed separately (per the owner's explicit direction this PR must not silently absorb them):
Verification
No live VNC server or ansible target host is available in this environment. The DES/RFB crypto and protocol logic is proven correct against fake in-memory peers implementing exactly RFC 6143's wire format (not just self-consistent — cross-checked against an independent reference implementation, see Thinking Path), including a negative control (an upstream server offering only "None" is refused) and a fail-closed test (missing secret refuses the connection). The WS-message-boundary buffering is proven with fake WebSocket doubles that deliberately split reads and leave one extra byte past the handshake boundary on each leg.
websocket_proxy's wiring (handshake failure → close 1011 before any relay; success → relay starts with the right arguments) is proven against the real route object with the aiohttp/FastAPI session chain mocked. No frontend build tooling (node_modules) is available locally; the source-level guard is the strongest check available in this environment and would need a CI build-output grep to additionally verify (flagged, not added — no workflow config change made).Model Used
Claude Sonnet 5
Acceptance Criteria
VITE_*variable carries a VNC credential; auth moves server-side (api/vnc_handshake_bridge.py) — the browser never holds or sends a password.env*template edits, pending the owner'sprotect-files.shpatch; the secrets-baseline guard and generate-on-first-run pieces) are tracked in separate PRs on security: guessable default credentials ship in templates, ansible defaults and the compose file #16299 — not this PRSingle-issue rationale
This PR closes no issue -- #16299 has several independent AC slices already split across separate PRs (this one, #17037's guessable-defaults slice, and the pending
.env*/protect-files piece), and #17054/#17055/#17059 are explicitly out of scope, filed separately per the owner's direction not to silently absorb them. A security-critical, from-scratch protocol implementation (new DES/RFB module, four review rounds) is also large and risky enough on its own that batching it with unrelated issues would make review harder, not easier.Issue Link
Refs #16299 (one of several slices; #16299 stays open until every AC is met)
Refs #17054, #17055, #17059 (scoped out, see What Changed)
Changelog Fragment
changelog/unreleased/16299-vnc-server-side-auth.md(type: security, scope: desktop)Checklist
<type>(scope): <description> (#issue)