diff --git a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs index a7420ed756..0f4f3c846d 100644 --- a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs +++ b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; import { createRoot } from "react-dom/client"; @@ -200,32 +200,38 @@ test("policy lookup failure never claims and can be retried", async () => { } }); -test("cancelled policy discovery cannot claim after its response arrives", async () => { +test("suspended policy discovery times out retryably without claiming", async () => { localStorage.clear(); const original = globalThis.fetch; - let resolvePolicy; + let lookups = 0; let started; const lookupStarted = new Promise((resolve) => { started = resolve; }); - const response = new Promise((resolve) => { - resolvePolicy = resolve; - }); - let claims = 0; - globalThis.fetch = async (url) => { + const claimReceipts = []; + globalThis.fetch = async (url, options) => { if (String(url).endsWith("/api/join-policy")) { + lookups++; + if (lookups > 1) return Response.json({}); started(); - return response; + // A blackholed relay: nothing settles until the request is aborted. + return new Promise((_, reject) => + options.signal.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ), + ); } - claims++; - throw new Error("unexpected claim"); - }; - try { - startCommunityOnboarding({ - source: "deep-link-join", - relayUrl: "wss://relay.example", - inviteCode: "code", + claimReceipts.push(JSON.parse(options.body).policy_receipt); + return Response.json({ + status: "joined", + community_id: "id", + host: "relay.example", + role: "member", }); + }; + mock.timers.enable({ apis: ["setTimeout"] }); + try { + startCommunityOnboarding(linkJoin); render( React.createElement( CommunityOnboardingProvider, @@ -234,16 +240,88 @@ test("cancelled policy discovery cannot claim after its response arrives", async ), ); await lookupStarted; - await act(async () => controls.clear()); - await act(async () => resolvePolicy(Response.json({}))); - assert.equal(controls.transaction, null); - assert.equal(claims, 0); + await act(async () => mock.timers.tick(14_999)); + assert.equal(controls.transaction.error, undefined); + await act(async () => mock.timers.tick(1)); + mock.timers.reset(); + await waitFor(() => + assert.equal( + controls.transaction.error, + "The community's join policy took too long to load.", + ), + ); + assert.equal(controls.transaction.stage, "claiming"); + assert.deepEqual(claimReceipts, []); + // Retry, as the claiming stage's button does; this relay has no policy. + await act(async () => controls.update({ error: undefined })); + await waitFor(() => assert.equal(controls.transaction.stage, "connecting")); + assert.equal(lookups, 2); + assert.deepEqual(claimReceipts, [undefined]); } finally { + mock.timers.reset(); cleanup(); globalThis.fetch = original; } }); +// Cancelling, or reopening the link mid-lookup, aborts the pending discovery. +// Its late settlement (here a response that ignores the abort) must not move +// the cleared or replacement transaction. +for (const [name, interrupt] of [ + ["cancelled", () => controls.clear()], + ["reopened", () => controls.start(linkJoin)], +]) { + test(`${name} policy discovery is aborted and cannot settle afterwards`, async () => { + localStorage.clear(); + const original = globalThis.fetch; + const signals = []; + let resolvePolicy; + let started; + const lookupStarted = new Promise((resolve) => { + started = resolve; + }); + let claims = 0; + globalThis.fetch = async (url, options) => { + if (String(url).endsWith("/api/join-policy")) { + signals.push(options.signal); + if (signals.length > 1) return new Promise(() => {}); + started(); + return new Promise((resolve) => { + resolvePolicy = resolve; + }); + } + claims++; + throw new Error("unexpected claim"); + }; + try { + startCommunityOnboarding(linkJoin); + render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + await lookupStarted; + await act(async () => interrupt()); + assert.equal(signals[0].aborted, true); + await act(async () => resolvePolicy(Response.json({ policy }))); + if (name === "cancelled") { + assert.equal(controls.transaction, null); + } else { + await waitFor(() => assert.equal(signals.length, 2)); + assert.equal(signals[1].aborted, false); + assert.equal(controls.transaction.stage, "claiming"); + assert.equal(controls.transaction.error, undefined); + } + assert.equal(claims, 0); + } finally { + cleanup(); + globalThis.fetch = original; + } + }); +} + for (const [sentinel, guidance] of [ [ "invite_restricted", diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts index 206031c7a7..700ce07d33 100644 --- a/desktop/src/features/onboarding/useClaimInvite.ts +++ b/desktop/src/features/onboarding/useClaimInvite.ts @@ -11,7 +11,8 @@ import { claimInvite, getJoinPolicy } from "@/shared/api/invites"; * Drive the `claiming` stage after machine onboarding completes: claim the * invite with the user's final identity, then advance to `connecting`. * Completion is fenced by transaction ID so cancelling or replacing the - * transaction while the request is pending cannot mutate the replacement. + * transaction while the request is pending cannot mutate the replacement; + * the same change aborts a pending policy lookup. * * The error guard keeps a failed claim parked on the caller's Retry * affordance — without it the effect refires on the error-bearing transaction @@ -21,10 +22,12 @@ export function useClaimInvite() { const { transaction, update } = useCommunityOnboarding(); const [isPending, setIsPending] = React.useState(false); const currentTransaction = React.useRef(transaction); + const policyLookup = React.useRef(null); React.useEffect(() => { currentTransaction.current = transaction; return () => { currentTransaction.current = null; + policyLookup.current?.abort(); }; }, [transaction]); @@ -33,9 +36,11 @@ export function useClaimInvite() { return; } setIsPending(true); + const lookup = new AbortController(); + policyLookup.current = lookup; void (async () => { const policy = !transaction.policyReceipt - ? await getJoinPolicy(transaction.relayUrl, "webview") + ? await getJoinPolicy(transaction.relayUrl, "webview", lookup.signal) : null; if (currentTransaction.current !== transaction) return; if (policy) { diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs index eb3754435e..b4459b13a7 100644 --- a/desktop/src/shared/api/invites.test.mjs +++ b/desktop/src/shared/api/invites.test.mjs @@ -53,6 +53,33 @@ test("getJoinPolicy fails closed on a policy endpoint error", async () => { ); }); +test("getJoinPolicy refuses a webview response past the 4 MiB native cap", async () => { + const oversized = 4 * 1024 * 1024 + 1; + // Streamed without a length, then declared up front. + const streamed = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"policy":null,"pad":"')); + controller.enqueue(new Uint8Array(oversized).fill(0x61)); + controller.enqueue(new TextEncoder().encode('"}')); + controller.close(); + }, + }); + for (const response of [ + new Response(streamed, { status: 200 }), + new Response("{}", { + status: 200, + headers: { "content-length": String(oversized) }, + }), + ]) { + await withFetch(response, () => + assert.rejects( + getJoinPolicy("wss://relay.example", "webview"), + /oversized join policy/, + ), + ); + } +}); + test("getJoinPolicy maps the native command response", async () => { const previousWindow = globalThis.window; globalThis.window = { diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts index d0a3c2ed2c..6614e5c2b9 100644 --- a/desktop/src/shared/api/invites.ts +++ b/desktop/src/shared/api/invites.ts @@ -21,6 +21,11 @@ const NIP98_KIND = 27235; // connect timeout (a minute or more on macOS). const INVITE_REQUEST_TIMEOUT_MS = 15_000; +// Matches the native fetch_join_policy cap: each relay policy document is at +// most 256 KiB, and four MiB covers two maximally escaped documents plus the +// response envelope. +const MAX_JOIN_POLICY_RESPONSE_BYTES = 4 * 1024 * 1024; + export type MintedInvite = { code: string; expiresAt: number; @@ -127,10 +132,42 @@ export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean { } } -/** Fetch relay-hosted policy content for any join surface. */ +/** Read a JSON body, refusing it once it streams past `maxBytes`. */ +async function readBoundedJson( + response: Response, + maxBytes: number, +): Promise { + if (Number(response.headers.get("content-length")) > maxBytes) { + throw new Error("relay returned oversized join policy"); + } + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + void reader.cancel(); + throw new Error("relay returned oversized join policy"); + } + text += decoder.decode(value, { stream: true }); + } + return JSON.parse(text + decoder.decode()); +} + +/** + * Fetch relay-hosted policy content for any join surface. + * + * The webview transport mirrors the native command's bounds: one deadline + * covers the headers and the whole body, and the body is capped while it + * streams. `signal` lets the caller abandon the request early. + */ export async function getJoinPolicy( relayWsUrl: string, transport: "native" | "webview", + signal?: AbortSignal, ): Promise { type RawJoinPolicy = { terms_markdown?: string; @@ -145,16 +182,38 @@ export async function getJoinPolicy( }); } else { const base = relayHttpFromWs(relayWsUrl); - const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`); - // Relays predating join-policy support have no configured policy. - if (response.status === 404) return null; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - raw = - ( - (await response.json()) as { - policy?: RawJoinPolicy; - } - ).policy ?? null; + const controller = new AbortController(); + const abort = () => controller.abort(); + signal?.addEventListener("abort", abort, { once: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, INVITE_REQUEST_TIMEOUT_MS); + try { + if (signal?.aborted) abort(); + const response = await fetch( + `${base.replace(/\/+$/, "")}/api/join-policy`, + { signal: controller.signal }, + ); + // Relays predating join-policy support have no configured policy. + if (response.status === 404) return null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + raw = + ( + (await readBoundedJson(response, MAX_JOIN_POLICY_RESPONSE_BYTES)) as { + policy?: RawJoinPolicy; + } + ).policy ?? null; + } catch (error) { + if (timedOut) { + throw new Error("The community's join policy took too long to load."); + } + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + } } return raw ? {