fix(matrix): reverse-proxy path support for Matrix homeserver URLs (#93516)

* feat(matrix): add configurable apiPrefix for non-standard homeserver API paths

* fix(matrix): remove public apiPrefix config, keep transport path fix

The apiPrefix config surface was incomplete: it was not carried through
resolveMatrixConfigForAccount, MatrixAuth, shared client keying, or
createMatrixClient, making it a no-op in normal runtime paths.

Following ClawSweeper review direction, this narrow fix:
- Removes apiPrefix from MatrixConfigSchema and MatrixConfig types
- Keeps apiPrefix as a private internal constant in MatrixAuthedHttpClient
  for bare-path resolution (DEFAULT_API_PREFIX = /_matrix/client/v3)
- Stops MatrixClient from accepting/passing apiPrefix
- Retains the transport.ts normalizeEndpoint() fix for homeserver URLs
  behind reverse proxies (avoids losing path segments on trailing-slash
  mismatches)

Test: 1393 tests pass across 121 matrix extension test files.

* fix(matrix): ensure homeserver URL ends with slash for new URL() resolution

Without a trailing slash, new URL(relPath, homeserver) treats the final
path segment as a filename, losing the reverse-proxy prefix path.

For example:
  new URL('_matrix/...', 'https://host/proxy')
  -> https://host/_matrix/... (wrong, 'proxy' dropped)

With trailing slash:
  new URL('_matrix/...', 'https://host/proxy/')
  -> https://host/proxy/_matrix/... (correct)

* test(matrix): add URL resolution coverage for path-prefixed homeservers

* fix(matrix): preserve reverse-proxy homeserver paths

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Papilionidae
2026-07-27 17:25:58 +08:00
committed by GitHub
parent dafecc756a
commit 0680dfc6a9
2 changed files with 66 additions and 1 deletions
@@ -29,6 +29,71 @@ describe("performMatrixRequest", () => {
clearTestUndiciRuntimeDepsOverride();
});
it.each([
{
name: "a root homeserver",
homeserverPath: "",
expectedPath: "/_matrix/client/v3/account/whoami",
},
{
name: "a proxy prefix without a trailing slash",
homeserverPath: "/matrix-proxy",
expectedPath: "/matrix-proxy/_matrix/client/v3/account/whoami",
},
{
name: "a proxy prefix with a trailing slash",
homeserverPath: "/matrix-proxy/",
expectedPath: "/matrix-proxy/_matrix/client/v3/account/whoami",
},
{
name: "an encoded nested proxy prefix",
homeserverPath: "/proxy%20base/tenant/",
expectedPath: "/proxy%20base/tenant/_matrix/client/v3/account/whoami",
},
])(
"preserves $name through the real HTTP transport",
async ({ homeserverPath, expectedPath }) => {
const requests: Array<{ url: string | undefined; authorization: string | undefined }> = [];
const server = http.createServer((request, response) => {
requests.push({
url: request.url,
authorization: request.headers.authorization,
});
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ user_id: "@bot:example.org" }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address() as { port: number };
try {
const result = await performMatrixRequest({
homeserver: `http://127.0.0.1:${port}${homeserverPath}`,
accessToken: "test-token",
method: "GET",
endpoint: "/_matrix/client/v3/account/whoami",
qs: { via: "proxy path" },
timeoutMs: 5000,
ssrfPolicy: { allowPrivateNetwork: true },
});
expect(result.response.status).toBe(200);
expect(JSON.parse(result.text)).toEqual({ user_id: "@bot:example.org" });
expect(requests).toEqual([
{
url: `${expectedPath}?via=proxy+path`,
authorization: "Bearer test-token",
},
]);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
},
);
it("rejects oversized raw responses before buffering the whole body", async () => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({ cancel });
@@ -328,7 +328,7 @@ export async function performMatrixRequest(params: {
const baseUrl = isAbsoluteEndpoint
? new URL(params.endpoint)
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
: new URL(`${params.homeserver.replace(/\/+$/u, "")}${normalizeEndpoint(params.endpoint)}`);
applyQuery(baseUrl, params.qs);
const headers = new Headers();