Skip to content

Commit 43d5516

Browse files
krithinclaude
andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QHDRWR469Sb2hQAoYRS3oN Signed-off-by: Krithin Sitaram <1389679+krithin@users.noreply.github.com>
1 parent d19cb75 commit 43d5516

2 files changed

Lines changed: 32 additions & 1 deletion

File tree

spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,34 @@ describe("MSC4108RendezvousSession", () => {
210210
}
211211
});
212212

213+
it("GET with content-type parameters, e.g. added by an intermediary proxy", async function () {
214+
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
215+
const transport = new MSC4108RendezvousSession({
216+
client,
217+
fallbackRzServer: "https://fallbackserver/rz",
218+
});
219+
{
220+
// initial POST
221+
fetchMock.postOnce("https://fallbackserver/rz", {
222+
status: 201,
223+
body: { url: "https://fallbackserver/rz/123" },
224+
});
225+
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
226+
await fetchMock.callHistory.flush(true);
227+
}
228+
{
229+
// GET where the content-type has a charset parameter appended, as nginx's
230+
// `charset` directive does — the payload must still be received
231+
fetchMock.getOnce("https://fallbackserver/rz/123", {
232+
status: 200,
233+
body: "foo=baa",
234+
headers: { "content-type": "text/plain; charset=utf-8", "etag": "aaa" },
235+
});
236+
await expect(transport.receive()).resolves.toEqual("foo=baa");
237+
await fetchMock.callHistory.flush(true);
238+
}
239+
});
240+
213241
it("POST and PUTs", async function () {
214242
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
215243
const transport = new MSC4108RendezvousSession({

src/rendezvous/transports/MSC4108RendezvousSession.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ export class MSC4108RendezvousSession {
203203
// rely on server expiring the channel rather than checking ourselves
204204

205205
const etag = poll.headers.get("etag") ?? undefined;
206-
if (poll.headers.get("content-type") !== "text/plain") {
206+
// Strip any parameters from the content type, e.g. a `charset` appended
207+
// by an intermediary proxy, as only the media type itself matters.
208+
const contentType = poll.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
209+
if (contentType !== "text/plain") {
207210
this.etag = etag;
208211
} else if (poll.status === 200) {
209212
if (!etag) {

0 commit comments

Comments
 (0)