Matrix: validate homeserver URLs at runtime

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 03:53:09 +00:00
parent e0a00e459f
commit 8af9b30ae7
7 changed files with 95 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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