285 lines
12 KiB
Markdown
285 lines
12 KiB
Markdown
# 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`.
|