From 53ffe26d63018b6dd78927d090dec28f126e197d Mon Sep 17 00:00:00 2001 From: Krithin Sitaram <1389679+krithin@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:01:40 -0700 Subject: [PATCH] Ignore Content-Type parameters when polling MSC4108 rendezvous channel MSC4108RendezvousSession.receive() compared the Content-Type header against "text/plain" with strict string equality. If a reverse proxy in front of the rendezvous server appends parameters to the header (e.g. nginx's `charset` directive rewrites it to `text/plain; charset=utf-8`), every incoming payload was treated as "no new message" and silently discarded, deadlocking QR code login until the session expired. Parse out the media type per RFC 9110 (parameters follow the type/subtype after a semicolon; type names are case-insensitive) before comparing. Co-Authored-By: Claude Fable 5 Signed-off-by: Krithin Sitaram <1389679+krithin@users.noreply.github.com> --- .../MSC4108RendezvousSession.spec.ts | 28 +++++++++++++++++++ .../transports/MSC4108RendezvousSession.ts | 5 +++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts index a3594a31ebb..1f9360c5f74 100644 --- a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -210,6 +210,34 @@ describe("MSC4108RendezvousSession", () => { } }); + it("GET with content-type parameters, e.g. added by an intermediary proxy", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const transport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + }); + { + // initial POST + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + body: { url: "https://fallbackserver/rz/123" }, + }); + await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined); + await fetchMock.callHistory.flush(true); + } + { + // GET where the content-type has a charset parameter appended, as nginx's + // `charset` directive does — the payload must still be received + fetchMock.getOnce("https://fallbackserver/rz/123", { + status: 200, + body: "foo=baa", + headers: { "content-type": "text/plain; charset=utf-8", "etag": "aaa" }, + }); + await expect(transport.receive()).resolves.toEqual("foo=baa"); + await fetchMock.callHistory.flush(true); + } + }); + it("POST and PUTs", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); const transport = new MSC4108RendezvousSession({ diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index a906e014113..0af94e7442a 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -203,7 +203,10 @@ export class MSC4108RendezvousSession { // rely on server expiring the channel rather than checking ourselves const etag = poll.headers.get("etag") ?? undefined; - if (poll.headers.get("content-type") !== "text/plain") { + // Strip any parameters from the content type, e.g. a `charset` appended + // by an intermediary proxy, as only the media type itself matters. + const contentType = poll.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase(); + if (contentType !== "text/plain") { this.etag = etag; } else if (poll.status === 200) { if (!etag) {