From b4fa5feb6ec4ab6043331b3eb4b473a4b5b5254e Mon Sep 17 00:00:00 2001 From: Davit Date: Mon, 27 Apr 2026 11:51:20 +0400 Subject: [PATCH] cors fix --- README.MD | 31 ++++++++++----- api/.env.example | 20 ++++++++-- api/src/@types/global.d.ts | 3 +- api/src/middlewares/cors.ts | 59 ++++++++++++++++++++++------ api/src/routes/message/controller.ts | 38 ++++++++++++++++-- client/src/pages/login/index.tsx | 40 ++++++++++++++++++- client/src/shared/api/baseApi.ts | 30 ++++++++++++-- package.json | 2 +- scripts/build-dist.mjs | 44 ++++++++++++--------- scripts/ports.mjs | 31 ++++++++++++--- scripts/service-runner.mjs | 7 +++- scripts/setup.js | 7 +++- 12 files changed, 249 insertions(+), 63 deletions(-) diff --git a/README.MD b/README.MD index 1400510..09b64d3 100644 --- a/README.MD +++ b/README.MD @@ -110,22 +110,33 @@ openclaw_client restart # production (installed via `npm start`) npm run dev # development ``` -`api/.env`, the Vite dev/preview server, the built `serve.mjs`, `ALLOWED_DOMAIN`, `API_PUBLIC_URL`, and the bundled client's `VITE_API_BASE_URL` are all derived from this file, so both dev and production stay consistent. +`api/.env`, the Vite dev/preview server, and the built `serve.mjs` all read this file so both dev and production stay consistent. The API derives `API_PUBLIC_URL` from each request's `Host` header (so workspace URLs match the hostname the user is actually browsing) and ships a permissive CORS default; see the **CORS / remote access** note below for how to lock it down. ### Environment Variables Generated automatically on first run in `api/.env` (see `api/.env.example` for reference): -| Variable | Default | Description | -| ---------------- | ------------------------ | ----------------------------------------------------- | -| `NODE_ENV` | `development` | Environment mode | -| `JWT_SECRET` | _(random)_ | Secret for JWT signing | -| `DB_PATH` | `./data/openclaw.sqlite` | Path to SQLite database file | -| `PORT` | _(API_PORT)_ | API listen port (driven by `~/.openclaw_client/.env`) | -| `ALLOWED_DOMAIN` | _(CLIENT origin)_ | CORS allowed origin(s), comma-separated | -| `API_PUBLIC_URL` | _(API origin)_ | Public base URL used for generated workspace URLs | +| Variable | Default | Description | +| ---------------------- | -------------------------- | ----------------------------------------------------------------- | +| `NODE_ENV` | `development` | Environment mode | +| `JWT_SECRET` | _(random)_ | Secret for JWT signing | +| `DB_PATH` | `./data/openclaw.sqlite` | Path to SQLite database file | +| `PORT` | _(API_PORT)_ | API listen port (driven by `~/.openclaw_client/.env`) | +| `ALLOWED_DOMAIN` | _(unset — allow all)_ | CORS allowlist, comma-separated; only enforced when `OPENCLAW_STRICT_CORS=1` | +| `OPENCLAW_STRICT_CORS` | _(off)_ | Set to `1` to reject any origin not in `ALLOWED_DOMAIN` | +| `API_PUBLIC_URL` | _(derived from request)_ | Optional fallback origin for workspace URLs when no `Host` header | -The client reads `VITE_API_BASE_URL` at build time; it is set automatically to match `API_PORT`. Override by setting it in `client/.env` only if you deploy behind a custom host. +> **CORS / remote access.** OpenClaw Client is a single-user local app. By +> default the API allows every origin and the client derives the API URL +> from `window.location`, so the same install works on `localhost`, on a +> LAN IP, and over Tailscale without any extra configuration. To lock it +> down to a fixed allowlist, set `ALLOWED_DOMAIN` and `OPENCLAW_STRICT_CORS=1` +> in `api/.env` (or `~/.openclaw_client/api/.env` for production installs). + +The client picks its API origin at runtime: `__OPENCLAW_CONFIG__.apiBaseUrl` +(injected by the production static server from the request host) ▸ +`VITE_API_BASE_URL` (build-time override) ▸ derived from +`window.location` + `VITE_API_PORT`. To regenerate secrets, delete `api/.env` and run `npm run dev` or `npm run setup` again. diff --git a/api/.env.example b/api/.env.example index 7c1363b..251327b 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,8 +1,20 @@ NODE_ENV=development JWT_SECRET=your-secret-here DB_PATH=./data/openclaw.sqlite -# The following three are normally driven by ~/.openclaw_client/.env -# (API_PORT / CLIENT_PORT). Listed here for reference. +# Normally driven by ~/.openclaw_client/.env (API_PORT / CLIENT_PORT). Listed +# here for reference. PORT=18802 -ALLOWED_DOMAIN=http://localhost:18800 -API_PUBLIC_URL=http://localhost:18802 + +# --- CORS --- +# OpenClaw Client is a single-user local app that you typically reach from +# multiple devices on your LAN / Tailscale, so the default policy is +# "allow every origin". Uncomment the two lines below to lock it down to +# a strict allowlist. +# ALLOWED_DOMAIN=http://localhost:18800,http://my-host.tail-net.ts.net:18800 +# OPENCLAW_STRICT_CORS=1 + +# --- API public URL --- +# Optional fallback used when the API can't read `Host` from the +# request (server-to-server callers). Normal browser traffic always +# uses the request host so workspace URLs match wherever the client is. +# API_PUBLIC_URL=http://localhost:18802 diff --git a/api/src/@types/global.d.ts b/api/src/@types/global.d.ts index 3710343..0e3f6b5 100644 --- a/api/src/@types/global.d.ts +++ b/api/src/@types/global.d.ts @@ -5,7 +5,8 @@ declare global { interface ProcessEnv { NODE_ENV: 'test' | 'development' | 'production'; JWT_SECRET: string; - ALLOWED_DOMAIN: string; + ALLOWED_DOMAIN?: string; + OPENCLAW_STRICT_CORS?: string; } } } diff --git a/api/src/middlewares/cors.ts b/api/src/middlewares/cors.ts index 5eedf1e..e2009c4 100644 --- a/api/src/middlewares/cors.ts +++ b/api/src/middlewares/cors.ts @@ -1,21 +1,58 @@ import cors from 'cors'; +/** + * CORS policy for the OpenClaw Client API. + * + * This is a single-user local desktop app (not a public multi-tenant + * service). Auth is JWT-bearer in the `Authorization` header — no + * cookies, so CSRF surface is essentially zero — and the whole point of + * exposing it on `0.0.0.0` is so the user can reach it from their other + * devices on the same LAN or over Tailscale. A strict origin allowlist + * is therefore the wrong default: it breaks every legitimate access + * pattern beyond "browser open on the install host" while protecting + * nothing of value. + * + * Policy: + * - In `development`, allow every origin (was already the case). + * - In production, allow every origin **by default** so installs + * accessed via Tailscale/LAN/IP just work. + * - If `ALLOWED_DOMAIN` is set (comma-separated), it acts as an + * allowlist *augmenting* the permissive default — and if + * `OPENCLAW_STRICT_CORS=1` is also set, the allowlist becomes the + * final word and everything else is rejected. + * + * Operators who want the old strict behaviour: + * `ALLOWED_DOMAIN=https://openclaw.example.com OPENCLAW_STRICT_CORS=1` + */ +const STRICT = ['1', 'true', 'yes'].includes( + String(process.env.OPENCLAW_STRICT_CORS || '').toLowerCase() +); + +const ALLOWLIST = (process.env.ALLOWED_DOMAIN || '') + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + export default cors({ exposedHeaders: 'access-token', origin: (origin, next) => { - if (!origin || process.env.NODE_ENV === 'development') return next(null, true); + // Same-origin / non-browser callers (curl, server-to-server) don't + // send Origin and shouldn't be blocked. + if (!origin) return next(null, true); - const allowed = process.env.ALLOWED_DOMAIN - ? process.env.ALLOWED_DOMAIN.split(',') - .map((d) => d.trim()) - .filter(Boolean) - : []; + if (process.env.NODE_ENV === 'development') return next(null, true); - if (allowed.includes(origin)) return next(null, true); + if (ALLOWLIST.includes(origin)) return next(null, true); - return next( - new Error('The CORS policy for this site does not allow access from the specified Origin.'), - false - ); + if (STRICT) { + return next( + new Error( + `CORS policy: origin ${origin} is not in ALLOWED_DOMAIN and OPENCLAW_STRICT_CORS is on.` + ), + false + ); + } + + return next(null, true); }, }); diff --git a/api/src/routes/message/controller.ts b/api/src/routes/message/controller.ts index ac51c00..f207936 100644 --- a/api/src/routes/message/controller.ts +++ b/api/src/routes/message/controller.ts @@ -14,7 +14,37 @@ import { } from '../../@types/message'; import * as ocService from '../../services/openclaw'; -const API_PUBLIC_URL = process.env.API_PUBLIC_URL || 'http://localhost:18802'; +/** + * Resolve the public origin to use when minting URLs back to the client. + * + * OpenClaw Client is deployed in two patterns: + * 1. Local-only: browser on the install host. `Host` header reads + * `localhost:` and the API_PUBLIC_URL env (default + * `http://localhost:18802`) was historically hardcoded — fine. + * 2. LAN/Tailscale/IP: browser on a different device. `Host` reads + * `:`. A hardcoded localhost URL would point + * the remote browser at *its own machine*, breaking workspace + * file previews and downloads silently. + * + * So we prefer `req.headers.host` (already validated by Express + the + * cors middleware) and only fall back to the env override / default + * for non-HTTP callers. `x-forwarded-host` is honoured for users + * running behind a reverse proxy. + */ +const apiPublicUrl = (req: { + headers: Record; + protocol?: string; +}): string => { + const envOverride = process.env.API_PUBLIC_URL; + const xfHost = req.headers['x-forwarded-host']; + const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost) || req.headers.host; + if (host) { + const xfProto = req.headers['x-forwarded-proto']; + const proto = (Array.isArray(xfProto) ? xfProto[0] : xfProto) || req.protocol || 'http'; + return `${proto}://${host}`; + } + return envOverride || 'http://localhost:18802'; +}; function stripWrapperTags(text: string): string { return text @@ -105,9 +135,11 @@ const chat: Chat = async (req, res, next) => { const agent = await agentRepo.findOneBy({ _id: conv.agentId }); const agentIdForFiles = agent?.openclawAgentId || 'main'; + const publicUrl = apiPublicUrl(req); + const msgCount = await msgRepo.count({ where: { conversationId: conv._id } }); if (msgCount === 0) { - ocService.appendBootstrapImageRule(agentIdForFiles, conv.agentId, API_PUBLIC_URL); + ocService.appendBootstrapImageRule(agentIdForFiles, conv.agentId, publicUrl); } const filePaths = uploadedFiles.map((uf) => @@ -121,7 +153,7 @@ const chat: Chat = async (req, res, next) => { originalName: f.originalname, mimetype: f.mimetype, size: f.size, - url: `${API_PUBLIC_URL}/api/agent/${conv.agentId}/workspace/uploads/${encodeURIComponent(savedName)}`, + url: `${publicUrl}/api/agent/${conv.agentId}/workspace/uploads/${encodeURIComponent(savedName)}`, }; }); diff --git a/client/src/pages/login/index.tsx b/client/src/pages/login/index.tsx index c81b5e5..fa5152d 100644 --- a/client/src/pages/login/index.tsx +++ b/client/src/pages/login/index.tsx @@ -3,6 +3,44 @@ import { Button, TextField, Card, Typography, Box, CircularProgress, Alert } fro import { useFormik, FormikProvider, Form } from 'formik'; import { useNavigate } from 'react-router'; import { useLoginMutation } from '../../features/auth'; +import { API_BASE_URL } from '../../shared/api/baseApi'; + +/** + * Translate an RTK Query error into a message that actually helps the + * user. Previously we collapsed every error to "Login failed. Please + * check your credentials." which made network/CORS failures look like + * bad passwords — sent us on a goose chase the first time the app was + * accessed over Tailscale. + */ +function describeLoginError(error: unknown): string { + if (!error || typeof error !== 'object') return 'Login failed. Please try again.'; + const e = error as { status?: number | string; data?: unknown; error?: string }; + if (e.status === 401) return 'Login failed. Please check your credentials.'; + if (e.status === 'FETCH_ERROR') { + return ( + `Could not reach the API at ${API_BASE_URL}. ` + + 'If you opened this page from another device, make sure the API ' + + 'is reachable on the same hostname (and that any firewall / ' + + 'reverse proxy forwards both the client and API ports).' + ); + } + if (e.status === 'PARSING_ERROR') { + return 'API responded with something that is not JSON. The server may have crashed mid-request — check the API logs.'; + } + if (typeof e.status === 'number' && e.status >= 500) { + return `Server error (${e.status}). Check the API logs.`; + } + if ( + e.data && + typeof e.data === 'object' && + 'message' in e.data && + typeof (e.data as { message: unknown }).message === 'string' + ) { + return (e.data as { message: string }).message; + } + if (typeof e.error === 'string') return e.error; + return 'Login failed. Please try again.'; +} export default function LoginPage() { const navigate = useNavigate(); @@ -53,7 +91,7 @@ export default function LoginPage() {
{error && ( - Login failed. Please check your credentials. + {describeLoginError(error)} )} `${k}=${v}`) + .concat('') + .join('\n') ); } else { - const overrides = { - DB_PATH: canonicalDbPath, - PORT: String(apiPort), - ALLOWED_DOMAIN: allowedDomain, - API_PUBLIC_URL: apiPublicUrl, - }; const seen = new Set(); const lines = readFileSync(envDist, 'utf-8').split('\n'); const updated = lines.map((line) => { diff --git a/scripts/ports.mjs b/scripts/ports.mjs index 83914fd..d09acda 100644 --- a/scripts/ports.mjs +++ b/scripts/ports.mjs @@ -64,9 +64,30 @@ export function readPorts() { } /** - * Derived env vars expected by API and Client code. - * Use these when spawning child processes so a single user-level .env is - * the source of truth. + * Derived env vars for spawning child processes. + * + * Note we deliberately do NOT export `ALLOWED_DOMAIN`, `API_PUBLIC_URL`, + * or `VITE_API_BASE_URL` here, even though the API and client both read + * those vars. Pinning them to `http://localhost:${port}` would block + * legitimate access from other devices on the user's LAN / Tailscale — + * the very deployment pattern this app is designed for. Instead: + * + * - `ALLOWED_DOMAIN` is unset by default; the API ships a permissive + * CORS policy and an `OPENCLAW_STRICT_CORS=1` opt-in for the strict + * allowlist behaviour. + * - `API_PUBLIC_URL` is derived per-request from the `Host` header + * (with `x-forwarded-*` honoured) — see `routes/message/controller`. + * - `VITE_API_BASE_URL` is left empty so the bundle isn't built with + * a baked-in `http://localhost:...` URL; the client derives the + * API origin at runtime from `__OPENCLAW_CONFIG__` or + * `window.location` — see `client/src/shared/api/baseApi`. + * + * Users who want strict mode can still set any of those keys in + * `~/.openclaw_client/.env` or `api/.env`; nothing here overwrites them. + * + * `VITE_API_PORT` is exported so the build can embed a sensible default + * for the runtime URL derivation when the user installs across a + * non-standard port. */ export function portEnv() { const { apiPort, clientPort } = readPorts(); @@ -74,8 +95,6 @@ export function portEnv() { API_PORT: String(apiPort), CLIENT_PORT: String(clientPort), PORT: String(apiPort), - ALLOWED_DOMAIN: `http://localhost:${clientPort}`, - API_PUBLIC_URL: `http://localhost:${apiPort}`, - VITE_API_BASE_URL: `http://localhost:${apiPort}/api`, + VITE_API_PORT: String(apiPort), }; } diff --git a/scripts/service-runner.mjs b/scripts/service-runner.mjs index c543b92..e5d66dc 100644 --- a/scripts/service-runner.mjs +++ b/scripts/service-runner.mjs @@ -35,14 +35,17 @@ function parseEnvFile(file) { const userEnv = parseEnvFile(USER_ENV); const apiPort = Number(userEnv.API_PORT) || 18802; const clientPort = Number(userEnv.CLIENT_PORT) || 18800; +// We deliberately do not export ALLOWED_DOMAIN / API_PUBLIC_URL here: +// the API has a permissive CORS default and derives public URLs from +// the request host, so the same install works on localhost, LAN, and +// Tailscale. Users who want strict CORS set ALLOWED_DOMAIN and +// OPENCLAW_STRICT_CORS=1 in ~/.openclaw_client/api/.env themselves. const childEnv = { ...process.env, NODE_ENV: 'production', API_PORT: String(apiPort), CLIENT_PORT: String(clientPort), PORT: String(apiPort), - ALLOWED_DOMAIN: `http://localhost:${clientPort}`, - API_PUBLIC_URL: `http://localhost:${apiPort}`, }; const children = []; diff --git a/scripts/setup.js b/scripts/setup.js index c4e19a6..e3636ea 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -17,6 +17,11 @@ if (fs.existsSync(API_ENV)) { const JWT_SECRET = crypto.randomBytes(32).toString('hex'); +// ALLOWED_DOMAIN / API_PUBLIC_URL are deliberately omitted: the API has +// a permissive CORS default and derives public URLs from the request +// host so the same install works on localhost, LAN, and Tailscale. +// Users who want strict CORS can set `ALLOWED_DOMAIN=...` and +// `OPENCLAW_STRICT_CORS=1` themselves. fs.writeFileSync( API_ENV, [ @@ -24,8 +29,6 @@ fs.writeFileSync( `JWT_SECRET=${JWT_SECRET}`, `DB_PATH=./data/openclaw.sqlite`, `PORT=${apiPort}`, - `ALLOWED_DOMAIN=http://localhost:${clientPort}`, - `API_PUBLIC_URL=http://localhost:${apiPort}`, '', ].join('\n'), );