mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
fix(gateway): harden browser websocket auth chain
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
|
||||
- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
|
||||
- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.
|
||||
- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
|
||||
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
|
||||
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
|
||||
|
||||
@@ -2145,8 +2145,9 @@ See [Plugins](/tools/plugin).
|
||||
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
|
||||
- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force).
|
||||
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
|
||||
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds.
|
||||
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
|
||||
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
|
||||
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
|
||||
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
|
||||
|
||||
@@ -16,6 +16,8 @@ export function attachGatewayWsHandlers(params: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
/** Browser-origin fallback limiter (loopback is never exempt). */
|
||||
browserRateLimiter?: AuthRateLimiter;
|
||||
gatewayMethods: string[];
|
||||
events: string[];
|
||||
logGateway: ReturnType<typeof createSubsystemLogger>;
|
||||
@@ -41,6 +43,7 @@ export function attachGatewayWsHandlers(params: {
|
||||
canvasHostServerPort: params.canvasHostServerPort,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
browserRateLimiter: params.browserRateLimiter,
|
||||
gatewayMethods: params.gatewayMethods,
|
||||
events: params.events,
|
||||
logGateway: params.logGateway,
|
||||
|
||||
@@ -672,6 +672,17 @@ describe("gateway server auth/connect", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects non-local browser origins for non-control-ui clients", async () => {
|
||||
const ws = await openWs(port, { origin: "https://attacker.example" });
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("origin not allowed");
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("returns control ui hint when token is missing", async () => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
const res = await connectReq(ws, {
|
||||
@@ -701,6 +712,27 @@ describe("gateway server auth/connect", () => {
|
||||
);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => {
|
||||
testState.gatewayAuth = {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true },
|
||||
};
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const firstWs = await openWs(port, { origin: originForPort(port) });
|
||||
const first = await connectReq(firstWs, { token: "wrong" });
|
||||
expect(first.ok).toBe(false);
|
||||
expect(first.error?.message ?? "").not.toContain("retry later");
|
||||
firstWs.close();
|
||||
|
||||
const secondWs = await openWs(port, { origin: originForPort(port) });
|
||||
const second = await connectReq(secondWs, { token: "wrong" });
|
||||
expect(second.ok).toBe(false);
|
||||
expect(second.error?.message ?? "").toContain("retry later");
|
||||
secondWs.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit none auth", () => {
|
||||
@@ -1214,6 +1246,43 @@ describe("gateway server auth/connect", () => {
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
|
||||
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
ws.close();
|
||||
|
||||
const browserWs = await openWs(port, { origin: originForPort(port) });
|
||||
const nonce = await readConnectChallengeNonce(browserWs);
|
||||
const { identity, device } = await createSignedDevice({
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
clientId: TEST_OPERATOR_CLIENT.id,
|
||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||
identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`),
|
||||
nonce,
|
||||
});
|
||||
const res = await connectReq(browserWs, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
device,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("pairing required");
|
||||
|
||||
const pairing = await listDevicePairing();
|
||||
const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId);
|
||||
expect(pending).toBeTruthy();
|
||||
expect(pending?.silent).toBe(false);
|
||||
|
||||
browserWs.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("merges remote node/operator pairing requests for the same unpaired device", async () => {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
@@ -316,6 +316,11 @@ export async function startGatewayServer(
|
||||
const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig
|
||||
? createAuthRateLimiter(rateLimitConfig)
|
||||
: undefined;
|
||||
// Always keep a browser-origin fallback limiter for WS auth attempts.
|
||||
const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({
|
||||
...rateLimitConfig,
|
||||
exemptLoopback: false,
|
||||
});
|
||||
|
||||
let controlUiRootState: ControlUiRootState | undefined;
|
||||
if (controlUiRootOverride) {
|
||||
@@ -574,6 +579,7 @@ export async function startGatewayServer(
|
||||
canvasHostServerPort,
|
||||
resolvedAuth,
|
||||
rateLimiter: authRateLimiter,
|
||||
browserRateLimiter: browserAuthRateLimiter,
|
||||
gatewayMethods,
|
||||
events: GATEWAY_EVENTS,
|
||||
logGateway: log,
|
||||
@@ -777,6 +783,7 @@ export async function startGatewayServer(
|
||||
}
|
||||
skillsChangeUnsub();
|
||||
authRateLimiter?.dispose();
|
||||
browserAuthRateLimiter.dispose();
|
||||
channelHealthMonitor?.stop();
|
||||
await close(opts);
|
||||
},
|
||||
|
||||
@@ -65,6 +65,8 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
/** Browser-origin fallback limiter (loopback is never exempt). */
|
||||
browserRateLimiter?: AuthRateLimiter;
|
||||
gatewayMethods: string[];
|
||||
events: string[];
|
||||
logGateway: SubsystemLogger;
|
||||
@@ -90,6 +92,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
canvasHostServerPort,
|
||||
resolvedAuth,
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
gatewayMethods,
|
||||
events,
|
||||
logGateway,
|
||||
@@ -278,6 +281,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
connectNonce,
|
||||
resolvedAuth,
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
gatewayMethods,
|
||||
events,
|
||||
extraHandlers,
|
||||
|
||||
@@ -99,6 +99,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
/** Browser-origin fallback limiter (loopback is never exempt). */
|
||||
browserRateLimiter?: AuthRateLimiter;
|
||||
gatewayMethods: string[];
|
||||
events: string[];
|
||||
extraHandlers: GatewayRequestHandlers;
|
||||
@@ -130,6 +132,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectNonce,
|
||||
resolvedAuth,
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
gatewayMethods,
|
||||
events,
|
||||
extraHandlers,
|
||||
@@ -192,6 +195,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
|
||||
const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== "");
|
||||
const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders;
|
||||
const browserRateLimitClientIp =
|
||||
hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp;
|
||||
const authRateLimiter =
|
||||
hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter;
|
||||
|
||||
socket.on("message", async (data) => {
|
||||
if (isClosed()) {
|
||||
@@ -329,7 +338,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||
const isWebchat = isWebchatConnect(connectParams);
|
||||
if (isControlUi || isWebchat) {
|
||||
if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) {
|
||||
const originCheck = checkBrowserOrigin({
|
||||
requestHost,
|
||||
origin: requestOrigin,
|
||||
@@ -377,8 +386,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
req: upgradeReq,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
clientIp,
|
||||
rateLimiter: authRateLimiter,
|
||||
clientIp: browserRateLimitClientIp,
|
||||
});
|
||||
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
|
||||
markHandshakeFailure("unauthorized", {
|
||||
@@ -556,8 +565,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
deviceId: device?.id,
|
||||
role,
|
||||
scopes,
|
||||
rateLimiter,
|
||||
clientIp,
|
||||
rateLimiter: authRateLimiter,
|
||||
clientIp: browserRateLimitClientIp,
|
||||
verifyDeviceToken,
|
||||
}));
|
||||
if (!authOk) {
|
||||
@@ -613,11 +622,15 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const requirePairing = async (
|
||||
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
|
||||
) => {
|
||||
const allowSilentLocalPairing =
|
||||
isLocalClient &&
|
||||
(!hasBrowserOriginHeader || isControlUi || isWebchat) &&
|
||||
(reason === "not-paired" || reason === "scope-upgrade");
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
...clientAccessMetadata,
|
||||
silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"),
|
||||
silent: allowSilentLocalPairing,
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
|
||||
Reference in New Issue
Block a user