fix: harden webhook auth-before-body handling

This commit is contained in:
Peter Steinberger
2026-03-02 17:20:46 +00:00
parent dded569626
commit d3e8b17aa6
15 changed files with 789 additions and 251 deletions

View File

@@ -1,12 +1,13 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess,
createReplyPrefixOptions,
readJsonBodyWithLimit,
readJsonWebhookBodyOrReject,
registerWebhookTargetWithPluginRoute,
rejectNonPostWebhookRequest,
isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -15,7 +16,6 @@ import {
resolveWebhookPath,
resolveWebhookTargets,
warnMissingProviderGroupPolicyFallbackOnce,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk";
@@ -67,6 +67,7 @@ type WebhookTarget = {
};
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
if (core.logging.shouldLogVerbose()) {
@@ -137,49 +138,31 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType |
return undefined;
}
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
const authHeader = String(req.headers.authorization ?? "");
const bearer = authHeader.toLowerCase().startsWith("bearer ")
? authHeader.slice("bearer ".length)
function extractBearerToken(header: unknown): string {
const authHeader = Array.isArray(header) ? String(header[0] ?? "") : String(header ?? "");
return authHeader.toLowerCase().startsWith("bearer ")
? authHeader.slice("bearer ".length).trim()
: "";
}
const body = await readJsonBodyWithLimit(req, {
maxBytes: 1024 * 1024,
timeoutMs: 30_000,
emptyObjectOnEmpty: false,
});
if (!body.ok) {
res.statusCode =
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
res.end(
body.code === "REQUEST_BODY_TIMEOUT"
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
: body.error,
);
return true;
}
type ParsedGoogleChatInboundPayload =
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
| { ok: false };
let raw = body.value;
function parseGoogleChatInboundPayload(
raw: unknown,
res: ServerResponse,
): ParsedGoogleChatInboundPayload {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
return { ok: false };
}
// Transform Google Workspace Add-on format to standard Chat API format
let eventPayload = raw;
let addOnBearerToken = "";
// Transform Google Workspace Add-on format to standard Chat API format.
const rawObj = raw as {
commonEventObject?: { hostApp?: string };
chat?: {
@@ -193,84 +176,173 @@ export async function handleGoogleChatWebhookRequest(
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
const chat = rawObj.chat;
const messagePayload = chat.messagePayload;
raw = {
eventPayload = {
type: "MESSAGE",
space: messagePayload?.space,
message: messagePayload?.message,
user: chat.user,
eventTime: chat.eventTime,
};
// For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
if (!bearer && systemIdToken) {
Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
}
addOnBearerToken = String(rawObj.authorizationEventObject?.systemIdToken ?? "").trim();
}
const event = raw as GoogleChatEvent;
const eventType = event.type ?? (raw as { eventType?: string }).eventType;
const event = eventPayload as GoogleChatEvent;
const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
if (typeof eventType !== "string") {
res.statusCode = 400;
res.end("invalid payload");
return true;
return { ok: false };
}
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
return { ok: false };
}
if (eventType === "MESSAGE") {
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
return { ok: false };
}
}
// Re-extract bearer in case it was updated from Add-on format
const authHeaderNow = String(req.headers.authorization ?? "");
const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
? authHeaderNow.slice("bearer ".length)
: bearer;
return { ok: true, event, addOnBearerToken };
}
const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => {
const audienceType = target.audienceType;
const audience = target.audience;
async function resolveGoogleChatWebhookTargetByBearer(
targets: readonly WebhookTarget[],
bearer: string,
) {
return await resolveSingleWebhookTargetAsync(targets, async (target) => {
const verification = await verifyGoogleChatRequest({
bearer: effectiveBearer,
audienceType,
audience,
bearer,
audienceType: target.audienceType,
audience: target.audience,
});
return verification.ok;
});
}
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
return true;
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { path, targets } = resolved;
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
return true;
}
const selected = matchedTarget.target;
selected.statusSink?.({ lastInboundAt: Date.now() });
processGoogleChatEvent(event, selected).catch((err) => {
selected?.runtime.error?.(
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
);
const requestLifecycle = beginWebhookRequestPipelineOrReject({
req,
res,
allowMethods: ["POST"],
requireJsonContentType: true,
inFlightLimiter: webhookInFlightLimiter,
inFlightKey: `${path}:${req.socket?.remoteAddress ?? "unknown"}`,
});
if (!requestLifecycle.ok) {
return true;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
try {
const headerBearer = extractBearerToken(req.headers.authorization);
let matchedTarget: Awaited<ReturnType<typeof resolveGoogleChatWebhookTargetByBearer>> | null =
null;
let parsedEvent: GoogleChatEvent | null = null;
let addOnBearerToken = "";
if (headerBearer) {
matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, headerBearer);
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
return true;
}
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile: "post-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return true;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
if (!parsed.ok) {
return true;
}
parsedEvent = parsed.event;
addOnBearerToken = parsed.addOnBearerToken;
} else {
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile: "pre-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return true;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
if (!parsed.ok) {
return true;
}
parsedEvent = parsed.event;
addOnBearerToken = parsed.addOnBearerToken;
if (!addOnBearerToken) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, addOnBearerToken);
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
return true;
}
}
if (!matchedTarget || !parsedEvent) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
const selected = matchedTarget.target;
selected.statusSink?.({ lastInboundAt: Date.now() });
processGoogleChatEvent(parsedEvent, selected).catch((err) => {
selected.runtime.error?.(
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
);
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
} finally {
requestLifecycle.release();
}
}
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {

View File

@@ -21,6 +21,7 @@ function createWebhookRequest(params: {
const req = new EventEmitter() as IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => IncomingMessage;
on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage;
};
req.method = "POST";
req.url = params.path ?? "/googlechat";
@@ -29,21 +30,50 @@ function createWebhookRequest(params: {
"content-type": "application/json",
};
req.destroyed = false;
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
req.destroy = () => {
req.destroyed = true;
return req;
};
void Promise.resolve().then(() => {
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
if (!req.destroyed) {
req.emit("end");
const originalOn = req.on.bind(req);
let bodyScheduled = false;
req.on = ((event: string, listener: (...args: unknown[]) => void) => {
const result = originalOn(event, listener);
if (!bodyScheduled && event === "data") {
bodyScheduled = true;
void Promise.resolve().then(() => {
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
if (!req.destroyed) {
req.emit("end");
}
});
}
});
return result;
}) as IncomingMessage["on"];
return req;
}
function createHeaderOnlyWebhookRequest(params: {
authorization?: string;
path?: string;
}): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = params.path ?? "/googlechat";
req.headers = {
authorization: params.authorization ?? "",
"content-type": "application/json",
};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
return req;
}
const baseAccount = (accountId: string) =>
({
accountId,
@@ -178,4 +208,59 @@ describe("Google Chat webhook routing", () => {
unregister();
}
});
it("rejects invalid bearer before attempting to read the body", async () => {
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
const { unregister } = registerTwoTargets();
try {
const req = createHeaderOnlyWebhookRequest({
authorization: "Bearer invalid-token",
});
const onSpy = vi.spyOn(req, "on");
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
} finally {
unregister();
}
});
it("supports add-on requests that provide systemIdToken in the body", async () => {
vi.mocked(verifyGoogleChatRequest)
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
.mockResolvedValueOnce({ ok: true });
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
payload: {
commonEventObject: { hostApp: "CHAT" },
authorizationEventObject: { systemIdToken: "addon-token" },
chat: {
eventTime: "2026-03-02T00:00:00.000Z",
user: { name: "users/12345", displayName: "Test User" },
messagePayload: {
space: { name: "spaces/AAA" },
message: { text: "Hello from add-on" },
},
},
},
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(sinkA).not.toHaveBeenCalled();
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregister();
}
});
});