Skip to content

Commit c39a928

Browse files
fix(bluebubbles): resolve SecretRef webhook password for auth (#76369)
Webhook auth assumed string passwords and called .trim(); SecretRef objects crash the HTTP route handler. Normalize inline passwords and resolve refs via the gateway secret resolver, matching outbound BlueBubbles usage. Treat synchronous boolean webhook target matches without awaiting promises in resolveSingleWebhookTargetAsync; defer mock HTTP bodies with setImmediate in tests so async auth reliably attaches listeners. Fixes #76369. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3d64fca commit c39a928

7 files changed

Lines changed: 69 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- BlueBubbles: resolve `channels.bluebubbles` SecretRef-backed webhook passwords via the gateway secret pipeline instead of calling `.trim` on unresolved objects, restoring inbound webhook delivery. Fixes #76369.
1617
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
1718
- Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev.
1819
- Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79.

extensions/bluebubbles/src/monitor.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { IncomingMessage, ServerResponse } from "node:http";
22
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3+
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
34
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
45
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
56
import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js";
@@ -23,14 +24,15 @@ import {
2324
} from "./monitor-shared.js";
2425
import { fetchBlueBubblesServerInfo } from "./probe.js";
2526
import { getBlueBubblesRuntime } from "./runtime.js";
27+
import { normalizeSecretInputString } from "./secret-input.js";
2628
import {
2729
WEBHOOK_RATE_LIMIT_DEFAULTS,
2830
createFixedWindowRateLimiter,
2931
createWebhookInFlightLimiter,
3032
registerWebhookTargetWithPluginRoute,
3133
readWebhookBodyOrReject,
3234
resolveRequestClientIp,
33-
resolveWebhookTargetWithAuthOrRejectSync,
35+
resolveWebhookTargetWithAuthOrReject,
3436
withResolvedWebhookRequestPipeline,
3537
} from "./webhook-ingress.js";
3638

@@ -189,12 +191,20 @@ export async function handleBlueBubblesWebhookRequest(
189191
req.headers["x-bluebubbles-guid"] ??
190192
req.headers["authorization"];
191193
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
192-
const target = resolveWebhookTargetWithAuthOrRejectSync({
194+
const target = await resolveWebhookTargetWithAuthOrReject({
193195
targets,
194196
res,
195-
isMatch: (target) => {
196-
const token = target.account.config.password?.trim() ?? "";
197-
return safeEqualAuthToken(guid, token);
197+
isMatch: (target): boolean | Promise<boolean> => {
198+
const direct = normalizeSecretInputString(target.account.config.password);
199+
if (direct) {
200+
return safeEqualAuthToken(guid, direct);
201+
}
202+
return resolveConfiguredSecretInputString({
203+
config: target.config,
204+
env: process.env,
205+
value: target.account.config.password,
206+
path: `channels.bluebubbles.accounts.${target.account.accountId}.password`,
207+
}).then((resolved) => safeEqualAuthToken(guid, resolved.value ?? ""));
198208
},
199209
});
200210
if (!target) {

extensions/bluebubbles/src/monitor.webhook-auth.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as secretInputRuntimeModule from "openclaw/plugin-sdk/secret-input-runtime";
12
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
34
import { fetchBlueBubblesHistory } from "./history.js";
@@ -411,7 +412,48 @@ describe("BlueBubbles webhook monitor", () => {
411412
});
412413

413414
it("authenticates via password query parameter", async () => {
414-
await expectProtectedWebhookRequestStatus(createProtectedPasswordQueryRequestParams(), 200);
415+
const resolveSpy = vi.spyOn(secretInputRuntimeModule, "resolveConfiguredSecretInputString");
416+
try {
417+
await expectProtectedWebhookRequestStatus(createProtectedPasswordQueryRequestParams(), 200);
418+
expect(resolveSpy).not.toHaveBeenCalled();
419+
} finally {
420+
resolveSpy.mockRestore();
421+
}
422+
});
423+
424+
describe("configured SecretRef password", () => {
425+
it("authenticates inbound webhooks via runtime-resolved SecretRef password", async () => {
426+
const resolveSpy = vi.spyOn(secretInputRuntimeModule, "resolveConfiguredSecretInputString");
427+
resolveSpy.mockResolvedValue({ value: TEST_WEBHOOK_PASSWORD });
428+
try {
429+
setupWebhookTarget({
430+
account: createMockAccount({
431+
// @ts-expect-error SecretRef is accepted at runtime for gateway-merged account config.
432+
password: { source: "exec", provider: "op-bb-password", id: "value" }, // pragma: allowlist secret
433+
}),
434+
});
435+
436+
await expectWebhookRequestStatusForTest(
437+
createProtectedPasswordQueryRequestParams(TEST_WEBHOOK_PASSWORD),
438+
200,
439+
"ok",
440+
);
441+
442+
expect(resolveSpy).toHaveBeenCalledTimes(1);
443+
expect(resolveSpy.mock.calls[0]?.[0]).toMatchObject({
444+
config: expect.any(Object),
445+
env: expect.any(Object),
446+
value: expect.objectContaining({
447+
source: "exec",
448+
provider: "op-bb-password",
449+
id: "value",
450+
}), // pragma: allowlist secret
451+
path: "channels.bluebubbles.accounts.default.password",
452+
});
453+
} finally {
454+
resolveSpy.mockRestore();
455+
}
456+
});
415457
});
416458

417459
it("authenticates via x-password header", async () => {

extensions/bluebubbles/src/monitor.webhook.test-helpers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ export function createMockRequest(
118118
req.headers = headers;
119119
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress };
120120

121-
// Emit body data after a microtask.
122-
void Promise.resolve().then(() => {
121+
// Defer emission until after microtasks drain so webhook handlers that
122+
// authenticate asynchronously can attach HTTP body listeners first.
123+
setImmediate(() => {
123124
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
124125
req.emit("data", Buffer.from(bodyStr));
125126
req.emit("end");

extensions/bluebubbles/src/runtime-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {
5151
readWebhookBodyOrReject,
5252
registerWebhookTargetWithPluginRoute,
5353
resolveRequestClientIp,
54+
resolveWebhookTargetWithAuthOrReject,
5455
resolveWebhookTargetWithAuthOrRejectSync,
5556
withResolvedWebhookRequestPipeline,
5657
} from "openclaw/plugin-sdk/webhook-ingress";

extensions/bluebubbles/src/webhook-ingress.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
registerWebhookTargetWithPluginRoute,
66
readWebhookBodyOrReject,
77
resolveRequestClientIp,
8+
resolveWebhookTargetWithAuthOrReject,
89
resolveWebhookTargetWithAuthOrRejectSync,
910
withResolvedWebhookRequestPipeline,
1011
} from "openclaw/plugin-sdk/webhook-ingress";

src/plugin-sdk/webhook-targets.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,13 @@ export function resolveSingleWebhookTarget<T>(
211211
/** Async variant of single-target resolution for auth checks that need I/O. */
212212
export async function resolveSingleWebhookTargetAsync<T>(
213213
targets: readonly T[],
214-
isMatch: (target: T) => Promise<boolean>,
214+
isMatch: (target: T) => boolean | Promise<boolean>,
215215
): Promise<WebhookTargetMatchResult<T>> {
216216
let matched: T | undefined;
217217
for (const target of targets) {
218-
if (!(await isMatch(target))) {
218+
const verdict = isMatch(target);
219+
const ok = typeof verdict === "boolean" ? verdict : await verdict;
220+
if (!ok) {
219221
continue;
220222
}
221223
const updated = updateMatchedWebhookTarget(matched, target);
@@ -237,9 +239,7 @@ export async function resolveWebhookTargetWithAuthOrReject<T>(params: {
237239
ambiguousStatusCode?: number;
238240
ambiguousMessage?: string;
239241
}): Promise<T | null> {
240-
const match = await resolveSingleWebhookTargetAsync(params.targets, async (target) =>
241-
params.isMatch(target),
242-
);
242+
const match = await resolveSingleWebhookTargetAsync(params.targets, params.isMatch);
243243
return resolveWebhookTargetMatchOrReject(params, match);
244244
}
245245

0 commit comments

Comments
 (0)