Matrix: guard private-network homeserver access

This commit is contained in:
Gustavo Madeira Santana
2026-03-19 23:19:30 -04:00
parent ab97cc3f11
commit f62be0ddcf
27 changed files with 655 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -8,6 +8,7 @@ export {
resolveScopedMatrixEnvConfig,
resolveMatrixAuth,
resolveMatrixAuthContext,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -79,6 +79,7 @@ export type ChannelSetupInput = {
audience?: string;
useEnv?: boolean;
homeserver?: string;
allowPrivateNetwork?: boolean;
userId?: string;
accessToken?: string;
password?: string;

View File

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

View File

@@ -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([

View File

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