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

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

```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:
   ```bash
   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:

```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=<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):

```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=<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:
   ```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
<?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**:

```bash
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:
```bash
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`.
