diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index b3092f60a24..5933c77dd7a 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -6,6 +6,7 @@ import { resolveMatrixAuthContext, resolveMatrixConfig, resolveMatrixConfigForAccount, + validateMatrixHomeserverUrl, } from "./client.js"; import * as credentialsModule from "./credentials.js"; import * as sdkModule from "./sdk.js"; @@ -146,6 +147,13 @@ describe("resolveMatrixConfig", () => { "default", ); }); + + it("rejects insecure public http Matrix homeservers", () => { + expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow( + "Matrix homeserver must use https:// unless it targets a private or loopback host", + ); + expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008"); + }); }); describe("resolveMatrixAuth", () => { @@ -273,6 +281,21 @@ describe("resolveMatrixAuth", () => { expect(saveMatrixCredentialsMock).not.toHaveBeenCalled(); }); + it("rejects embedded credentials in Matrix homeserver URLs", async () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://user:pass@matrix.example.org", + accessToken: "tok-123", + }, + }, + } as CoreConfig; + + await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( + "Matrix homeserver URL must not include embedded credentials", + ); + }); + it("falls back to config deviceId when cached credentials are missing it", async () => { vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ homeserver: "https://matrix.example.org", diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 91a91cb3cae..887b49f9799 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -9,6 +9,7 @@ export { resolveImplicitMatrixAccountId, resolveMatrixAuth, resolveMatrixAuthContext, + validateMatrixHomeserverUrl, } from "./client/config.js"; export { createMatrixClient } from "./client/create-client.js"; export { diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 147e1ab69f3..c06a2c563c3 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,8 +1,9 @@ import { DEFAULT_ACCOUNT_ID, + isPrivateOrLoopbackHost, normalizeAccountId, normalizeOptionalAccountId, -} from "openclaw/plugin-sdk/account-id"; +} from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; import { normalizeResolvedSecretInputString } from "../../secret-input.js"; import type { CoreConfig } from "../../types.js"; @@ -142,6 +143,40 @@ export function hasReadyMatrixEnvAuth(config: { return Boolean(homeserver && (accessToken || (userId && password))); } +export function validateMatrixHomeserverUrl(homeserver: string): string { + const trimmed = clean(homeserver, "matrix.homeserver"); + if (!trimmed) { + throw new Error("Matrix homeserver is required (matrix.homeserver)"); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Matrix homeserver must be a valid http(s) URL"); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Matrix homeserver must use http:// or https://"); + } + if (!parsed.hostname) { + throw new Error("Matrix homeserver must include a hostname"); + } + if (parsed.username || parsed.password) { + throw new Error("Matrix homeserver URL must not include embedded credentials"); + } + 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", + ); + } + + return trimmed; +} + export function resolveMatrixConfig( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, env: NodeJS.ProcessEnv = process.env, @@ -346,9 +381,7 @@ export async function resolveMatrixAuth(params?: { accountId?: string | null; }): Promise { const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params); - if (!resolved.homeserver) { - throw new Error("Matrix homeserver is required (matrix.homeserver)"); - } + const homeserver = validateMatrixHomeserverUrl(resolved.homeserver); const { loadMatrixCredentials, @@ -361,7 +394,7 @@ export async function resolveMatrixAuth(params?: { const cachedCredentials = cached && credentialsMatchConfig(cached, { - homeserver: resolved.homeserver, + homeserver, userId: resolved.userId || "", accessToken: resolved.accessToken, }) @@ -379,7 +412,7 @@ 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(resolved.homeserver, resolved.accessToken); + const tempClient = new MatrixClient(homeserver, resolved.accessToken); const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { user_id?: string; device_id?: string; @@ -404,7 +437,7 @@ export async function resolveMatrixAuth(params?: { if (shouldRefreshCachedCredentials) { await saveMatrixCredentials( { - homeserver: resolved.homeserver, + homeserver, userId, accessToken: resolved.accessToken, deviceId: knownDeviceId, @@ -417,7 +450,7 @@ export async function resolveMatrixAuth(params?: { } return { accountId, - homeserver: resolved.homeserver, + homeserver, userId, accessToken: resolved.accessToken, password: resolved.password, @@ -455,7 +488,7 @@ export async function resolveMatrixAuth(params?: { // Login with password using the same hardened request path as other Matrix HTTP calls. ensureMatrixSdkLoggingConfigured(); - const loginClient = new MatrixClient(resolved.homeserver, ""); + const loginClient = new MatrixClient(homeserver, ""); const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, { type: "m.login.password", identifier: { type: "m.id.user", user: resolved.userId }, @@ -475,7 +508,7 @@ export async function resolveMatrixAuth(params?: { const auth: MatrixAuth = { accountId, - homeserver: resolved.homeserver, + homeserver, userId: login.user_id ?? resolved.userId, accessToken, password: resolved.password, diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 0d290a0a794..1c66dbbc302 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { MatrixClient } from "../sdk.js"; +import { validateMatrixHomeserverUrl } from "./config.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { maybeMigrateLegacyStorage, @@ -21,11 +22,12 @@ export async function createMatrixClient(params: { }): Promise { ensureMatrixSdkLoggingConfigured(); const env = process.env; + const homeserver = validateMatrixHomeserverUrl(params.homeserver); const userId = params.userId?.trim() || "unknown"; const matrixClientUserId = params.userId?.trim() || undefined; const storagePaths = resolveMatrixStoragePaths({ - homeserver: params.homeserver, + homeserver, userId, accessToken: params.accessToken, accountId: params.accountId, @@ -39,14 +41,14 @@ export async function createMatrixClient(params: { writeStorageMeta({ storagePaths, - homeserver: params.homeserver, + homeserver, userId, accountId: params.accountId, }); const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`; - return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, { + return new MatrixClient(homeserver, params.accessToken, undefined, undefined, { userId: matrixClientUserId, password: params.password, deviceId: params.deviceId, diff --git a/extensions/matrix/src/matrix/probe.test.ts b/extensions/matrix/src/matrix/probe.test.ts index 2bbf2534565..3d0221e0709 100644 --- a/extensions/matrix/src/matrix/probe.test.ts +++ b/extensions/matrix/src/matrix/probe.test.ts @@ -68,4 +68,19 @@ describe("probeMatrix", () => { accountId: "ops", }); }); + + it("returns client validation errors for insecure public http homeservers", async () => { + createMatrixClientMock.mockRejectedValue( + new Error("Matrix homeserver must use https:// unless it targets a private or loopback host"), + ); + + const result = await probeMatrix({ + homeserver: "http://matrix.example.org", + accessToken: "tok", + timeoutMs: 500, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Matrix homeserver must use https://"); + }); }); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 123594f5e38..115a920d743 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -23,6 +23,7 @@ import { getMatrixScopedEnvVarNames, hasReadyMatrixEnvAuth, resolveScopedMatrixEnvConfig, + validateMatrixHomeserverUrl, } from "./matrix/client.js"; import { resolveMatrixConfigFieldPath, @@ -312,14 +313,12 @@ async function runMatrixConfigure(params: { message: "Matrix homeserver URL", initialValue: existing.homeserver ?? envHomeserver, validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; + try { + validateMatrixHomeserverUrl(String(value ?? "")); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : "Invalid Matrix homeserver URL"; } - if (!/^https?:\/\//i.test(raw)) { - return "Use a full URL (https://...)"; - } - return undefined; }, }), ).trim(); diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 54b54f97823..f64749e638b 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -123,6 +123,7 @@ export { resolveMatrixMigrationSnapshotOutputDir, } from "../infra/matrix-migration-snapshot.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { isPrivateOrLoopbackHost } from "../gateway/net.js"; export { getSessionBindingService, registerSessionBindingAdapter, @@ -142,6 +143,7 @@ export { normalizePollInput } from "../polls.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId, + normalizeOptionalAccountId, resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; export type { RuntimeEnv } from "../runtime.js";