Skip to content

Commit 5e78c8b

Browse files
authored
Webhooks: tighten pre-auth body handling (#46802)
* Webhooks: tighten pre-auth body handling * Webhooks: clean up request body guards
1 parent 7679eb3 commit 5e78c8b

8 files changed

Lines changed: 64 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
3030
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
3131
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
32+
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
3233
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
3334
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
3435
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.

extensions/googlechat/src/monitor-webhook.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ function extractBearerToken(header: unknown): string {
2121
: "";
2222
}
2323

24+
const ADD_ON_PREAUTH_MAX_BYTES = 16 * 1024;
25+
const ADD_ON_PREAUTH_TIMEOUT_MS = 3_000;
26+
2427
type ParsedGoogleChatInboundPayload =
2528
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
2629
| { ok: false };
@@ -112,6 +115,12 @@ export function createGoogleChatWebhookRequestHandler(params: {
112115
req,
113116
res,
114117
profile,
118+
...(profile === "pre-auth"
119+
? {
120+
maxBytes: ADD_ON_PREAUTH_MAX_BYTES,
121+
timeoutMs: ADD_ON_PREAUTH_TIMEOUT_MS,
122+
}
123+
: {}),
115124
emptyObjectOnEmpty: false,
116125
invalidJsonMessage: "invalid payload",
117126
});

extensions/mattermost/src/mattermost/slash-http.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { IncomingMessage, ServerResponse } from "node:http";
22
import { PassThrough } from "node:stream";
33
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost";
4-
import { describe, expect, it } from "vitest";
4+
import { describe, expect, it, vi } from "vitest";
55
import type { ResolvedMattermostAccount } from "./accounts.js";
66
import { createSlashCommandHttpHandler } from "./slash-http.js";
77

88
function createRequest(params: {
99
method?: string;
1010
body?: string;
1111
contentType?: string;
12+
autoEnd?: boolean;
1213
}): IncomingMessage {
1314
const req = new PassThrough();
1415
const incoming = req as unknown as IncomingMessage;
@@ -20,7 +21,9 @@ function createRequest(params: {
2021
if (params.body) {
2122
req.write(params.body);
2223
}
23-
req.end();
24+
if (params.autoEnd !== false) {
25+
req.end();
26+
}
2427
});
2528
return incoming;
2629
}
@@ -128,4 +131,27 @@ describe("slash-http", () => {
128131
expect(response.res.statusCode).toBe(401);
129132
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
130133
});
134+
135+
it("returns 408 when the request body stalls", async () => {
136+
vi.useFakeTimers();
137+
try {
138+
const handler = createSlashCommandHttpHandler({
139+
account: accountFixture,
140+
cfg: {} as OpenClawConfig,
141+
runtime: {} as RuntimeEnv,
142+
commandTokens: new Set(["valid-token"]),
143+
});
144+
const req = createRequest({ autoEnd: false });
145+
const response = createResponse();
146+
const pending = handler(req, response.res);
147+
148+
await vi.advanceTimersByTimeAsync(5_000);
149+
await pending;
150+
151+
expect(response.res.statusCode).toBe(408);
152+
expect(response.getBody()).toBe("Request body timeout");
153+
} finally {
154+
vi.useRealTimers();
155+
}
156+
});
131157
});

extensions/mattermost/src/mattermost/slash-http.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
buildModelsProviderData,
1111
createReplyPrefixOptions,
1212
createTypingCallbacks,
13+
isRequestBodyLimitError,
1314
logTypingFailure,
15+
readRequestBodyWithLimit,
1416
type OpenClawConfig,
1517
type ReplyPayload,
1618
type RuntimeEnv,
@@ -54,24 +56,16 @@ type SlashHttpHandlerParams = {
5456
log?: (msg: string) => void;
5557
};
5658

59+
const MAX_BODY_BYTES = 64 * 1024;
60+
const BODY_READ_TIMEOUT_MS = 5_000;
61+
5762
/**
5863
* Read the full request body as a string.
5964
*/
6065
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
61-
return new Promise((resolve, reject) => {
62-
const chunks: Buffer[] = [];
63-
let size = 0;
64-
req.on("data", (chunk: Buffer) => {
65-
size += chunk.length;
66-
if (size > maxBytes) {
67-
req.destroy();
68-
reject(new Error("Request body too large"));
69-
return;
70-
}
71-
chunks.push(chunk);
72-
});
73-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
74-
req.on("error", reject);
66+
return readRequestBodyWithLimit(req, {
67+
maxBytes,
68+
timeoutMs: BODY_READ_TIMEOUT_MS,
7569
});
7670
}
7771

@@ -215,8 +209,6 @@ async function authorizeSlashInvocation(params: {
215209
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
216210
const { account, cfg, runtime, commandTokens, triggerMap, log } = params;
217211

218-
const MAX_BODY_BYTES = 64 * 1024; // 64KB
219-
220212
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
221213
if (req.method !== "POST") {
222214
res.statusCode = 405;
@@ -228,7 +220,12 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
228220
let body: string;
229221
try {
230222
body = await readBody(req, MAX_BODY_BYTES);
231-
} catch {
223+
} catch (error) {
224+
if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) {
225+
res.statusCode = 408;
226+
res.end("Request body timeout");
227+
return;
228+
}
232229
res.statusCode = 413;
233230
res.end("Payload Too Large");
234231
return;

extensions/msteams/src/monitor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export async function monitorMSTeamsProvider(
269269

270270
// Create Express server
271271
const expressApp = express.default();
272+
expressApp.use(authorizeJWT(authConfig));
272273
expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES }));
273274
expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => {
274275
if (err && typeof err === "object" && "status" in err && err.status === 413) {
@@ -277,7 +278,6 @@ export async function monitorMSTeamsProvider(
277278
}
278279
next(err);
279280
});
280-
expressApp.use(authorizeJWT(authConfig));
281281

282282
// Set up the messages endpoint - use configured path and /api/messages as fallback
283283
const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";

extensions/nextcloud-talk/src/monitor.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
2525
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
2626
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
2727
const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
28+
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
29+
const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
2830
const HEALTH_PATH = "/healthz";
2931
const WEBHOOK_ERRORS = {
3032
missingSignatureHeaders: "Missing signature headers",
@@ -171,8 +173,10 @@ export function readNextcloudTalkWebhookBody(
171173
maxBodyBytes: number,
172174
): Promise<string> {
173175
return readRequestBodyWithLimit(req, {
174-
maxBytes: maxBodyBytes,
175-
timeoutMs: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS,
176+
// This read happens before signature verification, so keep the unauthenticated
177+
// body budget bounded even if the operator-configured post-parse limit is larger.
178+
maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
179+
timeoutMs: PREAUTH_WEBHOOK_BODY_TIMEOUT_MS,
176180
});
177181
}
178182

extensions/synology-chat/src/webhook-handler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./type
1616

1717
// One rate limiter per account, created lazily
1818
const rateLimiters = new Map<string, RateLimiter>();
19+
const PREAUTH_MAX_BODY_BYTES = 64 * 1024;
20+
const PREAUTH_BODY_TIMEOUT_MS = 5_000;
1921

2022
function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
2123
let rl = rateLimiters.get(account.accountId);
@@ -49,8 +51,8 @@ async function readBody(req: IncomingMessage): Promise<
4951
> {
5052
try {
5153
const body = await readRequestBodyWithLimit(req, {
52-
maxBytes: 1_048_576,
53-
timeoutMs: 30_000,
54+
maxBytes: PREAUTH_MAX_BODY_BYTES,
55+
timeoutMs: PREAUTH_BODY_TIMEOUT_MS,
5456
});
5557
return { ok: true, body };
5658
} catch (err) {

src/plugin-sdk/mattermost.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,4 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js";
104104
export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
105105
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
106106
export { createScopedPairingAccess } from "./pairing-access.js";
107+
export { isRequestBodyLimitError, readRequestBodyWithLimit } from "../infra/http-body.js";

0 commit comments

Comments
 (0)