Files
hermes-config/skills/self-hosting/docker-service-deployment/references/kasmvnc-chrome.md
T
2026-07-12 10:17:17 -04:00

12 KiB

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

docker run -d \
  --name chrome-vnc \
  --restart unless-stopped \
  --shm-size=2g \
  -p 127.0.0.1:6901:6901 \
  -e VNC_PW=<password> \
  -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

server {
    listen <PORT> 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

server {
    server_name browser.graj-media.com;
    location / {
        proxy_pass https://100.93.253.36:<PORT>;
        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:
    sudo iptables -I INPUT -i docker0 -p tcp --dport <PORT> -j ACCEPT
    
    Then access services from inside Chrome at https://host.docker.internal:<PORT>. 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:

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=<new_password> \
  -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:<port> — 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):

# 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:

limit_req_zone $binary_remote_addr zone=kasmvnc:10m rate=5r/m;

Apply in the server block (before location):

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:

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:
    docker run -d \
      --name chrome-vnc \
      --restart unless-stopped \
      --shm-size=2g \
      -p 127.0.0.1:6901:6901 \
      -e VNC_PW=<password> \
      -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:

    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:

# 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
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<FileZilla3>
    <Servers>
        <Server>
            <Host>example.com</Host>
            <Port>22</Port>
            <Protocol>1</Protocol>
            <Type>0</Type>
            <User>username</User>
            <Pass encoding="base64">$PASS_B64</Pass>
            <Logontype>1</Logontype>
            <Name>Seedbox (SFTP)</Name>
            <PasvMode>0</PasvMode>
        </Server>
    </Servers>
</FileZilla3>
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/<config> and nginx doesn't pick up the change even after a reload, check whether sites-enabled/<config> is a symlink or a separate copy:

ls -la /etc/nginx/sites-enabled/<config>
# 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:

sudo rm /etc/nginx/sites-enabled/<config>
sudo ln -s /etc/nginx/sites-available/<config> /etc/nginx/sites-enabled/<config>
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.