Moltbot(Clawdbot) security

Securing Your Moltbot: Moving the Control UI Behind Tailscale

TL;DR: If you’re running Moltbot on a VPS, your admin control panel is probably exposed to the internet. Here’s how to lock it down using Tailscale Funnel while keeping your Telegram bot working.


The Problem I Discovered

I was setting up network hardening on my Moltbot VPS when I realized something uncomfortable: I could access the Moltbot control UI from my laptop – a laptop that wasn’t even on my Tailscale network.

Wait, what?

I’d gone through the trouble of setting up Tailscale, configuring UFW to block everything except the Tailscale interface, and feeling pretty good about my security posture. But there was a catch: port 50473 was left open for “webhooks.”

The problem? The Moltbot gateway serves both webhooks AND the control UI on the same port. So while I thought I was just exposing a webhook endpoint, I was actually exposing the entire admin interface to anyone who could find my server’s IP.

Understanding the Attack Surface

Here’s what was exposed:

  • Control UI – Full admin access to the bot
  • Gateway API – WebSocket connections for managing sessions
  • Configuration – Ability to view and modify bot settings

Anyone who discovered my server’s IP could access the full admin interface.

The Solution: Localhost + Tailscale Funnel

The fix involves four key changes:

  1. Bind Docker ports to localhost only – The container can’t be reached from external interfaces
  2. Configure trustedProxies – So Moltbot recognizes Tailscale connections
  3. Use Tailscale Funnel – Provides HTTPS access with automatic TLS certificates
  4. Remove the public firewall rule – No more port 50473 exposed to the internet

Step 1: Update docker-compose.yml

The critical change is in the ports section:

ports:
  # Before (vulnerable):
  - "${PORT}:18789"

  # After (secure):
  - "127.0.0.1:${PORT}:18789"

That 127.0.0.1: prefix means Docker only binds to localhost, not all interfaces. This single change blocks all direct public access.

Step 2: Configure trustedProxies

Here’s a gotcha that took a while to figure out: when you put Moltbot behind Tailscale, it sees connections coming from the Docker network (172.x.x.x) with forwarded headers from Tailscale (100.x.x.x). By default, Moltbot doesn’t trust these proxy headers, which causes authentication failures.

The fix is to add trustedProxies to your gateway config:

{
  "gateway": {
    "trustedProxies": ["127.0.0.1", "172.16.0.0/12", "100.64.0.0/10"],
    "auth": {
      "mode": "token",
      "token": "YOUR_GATEWAY_TOKEN"
    },
    "controlUi": {
      "allowInsecureAuth": true
    }
  }
}

The ranges cover:

  • 127.0.0.1 – Localhost
  • 172.16.0.0/12 – Docker networks
  • 100.64.0.0/10 – Tailscale CGNAT range

Important: Keep allowInsecureAuth: true. The security now comes from the localhost binding and Tailscale, not from Moltbot’s auth layer. Setting it to false causes “pairing required” errors when behind a proxy.

Step 3: Update the Config in Docker Volume

Another gotcha: Moltbot’s config persists in a Docker volume. If you update docker-compose.yml, the startup script writes to the config file, but Moltbot merges it with the existing config in the volume. To ensure your changes take effect:

# Stop the container
cd /docker/clawdbot-ii5q && docker compose down

# Edit the config directly in the volume
cat > /var/lib/docker/volumes/clawdbot-ii5q_clawdbot_config/_data/clawdbot.json << 'EOF'
{
  "gateway": {
    "mode": "local",
    "trustedProxies": ["127.0.0.1", "172.16.0.0/12", "100.64.0.0/10"],
    "auth": {
      "mode": "token",
      "token": "YOUR_GATEWAY_TOKEN"
    },
    "controlUi": {
      "allowInsecureAuth": true
    }
  },
  "channels": {
    "telegram": {
      "enabled": true,
      "botToken": "YOUR_TELEGRAM_BOT_TOKEN",
      "dmPolicy": "open",
      "allowFrom": ["*"]
    }
  }
}
EOF

# Start the container
docker compose up -d

Step 4: Set Up Tailscale Funnel

The Moltbot control UI requires a secure context – either HTTPS or localhost. A plain HTTP connection won’t work; you’ll get a WebSocket error:

disconnected (1008): control ui requires HTTPS or localhost (secure context)

Tailscale Funnel provides HTTPS with automatic TLS certificates and makes the service accessible from anywhere (not just your Tailscale network):

tailscale funnel --bg --https=443 http://127.0.0.1:50473

This gives you a URL like:

https://your-hostname.tailnet-name.ts.net/

Why Funnel instead of Serve? Tailscale Serve only works from devices on your Tailscale network. Funnel makes the endpoint publicly accessible – but the security comes from your gateway token, and the public can no longer access the raw port on your server.

Step 5: Close the Public Port

ufw delete allow 50473/tcp

The firewall now only allows traffic on the Tailscale interface:

Status: active

To                         Action      From
--                         ------      ----
Anywhere on tailscale0     ALLOW       Anywhere

What About Telegram?

You might wonder: if the port is closed, how do webhooks work?

They don’t – and that’s fine. Moltbot uses polling by default, not webhooks. The bot makes outbound connections to Telegram’s API to check for new messages. No inbound port needed.

If you previously set a webhook (like I mistakenly did), delete it:

curl "https://api.telegram.org/botYOUR_TOKEN/deleteWebhook"

Then restart the container. Moltbot will automatically switch to polling mode.

The Result

Before:

Internet --> 76.13.23.47:50473 --> Control UI (EXPOSED)

After:

Internet --> 76.13.23.47:50473 --> BLOCKED (localhost binding)

Internet --> https://hostname.ts.net/?token=xxx --> Tailscale Funnel --> localhost:50473 --> Control UI

To access the control UI, you now need:

  1. The Tailscale Funnel HTTPS URL
  2. Your gateway token in the URL

Access via tokenized URL:

https://your-hostname.tailnet-name.ts.net/?token=YOUR_GATEWAY_TOKEN

The public IP no longer exposes anything on port 50473.

Testing the Fix

Quick verification:

# Should timeout/refuse (good - public port blocked)
curl --connect-timeout 5 http://YOUR_PUBLIC_IP:50473

# Should return HTML (Funnel working)
curl https://your-hostname.tailnet-name.ts.net/

# Check Docker is bound to localhost only
docker ps --format "{{.Ports}}"
# Should show: 127.0.0.1:50473->18789/tcp

Gotchas I Encountered

1. “pairing required” Error

If you see disconnected (1008): pairing required, check:

  • trustedProxies includes Docker and Tailscale ranges
  • allowInsecureAuth is true

2. “Proxy headers detected from untrusted address”

This log warning means trustedProxies isn’t configured correctly. Add the ranges listed above.

3. Config Not Updating

Moltbot’s config persists in a Docker volume. Stop the container, edit the volume directly, then restart.

4. Tailscale Serve vs Funnel

  • Serve = Tailscale network only (requires Tailscale on your device)
  • Funnel = Public internet via Tailscale infrastructure

Use Funnel if you want to access from devices without Tailscale.

Lessons Learned

  1. Audit what’s actually exposed – I assumed “webhook port” meant minimal exposure. It didn’t.
  2. Localhost binding is powerful – A simple 127.0.0.1: prefix in Docker completely changes the security model.
  3. Trust your proxies – When putting services behind reverse proxies, configure trustedProxies or authentication will break.
  4. Config persistence matters – Docker volumes retain config across restarts. Edit the volume directly for persistent changes.
  5. Tailscale Funnel is underrated – It elegantly provides HTTPS access to localhost services without exposing ports.

If you’re running Moltbot or any self-hosted service on a VPS, take a few minutes to audit what’s actually reachable from the internet. You might be surprised.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *