diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ac0eeb41f..e1198ba7bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/bluebubbles/src/account-resolve.test.ts b/extensions/bluebubbles/src/account-resolve.test.ts new file mode 100644 index 00000000000..faf642df963 --- /dev/null +++ b/extensions/bluebubbles/src/account-resolve.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 43d304571cc..49d14711c0d 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -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), }; } diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts index 02fd967178c..09f79f0ef3b 100644 --- a/extensions/bluebubbles/src/accounts.ts +++ b/extensions/bluebubbles/src/accounts.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeBlueBubblesPrivateNetworkAliases( + config: Record | undefined, +): Record | 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> | undefined, +): Record> | undefined { + if (!accounts) { + return undefined; + } + return Object.fromEntries( + Object.entries(accounts).map(([accountKey, accountConfig]) => [ + accountKey, + normalizeBlueBubblesPrivateNetworkAliases(accountConfig) as Partial, + ]), + ); +} + function mergeBlueBubblesAccountConfig( cfg: OpenClawConfig, accountId: string, ): BlueBubblesAccountConfig { - const merged = resolveMergedAccountConfig({ - 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> | undefined, + ); + const merged = resolveMergedAccountConfig({ + 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 })) diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 0ea5b198b31..419aabc917c 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -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; + 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; 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; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] }); + }); }); describe("sendBlueBubblesAttachment", () => { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 5a25f348bed..685fd936c73 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -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) => diff --git a/extensions/bluebubbles/src/channel.status.test.ts b/extensions/bluebubbles/src/channel.status.test.ts new file mode 100644 index 00000000000..ad469ba4198 --- /dev/null +++ b/extensions/bluebubbles/src/channel.status.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index b364cd41e19..3166313fc3e 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -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 { const running = runtime?.running ?? false; diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index b6b7edd482f..c081a319b75 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -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}`); diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts index 5abe31f045f..448e609033f 100644 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -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", () => { diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 87379280c48..4b67bdc53e1 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeBlueBubblesPrivateNetworkAliases( + config: Record | undefined, +): Record | 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 | undefined> | undefined, +): Record | 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 } }; accountId?: string; }) { - const baseConfig = params.cfg?.channels?.bluebubbles ?? {}; + const baseConfig = + normalizeBlueBubblesPrivateNetworkAliases(params.cfg?.channels?.bluebubbles ?? {}) ?? {}; + const accounts = normalizeBlueBubblesAccountsMap( + baseConfig.accounts as Record | undefined> | undefined, + ); const accountId = params.accountId ?? "default"; const accountConfig = - accountId === "default" - ? {} - : ((baseConfig.accounts as Record | undefined> | undefined)?.[ - accountId - ] ?? {}); - const config = { + normalizeBlueBubblesPrivateNetworkAliases(accounts?.[accountId] ?? {}) ?? {}; + const config: Record = { ...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), + ...(accountConfig.network as Record), + } + : (accountConfig.network ?? baseConfig.network), }; return { accountId, @@ -51,9 +127,57 @@ export function resolveBlueBubblesAccountFromConfig(params: { }; } +function resolveBlueBubblesPrivateNetworkConfigValueFromConfig( + config: Record | 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; +}) { + 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, + ), }; }