fix: harden msteams webhook ingress lifecycle integration (#25960) (thanks @bmendonca3)

This commit is contained in:
Peter Steinberger
2026-03-02 20:33:52 +00:00
parent 9c804a45f1
commit 82ccc3ffe1
4 changed files with 33 additions and 17 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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");

View File

@@ -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<void>((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<void>((resolve) => {
const closePromise = new Promise<void>((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 };