diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d6618ff48..01b575a2bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- MSTeams/webhook ingress hardening: apply explicit HTTP server timeouts, run auth middleware before JSON body parsing, and keep timeout/lifecycle wiring compatible with gateway-managed monitor startup. (#25960) Thanks @bmendonca3. - Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source. - Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3. - Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67. diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index abf69b23d0e..ae4aa508ae9 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -6,6 +6,10 @@ import type { MSTeamsPollStore } from "./polls.js"; type FakeServer = EventEmitter & { close: (callback?: (err?: Error | null) => void) => void; + setTimeout: (msecs: number, callback?: () => void) => FakeServer; + timeout: number; + requestTimeout: number; + headersTimeout: number; }; const expressControl = vi.hoisted(() => ({ @@ -31,6 +35,13 @@ vi.mock("express", () => { post: vi.fn(), listen: vi.fn((_port: number) => { const server = new EventEmitter() as FakeServer; + server.timeout = 0; + server.requestTimeout = 0; + server.headersTimeout = 0; + server.setTimeout = vi.fn((msecs: number) => { + server.timeout = msecs; + return server; + }); server.close = (callback?: (err?: Error | null) => void) => { queueMicrotask(() => { server.emit("close"); diff --git a/extensions/msteams/src/monitor.test.ts b/extensions/msteams/src/monitor.test.ts index ea277750db2..69bb66991e0 100644 --- a/extensions/msteams/src/monitor.test.ts +++ b/extensions/msteams/src/monitor.test.ts @@ -37,7 +37,7 @@ async function waitForSlowBodySocketClose(port: number, timeoutMs: number): Prom } describe("msteams monitor webhook hardening", () => { - it("applies explicit webhook timeout values", async () => { + it("applies timeout values and clamps headersTimeout to requestTimeout", async () => { const app = express(); const server = app.listen(0, "127.0.0.1"); await once(server, "listening"); diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts index 8ae4f7e3173..ff60a5d4e7d 100644 --- a/extensions/msteams/src/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -268,6 +268,7 @@ export async function monitorMSTeamsProvider( // Create Express server const expressApp = express.default(); + expressApp.use(authorizeJWT(authConfig)); expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES })); expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => { if (err && typeof err === "object" && "status" in err && err.status === 413) { @@ -276,7 +277,6 @@ export async function monitorMSTeamsProvider( } next(err); }); - expressApp.use(authorizeJWT(authConfig)); // Set up the messages endpoint - use configured path and /api/messages as fallback const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages"; @@ -301,9 +301,9 @@ export async function monitorMSTeamsProvider( // Start listening and fail fast if bind/listen fails. const httpServer = expressApp.listen(port); + applyMSTeamsWebhookTimeouts(httpServer); await new Promise((resolve, reject) => { const onListening = () => { - httpServer.off("error", onError); log.info(`msteams provider started on port ${port}`); resolve(); }; @@ -315,8 +315,6 @@ export async function monitorMSTeamsProvider( httpServer.once("listening", onListening); httpServer.once("error", onError); }); - applyMSTeamsWebhookTimeouts(httpServer); - httpServer.on("error", (err) => { log.error("msteams server error", { error: String(err) }); }); @@ -333,24 +331,30 @@ export async function monitorMSTeamsProvider( }); }; - // Handle abort signal - const onAbort = () => { - void shutdown(); - }; - if (opts.abortSignal) { - if (opts.abortSignal.aborted) { - onAbort(); - } else { - opts.abortSignal.addEventListener("abort", onAbort, { once: true }); - } + // Some direct callers may invoke monitor without lifecycle wiring. + // Return immediately so they can call shutdown explicitly. + if (!opts.abortSignal) { + return { app: expressApp, shutdown }; } - // Keep this task alive until shutdown/close so gateway runtime does not treat startup as exit. - await new Promise((resolve) => { + const closePromise = new Promise((resolve) => { httpServer.once("close", () => { resolve(); }); }); + + // Handle abort signal + const onAbort = () => { + void shutdown(); + }; + if (opts.abortSignal.aborted) { + onAbort(); + } else { + opts.abortSignal.addEventListener("abort", onAbort, { once: true }); + } + + // Keep this task alive until shutdown/close so gateway runtime does not treat startup as exit. + await closePromise; opts.abortSignal?.removeEventListener("abort", onAbort); return { app: expressApp, shutdown };