mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
fix(googlechat): reject ambiguous webhook routing
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
|
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
|
||||||
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
||||||
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
|
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
|
||||||
|
- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
|
||||||
- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
|
- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
|
||||||
- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
|
- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
|
||||||
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
|
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
? authHeaderNow.slice("bearer ".length)
|
? authHeaderNow.slice("bearer ".length)
|
||||||
: bearer;
|
: bearer;
|
||||||
|
|
||||||
let selected: WebhookTarget | undefined;
|
const matchedTargets: WebhookTarget[] = [];
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const audienceType = target.audienceType;
|
const audienceType = target.audienceType;
|
||||||
const audience = target.audience;
|
const audience = target.audience;
|
||||||
@@ -258,17 +258,26 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
audience,
|
audience,
|
||||||
});
|
});
|
||||||
if (verification.ok) {
|
if (verification.ok) {
|
||||||
selected = target;
|
matchedTargets.push(target);
|
||||||
break;
|
if (matchedTargets.length > 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (matchedTargets.length === 0) {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end("unauthorized");
|
res.end("unauthorized");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (matchedTargets.length > 1) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.end("ambiguous webhook target");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = matchedTargets[0];
|
||||||
selected.statusSink?.({ lastInboundAt: Date.now() });
|
selected.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
processGoogleChatEvent(event, selected).catch((err) => {
|
processGoogleChatEvent(event, selected).catch((err) => {
|
||||||
selected?.runtime.error?.(
|
selected?.runtime.error?.(
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||||
|
import { verifyGoogleChatRequest } from "./auth.js";
|
||||||
|
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
|
||||||
|
|
||||||
|
vi.mock("./auth.js", () => ({
|
||||||
|
verifyGoogleChatRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWebhookRequest(params: {
|
||||||
|
authorization?: string;
|
||||||
|
payload: unknown;
|
||||||
|
path?: string;
|
||||||
|
}): IncomingMessage {
|
||||||
|
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
|
||||||
|
req.method = "POST";
|
||||||
|
req.url = params.path ?? "/googlechat";
|
||||||
|
req.headers = {
|
||||||
|
authorization: params.authorization ?? "",
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
req.destroyed = false;
|
||||||
|
req.destroy = () => {
|
||||||
|
req.destroyed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
|
||||||
|
if (!req.destroyed) {
|
||||||
|
req.emit("end");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebhookResponse(): ServerResponse & { body?: string } {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const res = {
|
||||||
|
headersSent: false,
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader: (key: string, value: string) => {
|
||||||
|
headers[key.toLowerCase()] = value;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
end: (body?: string) => {
|
||||||
|
res.headersSent = true;
|
||||||
|
res.body = body;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
} as unknown as ServerResponse & { body?: string };
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAccount = (accountId: string) =>
|
||||||
|
({
|
||||||
|
accountId,
|
||||||
|
enabled: true,
|
||||||
|
credentialSource: "none",
|
||||||
|
config: {},
|
||||||
|
}) as ResolvedGoogleChatAccount;
|
||||||
|
|
||||||
|
describe("Google Chat webhook routing", () => {
|
||||||
|
it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => {
|
||||||
|
vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const sinkA = vi.fn();
|
||||||
|
const sinkB = vi.fn();
|
||||||
|
const core = {} as PluginRuntime;
|
||||||
|
const config = {} as OpenClawConfig;
|
||||||
|
|
||||||
|
const unregisterA = registerGoogleChatWebhookTarget({
|
||||||
|
account: baseAccount("A"),
|
||||||
|
config,
|
||||||
|
runtime: {},
|
||||||
|
core,
|
||||||
|
path: "/googlechat",
|
||||||
|
statusSink: sinkA,
|
||||||
|
mediaMaxMb: 5,
|
||||||
|
});
|
||||||
|
const unregisterB = registerGoogleChatWebhookTarget({
|
||||||
|
account: baseAccount("B"),
|
||||||
|
config,
|
||||||
|
runtime: {},
|
||||||
|
core,
|
||||||
|
path: "/googlechat",
|
||||||
|
statusSink: sinkB,
|
||||||
|
mediaMaxMb: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = createWebhookResponse();
|
||||||
|
const handled = await handleGoogleChatWebhookRequest(
|
||||||
|
createWebhookRequest({
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } },
|
||||||
|
}),
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(sinkA).not.toHaveBeenCalled();
|
||||||
|
expect(sinkB).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
unregisterA();
|
||||||
|
unregisterB();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes to the single verified target when earlier targets fail verification", async () => {
|
||||||
|
vi.mocked(verifyGoogleChatRequest)
|
||||||
|
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||||
|
.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const sinkA = vi.fn();
|
||||||
|
const sinkB = vi.fn();
|
||||||
|
const core = {} as PluginRuntime;
|
||||||
|
const config = {} as OpenClawConfig;
|
||||||
|
|
||||||
|
const unregisterA = registerGoogleChatWebhookTarget({
|
||||||
|
account: baseAccount("A"),
|
||||||
|
config,
|
||||||
|
runtime: {},
|
||||||
|
core,
|
||||||
|
path: "/googlechat",
|
||||||
|
statusSink: sinkA,
|
||||||
|
mediaMaxMb: 5,
|
||||||
|
});
|
||||||
|
const unregisterB = registerGoogleChatWebhookTarget({
|
||||||
|
account: baseAccount("B"),
|
||||||
|
config,
|
||||||
|
runtime: {},
|
||||||
|
core,
|
||||||
|
path: "/googlechat",
|
||||||
|
statusSink: sinkB,
|
||||||
|
mediaMaxMb: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = createWebhookResponse();
|
||||||
|
const handled = await handleGoogleChatWebhookRequest(
|
||||||
|
createWebhookRequest({
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } },
|
||||||
|
}),
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(sinkA).not.toHaveBeenCalled();
|
||||||
|
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
unregisterA();
|
||||||
|
unregisterB();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user