mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: harden webhook auth-before-body handling
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user