Skip to content

security(desktop): authenticate to VNC server-side, close the VITE_* password leak (#16299) - #17069

Merged
mrveiss merged 11 commits into
mainfrom
issue-16299-vnc-credentials
Sep 19, 2026
Merged

mrveiss merged 11 commits into
mainfrom
issue-16299-vnc-credentials

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

#16299 (guessable default credentials) names VITE_DESKTOP_VNC_PASSWORD/VITE_TERMINAL_VNC_PASSWORD/VITE_PLAYWRIGHT_VNC_PASSWORD as compiled into every browser's bundle — Vite inlines every import.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's websocket_proxy is 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:

  1. Initially assumed the backend could read the VNC password off a local file (/etc/autobot/vnc-secrets.env / the x11vnc passwd file the ansible vnc role already writes). Traced the actual topology (NetworkConstants.MAIN_MACHINE_IP/BACKEND_API show 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 existing POST /api/v2/secrets/system route (already AUTOBOT_INTERNAL_API_KEY-authenticated) instead.
  2. Traced whether "browser" VNC is actually wired through the vnc ansible role at all — tasks/register-credentials.yml's own vnc_type: "desktop" is hardcoded, and no playbook applies this role for the browser node_role today. Rather than fork the mechanism by host, parameterized the new registration task by a vnc_type role default (mirrors api.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:

  • RFC 6143 §7.2.2 (password truncate/pad to 8 bytes).
  • LibVNCServer's src/common/d3des.c — its own comment documents the bytebit[] 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 identical cryptography.hazmat.decrepit.ciphers.algorithms.TripleDES primitive already available in this repo's pinned cryptography>=50.0.1. Its reverse_bits was 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's TripleDES(key*3) in ECB mode was independently verified against the canonical FIPS DES test vector (key 133457799BBCDFF1 → ciphertext 85E813540F0AB405, 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) and offer_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): adapts websocket_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_password reads the secret via SecretsCoordinator at connection time — fails closed (raises, never falls back to unauthenticated) when nothing is registered.
  • autobot-backend/api/vnc_proxy.py: websocket_proxy now calls the handshake before starting the relay; a VncAuthError closes 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 via POST /api/v2/secrets/system using the internal API key already read from slm-secrets.env. Flagged for reviewer confirmation: reaches the backend via {{ main_host }}:{{ backend_port }}, mirroring NetworkConstants.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: new vnc_type: "desktop" default, naming the secret this role registers.
  • Frontend (AppConfig.js, ssot-config.ts, defaults.js, useChatStore.ts, ssot-config.spec.ts): every password field/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). VNCConfig interface 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/.js source files asserting none reference a VITE_*_VNC_PASSWORD env var — a source-level guard (this environment has no node_modules to 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

$ python3 -m pytest autobot-backend/security/vnc_rfb_auth_test.py autobot-backend/api/vnc_handshake_bridge_test.py autobot-backend/api/vnc_proxy_handshake_wiring_test.py autobot-backend/api/vnc_proxy_control_lock_test.py autobot-backend/api/vnc_proxy_websocket_auth_test.py repo_tests/vnc_password_not_in_frontend_source_16299_test.py repo_tests/python_file_size_ratchet_test.py repo_tests/guard_reach_meta_test.py -q
83 passed
$ ansible-playbook --syntax-check autobot-slm-backend/ansible/setup-user-backend.yml
no errors (full task-graph parse, not just YAML)

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

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

  • Tests added/updated and passing locally
  • Changelog fragment added
  • No hardcoded values introduced
  • Commit message follows <type>(scope): <description> (#issue)

…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
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: cf90c5fd-e509-4215-a678-afcfb3024480

📥 Commits

Reviewing files that changed from the base of the PR and between 88ee31c and 36862bc.

📒 Files selected for processing (20)
  • .secrets.baseline
  • autobot-backend/api/vnc_handshake_bridge.py
  • autobot-backend/api/vnc_handshake_bridge_test.py
  • autobot-backend/api/vnc_proxy.py
  • autobot-backend/api/vnc_proxy_handshake_wiring_test.py
  • autobot-backend/security/vnc_rfb_auth.py
  • autobot-backend/security/vnc_rfb_auth_test.py
  • autobot-frontend/src/config/AppConfig.js
  • autobot-frontend/src/config/__tests__/ssot-config.spec.ts
  • autobot-frontend/src/config/defaults.js
  • autobot-frontend/src/config/ssot-config.ts
  • autobot-frontend/src/stores/useChatStore.ts
  • autobot-slm-backend/ansible/roles/vnc/defaults/main.yml
  • autobot-slm-backend/ansible/roles/vnc/tasks/main.yml
  • autobot-slm-backend/ansible/roles/vnc/tasks/register-vnc-password.yml
  • autobot-slm-backend/ansible/setup-user-backend.yml
  • changelog/unreleased/16299-vnc-server-side-auth.md
  • repo_tests/glob_declared_reads_15900_test.py
  • repo_tests/vnc_ansible_secrets_registration_16299_test.py
  • repo_tests/vnc_password_not_in_frontend_source_16299_test.py

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

…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
@mrveiss mrveiss added the land-next Landing set: CI capacity goes to these PRs first (owner direction 2026-09-18, #15397) label Sep 18, 2026
…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.
@mrveiss
mrveiss merged commit e523ed7 into main Sep 19, 2026
35 of 36 checks passed
@mrveiss
mrveiss deleted the issue-16299-vnc-credentials branch September 19, 2026 05:00
@mrveiss mrveiss removed the land-next Landing set: CI capacity goes to these PRs first (owner direction 2026-09-18, #15397) label Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant