mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix(bluebubbles): localhost probe respects private-network opt-out (#59373)
* honor localhost private-network policy * drop flaky monitor private-network test * align mocks and imports * preserve account private-network overrides * keep default account config * strip stale private-network aliases * fix(bluebubbles): remove unused channel imports * fix: add changelog for bluebubbles private-network opt-out landing (#59373) (thanks @jpreagan) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
|
||||
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistral’s Chat Completions API. (#62162) Thanks @neeravmakwana.
|
||||
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
|
||||
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
|
||||
|
||||
## 2026.4.5
|
||||
|
||||
|
||||
82
extensions/bluebubbles/src/account-resolve.test.ts
Normal file
82
extensions/bluebubbles/src/account-resolve.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
|
||||
describe("resolveBlueBubblesServerAccount", () => {
|
||||
it("respects an explicit private-network opt-out for loopback server URLs", () => {
|
||||
expect(
|
||||
resolveBlueBubblesServerAccount({
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a legacy per-account opt-in override a channel-level canonical default", () => {
|
||||
expect(
|
||||
resolveBlueBubblesServerAccount({
|
||||
accountId: "personal",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
accounts: {
|
||||
personal: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
accountId: "personal",
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
allowPrivateNetworkConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses accounts.default config for the default BlueBubbles account", () => {
|
||||
expect(
|
||||
resolveBlueBubblesServerAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
accounts: {
|
||||
default: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
allowPrivateNetwork: true,
|
||||
allowPrivateNetworkConfig: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
isBlockedHostnameOrIp,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
resolveBlueBubblesAccount,
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetwork,
|
||||
resolveBlueBubblesPrivateNetworkConfigValue,
|
||||
} from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesAccountResolveOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -19,6 +18,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
password: string;
|
||||
accountId: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
allowPrivateNetworkConfig?: boolean;
|
||||
} {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
@@ -49,18 +49,14 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
|
||||
let autoAllowPrivateNetwork = false;
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim();
|
||||
autoAllowPrivateNetwork = Boolean(hostname) && isBlockedHostnameOrIp(hostname);
|
||||
} catch {
|
||||
autoAllowPrivateNetwork = false;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
password,
|
||||
accountId: account.accountId,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config) || autoAllowPrivateNetwork,
|
||||
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
|
||||
baseUrl,
|
||||
config: account.config,
|
||||
}),
|
||||
allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
@@ -24,17 +25,88 @@ const {
|
||||
} = createAccountListHelpers("bluebubbles");
|
||||
export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesPrivateNetworkAliases(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return config;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
const canonicalValue =
|
||||
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? network.dangerouslyAllowPrivateNetwork
|
||||
: typeof network?.allowPrivateNetwork === "boolean"
|
||||
? network.allowPrivateNetwork
|
||||
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? record.dangerouslyAllowPrivateNetwork
|
||||
: typeof record.allowPrivateNetwork === "boolean"
|
||||
? record.allowPrivateNetwork
|
||||
: undefined;
|
||||
|
||||
if (canonicalValue === undefined) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const {
|
||||
allowPrivateNetwork: _legacyFlatAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
|
||||
...rest
|
||||
} = record;
|
||||
const {
|
||||
allowPrivateNetwork: _legacyNetworkAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
|
||||
...restNetwork
|
||||
} = network ?? {};
|
||||
|
||||
return {
|
||||
...rest,
|
||||
network: {
|
||||
...restNetwork,
|
||||
dangerouslyAllowPrivateNetwork: canonicalValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesAccountsMap(
|
||||
accounts: Record<string, Partial<BlueBubblesAccountConfig>> | undefined,
|
||||
): Record<string, Partial<BlueBubblesAccountConfig>> | undefined {
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(accounts).map(([accountKey, accountConfig]) => [
|
||||
accountKey,
|
||||
normalizeBlueBubblesPrivateNetworkAliases(accountConfig) as Partial<BlueBubblesAccountConfig>,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeBlueBubblesAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): BlueBubblesAccountConfig {
|
||||
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
|
||||
channelConfig: cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
|
||||
accounts: cfg.channels?.bluebubbles?.accounts as
|
||||
const channelConfig = normalizeBlueBubblesPrivateNetworkAliases(
|
||||
cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
|
||||
) as BlueBubblesAccountConfig | undefined;
|
||||
const accounts = normalizeBlueBubblesAccountsMap(
|
||||
cfg.channels?.bluebubbles?.accounts as
|
||||
| Record<string, Partial<BlueBubblesAccountConfig>>
|
||||
| undefined,
|
||||
);
|
||||
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
|
||||
channelConfig,
|
||||
accounts,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
normalizeAccountId,
|
||||
nestedObjectKeys: ["network"],
|
||||
});
|
||||
return {
|
||||
...merged,
|
||||
@@ -66,6 +138,48 @@ export function resolveBlueBubblesAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesPrivateNetworkConfigValue(
|
||||
config: BlueBubblesAccountConfig | null | undefined,
|
||||
): boolean | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return network.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof network?.allowPrivateNetwork === "boolean") {
|
||||
return network.allowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return record.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.allowPrivateNetwork === "boolean") {
|
||||
return record.allowPrivateNetwork;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesEffectiveAllowPrivateNetwork(params: {
|
||||
baseUrl?: string;
|
||||
config?: BlueBubblesAccountConfig | null;
|
||||
}): boolean {
|
||||
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValue(params.config);
|
||||
if (configuredValue !== undefined) {
|
||||
return configuredValue;
|
||||
}
|
||||
if (!params.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
|
||||
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
|
||||
return listBlueBubblesAccountIds(cfg)
|
||||
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
|
||||
|
||||
@@ -318,6 +318,28 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("respects an explicit private-network opt-out for loopback serverUrl", async () => {
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-opt-out" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
@@ -330,6 +352,28 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
|
||||
});
|
||||
|
||||
it("keeps public serverUrl hostname pinning when private-network access is explicitly disabled", async () => {
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-public-host-opt-out" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "https://bluebubbles.example.com:1234",
|
||||
password: "test",
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesAttachment", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
@@ -101,7 +102,8 @@ export async function downloadBlueBubblesAttachment(
|
||||
if (!guid) {
|
||||
throw new Error("BlueBubbles attachment guid is required");
|
||||
}
|
||||
const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
|
||||
const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } =
|
||||
resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||
@@ -109,6 +111,7 @@ export async function downloadBlueBubblesAttachment(
|
||||
});
|
||||
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||
const trustedHostname = safeExtractHostname(baseUrl);
|
||||
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
|
||||
try {
|
||||
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
||||
url,
|
||||
@@ -116,7 +119,7 @@ export async function downloadBlueBubblesAttachment(
|
||||
maxBytes,
|
||||
ssrfPolicy: allowPrivateNetwork
|
||||
? { allowPrivateNetwork: true }
|
||||
: trustedHostname
|
||||
: trustedHostname && (allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
|
||||
? { allowedHostnames: [trustedHostname] }
|
||||
: undefined,
|
||||
fetchImpl: async (input, init) =>
|
||||
|
||||
80
extensions/bluebubbles/src/channel.status.test.ts
Normal file
80
extensions/bluebubbles/src/channel.status.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
const probeBlueBubblesMock = vi.hoisted(() => vi.fn());
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
blueBubblesChannelRuntime: {
|
||||
probeBlueBubbles: probeBlueBubblesMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
|
||||
bundledChannelPlugins: [],
|
||||
bundledChannelSetupPlugins: [],
|
||||
}));
|
||||
|
||||
let bluebubblesPlugin: typeof import("./channel.js").bluebubblesPlugin;
|
||||
|
||||
describe("bluebubblesPlugin.status.probeAccount", () => {
|
||||
beforeAll(async () => {
|
||||
({ bluebubblesPlugin } = await import("./channel.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
probeBlueBubblesMock.mockReset();
|
||||
probeBlueBubblesMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
});
|
||||
|
||||
it("auto-enables private-network probes for loopback server URLs", async () => {
|
||||
await bluebubblesPlugin.status?.probeAccount?.({
|
||||
cfg,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
baseUrl: "http://localhost:1234",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(probeBlueBubblesMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
timeoutMs: 5000,
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects an explicit private-network opt-out for loopback server URLs", async () => {
|
||||
await bluebubblesPlugin.status?.probeAccount?.({
|
||||
cfg,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
baseUrl: "http://localhost:1234",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(probeBlueBubblesMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
timeoutMs: 5000,
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import {
|
||||
type ResolvedBlueBubblesAccount,
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetwork,
|
||||
} from "./accounts.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import {
|
||||
bluebubblesCapabilities,
|
||||
@@ -226,7 +228,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
|
||||
baseUrl: account.baseUrl,
|
||||
password: account.config.password ?? null,
|
||||
timeoutMs,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
|
||||
baseUrl: account.baseUrl,
|
||||
config: account.config,
|
||||
}),
|
||||
}),
|
||||
resolveAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const running = runtime?.running ?? false;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import {
|
||||
asRecord,
|
||||
@@ -321,6 +321,10 @@ export async function monitorBlueBubblesProvider(
|
||||
const { account, config, runtime, abortSignal, statusSink } = options;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
||||
const allowPrivateNetwork = resolveBlueBubblesEffectiveAllowPrivateNetwork({
|
||||
baseUrl: account.baseUrl,
|
||||
config: account.config,
|
||||
});
|
||||
|
||||
// Fetch and cache server info (for macOS version detection in action gating)
|
||||
const serverInfo = await fetchBlueBubblesServerInfo({
|
||||
@@ -328,7 +332,7 @@ export async function monitorBlueBubblesProvider(
|
||||
password: account.config.password,
|
||||
accountId: account.accountId,
|
||||
timeoutMs: 5000,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
allowPrivateNetwork,
|
||||
}).catch(() => null);
|
||||
if (serverInfo?.os_version) {
|
||||
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSetupWizardAdapter,
|
||||
@@ -322,6 +323,36 @@ describe("resolveBlueBubblesAccount", () => {
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
|
||||
it("strips stale legacy private-network aliases after canonical normalization", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
network: {
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.config.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
expect("allowPrivateNetwork" in resolved.config).toBe(false);
|
||||
expect(isPrivateNetworkOptInEnabled(resolved.config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlueBubblesConfigSchema", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { _setFetchGuardForTesting } from "./types.js";
|
||||
import { _setFetchGuardForTesting, normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
|
||||
enabled: true,
|
||||
@@ -27,21 +28,96 @@ export function mockBlueBubblesPrivateApiStatusOnce(
|
||||
mock.mockReturnValueOnce(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesPrivateNetworkAliases(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return config;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
const canonicalValue =
|
||||
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? network.dangerouslyAllowPrivateNetwork
|
||||
: typeof network?.allowPrivateNetwork === "boolean"
|
||||
? network.allowPrivateNetwork
|
||||
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
|
||||
? record.dangerouslyAllowPrivateNetwork
|
||||
: typeof record.allowPrivateNetwork === "boolean"
|
||||
? record.allowPrivateNetwork
|
||||
: undefined;
|
||||
|
||||
if (canonicalValue === undefined) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const {
|
||||
allowPrivateNetwork: _legacyFlatAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
|
||||
...rest
|
||||
} = record;
|
||||
const {
|
||||
allowPrivateNetwork: _legacyNetworkAllow,
|
||||
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
|
||||
...restNetwork
|
||||
} = network ?? {};
|
||||
|
||||
return {
|
||||
...rest,
|
||||
network: {
|
||||
...restNetwork,
|
||||
dangerouslyAllowPrivateNetwork: canonicalValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlueBubblesAccountsMap(
|
||||
accounts: Record<string, Record<string, unknown> | undefined> | undefined,
|
||||
): Record<string, Record<string, unknown> | undefined> | undefined {
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(accounts).map(([accountKey, accountConfig]) => [
|
||||
accountKey,
|
||||
normalizeBlueBubblesPrivateNetworkAliases(accountConfig),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesAccountFromConfig(params: {
|
||||
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
|
||||
accountId?: string;
|
||||
}) {
|
||||
const baseConfig = params.cfg?.channels?.bluebubbles ?? {};
|
||||
const baseConfig =
|
||||
normalizeBlueBubblesPrivateNetworkAliases(params.cfg?.channels?.bluebubbles ?? {}) ?? {};
|
||||
const accounts = normalizeBlueBubblesAccountsMap(
|
||||
baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined,
|
||||
);
|
||||
const accountId = params.accountId ?? "default";
|
||||
const accountConfig =
|
||||
accountId === "default"
|
||||
? {}
|
||||
: ((baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined)?.[
|
||||
accountId
|
||||
] ?? {});
|
||||
const config = {
|
||||
normalizeBlueBubblesPrivateNetworkAliases(accounts?.[accountId] ?? {}) ?? {};
|
||||
const config: Record<string, unknown> = {
|
||||
...baseConfig,
|
||||
...accountConfig,
|
||||
network:
|
||||
typeof baseConfig.network === "object" &&
|
||||
baseConfig.network &&
|
||||
!Array.isArray(baseConfig.network) &&
|
||||
typeof accountConfig.network === "object" &&
|
||||
accountConfig.network &&
|
||||
!Array.isArray(accountConfig.network)
|
||||
? {
|
||||
...(baseConfig.network as Record<string, unknown>),
|
||||
...(accountConfig.network as Record<string, unknown>),
|
||||
}
|
||||
: (accountConfig.network ?? baseConfig.network),
|
||||
};
|
||||
return {
|
||||
accountId,
|
||||
@@ -51,9 +127,57 @@ export function resolveBlueBubblesAccountFromConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBlueBubblesPrivateNetworkConfigValueFromConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): boolean | undefined {
|
||||
const record = asRecord(config);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const network = asRecord(record.network);
|
||||
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return network.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof network?.allowPrivateNetwork === "boolean") {
|
||||
return network.allowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
|
||||
return record.dangerouslyAllowPrivateNetwork;
|
||||
}
|
||||
if (typeof record.allowPrivateNetwork === "boolean") {
|
||||
return record.allowPrivateNetwork;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params: {
|
||||
baseUrl?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValueFromConfig(params.config);
|
||||
if (configuredValue !== undefined) {
|
||||
return configuredValue;
|
||||
}
|
||||
if (!params.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
|
||||
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlueBubblesAccountsMockModule() {
|
||||
return {
|
||||
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetwork: vi.fn(
|
||||
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
|
||||
),
|
||||
resolveBlueBubblesPrivateNetworkConfigValue: vi.fn(
|
||||
resolveBlueBubblesPrivateNetworkConfigValueFromConfig,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user