mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 01:48:28 +00:00
Gateway: stop repeated unauthorized WS request floods per connection (#24294)
* Gateway WS: add unauthorized flood guard primitive * Gateway WS: close repeated unauthorized post-handshake request floods * Gateway WS: test unauthorized flood guard behavior * Changelog: note gateway WS unauthorized flood guard hardening * Update CHANGELOG.md
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||||
- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051.
|
- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051.
|
||||||
- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316.
|
- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316.
|
||||||
- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc.
|
- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc.
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
resolveControlUiAuthPolicy,
|
resolveControlUiAuthPolicy,
|
||||||
shouldSkipControlUiPairing,
|
shouldSkipControlUiPairing,
|
||||||
} from "./connect-policy.js";
|
} from "./connect-policy.js";
|
||||||
|
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
|
||||||
|
|
||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||||
|
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
|
||||||
|
|
||||||
socket.on("message", async (data) => {
|
socket.on("message", async (data) => {
|
||||||
if (isClosed()) {
|
if (isClosed()) {
|
||||||
@@ -908,6 +910,33 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
meta?: Record<string, unknown>,
|
meta?: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
send({ type: "res", id: req.id, ok, payload, error });
|
send({ type: "res", id: req.id, ok, payload, error });
|
||||||
|
const unauthorizedRoleError = isUnauthorizedRoleError(error);
|
||||||
|
let logMeta = meta;
|
||||||
|
if (unauthorizedRoleError) {
|
||||||
|
const unauthorizedDecision = unauthorizedFloodGuard.registerUnauthorized();
|
||||||
|
if (unauthorizedDecision.suppressedSinceLastLog > 0) {
|
||||||
|
logMeta = {
|
||||||
|
...logMeta,
|
||||||
|
suppressedUnauthorizedResponses: unauthorizedDecision.suppressedSinceLastLog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!unauthorizedDecision.shouldLog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unauthorizedDecision.shouldClose) {
|
||||||
|
setCloseCause("repeated-unauthorized-requests", {
|
||||||
|
unauthorizedCount: unauthorizedDecision.count,
|
||||||
|
method: req.method,
|
||||||
|
});
|
||||||
|
queueMicrotask(() => close(1008, "repeated unauthorized calls"));
|
||||||
|
}
|
||||||
|
logMeta = {
|
||||||
|
...logMeta,
|
||||||
|
unauthorizedCount: unauthorizedDecision.count,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
unauthorizedFloodGuard.reset();
|
||||||
|
}
|
||||||
logWs("out", "res", {
|
logWs("out", "res", {
|
||||||
connId,
|
connId,
|
||||||
id: req.id,
|
id: req.id,
|
||||||
@@ -915,7 +944,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
method: req.method,
|
method: req.method,
|
||||||
errorCode: error?.code,
|
errorCode: error?.code,
|
||||||
errorMessage: error?.message,
|
errorMessage: error?.message,
|
||||||
...meta,
|
...logMeta,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ErrorCodes, errorShape } from "../../protocol/index.js";
|
||||||
|
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
|
||||||
|
|
||||||
|
describe("UnauthorizedFloodGuard", () => {
|
||||||
|
it("suppresses repeated unauthorized responses and closes after threshold", () => {
|
||||||
|
const guard = new UnauthorizedFloodGuard({ closeAfter: 2, logEvery: 3 });
|
||||||
|
|
||||||
|
const first = guard.registerUnauthorized();
|
||||||
|
expect(first).toEqual({
|
||||||
|
shouldClose: false,
|
||||||
|
shouldLog: true,
|
||||||
|
count: 1,
|
||||||
|
suppressedSinceLastLog: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = guard.registerUnauthorized();
|
||||||
|
expect(second).toEqual({
|
||||||
|
shouldClose: false,
|
||||||
|
shouldLog: false,
|
||||||
|
count: 2,
|
||||||
|
suppressedSinceLastLog: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const third = guard.registerUnauthorized();
|
||||||
|
expect(third).toEqual({
|
||||||
|
shouldClose: true,
|
||||||
|
shouldLog: true,
|
||||||
|
count: 3,
|
||||||
|
suppressedSinceLastLog: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets counters", () => {
|
||||||
|
const guard = new UnauthorizedFloodGuard({ closeAfter: 10, logEvery: 50 });
|
||||||
|
guard.registerUnauthorized();
|
||||||
|
guard.registerUnauthorized();
|
||||||
|
guard.reset();
|
||||||
|
|
||||||
|
const next = guard.registerUnauthorized();
|
||||||
|
expect(next).toEqual({
|
||||||
|
shouldClose: false,
|
||||||
|
shouldLog: true,
|
||||||
|
count: 1,
|
||||||
|
suppressedSinceLastLog: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isUnauthorizedRoleError", () => {
|
||||||
|
it("detects unauthorized role responses", () => {
|
||||||
|
expect(
|
||||||
|
isUnauthorizedRoleError(errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized role: node")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-role authorization errors", () => {
|
||||||
|
expect(
|
||||||
|
isUnauthorizedRoleError(
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isUnauthorizedRoleError(errorShape(ErrorCodes.UNAVAILABLE, "service unavailable"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/gateway/server/ws-connection/unauthorized-flood-guard.ts
Normal file
69
src/gateway/server/ws-connection/unauthorized-flood-guard.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ErrorCodes, type ErrorShape } from "../../protocol/index.js";
|
||||||
|
|
||||||
|
export type UnauthorizedFloodGuardOptions = {
|
||||||
|
closeAfter?: number;
|
||||||
|
logEvery?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnauthorizedFloodDecision = {
|
||||||
|
shouldClose: boolean;
|
||||||
|
shouldLog: boolean;
|
||||||
|
count: number;
|
||||||
|
suppressedSinceLastLog: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CLOSE_AFTER = 10;
|
||||||
|
const DEFAULT_LOG_EVERY = 100;
|
||||||
|
|
||||||
|
export class UnauthorizedFloodGuard {
|
||||||
|
private readonly closeAfter: number;
|
||||||
|
private readonly logEvery: number;
|
||||||
|
private count = 0;
|
||||||
|
private suppressedSinceLastLog = 0;
|
||||||
|
|
||||||
|
constructor(options?: UnauthorizedFloodGuardOptions) {
|
||||||
|
this.closeAfter = Math.max(1, Math.floor(options?.closeAfter ?? DEFAULT_CLOSE_AFTER));
|
||||||
|
this.logEvery = Math.max(1, Math.floor(options?.logEvery ?? DEFAULT_LOG_EVERY));
|
||||||
|
}
|
||||||
|
|
||||||
|
registerUnauthorized(): UnauthorizedFloodDecision {
|
||||||
|
this.count += 1;
|
||||||
|
const shouldClose = this.count > this.closeAfter;
|
||||||
|
const shouldLog = this.count === 1 || this.count % this.logEvery === 0 || shouldClose;
|
||||||
|
|
||||||
|
if (!shouldLog) {
|
||||||
|
this.suppressedSinceLastLog += 1;
|
||||||
|
return {
|
||||||
|
shouldClose,
|
||||||
|
shouldLog: false,
|
||||||
|
count: this.count,
|
||||||
|
suppressedSinceLastLog: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppressedSinceLastLog = this.suppressedSinceLastLog;
|
||||||
|
this.suppressedSinceLastLog = 0;
|
||||||
|
return {
|
||||||
|
shouldClose,
|
||||||
|
shouldLog: true,
|
||||||
|
count: this.count,
|
||||||
|
suppressedSinceLastLog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.count = 0;
|
||||||
|
this.suppressedSinceLastLog = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnauthorizedRoleError(error?: ErrorShape): boolean {
|
||||||
|
if (!error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
error.code === ErrorCodes.INVALID_REQUEST &&
|
||||||
|
typeof error.message === "string" &&
|
||||||
|
error.message.startsWith("unauthorized role:")
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user