# KasmVNC Chrome Browser (Browser-in-Browser) Deploy a full Chrome browser accessible via web browser (no VNC client needed) using `kasmweb/chrome`. ## Architecture - Docker container: `kasmweb/chrome:1.16.0` - KasmVNC serves its own built-in noVNC web UI on port **6901** (HTTPS with self-signed cert) - No separate web server needed — Xvnc process runs `-httpd /usr/share/kasmvnc/www` internally - Auth: HTTP Basic Auth, username `kasm_user`, password set via `VNC_PW` env var - Chrome runs inside the container with `--shm-size=2g` (required) ## Deployment ```bash docker run -d \ --name chrome-vnc \ --restart unless-stopped \ --shm-size=2g \ -p 127.0.0.1:6901:6901 \ -e VNC_PW= \ -e LANG=en_US.UTF-8 \ -v /mnt/seagate8tb/docker/chrome-vnc:/home/kasm-user \ kasmweb/chrome:1.16.0 ``` ## Nginx Reverse Proxy CRITICAL: KasmVNC sends `Cross-Origin-Embedder-Policy: require-corp` and `Cross-Origin-Opener-Policy: same-origin` headers that break browser rendering when the page is proxied. These MUST be stripped on EVERY nginx layer in the chain (home server + VPS). ### Home server nginx ```nginx server { listen ssl; server_name grajmedia.duckdns.org browser.graj-media.com; # ... SSL cert config ... location / { proxy_pass https://127.0.0.1:6901; proxy_http_version 1.1; proxy_ssl_verify off; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400; proxy_buffering off; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; } } ``` ### VPS nginx ```nginx server { server_name browser.graj-media.com; location / { proxy_pass https://100.93.253.36:; proxy_http_version 1.1; proxy_ssl_verify off; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400; proxy_buffering off; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; } # ... SSL ... } ``` ## Pitfalls 1. **Port mapping confusion**: Do NOT map to 4902 or other internal service ports. Map `6901:6901` — KasmVNC's built-in HTTP server runs on 6901 alongside the VNC server. 2. **COEP/COOP headers**: If the page shows "This page isn't working" or a blank screen, check that `proxy_hide_header` directives are present on ALL nginx layers. These headers are set by KasmVNC internally. 3. **Must use `proxy_ssl_verify off`** — KasmVNC uses a self-signed cert internally. 4. **WebSocket upgrade required** — noVNC uses WebSockets for the VNC connection. 5. **DO NOT use `--network host`**: KasmVNC detects the host X display (`/tmp/.X11-unix/X0`) and fails with "Authorization required, but no authorization protocol specified". This is a fatal error — the container will not serve the web UI. Use bridge networking instead. 6. **Bridge → host access**: Docker's default bridge network isolates containers from the host. If the remote Chrome needs to access host services (PocketBase, Immich, HA, etc.), use `--add-host=host.docker.internal:host-gateway` and add an iptables rule: ```bash sudo iptables -I INPUT -i docker0 -p tcp --dport -j ACCEPT ``` Then access services from inside Chrome at `https://host.docker.internal:`. This rule does NOT survive reboots — make it persistent via `iptables-persistent` or a systemd unit. ## Password change Recreate the container with a new `VNC_PW` value: ```bash docker stop chrome-vnc && docker rm chrome-vnc docker run -d --name chrome-vnc --restart unless-stopped --shm-size=2g \ -p 127.0.0.1:6901:6901 \ -e VNC_PW= \ -e LANG=en_US.UTF-8 \ -v /mnt/seagate8tb/docker/chrome-vnc:/home/kasm-user \ kasmweb/chrome:1.16.0 ``` The `/home/kasm-user` volume preserves Chrome profile data across recreates. ## Security Assessment When auditing or hardening a browser.graj-media.com-style deployment (KasmVNC + Chrome behind nginx + VPS reverse proxy), check these areas: ### Firewall Posture (STRONG) - UFW restricts port to LAN (192.168.50.0/24) and Tailscale (100.64.0.0/10, fd7a::/48) only - Public DNS points to VPS proxy, not the home server IP directly - External traffic traverses VPS → Tailscale encrypted tunnel → home server - **This is the single strongest defense** — even if KasmVNC had an 0-day, the network is unreachable from the internet ### Authentication (MODERATE) - Single auth layer: KasmVNC web UI username + password (set via `VNC_PW`) - No nginx-level `auth_basic` for defense-in-depth - **Recommendation**: Add nginx HTTP Basic Auth as a second factor. Even simple shared credentials at the nginx layer prevent unauthenticated scans from reaching the KasmVNC login prompt. ### Host Networking Risk (CRITICAL if used) When running with `--network host` (as opposed to bridge): - The Chrome browser has full network-level access to the host machine - All Docker services (Immich, PocketBase, Paperless, HA, Mealie, etc.) are reachable from inside the browser at `localhost:` — no additional auth required beyond what the service itself has - A malicious site visited in the remote browser could attempt DNS rebinding or CSRF against host services - **Recommendation**: Prefer bridge networking with `--add-host=host.docker.internal:host-gateway` + iptables rules for specific host port access. See "Bridge → host access" section above. ### Missing Security Headers (nginx) No security headers were set on the nginx proxy. Add them at the **location block** level (they do NOT take effect at the server level when `proxy_pass` is in a nested location): ```nginx # Home server nginx location block: location / { add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), midi=(), sync-xhr=(), clipboard-read=(), clipboard-write=()" always; # ... existing proxy_pass directives ... } ``` **Important**: `add_header` directives in the `server` block may be silently dropped when a `location` block with `proxy_pass` is present. Put them inside the `location` block for guaranteed effect. Use the `always` parameter to include them on error responses (4xx/5xx) too. KasmVNC already sends COEP/COOP headers that must be hidden (already in the config above). CSP is tricky with noVNC since it needs inline scripts and WebSocket connections — test before adding. ### No Rate Limiting No `limit_req` on the nginx endpoint. An attacker with credentials can brute-force the KasmVNC login without throttling. **Add zone to nginx.conf http block:** ```nginx limit_req_zone $binary_remote_addr zone=kasmvnc:10m rate=5r/m; ``` **Apply in the server block (before location):** ```nginx limit_req zone=kasmvnc burst=5 nodelay; ``` ### No fail2ban The VPS proxy lacks brute-force protection beyond nginx's rate limiter. Install fail2ban and create a jail for nginx HTTP auth failures — note it watches **error.log**, not access.log: ```bash sudo apt-get install -y fail2ban sudo tee /etc/fail2ban/jail.d/nginx-http-auth.conf << 'EOF' [nginx-http-auth] enabled = true port = http,https filter = nginx-http-auth logpath = /var/log/nginx/error.log maxretry = 5 bantime = 3600 findtime = 300 EOF sudo systemctl restart fail2ban ``` ### Old Base OS - Image built August 2025 (~11 months old as of July 2026) - KasmVNC server 1.2.0, Chrome 138.0.7204.183 (current) - **Recommendation**: Periodically pull a fresh `kasmweb/chrome` image to get updated base OS packages. The Chrome binary updates within the container but system libraries don't. ### Default Container Capabilities - No `--cap-drop` flags — container runs with default Docker capabilities - **Recommendation**: Drop the most dangerous capabilities. Chrome needs CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID for its sandbox, but does NOT need SYS_ADMIN, SYS_PTRACE, SYS_BOOT, SYS_MODULE, NET_ADMIN, SYS_RAWIO, SYS_TIME, or similar high-risk caps. Apply when recreating: ```bash docker run -d \ --name chrome-vnc \ --restart unless-stopped \ --shm-size=2g \ -p 127.0.0.1:6901:6901 \ -e VNC_PW= \ -e LANG=en_US.UTF-8 \ -v /mnt/seagate8tb/docker/chrome-vnc:/home/kasm-user \ --cap-drop=SETPCAP \ --cap-drop=SYS_ADMIN \ --cap-drop=SYS_PTRACE \ --cap-drop=SYS_BOOT \ --cap-drop=SYS_MODULE \ --cap-drop=NET_ADMIN \ --cap-drop=SYS_RAWIO \ --cap-drop=SYS_TIME \ --cap-drop=SYSLOG \ --cap-drop=AUDIT_CONTROL \ --cap-drop=MKNOD \ --cap-drop=SETFCAP \ --cap-drop=NET_RAW \ --cap-drop=IPC_LOCK \ kasmweb/chrome:1.16.0 ``` Verify with: `docker inspect chrome-vnc --format 'CapDrop: {{.HostConfig.CapDrop}}'` ### DOCKER-USER Firewall Blocks Container Outbound Internet (Not Just Ports) The host iptables DOCKER-USER chain restricts Docker containers broadly. Managed by `docker-iptables-restrict.service` (at `/home/ray/docker/docker-iptables-restrict.sh`), the rules allow LAN, Tailscale, and established connections — everything else is DROPPED. **Two failure modes:** 1. **"No internet at all"** — the container can't reach ANY external IP. Root cause: the DOCKER-USER chain allows traffic from LAN (192.168.50.0/24) and Tailscale (100.64.0.0/10) but has **no egress rule for Docker bridge subnets**. New outgoing connections from bridge containers hit the DROP. Fix: ```bash sudo iptables -I DOCKER-USER -o enp2s0 -s 172.16.0.0/12 -j ACCEPT ``` Add the same rule to `/home/ray/docker/docker-iptables-restrict.sh` using its idempotency pattern. 2. **"Specific ports blocked"** — container has internet (HTTP/HTTPS work) but SFTP (22), FTP (21), or other ports time out. This is the classic FileZilla scenario. Fix: Add a per-container exception to DOCKER-USER + persist in the script. ### FileZilla Setup Install and configure FileZilla in the Kasm container for FTP/SFTP transfers: ```bash # Install docker exec -u root chrome-vnc apt-get install -y filezilla # Launch docker exec -u kasm-user -d chrome-vnc sh -c "DISPLAY=:1 filezilla" # Configure site via sitemanager.xml PASS_B64=$(echo -n "password" | base64) docker exec -i -u kasm-user chrome-vnc tee /home/kasm-user/.config/filezilla/sitemanager.xml > /dev/null << FZEOF example.com 22 1 0 username $PASS_B64 1 Seedbox (SFTP) 0 FZEOF ``` If FileZilla was running when the XML was written, restart it: `docker exec -u kasm-user chrome-vnc pkill filezilla` then relaunch. ### Pitfall: nginx `sites-enabled` version skew If you edit `sites-available/` and nginx doesn't pick up the change even after a reload, check whether `sites-enabled/` is a **symlink** or a **separate copy**: ```bash ls -la /etc/nginx/sites-enabled/ # Want: lrwxrwxrwx (symlink to sites-available) # Have: -rw-r--r-- (separate file — changes to sites-available are ignored) ``` If it's a copy, replace it with a symlink: ```bash sudo rm /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx ``` This can happen when configs were manually deployed or migrated from another setup rather than created fresh via `ln -sf`.