diff --git a/README.md b/README.md index 251c2321..844e0c53 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,11 @@ If a mounted custom JSON already defines `models.providers["openai-compatible"]` | Variable | Default | Description | |---|---|---| -| `AUTH_PASSWORD` | *(none)* | If set, nginx enforces HTTP basic auth on all routes except `/healthz` and the hooks path (when hooks are enabled). If unset, no auth — gateway is open. | +| `AUTH_PASSWORD` | *(none)* | If set, nginx enforces HTTP basic auth on all routes except `/healthz`, the hooks path (when hooks are enabled), and `Authorization: Bearer` requests to the Control UI routes the gateway itself token-checks. If unset, the generated nginx config is unchanged and no auth is required. | | `AUTH_USERNAME` | `admin` | Username for basic auth. | +When `AUTH_PASSWORD` is set, the Control UI's own `Authorization: Bearer` fetches (device token, gateway token, or password) would otherwise replace the browser's Basic credentials and make nginx re-prompt. nginx skips basic auth only for those exact paths, forwards the client's `Authorization` header, and does not append its own `token=` query parameter. `/`, the WebSocket, and `/browser/` stay on basic auth. An invalid or empty Bearer token is rejected by the gateway. + ### Gateway | Variable | Default | Description | @@ -185,6 +187,8 @@ If a mounted custom JSON already defines `models.providers["openai-compatible"]` | `OPENCLAW_GATEWAY_TOKEN` | *(auto-generated)* | Bearer token for gateway auth. Auto-generated and persisted to `/gateway.token` if not set. | | `OPENCLAW_GATEWAY_PORT` | `18789` | Internal port the gateway binds to. | | `OPENCLAW_GATEWAY_BIND` | `loopback` | Gateway bind mode. `loopback` = 127.0.0.1 only (nginx proxies LAN traffic). `lan` = 0.0.0.0 (direct access, bypasses nginx auth). Also: `tailnet`, `auto`, `custom`. | + +`configure.js` sets `gateway.trustedProxies` to `127.0.0.1` and `::1` when the key is not already in the config. nginx overwrites `X-Forwarded-For` with the connecting client when basic auth is on, so OpenClaw's auth rate limit sees that client instead of treating everyone as loopback (which is exempt from lockout). This does not enable trusted-proxy authentication; `gateway.auth.mode` stays `token`. Set your own `gateway.trustedProxies` in custom JSON to override. With Docker port publishing, that client is often the bridge address shared by every visitor, and current OpenClaw builds may ask for a one-time device pairing for a non-loopback address. | `OPENCLAW_STATE_DIR` | `/data/.openclaw` | Persistent state directory. Mount a volume here. | | `OPENCLAW_WORKSPACE_DIR` | `/data/workspace` | Workspace directory for openclaw projects. | | `OPENCLAW_CONFIG_PATH` | `/openclaw.json` | Override path to the config file. | diff --git a/scripts/configure.js b/scripts/configure.js index d05e0919..64f8f616 100644 --- a/scripts/configure.js +++ b/scripts/configure.js @@ -132,6 +132,17 @@ if (config.gateway.bind === undefined) { config.gateway.bind = process.env.OPENCLAW_GATEWAY_BIND || "loopback"; } +// nginx is the only process that should connect to the loopback gateway, so +// every browser otherwise shares 127.0.0.1. OpenClaw exempts loopback from +// auth lockout. Trust that hop and let nginx set X-Forwarded-For to the TCP +// peer (entrypoint.sh) so failures are counted per client. This does not +// change gateway.auth.mode — token auth stays in place. A list already set +// by custom JSON or a previous run is kept (trusted-proxy deployments). +if (!Array.isArray(config.gateway.trustedProxies)) { + config.gateway.trustedProxies = ["127.0.0.1", "::1"]; + console.log("[configure] gateway.trustedProxies: 127.0.0.1, ::1 (nginx)"); +} + // ── Agents defaults ───────────────────────────────────────────────────────── ensure(config, "agents", "defaults"); diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index c43ccfde..ec2ba343 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -156,11 +156,105 @@ AUTH_USERNAME="${AUTH_USERNAME:-admin}" NGINX_CONF="/etc/nginx/conf.d/openclaw.conf" AUTH_BLOCK="" +BROWSER_AUTH_BLOCK="" +# Bearer-aware pieces. Empty unless basic auth is on, so the no-auth path below +# generates a byte-identical config to before. +BEARER_MAPS="" +UPSTREAM_AUTH="\"Bearer ${GATEWAY_TOKEN}\"" +PROXY_ARGS_MAP="map \"\$ocw_has_token:\$args\" \$ocw_proxy_args { + ~^1: \$args; + ~^0:.+ \"\$args&token=${GATEWAY_TOKEN}\"; + default \"token=${GATEWAY_TOKEN}\"; +}" +# Inserted directly into the unquoted nginx heredoc (expanded once, not rescanned). +XFF_DIRECT='$proxy_add_x_forwarded_for' +# Hooks block is expanded again by that heredoc, so its dollar signs stay escaped. +HOOKS_XFF='\$proxy_add_x_forwarded_for' + if [ -n "$AUTH_PASSWORD" ]; then echo "[entrypoint] setting up nginx basic auth (user: $AUTH_USERNAME)" htpasswd -bc /etc/nginx/.htpasswd "$AUTH_USERNAME" "$AUTH_PASSWORD" 2>/dev/null - AUTH_BLOCK='auth_basic "Openclaw"; + # /browser/ never consults the bearer map. It is the VNC sidecar, not a gateway route. + BROWSER_AUTH_BLOCK='auth_basic "Openclaw"; + auth_basic_user_file /etc/nginx/.htpasswd;' + AUTH_BLOCK='auth_basic $ocw_auth_realm; auth_basic_user_file /etc/nginx/.htpasswd;' + + # The Control UI authenticates to the gateway with `Authorization: Bearer + # ` (a per-device token, the stored gateway token, or the password). + # A request carries only one Authorization header, so that Bearer replaces the + # browser's Basic credentials, auth_basic returns 401 + WWW-Authenticate, and + # the browser prompts again. Skip basic auth only for the exact paths the UI + # fetches that way, and only when the header is Bearer, then forward the + # client's header unchanged so the gateway — not nginx — accepts or rejects it. + # + # "/" and the WebSocket stay on basic auth: the gateway serves the UI shell + # without its own token check, so a bogus Bearer there must not walk past nginx. + # Every pattern is anchored. Prefixes such as /api/ or /__openclaw__/ are not + # used, and /rpc is not included: gateway RPC is the WebSocket, and an + # unanchored ^/rpc match would also exempt /rpcanything. + # + # $ocw_path is the raw request path with the query stripped, so percent-encoding + # stays intact (catalog icons encode slashes) and ?token= cannot change the match. + BEARER_MAPS="map \$http_authorization \$ocw_is_bearer { + default 0; + \"~*^Bearer\$\" 1; + \"~*^Bearer[[:space:]]\" 1; +} + +map \$request_uri \$ocw_path { + \"~^([^?]*)\" \$1; + default \$uri; +} + +map \$ocw_path \$ocw_is_gateway_route { + default 0; + \"~^/control-ui-config\\.json\$\" 1; + \"~^/__openclaw__/control-ui-config\\.json\$\" 1; + \"~^/api/users/[^/]+/avatar\$\" 1; + \"~^/avatar/[^/]+\$\" 1; + \"~^/__openclaw__/assistant-media\$\" 1; + \"~^/__openclaw__/workspace-icon/[^/]+\$\" 1; + \"~^/__openclaw__/plugin-icon/[^/]+\$\" 1; + \"~^/__openclaw__/plugin-activity-icon/[^/]+\$\" 1; + \"~^/__openclaw__/catalog-icon/[^/]+\$\" 1; + \"~^/__openclaw__/link-favicon/[^/]+\$\" 1; + \"~^/__openclaw__/channel-avatar/[^/]+\$\" 1; + \"~^/__openclaw__/plugin-theme-art/[^/]+/[^/]+/[^/]+/[^/]+\$\" 1; + \"~^/__openclaw__/plugins/control-ui/[^/]+/[0-9a-fA-F]{64}/.+\$\" 1; + \"~^/api/chat/media/outgoing/[^/]+/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/(full|thumbnail)\$\" 1; +} + +map \"\$ocw_is_bearer\$ocw_is_gateway_route\" \$ocw_bearer_passthru { + default 0; + \"11\" 1; +} + +map \$ocw_bearer_passthru \$ocw_auth_realm { + default \"Openclaw\"; + 1 \"off\"; +} + +map \$ocw_bearer_passthru \$ocw_upstream_auth { + default \"Bearer ${GATEWAY_TOKEN}\"; + 1 \$http_authorization; +} + +" + UPSTREAM_AUTH="\$ocw_upstream_auth" + # Never append nginx's own token to a passthrough request either, or the + # carve-out would hand out authenticated access instead of delegating it. + PROXY_ARGS_MAP="map \"\$ocw_bearer_passthru:\$ocw_has_token:\$args\" \$ocw_proxy_args { + \"~^1:[01]:(?.*)\$\" \$ocw_bargs; + \"~^0:1:\" \$args; + \"~^0:0:.+\" \"\$args&token=${GATEWAY_TOKEN}\"; + default \"token=${GATEWAY_TOKEN}\"; +}" + # Overwrite, do not append. The gateway trusts 127.0.0.1 and reads the + # rightmost untrusted X-Forwarded-For hop; a client-supplied value must not + # be that hop when the TCP peer is loopback. + XFF_DIRECT='$remote_addr' + HOOKS_XFF='\$remote_addr' else echo "[entrypoint] no AUTH_PASSWORD set, nginx will not require authentication" fi @@ -174,7 +268,7 @@ if [ -n "$HOOKS_PATH" ]; then proxy_set_header Host \\\$host; proxy_set_header X-Real-IP \\\$remote_addr; - proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For ${HOOKS_XFF}; proxy_set_header X-Forwarded-Proto \\\$scheme; proxy_http_version 1.1; @@ -230,11 +324,7 @@ map \$arg_token \$ocw_has_token { default 1; } -map "\$ocw_has_token:\$args" \$ocw_proxy_args { - ~^1: \$args; - ~^0:.+ "\$args&token=${GATEWAY_TOKEN}"; - default "token=${GATEWAY_TOKEN}"; -} +${BEARER_MAPS}${PROXY_ARGS_MAP} server { listen ${PORT:-8080} default_server; @@ -260,11 +350,11 @@ server { ${AUTH_BLOCK} proxy_pass http://127.0.0.1:${GATEWAY_PORT}\$uri?\$ocw_proxy_args; - proxy_set_header Authorization "Bearer ${GATEWAY_TOKEN}"; + proxy_set_header Authorization ${UPSTREAM_AUTH}; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For ${XFF_DIRECT}; proxy_set_header X-Forwarded-Proto \$scheme; proxy_http_version 1.1; @@ -284,7 +374,7 @@ server { # Browser sidecar proxy (VNC web UI) location /browser/ { - ${AUTH_BLOCK} + ${BROWSER_AUTH_BLOCK} proxy_pass http://browser:3000/; proxy_set_header Host \$host; diff --git a/scripts/nginx-bearer.test.js b/scripts/nginx-bearer.test.js new file mode 100644 index 00000000..261f1e77 --- /dev/null +++ b/scripts/nginx-bearer.test.js @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * Renders the nginx config the entrypoint would write, without starting nginx + * or the gateway. Checks the bearer bypass is anchored to gateway-checked + * routes, and that AUTH_PASSWORD unset stays byte-identical to main. + * + * Run: node scripts/nginx-bearer.test.js + */ + +const { execFileSync } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const ENTRYPOINT = path.join(ROOT, "scripts", "entrypoint.sh"); + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${label}`); + } +} + +function render(entrypoint, env) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ocw-nginx-")); + const conf = path.join(dir, "openclaw.conf"); + const script = fs.readFileSync(entrypoint, "utf8"); + const start = script.indexOf("# ── Generate nginx config"); + const end = script.indexOf("# ── Start nginx"); + if (start < 0 || end < 0) { + throw new Error(`nginx section markers missing in ${entrypoint}`); + } + const body = script + .slice(start, end) + .replace( + 'NGINX_CONF="/etc/nginx/conf.d/openclaw.conf"', + `NGINX_CONF=${JSON.stringify(conf)}`, + ) + .replaceAll("/etc/nginx/.htpasswd", path.join(dir, "htpasswd")) + .replaceAll("/usr/share/nginx/html", path.join(dir, "nginx-html")); + const harness = ` +set -euo pipefail +GATEWAY_TOKEN="\${OPENCLAW_GATEWAY_TOKEN}" +GATEWAY_PORT="\${OPENCLAW_GATEWAY_PORT:-18789}" +HOOKS_PATH="\${HOOKS_PATH:-}" +mkdir -p ${JSON.stringify(dir)}/html +# startup page writes under /usr/share; redirect that mkdir by shadowing nothing — +# the section uses absolute /usr/share/nginx/html, so stub it if unwritable. +if [ ! -w /usr/share/nginx/html ] 2>/dev/null; then + mkdir -p ${JSON.stringify(dir)}/nginx-html + # The heredoc target is fixed. Provide a writable stand-in via a function override + # by rewriting the two absolute paths before execution. + true +fi +${body.replaceAll("/usr/share/nginx/html", path.join(dir, "nginx-html"))} +`; + execFileSync("bash", ["-c", harness], { + env: { + ...process.env, + PATH: process.env.PATH, + OPENCLAW_GATEWAY_TOKEN: "gateway-token-test", + AUTH_PASSWORD: "", + AUTH_USERNAME: "admin", + PORT: "8080", + HOOKS_PATH: "", + ...env, + }, + }); + return fs.readFileSync(conf, "utf8"); +} + +execFileSync("bash", ["-n", ENTRYPOINT]); +assert(true, "entrypoint.sh passes bash -n"); + +// Generated per run. A fixed password literal in this file is flagged by secret scanners. +const authPassword = crypto.randomBytes(18).toString("base64url"); +const withAuth = render(ENTRYPOINT, { AUTH_PASSWORD: authPassword }); +const noAuth = render(ENTRYPOINT, { AUTH_PASSWORD: "" }); + +assert(!withAuth.includes("~^/api/\""), "no broad /api/ prefix"); +assert(!withAuth.includes("~^/__openclaw__/\""), "no broad /__openclaw__/ prefix"); +assert(!/~\^\/rpc[^\"]/.test(withAuth), "no unanchored /rpc bypass"); +assert(withAuth.includes("~^/control-ui-config\\.json$"), "bootstrap config route anchored"); +assert( + withAuth.includes("~^/__openclaw__/control-ui-config\\.json$"), + "namespaced bootstrap config route anchored", +); +assert(withAuth.includes("~^/api/users/[^/]+/avatar$"), "user avatar route anchored"); +assert(withAuth.includes("~^/__openclaw__/assistant-media$"), "assistant media route anchored"); +assert(withAuth.includes("~^/__openclaw__/workspace-icon/[^/]+$"), "workspace icon route anchored"); +assert(withAuth.includes("~^/__openclaw__/plugin-icon/[^/]+$"), "plugin icon route anchored"); +assert( + withAuth.includes("~^/api/chat/media/outgoing/"), + "chat outgoing media route present", +); + +const routeBlock = withAuth.slice( + withAuth.indexOf("map $ocw_path $ocw_is_gateway_route"), + withAuth.indexOf("map \"$ocw_is_bearer$ocw_is_gateway_route\""), +); +for (const line of routeBlock.split("\n")) { + const match = line.match(/"(~[^"]+)"/); + if (!match) continue; + assert(match[1].startsWith("~^"), `route regex anchored at start: ${match[1]}`); + assert(match[1].endsWith("$"), `route regex anchored at end: ${match[1]}`); +} + +assert(withAuth.includes("1 $http_authorization;"), "passthrough forwards client Authorization"); +assert( + !withAuth.includes('proxy_set_header Authorization "Bearer gateway-token-test";'), + "location / does not hardcode the gateway token when basic auth is on", +); +assert(withAuth.includes("proxy_set_header Authorization $ocw_upstream_auth;"), "upstream auth is the map"); +assert(withAuth.includes('location /browser/') && withAuth.includes('auth_basic "Openclaw";'), "browser keeps literal basic auth"); +assert( + withAuth.indexOf('auth_basic "Openclaw";') > withAuth.indexOf("location /browser/"), + "literal basic realm belongs to the browser location", +); +assert(withAuth.includes("proxy_set_header X-Forwarded-For $remote_addr;"), "gateway hop overwrites X-Forwarded-For"); +assert( + withAuth.includes("proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"), + "browser sidecar still appends X-Forwarded-For", +); + +assert(!noAuth.includes("ocw_is_bearer"), "no bearer map when AUTH_PASSWORD is unset"); +assert(!noAuth.includes("ocw_bearer_passthru"), "no passthrough map when AUTH_PASSWORD is unset"); +assert(!noAuth.includes("auth_basic"), "no basic auth when AUTH_PASSWORD is unset"); +assert( + noAuth.includes('proxy_set_header Authorization "Bearer gateway-token-test";'), + "unset password still injects the gateway token", +); +assert( + noAuth.includes("proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"), + "unset password keeps the previous X-Forwarded-For", +); + +const mainEntrypoint = execFileSync("git", ["show", "origin/main:scripts/entrypoint.sh"], { + cwd: ROOT, + encoding: "utf8", +}); +const mainFile = path.join(os.tmpdir(), "entrypoint-main.sh"); +fs.writeFileSync(mainFile, mainEntrypoint); +const mainNoAuth = render(mainFile, { AUTH_PASSWORD: "" }); +function normalizeRendered(text) { + return text.replace(/\/tmp\/ocw-nginx-[A-Za-z0-9]+/g, "/tmp/ocw-nginx-TEST"); +} +const noAuthNorm = normalizeRendered(noAuth); +const mainNoAuthNorm = normalizeRendered(mainNoAuth); +assert(noAuthNorm === mainNoAuthNorm, "AUTH_PASSWORD unset nginx config is byte-identical to main"); +if (noAuthNorm !== mainNoAuthNorm) { + const a = noAuthNorm.split("\n"); + const b = mainNoAuthNorm.split("\n"); + const n = Math.max(a.length, b.length); + for (let i = 0; i < n; i++) { + if (a[i] !== b[i]) { + console.error(` line ${i + 1}\n now: ${a[i]}\n main: ${b[i]}`); + } + } +} + +// trustedProxies default, and a custom list is preserved. +function runConfigure(extraEnv, custom) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ocw-cfg-")); + const configPath = path.join(dir, "openclaw.json"); + const customPath = path.join(dir, "custom.json"); + if (custom) fs.writeFileSync(customPath, JSON.stringify(custom)); + execFileSync("node", [path.join(ROOT, "scripts/configure.js")], { + env: { + ...process.env, + OPENCLAW_GATEWAY_TOKEN: "tok", + ANTHROPIC_API_KEY: "sk-test", + OPENCLAW_STATE_DIR: path.join(dir, "state"), + OPENCLAW_WORKSPACE_DIR: path.join(dir, "workspace"), + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_CUSTOM_CONFIG: custom ? customPath : path.join(dir, "missing.json"), + ...extraEnv, + }, + stdio: ["ignore", "ignore", "inherit"], + }); + return JSON.parse(fs.readFileSync(configPath, "utf8")); +} + +const configured = runConfigure(); +assert( + JSON.stringify(configured.gateway.trustedProxies) === JSON.stringify(["127.0.0.1", "::1"]), + "trustedProxies defaults to the nginx loopback hops", +); +assert(configured.gateway.auth.mode === "token", "auth mode stays token"); +const custom = runConfigure( + {}, + { gateway: { trustedProxies: ["10.0.0.1"], auth: { mode: "token", token: "from-json" } } }, +); +assert( + JSON.stringify(custom.gateway.trustedProxies) === JSON.stringify(["10.0.0.1"]), + "custom trustedProxies is not overwritten", +); + +if (process.env.DUMP_NGINX === "1") { + fs.writeFileSync("/tmp/nginx-with-auth.conf", withAuth); + fs.writeFileSync("/tmp/nginx-no-auth.conf", noAuth); +} + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0);