mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 14:30:57 +00:00
Matrix: guard private-network homeserver access
This commit is contained in:
@@ -250,4 +250,31 @@ describe("matrix setup post-write bootstrap", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("clears allowPrivateNetwork when deleting the default Matrix account config", () => {
|
||||
const updated = matrixPlugin.config.deleteAccount?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "http://localhost.localdomain:8008",
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "default",
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.matrix).toEqual({
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ const matrixConfigAdapter = createScopedChannelConfigAdapter<
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"allowPrivateNetwork",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
@@ -396,6 +397,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
userId: auth.userId,
|
||||
timeoutMs,
|
||||
accountId: account.accountId,
|
||||
allowPrivateNetwork: auth.allowPrivateNetwork,
|
||||
ssrfPolicy: auth.ssrfPolicy,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -164,6 +164,7 @@ async function addMatrixAccount(params: {
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
useEnv?: boolean;
|
||||
}): Promise<MatrixCliAccountAddResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
@@ -176,6 +177,7 @@ async function addMatrixAccount(params: {
|
||||
name: params.name,
|
||||
avatarUrl: params.avatarUrl,
|
||||
homeserver: params.homeserver,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
password: params.password,
|
||||
@@ -673,6 +675,10 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.option("--name <name>", "Optional display name for this account")
|
||||
.option("--avatar-url <url>", "Optional Matrix avatar URL (mxc:// or http(s) URL)")
|
||||
.option("--homeserver <url>", "Matrix homeserver URL")
|
||||
.option(
|
||||
"--allow-private-network",
|
||||
"Allow Matrix homeserver traffic to private/internal hosts for this account",
|
||||
)
|
||||
.option("--user-id <id>", "Matrix user ID")
|
||||
.option("--access-token <token>", "Matrix access token")
|
||||
.option("--password <password>", "Matrix password")
|
||||
@@ -690,6 +696,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
homeserver?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
@@ -708,6 +715,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
name: options.name,
|
||||
avatarUrl: options.avatarUrl,
|
||||
homeserver: options.homeserver,
|
||||
allowPrivateNetwork: options.allowPrivateNetwork === true,
|
||||
userId: options.userId,
|
||||
accessToken: options.accessToken,
|
||||
password: options.password,
|
||||
|
||||
@@ -46,7 +46,7 @@ function resolveMatrixDirectoryLimit(limit?: number | null): number {
|
||||
}
|
||||
|
||||
function createMatrixDirectoryClient(auth: MatrixResolvedAuth): MatrixAuthedHttpClient {
|
||||
return new MatrixAuthedHttpClient(auth.homeserver, auth.accessToken);
|
||||
return new MatrixAuthedHttpClient(auth.homeserver, auth.accessToken, auth.ssrfPolicy);
|
||||
}
|
||||
|
||||
async function resolveMatrixDirectoryContext(params: MatrixDirectoryLiveParams): Promise<{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../runtime-api.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
@@ -7,11 +8,21 @@ import {
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
import * as credentialsReadModule from "./credentials-read.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0]!;
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -325,6 +336,28 @@ describe("resolveMatrixConfig", () => {
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
});
|
||||
|
||||
it("accepts internal http homeservers only when private-network access is enabled", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(
|
||||
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
).toBe("http://matrix-synapse:8008");
|
||||
});
|
||||
|
||||
it("rejects public http homeservers even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
@@ -504,6 +537,28 @@ describe("resolveMatrixAuth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the private-network opt-in through Matrix auth resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
allowPrivateNetwork: true,
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
allowPrivateNetwork: true,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves token-only non-default account userId from whoami instead of inheriting the base user", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
user_id: "@ops:example.org",
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
|
||||
@@ -6,10 +6,13 @@ import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
isPrivateOrLoopbackHost,
|
||||
type LookupFn,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
normalizeResolvedSecretInputString,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
} from "../../runtime-api.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
@@ -69,6 +72,21 @@ function clampMatrixInitialSyncLimit(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? Math.max(0, Math.floor(value)) : undefined;
|
||||
}
|
||||
|
||||
const MATRIX_HTTP_HOMESERVER_ERROR =
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host";
|
||||
|
||||
function buildMatrixNetworkFields(
|
||||
allowPrivateNetwork: boolean | undefined,
|
||||
): Pick<MatrixResolvedConfig, "allowPrivateNetwork" | "ssrfPolicy"> {
|
||||
if (!allowPrivateNetwork) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
allowPrivateNetwork: true,
|
||||
ssrfPolicy: ssrfPolicyFromAllowPrivateNetwork(true),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
|
||||
return {
|
||||
homeserver: clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER"),
|
||||
@@ -163,7 +181,10 @@ export function hasReadyMatrixEnvAuth(config: {
|
||||
return Boolean(homeserver && (accessToken || (userId && password)));
|
||||
}
|
||||
|
||||
export function validateMatrixHomeserverUrl(homeserver: string): string {
|
||||
export function validateMatrixHomeserverUrl(
|
||||
homeserver: string,
|
||||
opts?: { allowPrivateNetwork?: boolean },
|
||||
): string {
|
||||
const trimmed = clean(homeserver, "matrix.homeserver");
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
@@ -188,15 +209,30 @@ export function validateMatrixHomeserverUrl(homeserver: string): string {
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error("Matrix homeserver URL must not include query strings or fragments");
|
||||
}
|
||||
if (parsed.protocol === "http:" && !isPrivateOrLoopbackHost(parsed.hostname)) {
|
||||
throw new Error(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
if (
|
||||
parsed.protocol === "http:" &&
|
||||
opts?.allowPrivateNetwork !== true &&
|
||||
!isPrivateOrLoopbackHost(parsed.hostname)
|
||||
) {
|
||||
throw new Error(MATRIX_HTTP_HOMESERVER_ERROR);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export async function resolveValidatedMatrixHomeserverUrl(
|
||||
homeserver: string,
|
||||
opts?: { allowPrivateNetwork?: boolean; lookupFn?: LookupFn },
|
||||
): Promise<string> {
|
||||
const normalized = validateMatrixHomeserverUrl(homeserver, opts);
|
||||
await assertHttpUrlTargetsPrivateNetwork(normalized, {
|
||||
allowPrivateNetwork: opts?.allowPrivateNetwork,
|
||||
lookupFn: opts?.lookupFn,
|
||||
errorMessage: MATRIX_HTTP_HOMESERVER_ERROR,
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -219,6 +255,7 @@ export function resolveMatrixConfig(
|
||||
});
|
||||
const initialSyncLimit = clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption = matrix.encryption ?? false;
|
||||
const allowPrivateNetwork = matrix.allowPrivateNetwork === true ? true : undefined;
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
@@ -228,6 +265,7 @@ export function resolveMatrixConfig(
|
||||
deviceName: resolvedStrings.deviceName || undefined,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
...buildMatrixNetworkFields(allowPrivateNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,6 +308,8 @@ export function resolveMatrixConfigForAccount(
|
||||
accountInitialSyncLimit ?? clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption =
|
||||
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
|
||||
const allowPrivateNetwork =
|
||||
account.allowPrivateNetwork === true || matrix.allowPrivateNetwork === true ? true : undefined;
|
||||
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
@@ -280,6 +320,7 @@ export function resolveMatrixConfigForAccount(
|
||||
deviceName: resolvedStrings.deviceName || undefined,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
...buildMatrixNetworkFields(allowPrivateNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,7 +379,9 @@ export async function resolveMatrixAuth(params?: {
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixAuth> {
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
|
||||
const homeserver = validateMatrixHomeserverUrl(resolved.homeserver);
|
||||
const homeserver = await resolveValidatedMatrixHomeserverUrl(resolved.homeserver, {
|
||||
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
});
|
||||
let credentialsWriter: typeof import("../credentials-write.runtime.js") | undefined;
|
||||
const loadCredentialsWriter = async () => {
|
||||
credentialsWriter ??= await import("../credentials-write.runtime.js");
|
||||
@@ -367,7 +410,9 @@ export async function resolveMatrixAuth(params?: {
|
||||
if (!userId || !knownDeviceId) {
|
||||
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const tempClient = new MatrixClient(homeserver, resolved.accessToken);
|
||||
const tempClient = new MatrixClient(homeserver, resolved.accessToken, undefined, undefined, {
|
||||
ssrfPolicy: resolved.ssrfPolicy,
|
||||
});
|
||||
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
@@ -415,6 +460,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
...buildMatrixNetworkFields(resolved.allowPrivateNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -431,6 +477,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
...buildMatrixNetworkFields(resolved.allowPrivateNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -446,7 +493,9 @@ export async function resolveMatrixAuth(params?: {
|
||||
|
||||
// Login with password using the same hardened request path as other Matrix HTTP calls.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const loginClient = new MatrixClient(homeserver, "");
|
||||
const loginClient = new MatrixClient(homeserver, "", undefined, undefined, {
|
||||
ssrfPolicy: resolved.ssrfPolicy,
|
||||
});
|
||||
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
@@ -474,6 +523,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
...buildMatrixNetworkFields(resolved.allowPrivateNetwork),
|
||||
};
|
||||
|
||||
const { saveMatrixCredentials } = await loadCredentialsWriter();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { validateMatrixHomeserverUrl } from "./config.js";
|
||||
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
@@ -19,10 +20,14 @@ export async function createMatrixClient(params: {
|
||||
initialSyncLimit?: number;
|
||||
accountId?: string | null;
|
||||
autoBootstrapCrypto?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<MatrixClient> {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
const homeserver = validateMatrixHomeserverUrl(params.homeserver);
|
||||
const homeserver = await resolveValidatedMatrixHomeserverUrl(params.homeserver, {
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
const userId = params.userId?.trim() || "unknown";
|
||||
const matrixClientUserId = params.userId?.trim() || undefined;
|
||||
|
||||
@@ -62,5 +67,6 @@ export async function createMatrixClient(params: {
|
||||
idbSnapshotPath: storagePaths.idbSnapshotPath,
|
||||
cryptoDatabasePrefix,
|
||||
autoBootstrapCrypto: params.autoBootstrapCrypto,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ function buildSharedClientKey(auth: MatrixAuth): string {
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
auth.allowPrivateNetwork ? "private-net" : "strict-net",
|
||||
auth.accountId,
|
||||
].join("|");
|
||||
}
|
||||
@@ -42,6 +43,8 @@ async function createSharedMatrixClient(params: {
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: params.auth.initialSyncLimit,
|
||||
accountId: params.auth.accountId,
|
||||
allowPrivateNetwork: params.auth.allowPrivateNetwork,
|
||||
ssrfPolicy: params.auth.ssrfPolicy,
|
||||
});
|
||||
return {
|
||||
client,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
@@ -7,6 +9,8 @@ export type MatrixResolvedConfig = {
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,6 +31,8 @@ export type MatrixAuth = {
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
};
|
||||
|
||||
export type MatrixStoragePaths = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SsrFPolicy } from "../runtime-api.js";
|
||||
import type { BaseProbeResult } from "../runtime-api.js";
|
||||
import { createMatrixClient, isBunRuntime } from "./client.js";
|
||||
|
||||
@@ -13,6 +14,8 @@ export async function probeMatrix(params: {
|
||||
userId?: string;
|
||||
timeoutMs: number;
|
||||
accountId?: string | null;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<MatrixProbe> {
|
||||
const started = Date.now();
|
||||
const result: MatrixProbe = {
|
||||
@@ -50,6 +53,8 @@ export async function probeMatrix(params: {
|
||||
accessToken: params.accessToken,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
});
|
||||
// The client wrapper resolves user ID via whoami when needed.
|
||||
const userId = await client.getUserId();
|
||||
|
||||
@@ -220,6 +220,18 @@ describe("MatrixClient request hardening", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("injects a guarded fetchFn into matrix-js-sdk", () => {
|
||||
new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
expect(lastCreateClientOpts).toMatchObject({
|
||||
baseUrl: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
});
|
||||
expect(lastCreateClientOpts?.fetchFn).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("prefers authenticated client media downloads", async () => {
|
||||
const payload = Buffer.from([1, 2, 3, 4]);
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
@@ -227,7 +239,9 @@ describe("MatrixClient request hardening", () => {
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", undefined, undefined, {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -255,7 +269,9 @@ describe("MatrixClient request hardening", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", undefined, undefined, {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
@@ -423,16 +439,18 @@ describe("MatrixClient request hardening", () => {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "http://evil.example.org/next",
|
||||
location: "https://127.0.0.2:8008/next",
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", undefined, undefined, {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
|
||||
client.doRequest("GET", "http://127.0.0.1:8008/start", undefined, undefined, {
|
||||
allowAbsoluteEndpoint: true,
|
||||
}),
|
||||
).rejects.toThrow("Blocked cross-protocol redirect");
|
||||
@@ -448,7 +466,7 @@ describe("MatrixClient request hardening", () => {
|
||||
if (calls.length === 1) {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.example.org/next" },
|
||||
headers: { location: "http://127.0.0.2:8008/next" },
|
||||
});
|
||||
}
|
||||
return new Response("{}", {
|
||||
@@ -458,15 +476,17 @@ describe("MatrixClient request hardening", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", undefined, undefined, {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
await client.doRequest("GET", "http://127.0.0.1:8008/start", undefined, undefined, {
|
||||
allowAbsoluteEndpoint: true,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toBe("https://matrix.example.org/start");
|
||||
expect(calls[0]?.url).toBe("http://127.0.0.1:8008/start");
|
||||
expect(calls[0]?.headers.get("authorization")).toBe("Bearer token");
|
||||
expect(calls[1]?.url).toBe("https://cdn.example.org/next");
|
||||
expect(calls[1]?.url).toBe("http://127.0.0.2:8008/next");
|
||||
expect(calls[1]?.headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -481,8 +501,9 @@ describe("MatrixClient request hardening", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", undefined, undefined, {
|
||||
localTimeoutMs: 25,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
|
||||
const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "matrix-js-sdk";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import type { SsrFPolicy } from "../runtime-api.js";
|
||||
import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js";
|
||||
import { FileBackedMatrixSyncStore } from "./client/file-sync-store.js";
|
||||
import { createMatrixJsSdkClientLogger } from "./client/logging.js";
|
||||
@@ -23,7 +24,7 @@ import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
|
||||
import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js";
|
||||
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
|
||||
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
|
||||
import { type HttpMethod, type QueryParams } from "./sdk/transport.js";
|
||||
import { createMatrixGuardedFetch, type HttpMethod, type QueryParams } from "./sdk/transport.js";
|
||||
import type {
|
||||
MatrixClientEventMap,
|
||||
MatrixCryptoBootstrapApi,
|
||||
@@ -219,9 +220,10 @@ export class MatrixClient {
|
||||
idbSnapshotPath?: string;
|
||||
cryptoDatabasePrefix?: string;
|
||||
autoBootstrapCrypto?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
} = {},
|
||||
) {
|
||||
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken);
|
||||
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken, opts.ssrfPolicy);
|
||||
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
|
||||
this.initialSyncLimit = opts.initialSyncLimit;
|
||||
this.encryptionEnabled = opts.encryption === true;
|
||||
@@ -242,6 +244,7 @@ export class MatrixClient {
|
||||
deviceId: opts.deviceId,
|
||||
logger: createMatrixJsSdkClientLogger("MatrixClient"),
|
||||
localTimeoutMs: this.localTimeoutMs,
|
||||
fetchFn: createMatrixGuardedFetch({ ssrfPolicy: opts.ssrfPolicy }),
|
||||
store: this.syncStore,
|
||||
cryptoCallbacks: cryptoCallbacks as never,
|
||||
verificationMethods: [
|
||||
|
||||
@@ -25,7 +25,9 @@ describe("MatrixAuthedHttpClient", () => {
|
||||
buffer: Buffer.from('{"ok":true}', "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token", {
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
const result = await client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
|
||||
@@ -39,6 +41,7 @@ describe("MatrixAuthedHttpClient", () => {
|
||||
method: "GET",
|
||||
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
|
||||
allowAbsoluteEndpoint: true,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||
import { buildHttpError } from "./event-helpers.js";
|
||||
import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js";
|
||||
|
||||
@@ -5,6 +6,7 @@ export class MatrixAuthedHttpClient {
|
||||
constructor(
|
||||
private readonly homeserver: string,
|
||||
private readonly accessToken: string,
|
||||
private readonly ssrfPolicy?: SsrFPolicy,
|
||||
) {}
|
||||
|
||||
async requestJson(params: {
|
||||
@@ -23,6 +25,7 @@ export class MatrixAuthedHttpClient {
|
||||
qs: params.qs,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -57,6 +60,7 @@ export class MatrixAuthedHttpClient {
|
||||
raw: true,
|
||||
maxBytes: params.maxBytes,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -22,13 +22,14 @@ describe("performMatrixRequest", () => {
|
||||
|
||||
await expect(
|
||||
performMatrixRequest({
|
||||
homeserver: "https://matrix.example.org",
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
accessToken: "token",
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/media/v3/download/example/id",
|
||||
timeoutMs: 5000,
|
||||
raw: true,
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
});
|
||||
@@ -54,13 +55,14 @@ describe("performMatrixRequest", () => {
|
||||
|
||||
await expect(
|
||||
performMatrixRequest({
|
||||
homeserver: "https://matrix.example.org",
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
accessToken: "token",
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/media/v3/download/example/id",
|
||||
timeoutMs: 5000,
|
||||
raw: true,
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type SsrFPolicy,
|
||||
} from "../../runtime-api.js";
|
||||
import { readResponseWithLimit } from "./read-response-with-limit.js";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
@@ -44,60 +50,196 @@ function isRedirectStatus(statusCode: number): boolean {
|
||||
return statusCode >= 300 && statusCode < 400;
|
||||
}
|
||||
|
||||
async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise<Response> {
|
||||
let currentUrl = new URL(url.toString());
|
||||
let method = (init.method ?? "GET").toUpperCase();
|
||||
let body = init.body;
|
||||
let headers = new Headers(init.headers ?? {});
|
||||
const maxRedirects = 5;
|
||||
function toFetchUrl(resource: RequestInfo | URL): string {
|
||||
if (resource instanceof URL) {
|
||||
return resource.toString();
|
||||
}
|
||||
if (typeof resource === "string") {
|
||||
return resource;
|
||||
}
|
||||
return resource.url;
|
||||
}
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
|
||||
const response = await fetch(currentUrl, {
|
||||
...init,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
function buildBufferedResponse(params: {
|
||||
source: Response;
|
||||
body: ArrayBuffer;
|
||||
url: string;
|
||||
}): Response {
|
||||
const response = new Response(params.body, {
|
||||
status: params.source.status,
|
||||
statusText: params.source.statusText,
|
||||
headers: new Headers(params.source.headers),
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(response, "url", {
|
||||
value: params.source.url || params.url,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Response.url is read-only in some runtimes; metadata is best-effort only.
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!isRedirectStatus(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
|
||||
}
|
||||
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
if (nextUrl.protocol !== currentUrl.protocol) {
|
||||
throw new Error(
|
||||
`Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (nextUrl.origin !== currentUrl.origin) {
|
||||
headers = new Headers(headers);
|
||||
headers.delete("authorization");
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 303 ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method !== "GET" &&
|
||||
method !== "HEAD")
|
||||
) {
|
||||
method = "GET";
|
||||
body = undefined;
|
||||
headers = new Headers(headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
}
|
||||
|
||||
currentUrl = nextUrl;
|
||||
function buildAbortSignal(params: { timeoutMs?: number; signal?: AbortSignal }): {
|
||||
signal?: AbortSignal;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const { timeoutMs, signal } = params;
|
||||
if (!timeoutMs && !signal) {
|
||||
return { signal: undefined, cleanup: () => {} };
|
||||
}
|
||||
if (!timeoutMs) {
|
||||
return { signal, cleanup: () => {} };
|
||||
}
|
||||
|
||||
throw new Error(`Too many redirects while requesting ${url.toString()}`);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithMatrixGuardedRedirects(params: {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ response: Response; release: () => Promise<void>; finalUrl: string }> {
|
||||
let currentUrl = new URL(params.url);
|
||||
let method = (params.init?.method ?? "GET").toUpperCase();
|
||||
let body = params.init?.body;
|
||||
let headers = new Headers(params.init?.headers ?? {});
|
||||
const maxRedirects = 5;
|
||||
const visited = new Set<string>();
|
||||
const { signal, cleanup } = buildAbortSignal({
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
|
||||
let dispatcher: ReturnType<typeof createPinnedDispatcher> | undefined;
|
||||
try {
|
||||
const pinned = await resolvePinnedHostnameWithPolicy(currentUrl.hostname, {
|
||||
policy: params.ssrfPolicy,
|
||||
});
|
||||
dispatcher = createPinnedDispatcher(pinned, undefined, params.ssrfPolicy);
|
||||
const response = await fetch(currentUrl.toString(), {
|
||||
...params.init,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal,
|
||||
dispatcher,
|
||||
} as RequestInit & { dispatcher: unknown });
|
||||
|
||||
if (!isRedirectStatus(response.status)) {
|
||||
return {
|
||||
response,
|
||||
release: async () => {
|
||||
cleanup();
|
||||
await closeDispatcher(dispatcher);
|
||||
},
|
||||
finalUrl: currentUrl.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
cleanup();
|
||||
await closeDispatcher(dispatcher);
|
||||
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
|
||||
}
|
||||
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
if (nextUrl.protocol !== currentUrl.protocol) {
|
||||
cleanup();
|
||||
await closeDispatcher(dispatcher);
|
||||
throw new Error(
|
||||
`Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextUrlString = nextUrl.toString();
|
||||
if (visited.has(nextUrlString)) {
|
||||
cleanup();
|
||||
await closeDispatcher(dispatcher);
|
||||
throw new Error("Redirect loop detected");
|
||||
}
|
||||
visited.add(nextUrlString);
|
||||
|
||||
if (nextUrl.origin !== currentUrl.origin) {
|
||||
headers = new Headers(headers);
|
||||
headers.delete("authorization");
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 303 ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method !== "GET" &&
|
||||
method !== "HEAD")
|
||||
) {
|
||||
method = "GET";
|
||||
body = undefined;
|
||||
headers = new Headers(headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
}
|
||||
|
||||
void response.body?.cancel();
|
||||
await closeDispatcher(dispatcher);
|
||||
currentUrl = nextUrl;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
await closeDispatcher(dispatcher);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
throw new Error(`Too many redirects while requesting ${params.url}`);
|
||||
}
|
||||
|
||||
export function createMatrixGuardedFetch(params: { ssrfPolicy?: SsrFPolicy }): typeof fetch {
|
||||
return (async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = toFetchUrl(resource);
|
||||
const { signal, ...requestInit } = init ?? {};
|
||||
const { response, release } = await fetchWithMatrixGuardedRedirects({
|
||||
url,
|
||||
init: requestInit,
|
||||
signal: signal ?? undefined,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
const body = await response.arrayBuffer();
|
||||
return buildBufferedResponse({
|
||||
source: response,
|
||||
body,
|
||||
url,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
export async function performMatrixRequest(params: {
|
||||
@@ -111,6 +253,7 @@ export async function performMatrixRequest(params: {
|
||||
raw?: boolean;
|
||||
maxBytes?: number;
|
||||
readIdleTimeoutMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
|
||||
const isAbsoluteEndpoint =
|
||||
@@ -146,15 +289,18 @@ export async function performMatrixRequest(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
try {
|
||||
const response = await fetchWithSafeRedirects(baseUrl, {
|
||||
const { response, release } = await fetchWithMatrixGuardedRedirects({
|
||||
url: baseUrl.toString(),
|
||||
init: {
|
||||
method: params.method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
if (params.raw) {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (params.maxBytes && contentLength) {
|
||||
@@ -187,6 +333,6 @@ export async function performMatrixRequest(params: {
|
||||
buffer: Buffer.from(text, "utf8"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,72 @@ describe("matrix onboarding", () => {
|
||||
expect(noteText).toContain("MATRIX_<ACCOUNT_ID>_DEVICE_NAME");
|
||||
});
|
||||
|
||||
it("prompts for private-network access when onboarding an internal http homeserver", async () => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
|
||||
(homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
select: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Matrix auth method") {
|
||||
return "token";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
}),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Matrix homeserver URL") {
|
||||
return "http://localhost.localdomain:8008";
|
||||
}
|
||||
if (message === "Matrix access token") {
|
||||
return "ops-token";
|
||||
}
|
||||
if (message === "Matrix device name (optional)") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`unexpected text prompt: ${message}`);
|
||||
}),
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Allow private/internal Matrix homeserver traffic for this account?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Enable end-to-end encryption (E2EE)?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const result = await matrixOnboardingAdapter.configureInteractive!({
|
||||
cfg: {} as CoreConfig,
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
|
||||
prompter,
|
||||
options: undefined,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
forceAllowFrom: false,
|
||||
configured: false,
|
||||
label: "Matrix",
|
||||
});
|
||||
|
||||
expect(result).not.toBe("skip");
|
||||
if (result === "skip") {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.cfg.channels?.matrix).toMatchObject({
|
||||
homeserver: "http://localhost.localdomain:8008",
|
||||
allowPrivateNetwork: true,
|
||||
accessToken: "ops-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves status using the overridden Matrix account", async () => {
|
||||
const status = await matrixOnboardingAdapter.getStatus({
|
||||
cfg: {
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
resolveMatrixAccount,
|
||||
resolveMatrixAccountConfig,
|
||||
} from "./matrix/accounts.js";
|
||||
import { resolveMatrixEnvAuthReadiness, validateMatrixHomeserverUrl } from "./matrix/client.js";
|
||||
import {
|
||||
resolveMatrixEnvAuthReadiness,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./matrix/client.js";
|
||||
import {
|
||||
resolveMatrixConfigFieldPath,
|
||||
resolveMatrixConfigPath,
|
||||
@@ -20,6 +24,7 @@ import type { DmPolicy } from "./runtime-api.js";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
isPrivateOrLoopbackHost,
|
||||
mergeAllowFromEntries,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
@@ -117,6 +122,15 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function requiresMatrixPrivateNetworkOptIn(homeserver: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(homeserver);
|
||||
return parsed.protocol === "http:" && !isPrivateOrLoopbackHost(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptMatrixAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
@@ -343,7 +357,9 @@ async function runMatrixConfigure(params: {
|
||||
initialValue: existing.homeserver ?? envHomeserver,
|
||||
validate: (value) => {
|
||||
try {
|
||||
validateMatrixHomeserverUrl(String(value ?? ""));
|
||||
validateMatrixHomeserverUrl(String(value ?? ""), {
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Invalid Matrix homeserver URL";
|
||||
@@ -351,6 +367,23 @@ async function runMatrixConfigure(params: {
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const requiresAllowPrivateNetwork = requiresMatrixPrivateNetworkOptIn(homeserver);
|
||||
const shouldPromptAllowPrivateNetwork =
|
||||
requiresAllowPrivateNetwork || existing.allowPrivateNetwork === true;
|
||||
const allowPrivateNetwork = shouldPromptAllowPrivateNetwork
|
||||
? await params.prompter.confirm({
|
||||
message: "Allow private/internal Matrix homeserver traffic for this account?",
|
||||
initialValue: existing.allowPrivateNetwork === true || requiresAllowPrivateNetwork,
|
||||
})
|
||||
: false;
|
||||
if (requiresAllowPrivateNetwork && !allowPrivateNetwork) {
|
||||
throw new Error(
|
||||
"Matrix homeserver requires allowPrivateNetwork for trusted private/internal access",
|
||||
);
|
||||
}
|
||||
await resolveValidatedMatrixHomeserverUrl(homeserver, {
|
||||
allowPrivateNetwork,
|
||||
});
|
||||
|
||||
let accessToken = existing.accessToken ?? "";
|
||||
let password = typeof existing.password === "string" ? existing.password : "";
|
||||
@@ -429,6 +462,9 @@ async function runMatrixConfigure(params: {
|
||||
next = updateMatrixAccountConfig(next, accountId, {
|
||||
enabled: true,
|
||||
homeserver,
|
||||
...(shouldPromptAllowPrivateNetwork
|
||||
? { allowPrivateNetwork: allowPrivateNetwork ? true : null }
|
||||
: {}),
|
||||
userId: userId || null,
|
||||
accessToken: accessToken || null,
|
||||
password: password || null,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export * from "openclaw/plugin-sdk/matrix";
|
||||
export {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
// Keep auth-precedence available internally without re-exporting helper-api
|
||||
// twice through both plugin-sdk/matrix and ../runtime-api.js.
|
||||
export * from "./auth-precedence.js";
|
||||
|
||||
@@ -65,6 +65,7 @@ export function applyMatrixSetupAccountConfig(params: {
|
||||
return updateMatrixAccountConfig(next, normalizedAccountId, {
|
||||
enabled: true,
|
||||
homeserver: null,
|
||||
allowPrivateNetwork: null,
|
||||
userId: null,
|
||||
accessToken: null,
|
||||
password: null,
|
||||
@@ -79,6 +80,10 @@ export function applyMatrixSetupAccountConfig(params: {
|
||||
return updateMatrixAccountConfig(next, normalizedAccountId, {
|
||||
enabled: true,
|
||||
homeserver: params.input.homeserver?.trim(),
|
||||
allowPrivateNetwork:
|
||||
typeof params.input.allowPrivateNetwork === "boolean"
|
||||
? params.input.allowPrivateNetwork
|
||||
: undefined,
|
||||
userId: password && !userId ? null : userId,
|
||||
accessToken: accessToken || (password ? null : undefined),
|
||||
password: password || (accessToken ? null : undefined),
|
||||
|
||||
@@ -19,6 +19,7 @@ export function buildMatrixConfigUpdate(
|
||||
cfg: CoreConfig,
|
||||
input: {
|
||||
homeserver?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
@@ -29,6 +30,7 @@ export function buildMatrixConfigUpdate(
|
||||
return updateMatrixAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {
|
||||
enabled: true,
|
||||
homeserver: input.homeserver,
|
||||
allowPrivateNetwork: input.allowPrivateNetwork,
|
||||
userId: input.userId,
|
||||
accessToken: input.accessToken,
|
||||
password: input.password,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SsrFPolicy } from "../../api.js";
|
||||
export { ssrfPolicyFromAllowPrivateNetwork } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { validateUrbitBaseUrl } from "./base-url.js";
|
||||
import { UrbitUrlError } from "./errors.js";
|
||||
|
||||
@@ -40,12 +41,6 @@ export function getUrbitContext(url: string, ship?: string): UrbitContext {
|
||||
};
|
||||
}
|
||||
|
||||
export function ssrfPolicyFromAllowPrivateNetwork(
|
||||
allowPrivateNetwork: boolean | null | undefined,
|
||||
): SsrFPolicy | undefined {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default SSRF policy for image uploads.
|
||||
* Uses a restrictive policy that blocks private networks by default.
|
||||
|
||||
@@ -79,6 +79,7 @@ export type ChannelSetupInput = {
|
||||
audience?: string;
|
||||
useEnv?: boolean;
|
||||
homeserver?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
|
||||
@@ -37,3 +37,4 @@ export * from "../infra/system-message.ts";
|
||||
export * from "../infra/tmp-openclaw-dir.js";
|
||||
export * from "../infra/transport-ready.js";
|
||||
export * from "../infra/wsl.ts";
|
||||
export * from "./ssrf-policy.js";
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||
normalizeHostnameSuffixAllowlist,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
} from "./ssrf-policy.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0];
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
describe("ssrfPolicyFromAllowPrivateNetwork", () => {
|
||||
it("returns undefined unless private-network access is explicitly enabled", () => {
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(undefined)).toBeUndefined();
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(false)).toBeUndefined();
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(true)).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertHttpUrlTargetsPrivateNetwork", () => {
|
||||
it("allows https targets without private-network checks", async () => {
|
||||
await expect(
|
||||
assertHttpUrlTargetsPrivateNetwork("https://matrix.example.org", {
|
||||
allowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows internal DNS names only when they resolve exclusively to private IPs", async () => {
|
||||
await expect(
|
||||
assertHttpUrlTargetsPrivateNetwork("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "10.0.0.5", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects cleartext public hosts even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
assertHttpUrlTargetsPrivateNetwork("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
errorMessage:
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHostnameSuffixAllowlist", () => {
|
||||
it("uses defaults when input is missing", () => {
|
||||
expect(normalizeHostnameSuffixAllowlist(undefined, ["GRAPH.MICROSOFT.COM"])).toEqual([
|
||||
|
||||
@@ -1,4 +1,56 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
isBlockedHostnameOrIp,
|
||||
isPrivateIpAddress,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "../infra/net/ssrf.js";
|
||||
|
||||
export function ssrfPolicyFromAllowPrivateNetwork(
|
||||
allowPrivateNetwork: boolean | null | undefined,
|
||||
): SsrFPolicy | undefined {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
export async function assertHttpUrlTargetsPrivateNetwork(
|
||||
url: string,
|
||||
params: {
|
||||
allowPrivateNetwork?: boolean | null;
|
||||
lookupFn?: LookupFn;
|
||||
errorMessage?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:") {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
params.errorMessage ?? "HTTP URL must target a trusted private/internal host";
|
||||
const { hostname } = parsed;
|
||||
if (!hostname) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Literal loopback/private hosts can stay local without DNS.
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.allowPrivateNetwork !== true) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// allowPrivateNetwork is an opt-in for trusted private/internal targets, not
|
||||
// a blanket exemption for cleartext public internet hosts.
|
||||
const pinned = await resolvePinnedHostnameWithPolicy(hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: ssrfPolicyFromAllowPrivateNetwork(true),
|
||||
});
|
||||
if (!pinned.addresses.every((address) => isPrivateIpAddress(address))) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostnameSuffix(value: string): string {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user