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:
James Reagan
2026-04-07 06:29:21 -10:00
committed by GitHub
parent 23edd9921e
commit dac72889e5
11 changed files with 516 additions and 32 deletions

View File

@@ -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 Mistrals 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

View 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,
});
});
});

View File

@@ -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),
};
}

View File

@@ -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 }))

View File

@@ -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", () => {

View File

@@ -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) =>

View 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,
});
});
});

View File

@@ -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;

View File

@@ -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}`);

View File

@@ -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", () => {

View File

@@ -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,
),
};
}