From c2a50abf04c78dc702214425b1bc8a8115e9fc9e Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 22 Mar 2026 12:59:10 +0700 Subject: [PATCH 1/3] feat: add backend proxy layer to keep auth tokens server-side Introduces a WebSocket proxy server (server/) that sits between the webchat widget and GoClaw Gateway, preventing auth token exposure in client-side JavaScript. - Proxy intercepts WS connect frame and injects gateway token - Widget updated with proxyUrl config option for proxy mode - Origin validation, per-IP rate limiting, TRUST_PROXY support - 512KB max frame size, message buffering, graceful shutdown - Example page and documentation updated --- CLAUDE.md | 23 +- README.md | 38 +- docs/code-standards.md | 39 +- docs/project-overview-pdr.md | 22 +- docs/system-architecture.md | 60 ++- examples/proxy-mode.html | 59 +++ server/.env.example | 27 ++ server/package-lock.json | 625 ++++++++++++++++++++++++++ server/package.json | 22 + server/src/connection-tracker.ts | 47 ++ server/src/index.ts | 13 + server/src/proxy-config.ts | 67 +++ server/src/proxy-server.ts | 127 ++++++ server/src/websocket-proxy-session.ts | 178 ++++++++ server/tsconfig.json | 18 + src/types.ts | 11 +- src/websocket-client.ts | 12 +- 17 files changed, 1370 insertions(+), 18 deletions(-) create mode 100644 examples/proxy-mode.html create mode 100644 server/.env.example create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/connection-tracker.ts create mode 100644 server/src/index.ts create mode 100644 server/src/proxy-config.ts create mode 100644 server/src/proxy-server.ts create mode 100644 server/src/websocket-proxy-session.ts create mode 100644 server/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index 9813a43..ce8985a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Embeddable JavaScript chat widget for GoClaw AI agent gateway. ``` src/ ├── index.ts Entry point, init(), window auto-attach -├── types.ts All TypeScript interfaces +├── types.ts All TypeScript interfaces (GoClawConfig includes proxyUrl) ├── websocket-client.ts WS connection, auth, RPC, events, reconnect ├── chat-widget.ts Shadow DOM UI, message rendering ├── markdown-renderer.ts Lightweight markdown→HTML @@ -22,14 +22,30 @@ src/ └── wrappers/ ├── react-wrapper.tsx React component └── vue-wrapper.ts Vue 3 plugin +server/ Backend proxy (keeps auth token server-side) +├── src/ +│ ├── index.ts Entry point +│ ├── proxy-config.ts Config from environment variables +│ ├── proxy-server.ts HTTP + WebSocket server +│ ├── websocket-proxy-session.ts Single client↔upstream proxy session +│ └── connection-tracker.ts Per-IP connection rate limiting +├── .env.example Example configuration +├── package.json Server dependencies (ws) +└── tsconfig.json Server TypeScript config ``` ## Commands ```bash -npm install # Install deps -npm run dev # Dev server +npm install # Install widget deps +npm run dev # Widget dev server npm run build # tsc --noEmit && vite build npm run lint # Type-check only + +# Proxy server +cd server && npm install +npm run dev # Start proxy (tsx watch) +npm run build # Compile to dist/ +npm start # Run compiled proxy ``` ## Key Patterns @@ -38,3 +54,4 @@ npm run lint # Type-check only - GoClaw WebSocket Protocol v3 (req/res/event frames) - Exponential backoff reconnection - Async snippet loader pattern (like Intercom) +- Proxy mode: backend injects auth token into WS connect frame, client never sees it diff --git a/README.md b/README.md index 1b557ae..9837daa 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,48 @@ app.use(GoClawPlugin, { }); ``` +## Proxy Mode (Recommended for Production) + +By default, the widget connects directly to the GoClaw Gateway and requires a `token` in client-side code — which is **visible in browser devtools**. For production, use the included **proxy server** to keep the token server-side: + +``` +Browser Widget ←→ Proxy Server (:3100) ←→ GoClaw Gateway (:9090) + (no token) (holds token) (validates token) +``` + +### Setup + +```bash +cd server/ +cp .env.example .env +# Edit .env: set GOCLAW_URL and GOCLAW_TOKEN +npm install +npm run dev +``` + +### Client Config (no token needed!) + +```html + + +``` + +The proxy supports origin validation (`ALLOWED_ORIGINS`), per-IP connection limits, and graceful shutdown. + ## Configuration | Option | Type | Default | Description | |--------|------|---------|-------------| | `url` | `string` | *required* | GoClaw WebSocket URL (`wss://...`) | -| `token` | `string` | — | Gateway authentication token | +| `proxyUrl` | `string` | — | Proxy server WebSocket URL (recommended for production) | +| `token` | `string` | — | Gateway authentication token (not needed with proxy) | | `userId` | `string` | auto-generated | User identifier | | `agentId` | `string` | — | Specific agent to chat with | | `sessionId` | `string` | — | Resume a previous session | diff --git a/docs/code-standards.md b/docs/code-standards.md index 3b96de6..a2e454d 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -1,6 +1,8 @@ # Code Standards -## Language & Tooling +## Widget (src/) + +### Language & Tooling - TypeScript strict mode - ESM modules (Vite library mode) - No runtime dependencies @@ -21,8 +23,41 @@ - Event-driven WebSocket communication - Builder pattern for configuration (single config object) -## Security +### Security - HTML escaping for all user content - No `eval()` or `innerHTML` with raw user input - Markdown renderer escapes HTML before processing - XSS-safe link rendering (only http/https) + +## Proxy Server (server/) + +### Language & Tooling +- TypeScript strict mode +- Node.js ESM (type: module in package.json) +- Runtime dependency: `ws` (WebSocket library v8.18+) +- Dev dependencies: TypeScript, tsx (development server), @types/node, @types/ws + +### File Naming +- kebab-case for all source files +- Descriptive names (e.g., `websocket-proxy-session.ts`, `connection-tracker.ts`) + +### Code Style +- 2-space indentation +- Single quotes for strings +- Explicit types on public API, inferred internally +- Error handling with try-catch blocks + +### Architecture Patterns +- Config-driven initialization (environment variables via `proxy-config.ts`) +- Per-IP connection tracking with exponential backoff +- WebSocket frame buffering until upstream connection ready +- Graceful shutdown with drain timeout +- Non-JSON frames dropped silently + +### Security +- Auth token stored server-side only, never sent to client +- Origin validation via `ALLOWED_ORIGINS` environment variable (empty = allow all) +- Per-IP connection limits via `MAX_CONNECTIONS_PER_IP` (default: 10) +- TRUST_PROXY flag for reverse proxy (nginx, Cloudflare) deployments +- Max frame size: 512KB +- Upstream gateway URL not included in health endpoint responses diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 05329c5..126032e 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -12,10 +12,12 @@ Embeddable JavaScript chat widget allowing website owners to add AI chat powered - **Protocol**: GoClaw WebSocket Protocol v3 (req/res/event frames) ## Architecture + +### Widget (src/) - `src/index.ts` — Entry point, `init()` function, window auto-attach -- `src/websocket-client.ts` — WebSocket connection, auth, RPC, event handling, reconnection +- `src/websocket-client.ts` — WebSocket connection, auth, RPC, event handling, reconnection (supports proxy mode) - `src/chat-widget.ts` — Shadow DOM UI, message rendering, input handling -- `src/types.ts` — All TypeScript interfaces +- `src/types.ts` — All TypeScript interfaces (includes `proxyUrl` config option) - `src/markdown-renderer.ts` — Lightweight markdown-to-HTML - `src/svg-icons.ts` — Inline SVG icons - `src/styles/theme-variables.ts` — Theme system (light/dark/auto/custom) @@ -23,14 +25,30 @@ Embeddable JavaScript chat widget allowing website owners to add AI chat powered - `src/wrappers/react-wrapper.tsx` — React component - `src/wrappers/vue-wrapper.ts` — Vue 3 plugin +### Proxy Server (server/) +- `server/src/index.ts` — Server entry point, HTTP + WebSocket listener +- `server/src/proxy-config.ts` — Configuration from environment variables +- `server/src/proxy-server.ts` — HTTP + WebSocket server with origin validation, per-IP limits, graceful shutdown +- `server/src/websocket-proxy-session.ts` — Single proxy session: intercepts WS `connect` frame to inject gateway token, buffers upstream messages +- `server/src/connection-tracker.ts` — Per-IP connection rate limiting + ## Distribution + +### Widget - Script tag (UMD): ` + + + diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..7730da3 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,27 @@ +# GoClaw WebChat Proxy Server Configuration +# Copy this file to .env and fill in the values + +# Required: GoClaw Gateway WebSocket URL +GOCLAW_URL=ws://localhost:9090/ws + +# Required: Gateway auth token (kept server-side, never exposed to browser) +GOCLAW_TOKEN=your-gateway-token-here + +# Optional: Proxy server port (default: 3100) +PORT=3100 + +# Optional: Allowed origins for CORS (comma-separated, empty = allow all) +# ALLOWED_ORIGINS=https://example.com,https://app.example.com + +# Optional: Default agent ID (used if client doesn't specify one) +# DEFAULT_AGENT_ID=your-agent-id + +# Optional: Max WebSocket connections per IP (default: 10) +# MAX_CONNECTIONS_PER_IP=10 + +# Optional: Trust X-Forwarded-For/X-Real-IP headers (default: false) +# Set to "true" when running behind a reverse proxy (nginx, cloudflare, etc.) +# TRUST_PROXY=true + +# Optional: Log level: debug, info, warn, error (default: info) +# LOG_LEVEL=info diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..243701b --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,625 @@ +{ + "name": "@goclaw/webchat-proxy", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@goclaw/webchat-proxy", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..d0d0708 --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "@goclaw/webchat-proxy", + "version": "0.1.0", + "description": "WebSocket proxy server for GoClaw WebChat - keeps auth tokens server-side", + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "tsc --noEmit" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/server/src/connection-tracker.ts b/server/src/connection-tracker.ts new file mode 100644 index 0000000..dc54a35 --- /dev/null +++ b/server/src/connection-tracker.ts @@ -0,0 +1,47 @@ +// ── Per-IP connection tracking for rate limiting ── + +/** Tracks active WebSocket connections per IP address */ +export class ConnectionTracker { + private counts = new Map(); + private maxPerIp: number; + + constructor(maxPerIp: number) { + this.maxPerIp = maxPerIp; + } + + /** Check if IP can accept a new connection */ + canConnect(ip: string): boolean { + const count = this.counts.get(ip) ?? 0; + return count < this.maxPerIp; + } + + /** Register a new connection from IP */ + add(ip: string): void { + const count = this.counts.get(ip) ?? 0; + this.counts.set(ip, count + 1); + } + + /** Unregister a connection from IP */ + remove(ip: string): void { + const count = this.counts.get(ip) ?? 0; + if (count <= 1) { + this.counts.delete(ip); + } else { + this.counts.set(ip, count - 1); + } + } + + /** Get current connection count for an IP */ + getCount(ip: string): number { + return this.counts.get(ip) ?? 0; + } + + /** Get total active connections */ + get totalConnections(): number { + let total = 0; + for (const count of this.counts.values()) { + total += count; + } + return total; + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..2a5903f --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,13 @@ +// ── GoClaw WebChat Proxy Server ── +// Keeps gateway auth tokens server-side, proxies WebSocket frames to GoClaw Gateway. + +import { loadConfig } from './proxy-config.js'; +import { startProxyServer } from './proxy-server.js'; + +try { + const config = loadConfig(); + startProxyServer(config); +} catch (err) { + console.error(`[proxy] fatal: ${err instanceof Error ? err.message : err}`); + process.exit(1); +} diff --git a/server/src/proxy-config.ts b/server/src/proxy-config.ts new file mode 100644 index 0000000..6da5a57 --- /dev/null +++ b/server/src/proxy-config.ts @@ -0,0 +1,67 @@ +// ── Proxy server configuration from environment variables ── + +const VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; +type LogLevel = typeof VALID_LOG_LEVELS[number]; + +export interface ProxyConfig { + /** Port to listen on (default: 3100) */ + port: number; + /** GoClaw Gateway WebSocket URL (e.g., "ws://localhost:9090/ws") */ + goclawUrl: string; + /** GoClaw Gateway auth token (kept server-side, never exposed) */ + goclawToken: string; + /** Allowed origins for CORS/WebSocket origin check (comma-separated, empty = allow all) */ + allowedOrigins: string[]; + /** Default agent ID to use if client doesn't specify one */ + defaultAgentId?: string; + /** Max concurrent connections per IP (default: 10) */ + maxConnectionsPerIp: number; + /** Trust X-Forwarded-For/X-Real-IP headers (default: false, set true when behind reverse proxy) */ + trustProxy: boolean; + /** Log level: "debug" | "info" | "warn" | "error" (default: "info") */ + logLevel: LogLevel; +} + +/** Load proxy config from environment variables with sensible defaults */ +export function loadConfig(): ProxyConfig { + const goclawUrl = process.env.GOCLAW_URL; + if (!goclawUrl) { + throw new Error('GOCLAW_URL environment variable is required (e.g., "ws://localhost:9090/ws")'); + } + + const goclawToken = process.env.GOCLAW_TOKEN ?? ''; + if (!goclawToken) { + console.warn('[proxy] WARNING: GOCLAW_TOKEN not set — proxy will connect without authentication'); + } + + const originsRaw = process.env.ALLOWED_ORIGINS ?? ''; + const allowedOrigins = originsRaw + ? originsRaw.split(',').map((o) => o.trim()).filter(Boolean) + : []; + + const port = parseInt(process.env.PORT ?? '3100', 10); + if (isNaN(port) || port < 1 || port > 65535) { + throw new Error('PORT must be a number between 1 and 65535'); + } + + const maxConnectionsPerIp = parseInt(process.env.MAX_CONNECTIONS_PER_IP ?? '10', 10); + if (isNaN(maxConnectionsPerIp) || maxConnectionsPerIp < 1) { + throw new Error('MAX_CONNECTIONS_PER_IP must be a positive number'); + } + + const logLevel = (process.env.LOG_LEVEL ?? 'info') as LogLevel; + if (!VALID_LOG_LEVELS.includes(logLevel)) { + throw new Error(`LOG_LEVEL must be one of: ${VALID_LOG_LEVELS.join(', ')}`); + } + + return { + port, + goclawUrl, + goclawToken, + allowedOrigins, + defaultAgentId: process.env.DEFAULT_AGENT_ID, + maxConnectionsPerIp, + trustProxy: process.env.TRUST_PROXY === 'true', + logLevel, + }; +} diff --git a/server/src/proxy-server.ts b/server/src/proxy-server.ts new file mode 100644 index 0000000..4501e6e --- /dev/null +++ b/server/src/proxy-server.ts @@ -0,0 +1,127 @@ +// ── WebSocket proxy server: accepts client connections and proxies to GoClaw Gateway ── + +import { createServer, type IncomingMessage } from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; +import type { ProxyConfig } from './proxy-config.js'; +import { ConnectionTracker } from './connection-tracker.js'; +import { WebSocketProxySession } from './websocket-proxy-session.js'; + +/** Start the WebSocket proxy server */ +export function startProxyServer(config: ProxyConfig): void { + const tracker = new ConnectionTracker(config.maxConnectionsPerIp); + const sessions = new Set(); + + const httpServer = createServer((req, res) => { + // Health check endpoint + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + connections: tracker.totalConnections, + })); + return; + } + + res.writeHead(404); + res.end('Not found'); + }); + + const wss = new WebSocketServer({ + server: httpServer, + path: '/ws', + maxPayload: 512 * 1024, // 512KB max frame size (matches GoClaw gateway limit) + verifyClient: ({ req }, callback) => { + // Origin check + if (!checkOrigin(req, config.allowedOrigins)) { + console.warn(`[proxy] origin rejected: ${req.headers.origin}`); + callback(false, 403, 'Origin not allowed'); + return; + } + + // Per-IP connection limit + const ip = extractClientIp(req, config.trustProxy); + if (!tracker.canConnect(ip)) { + console.warn(`[proxy] connection limit reached for ${ip}`); + callback(false, 429, 'Too many connections'); + return; + } + + callback(true); + }, + }); + + wss.on('connection', (clientWs: WebSocket, req: IncomingMessage) => { + const ip = extractClientIp(req, config.trustProxy); + tracker.add(ip); + + const session = new WebSocketProxySession(clientWs, config, ip); + sessions.add(session); + + // Clean up on disconnect + clientWs.on('close', () => { + tracker.remove(ip); + session.destroy(); + sessions.delete(session); + }); + + // Start proxying + session.start().catch((err) => { + console.error(`[proxy] session start failed: ${err}`); + clientWs.close(1011, 'proxy error'); + }); + }); + + httpServer.listen(config.port, () => { + console.log(`[proxy] listening on :${config.port}`); + console.log(`[proxy] upstream: ${config.goclawUrl}`); + console.log(`[proxy] auth token: ${config.goclawToken ? 'configured' : 'NOT SET'}`); + if (config.allowedOrigins.length > 0) { + console.log(`[proxy] allowed origins: ${config.allowedOrigins.join(', ')}`); + } else { + console.log('[proxy] allowed origins: * (all)'); + } + }); + + // Graceful shutdown with drain timeout + const shutdown = () => { + console.log('[proxy] shutting down...'); + for (const session of sessions) { + session.destroy(); + } + sessions.clear(); + wss.close(() => { + httpServer.close(() => process.exit(0)); + }); + // Force exit after 5 seconds if drain stalls + setTimeout(() => process.exit(1), 5000).unref(); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +/** Validate request origin against allowed origins list */ +function checkOrigin(req: IncomingMessage, allowedOrigins: string[]): boolean { + if (allowedOrigins.length === 0) return true; // no restriction + + const origin = req.headers.origin; + if (!origin) return true; // non-browser clients (CLI, SDK) + + return allowedOrigins.some((allowed) => allowed === '*' || allowed === origin); +} + +/** Extract client IP — only trust proxy headers when trustProxy is enabled */ +function extractClientIp(req: IncomingMessage, trustProxy: boolean): string { + if (trustProxy) { + const realIp = req.headers['x-real-ip']; + if (typeof realIp === 'string') return realIp; + + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + const first = forwarded.split(',')[0]?.trim(); + if (first) return first; + } + } + + return req.socket.remoteAddress ?? 'unknown'; +} diff --git a/server/src/websocket-proxy-session.ts b/server/src/websocket-proxy-session.ts new file mode 100644 index 0000000..3998b2a --- /dev/null +++ b/server/src/websocket-proxy-session.ts @@ -0,0 +1,178 @@ +// ── Single proxy session: client ↔ upstream GoClaw Gateway ── + +import WebSocket from 'ws'; +import type { ProxyConfig } from './proxy-config.js'; + +/** Represents a proxied WebSocket session between client and GoClaw Gateway */ +export class WebSocketProxySession { + private upstream: WebSocket | null = null; + private upstreamReady = false; + private closed = false; + private sessionId: string; + private pendingMessages: string[] = []; + private connectTimeout: ReturnType | null = null; + + constructor( + private client: WebSocket, + private config: ProxyConfig, + private clientIp: string, + ) { + this.sessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } + + /** Start the proxy: connect to upstream and begin relaying frames */ + async start(): Promise { + this.log('info', 'connecting to upstream'); + + try { + this.upstream = new WebSocket(this.config.goclawUrl, { + maxPayload: 512 * 1024, // 512KB max frame size (matches GoClaw gateway) + }); + } catch (err) { + this.log('error', `upstream connection failed: ${err}`); + this.client.close(1011, 'upstream connection failed'); + return; + } + + // Timeout if upstream doesn't connect within 10 seconds + this.connectTimeout = setTimeout(() => { + this.log('error', 'upstream connection timed out'); + this.upstream?.close(); + this.closeClient(1011, 'upstream timeout'); + }, 10_000); + + this.upstream.on('open', () => { + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } + this.upstreamReady = true; + this.log('info', 'upstream connected'); + + // Flush any messages queued while upstream was connecting + for (const msg of this.pendingMessages) { + this.upstream!.send(msg); + } + this.pendingMessages = []; + }); + + // Relay upstream → client (passthrough all frames) + this.upstream.on('message', (data) => { + if (this.closed || this.client.readyState !== WebSocket.OPEN) return; + this.client.send(data); + }); + + this.upstream.on('close', (code, reason) => { + this.log('info', `upstream closed: ${code} ${reason}`); + this.closeClient(code, reason.toString()); + }); + + this.upstream.on('error', (err) => { + this.log('error', `upstream error: ${err.message}`); + this.closeClient(1011, 'upstream error'); + }); + + // Relay client → upstream (intercept `connect` to inject token) + this.client.on('message', (data) => { + if (this.closed || !this.upstream) return; + + const raw = data.toString(); + const modified = this.interceptFrame(raw); + if (!modified) return; // dropped non-JSON frame + + // Buffer messages until upstream is open + if (!this.upstreamReady) { + this.log('debug', 'buffering message (upstream not ready)'); + this.pendingMessages.push(modified); + return; + } + + this.upstream.send(modified); + }); + + this.client.on('close', () => { + this.log('info', 'client disconnected'); + this.closeUpstream(); + }); + + this.client.on('error', (err) => { + this.log('error', `client error: ${err.message}`); + this.closeUpstream(); + }); + } + + /** Intercept outgoing frames to inject auth token into connect requests */ + private interceptFrame(raw: string): string { + try { + const frame = JSON.parse(raw); + + // Only intercept "connect" method requests + if (frame.type !== 'req' || frame.method !== 'connect') { + return raw; + } + + // Inject gateway token (server-side, never exposed to client) + if (this.config.goclawToken) { + frame.params = frame.params ?? {}; + frame.params.token = this.config.goclawToken; + } + + // Inject default agent_id if not already set by client + if (this.config.defaultAgentId && !frame.params?.agent_id) { + frame.params = frame.params ?? {}; + frame.params.agent_id = this.config.defaultAgentId; + } + + this.log('debug', 'injected auth token into connect frame'); + return JSON.stringify(frame); + } catch { + // GoClaw protocol is JSON-only — drop non-JSON frames + this.log('warn', 'dropping non-JSON frame from client'); + return ''; + } + } + + private closeClient(code: number, reason: string): void { + if (this.closed) return; + this.closed = true; + if (this.client.readyState === WebSocket.OPEN) { + this.client.close(code, reason); + } + } + + private closeUpstream(): void { + if (this.closed) return; + this.closed = true; + if (this.upstream && this.upstream.readyState === WebSocket.OPEN) { + this.upstream.close(); + } + this.upstream = null; + } + + /** Clean up both connections */ + destroy(): void { + this.closed = true; + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } + this.pendingMessages = []; + if (this.upstream && this.upstream.readyState !== WebSocket.CLOSED) { + this.upstream.close(); + } + this.upstream = null; + } + + private log(level: string, message: string): void { + const levels = ['debug', 'info', 'warn', 'error']; + const configLevel = levels.indexOf(this.config.logLevel); + const msgLevel = levels.indexOf(level); + if (msgLevel < configLevel) return; + + const prefix = `[proxy:${this.sessionId}]`; + const logFn = level === 'error' ? console.error + : level === 'warn' ? console.warn + : console.log; + logFn(`${prefix} ${message} (ip=${this.clientIp})`); + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..6fbc299 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/types.ts b/src/types.ts index 1c24e67..bbc6db5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,17 @@ // ── GoClaw WebSocket Protocol v3 Types ── export interface GoClawConfig { - /** WebSocket URL (e.g., "wss://goclaw.example.com/ws") */ + /** WebSocket URL for direct connection (e.g., "wss://goclaw.example.com/ws") */ url: string; - /** Gateway token for authentication */ + /** Gateway token for direct authentication (⚠️ exposed in client-side code) */ token?: string; + /** + * Proxy server WebSocket URL (e.g., "wss://proxy.example.com/ws"). + * When set, the widget connects through the proxy instead of directly to the gateway. + * The proxy keeps the auth token server-side — no token needed in client config. + * Mutually preferred over `url` + `token` for production deployments. + */ + proxyUrl?: string; /** User identifier */ userId?: string; /** Agent ID to chat with (optional, uses default agent if omitted) */ diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 82c4bfe..5858df6 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -28,9 +28,6 @@ export class GoClawWebSocketClient { timer: ReturnType; }>(); - // Promise that resolves when WS is open (for connect handshake) - private wsOpenPromise: Promise | null = null; - // Event listeners private eventListeners = new Map>(); private stateListeners = new Set(); @@ -186,7 +183,9 @@ export class GoClawWebSocketClient { private createAndAuthenticate(): Promise { return new Promise((resolve, reject) => { try { - this.ws = new WebSocket(this.config.url); + // Use proxy URL when configured (token is kept server-side by proxy) + const wsUrl = this.config.proxyUrl ?? this.config.url; + this.ws = new WebSocket(wsUrl); } catch (err) { reject(err instanceof Error ? err : new Error(String(err))); return; @@ -316,7 +315,10 @@ export class GoClawWebSocketClient { protocol: 3, user_id: this.config.userId || `web_${Date.now()}`, }; - if (this.config.token) params.token = this.config.token; + // Only send token in direct mode; proxy injects it server-side + if (!this.config.proxyUrl && this.config.token) { + params.token = this.config.token; + } return this.request('connect', params).then((res) => { if (!res.ok) { From c79993240cdf8bba2b1e91ac9b96bcc2ba6a866e Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 22 Mar 2026 15:01:50 +0700 Subject: [PATCH 2/3] fix(security): harden proxy against origin bypass, rate abuse, and token leakage - Reject missing Origin header when allowedOrigins is configured - Cap pending message buffer to 10 messages (prevents memory DoS) - Add per-session message rate limiting (60 msgs/min sliding window) - Sanitize upstream responses to strip token fields (defense in depth) --- server/src/proxy-server.ts | 8 +++-- server/src/websocket-proxy-session.ts | 46 +++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/server/src/proxy-server.ts b/server/src/proxy-server.ts index 4501e6e..f2d170e 100644 --- a/server/src/proxy-server.ts +++ b/server/src/proxy-server.ts @@ -100,12 +100,14 @@ export function startProxyServer(config: ProxyConfig): void { process.on('SIGTERM', shutdown); } -/** Validate request origin against allowed origins list */ +/** Validate request origin against allowed origins list. + * When origins are configured, missing Origin header is rejected + * to prevent bypass via non-browser clients. */ function checkOrigin(req: IncomingMessage, allowedOrigins: string[]): boolean { - if (allowedOrigins.length === 0) return true; // no restriction + if (allowedOrigins.length === 0) return true; // no restriction configured const origin = req.headers.origin; - if (!origin) return true; // non-browser clients (CLI, SDK) + if (!origin) return false; // reject missing origin when allowlist is active return allowedOrigins.some((allowed) => allowed === '*' || allowed === origin); } diff --git a/server/src/websocket-proxy-session.ts b/server/src/websocket-proxy-session.ts index 3998b2a..aff0893 100644 --- a/server/src/websocket-proxy-session.ts +++ b/server/src/websocket-proxy-session.ts @@ -3,6 +3,13 @@ import WebSocket from 'ws'; import type { ProxyConfig } from './proxy-config.js'; +/** Max messages buffered before upstream is ready */ +const MAX_PENDING_MESSAGES = 10; + +/** Max messages per minute per session (rate limit) */ +const MSG_RATE_LIMIT = 60; +const MSG_RATE_WINDOW_MS = 60_000; + /** Represents a proxied WebSocket session between client and GoClaw Gateway */ export class WebSocketProxySession { private upstream: WebSocket | null = null; @@ -11,6 +18,7 @@ export class WebSocketProxySession { private sessionId: string; private pendingMessages: string[] = []; private connectTimeout: ReturnType | null = null; + private msgTimestamps: number[] = []; // sliding window for rate limiting constructor( private client: WebSocket, @@ -56,10 +64,10 @@ export class WebSocketProxySession { this.pendingMessages = []; }); - // Relay upstream → client (passthrough all frames) + // Relay upstream → client (strip any token fields for defense in depth) this.upstream.on('message', (data) => { if (this.closed || this.client.readyState !== WebSocket.OPEN) return; - this.client.send(data); + this.client.send(this.sanitizeUpstreamFrame(data.toString())); }); this.upstream.on('close', (code, reason) => { @@ -76,12 +84,22 @@ export class WebSocketProxySession { this.client.on('message', (data) => { if (this.closed || !this.upstream) return; + // Rate limit: sliding window + if (!this.checkRateLimit()) { + this.log('warn', 'message rate limit exceeded'); + return; + } + const raw = data.toString(); const modified = this.interceptFrame(raw); if (!modified) return; // dropped non-JSON frame - // Buffer messages until upstream is open + // Buffer messages until upstream is open (with cap) if (!this.upstreamReady) { + if (this.pendingMessages.length >= MAX_PENDING_MESSAGES) { + this.log('warn', 'pending buffer full, dropping message'); + return; + } this.log('debug', 'buffering message (upstream not ready)'); this.pendingMessages.push(modified); return; @@ -132,6 +150,28 @@ export class WebSocketProxySession { } } + /** Strip token fields from upstream responses to prevent accidental leakage */ + private sanitizeUpstreamFrame(raw: string): string { + try { + const frame = JSON.parse(raw); + if (frame.type === 'res' && frame.payload?.token) { + delete frame.payload.token; + return JSON.stringify(frame); + } + } catch { /* non-JSON upstream frame — pass through */ } + return raw; + } + + /** Sliding window rate limiter: returns true if message is allowed */ + private checkRateLimit(): boolean { + const now = Date.now(); + // Remove timestamps outside the window + this.msgTimestamps = this.msgTimestamps.filter((t) => now - t < MSG_RATE_WINDOW_MS); + if (this.msgTimestamps.length >= MSG_RATE_LIMIT) return false; + this.msgTimestamps.push(now); + return true; + } + private closeClient(code: number, reason: string): void { if (this.closed) return; this.closed = true; From 3b86d150ab8b515697a1206e6de64f0ddbf6f6ad Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 22 Mar 2026 18:03:54 +0700 Subject: [PATCH 3/3] fix(security): migrate to proxy-only architecture, remove direct client-side auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes direct mode entirely — the webchat widget now connects exclusively through the backend proxy, which keeps the gateway auth token server-side. Client never receives or sends the token. Changes: - Config: removed `token` and `proxyUrl` fields; renamed `proxyUrl` → `url` (required), `proxyApiKey` → `apiKey` (optional). - Client: removed token sending logic; proxy injects token at connection time. - Server: added optional API key validation for proxy endpoints. - Docs: rewrote README for proxy-only setup, removed direct mode examples. This hardens security by eliminating token exposure in client-side code and browser storage. --- CLAUDE.md | 4 +- README.md | 85 ++++++++++----------------- server/.env.example | 4 ++ server/src/proxy-config.ts | 3 + server/src/proxy-server.ts | 24 ++++++++ server/src/websocket-proxy-session.ts | 60 ++++++++----------- src/types.ts | 14 ++--- src/websocket-client.ts | 13 ++-- src/wrappers/react-wrapper.tsx | 7 +-- src/wrappers/vue-wrapper.ts | 3 +- 10 files changed, 104 insertions(+), 113 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ce8985a..567b400 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Embeddable JavaScript chat widget for GoClaw AI agent gateway. ``` src/ ├── index.ts Entry point, init(), window auto-attach -├── types.ts All TypeScript interfaces (GoClawConfig includes proxyUrl) +├── types.ts All TypeScript interfaces (GoClawConfig — proxy-only, no direct mode) ├── websocket-client.ts WS connection, auth, RPC, events, reconnect ├── chat-widget.ts Shadow DOM UI, message rendering ├── markdown-renderer.ts Lightweight markdown→HTML @@ -54,4 +54,4 @@ npm start # Run compiled proxy - GoClaw WebSocket Protocol v3 (req/res/event frames) - Exponential backoff reconnection - Async snippet loader pattern (like Intercom) -- Proxy mode: backend injects auth token into WS connect frame, client never sees it +- Proxy-only: backend injects auth token into WS connect frame, client never sees it (no direct mode) diff --git a/README.md b/README.md index 9837daa..b3399c3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,19 @@ Embeddable chat widget for [GoClaw](https://goclaw.sh) AI agent gateway. Drop a ` - -``` - -The proxy supports origin validation (`ALLOWED_ORIGINS`), per-IP connection limits, and graceful shutdown. - ## Configuration | Option | Type | Default | Description | |--------|------|---------|-------------| -| `url` | `string` | *required* | GoClaw WebSocket URL (`wss://...`) | -| `proxyUrl` | `string` | — | Proxy server WebSocket URL (recommended for production) | -| `token` | `string` | — | Gateway authentication token (not needed with proxy) | +| `url` | `string` | *required* | Proxy server WebSocket URL (`wss://...`) | +| `apiKey` | `string` | — | API key for proxy authentication | | `userId` | `string` | auto-generated | User identifier | | `agentId` | `string` | — | Specific agent to chat with | | `sessionId` | `string` | — | Resume a previous session | @@ -165,7 +144,7 @@ The proxy supports origin validation (`ALLOWED_ORIGINS`), per-IP connection limi ```js GoClaw.init({ - url: 'wss://...', + url: 'wss://proxy.example.com/ws', theme: { base: 'dark', primaryColor: '#8b5cf6', @@ -198,7 +177,7 @@ widget.destroy(); // Remove widget & disconnect ```js GoClaw.init({ - url: 'wss://...', + url: 'wss://proxy.example.com/ws', onOpen: () => console.log('Chat opened'), onClose: () => console.log('Chat closed'), onMessage: (msg) => console.log('Message:', msg), @@ -211,17 +190,17 @@ GoClaw.init({ ## GoClaw Server Setup 1. Ensure your GoClaw server has WebSocket enabled (default on `/ws`) -2. Add your widget's domain to `allowed_origins` in GoClaw config: +2. Add the **proxy server's** origin to `allowed_origins` in GoClaw config: ```json { "gateway": { - "allowed_origins": ["https://your-website.com"] + "allowed_origins": ["https://your-proxy-server.com"] } } ``` -3. Use a gateway token for authentication, or set up browser pairing +3. Configure the gateway token in the proxy server's `.env` file ## Development diff --git a/server/.env.example b/server/.env.example index 7730da3..1a1b3ea 100644 --- a/server/.env.example +++ b/server/.env.example @@ -23,5 +23,9 @@ PORT=3100 # Set to "true" when running behind a reverse proxy (nginx, cloudflare, etc.) # TRUST_PROXY=true +# Optional: API key to authenticate proxy connections (empty = no auth) +# Clients pass via query param (?apiKey=xxx) or X-API-Key header +# PROXY_API_KEY=your-secret-api-key + # Optional: Log level: debug, info, warn, error (default: info) # LOG_LEVEL=info diff --git a/server/src/proxy-config.ts b/server/src/proxy-config.ts index 6da5a57..a1324f9 100644 --- a/server/src/proxy-config.ts +++ b/server/src/proxy-config.ts @@ -20,6 +20,8 @@ export interface ProxyConfig { trustProxy: boolean; /** Log level: "debug" | "info" | "warn" | "error" (default: "info") */ logLevel: LogLevel; + /** Optional API key to authenticate proxy connections (empty = no auth required) */ + proxyApiKey?: string; } /** Load proxy config from environment variables with sensible defaults */ @@ -63,5 +65,6 @@ export function loadConfig(): ProxyConfig { maxConnectionsPerIp, trustProxy: process.env.TRUST_PROXY === 'true', logLevel, + proxyApiKey: process.env.PROXY_API_KEY || undefined, }; } diff --git a/server/src/proxy-server.ts b/server/src/proxy-server.ts index f2d170e..07488cd 100644 --- a/server/src/proxy-server.ts +++ b/server/src/proxy-server.ts @@ -31,6 +31,13 @@ export function startProxyServer(config: ProxyConfig): void { path: '/ws', maxPayload: 512 * 1024, // 512KB max frame size (matches GoClaw gateway limit) verifyClient: ({ req }, callback) => { + // API key check (optional — when PROXY_API_KEY is set, clients must provide it) + if (config.proxyApiKey && !checkApiKey(req, config.proxyApiKey)) { + console.warn('[proxy] invalid or missing API key'); + callback(false, 401, 'Unauthorized'); + return; + } + // Origin check if (!checkOrigin(req, config.allowedOrigins)) { console.warn(`[proxy] origin rejected: ${req.headers.origin}`); @@ -74,7 +81,9 @@ export function startProxyServer(config: ProxyConfig): void { httpServer.listen(config.port, () => { console.log(`[proxy] listening on :${config.port}`); console.log(`[proxy] upstream: ${config.goclawUrl}`); + // SECURITY: Never log the actual token value — only log its presence console.log(`[proxy] auth token: ${config.goclawToken ? 'configured' : 'NOT SET'}`); + console.log(`[proxy] API key: ${config.proxyApiKey ? 'required' : 'disabled'}`); if (config.allowedOrigins.length > 0) { console.log(`[proxy] allowed origins: ${config.allowedOrigins.join(', ')}`); } else { @@ -100,6 +109,21 @@ export function startProxyServer(config: ProxyConfig): void { process.on('SIGTERM', shutdown); } +/** Validate API key from query string or header. + * Key can be passed as ?apiKey=xxx query param or X-API-Key header. */ +function checkApiKey(req: IncomingMessage, expectedKey: string): boolean { + // Check query param: /ws?apiKey=xxx + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const queryKey = url.searchParams.get('apiKey'); + if (queryKey === expectedKey) return true; + + // Check header: X-API-Key + const headerKey = req.headers['x-api-key']; + if (typeof headerKey === 'string' && headerKey === expectedKey) return true; + + return false; +} + /** Validate request origin against allowed origins list. * When origins are configured, missing Origin header is rejected * to prevent bypass via non-browser clients. */ diff --git a/server/src/websocket-proxy-session.ts b/server/src/websocket-proxy-session.ts index aff0893..f8639ca 100644 --- a/server/src/websocket-proxy-session.ts +++ b/server/src/websocket-proxy-session.ts @@ -18,7 +18,7 @@ export class WebSocketProxySession { private sessionId: string; private pendingMessages: string[] = []; private connectTimeout: ReturnType | null = null; - private msgTimestamps: number[] = []; // sliding window for rate limiting + private msgTimestamps: number[] = []; constructor( private client: WebSocket, @@ -34,7 +34,7 @@ export class WebSocketProxySession { try { this.upstream = new WebSocket(this.config.goclawUrl, { - maxPayload: 512 * 1024, // 512KB max frame size (matches GoClaw gateway) + maxPayload: 512 * 1024, }); } catch (err) { this.log('error', `upstream connection failed: ${err}`); @@ -42,11 +42,9 @@ export class WebSocketProxySession { return; } - // Timeout if upstream doesn't connect within 10 seconds this.connectTimeout = setTimeout(() => { this.log('error', 'upstream connection timed out'); - this.upstream?.close(); - this.closeClient(1011, 'upstream timeout'); + this.destroy(); }, 10_000); this.upstream.on('open', () => { @@ -57,14 +55,13 @@ export class WebSocketProxySession { this.upstreamReady = true; this.log('info', 'upstream connected'); - // Flush any messages queued while upstream was connecting for (const msg of this.pendingMessages) { this.upstream!.send(msg); } this.pendingMessages = []; }); - // Relay upstream → client (strip any token fields for defense in depth) + // Relay upstream → client (strip token fields for defense in depth) this.upstream.on('message', (data) => { if (this.closed || this.client.readyState !== WebSocket.OPEN) return; this.client.send(this.sanitizeUpstreamFrame(data.toString())); @@ -72,19 +69,18 @@ export class WebSocketProxySession { this.upstream.on('close', (code, reason) => { this.log('info', `upstream closed: ${code} ${reason}`); - this.closeClient(code, reason.toString()); + this.destroy(code, reason.toString()); }); this.upstream.on('error', (err) => { this.log('error', `upstream error: ${err.message}`); - this.closeClient(1011, 'upstream error'); + this.destroy(1011, 'upstream error'); }); // Relay client → upstream (intercept `connect` to inject token) this.client.on('message', (data) => { if (this.closed || !this.upstream) return; - // Rate limit: sliding window if (!this.checkRateLimit()) { this.log('warn', 'message rate limit exceeded'); return; @@ -92,9 +88,8 @@ export class WebSocketProxySession { const raw = data.toString(); const modified = this.interceptFrame(raw); - if (!modified) return; // dropped non-JSON frame + if (modified === null) return; // dropped non-JSON frame - // Buffer messages until upstream is open (with cap) if (!this.upstreamReady) { if (this.pendingMessages.length >= MAX_PENDING_MESSAGES) { this.log('warn', 'pending buffer full, dropping message'); @@ -110,21 +105,21 @@ export class WebSocketProxySession { this.client.on('close', () => { this.log('info', 'client disconnected'); - this.closeUpstream(); + this.destroy(); }); this.client.on('error', (err) => { this.log('error', `client error: ${err.message}`); - this.closeUpstream(); + this.destroy(); }); } - /** Intercept outgoing frames to inject auth token into connect requests */ - private interceptFrame(raw: string): string { + /** Intercept outgoing frames to inject auth token into connect requests. + * Returns null for non-JSON frames (dropped per GoClaw protocol). */ + private interceptFrame(raw: string): string | null { try { const frame = JSON.parse(raw); - // Only intercept "connect" method requests if (frame.type !== 'req' || frame.method !== 'connect') { return raw; } @@ -146,7 +141,7 @@ export class WebSocketProxySession { } catch { // GoClaw protocol is JSON-only — drop non-JSON frames this.log('warn', 'dropping non-JSON frame from client'); - return ''; + return null; } } @@ -165,42 +160,35 @@ export class WebSocketProxySession { /** Sliding window rate limiter: returns true if message is allowed */ private checkRateLimit(): boolean { const now = Date.now(); - // Remove timestamps outside the window this.msgTimestamps = this.msgTimestamps.filter((t) => now - t < MSG_RATE_WINDOW_MS); if (this.msgTimestamps.length >= MSG_RATE_LIMIT) return false; this.msgTimestamps.push(now); return true; } - private closeClient(code: number, reason: string): void { + /** Clean up both connections. Optionally close client with a specific code/reason. */ + destroy(clientCloseCode?: number, clientCloseReason?: string): void { if (this.closed) return; this.closed = true; - if (this.client.readyState === WebSocket.OPEN) { - this.client.close(code, reason); - } - } - - private closeUpstream(): void { - if (this.closed) return; - this.closed = true; - if (this.upstream && this.upstream.readyState === WebSocket.OPEN) { - this.upstream.close(); - } - this.upstream = null; - } - /** Clean up both connections */ - destroy(): void { - this.closed = true; if (this.connectTimeout) { clearTimeout(this.connectTimeout); this.connectTimeout = null; } + this.pendingMessages = []; + this.msgTimestamps = []; + + // Close upstream if (this.upstream && this.upstream.readyState !== WebSocket.CLOSED) { this.upstream.close(); } this.upstream = null; + + // Close client + if (this.client.readyState === WebSocket.OPEN) { + this.client.close(clientCloseCode ?? 1000, clientCloseReason ?? 'session ended'); + } } private log(level: string, message: string): void { diff --git a/src/types.ts b/src/types.ts index bbc6db5..5cac86d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,17 +1,11 @@ // ── GoClaw WebSocket Protocol v3 Types ── export interface GoClawConfig { - /** WebSocket URL for direct connection (e.g., "wss://goclaw.example.com/ws") */ + /** Proxy server WebSocket URL (e.g., "wss://proxy.example.com/ws"). + * The proxy keeps the gateway auth token server-side — never exposed to the client. */ url: string; - /** Gateway token for direct authentication (⚠️ exposed in client-side code) */ - token?: string; - /** - * Proxy server WebSocket URL (e.g., "wss://proxy.example.com/ws"). - * When set, the widget connects through the proxy instead of directly to the gateway. - * The proxy keeps the auth token server-side — no token needed in client config. - * Mutually preferred over `url` + `token` for production deployments. - */ - proxyUrl?: string; + /** API key for proxy authentication (appended as ?apiKey=xxx to url) */ + apiKey?: string; /** User identifier */ userId?: string; /** Agent ID to chat with (optional, uses default agent if omitted) */ diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 5858df6..de35792 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -183,8 +183,12 @@ export class GoClawWebSocketClient { private createAndAuthenticate(): Promise { return new Promise((resolve, reject) => { try { - // Use proxy URL when configured (token is kept server-side by proxy) - const wsUrl = this.config.proxyUrl ?? this.config.url; + let wsUrl = this.config.url; + // Append API key if configured + if (this.config.apiKey) { + const separator = wsUrl.includes('?') ? '&' : '?'; + wsUrl = `${wsUrl}${separator}apiKey=${encodeURIComponent(this.config.apiKey)}`; + } this.ws = new WebSocket(wsUrl); } catch (err) { reject(err instanceof Error ? err : new Error(String(err))); @@ -315,10 +319,7 @@ export class GoClawWebSocketClient { protocol: 3, user_id: this.config.userId || `web_${Date.now()}`, }; - // Only send token in direct mode; proxy injects it server-side - if (!this.config.proxyUrl && this.config.token) { - params.token = this.config.token; - } + // Auth token is injected server-side by the proxy — never sent from client return this.request('connect', params).then((res) => { if (!res.ok) { diff --git a/src/wrappers/react-wrapper.tsx b/src/wrappers/react-wrapper.tsx index a1c8640..fea76af 100644 --- a/src/wrappers/react-wrapper.tsx +++ b/src/wrappers/react-wrapper.tsx @@ -19,8 +19,7 @@ export interface GoClawChatProps extends GoClawConfig { * function App() { * return ( * @@ -48,9 +47,9 @@ export function GoClawChat({ widgetRef, ...config }: GoClawChatProps) { widgetInstance.current = null; if (widgetRef) widgetRef.current = null; }; - // Only re-create on url/token change + // Only re-create on url change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config.url, config.token]); + }, [config.url]); return
; } diff --git a/src/wrappers/vue-wrapper.ts b/src/wrappers/vue-wrapper.ts index c43fe36..53e7ec3 100644 --- a/src/wrappers/vue-wrapper.ts +++ b/src/wrappers/vue-wrapper.ts @@ -18,8 +18,7 @@ let widgetInstance: GoClawWidget | null = null; * * const app = createApp(App); * app.use(GoClawPlugin, { - * url: 'wss://goclaw.example.com/ws', - * token: 'your-token', + * url: 'wss://proxy.example.com/ws', * title: 'Support', * }); * ```