diff --git a/extensions/reef/src/transport.test.ts b/extensions/reef/src/transport.test.ts index c6181dbc12e3..e320ec121b99 100644 --- a/extensions/reef/src/transport.test.ts +++ b/extensions/reef/src/transport.test.ts @@ -1,7 +1,7 @@ import { createPublicKey, verify as verifySignature } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import { canonicalBytes, fromBase64url, sha256Hex } from "../protocol/index.js"; -import { ReefTransportClient } from "./transport.js"; +import { ReefRelayError, ReefTransportClient } from "./transport.js"; import type { ReefKeys, RelayFriend } from "./types.js"; const ts = 1_752_300_000; @@ -164,3 +164,157 @@ describe("ReefTransportClient device authentication", () => { expect(new Set(seenTs).size).toBe(3); }); }); + +const SUCCESS_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; +const ERROR_RESPONSE_MAX_BYTES = 64 * 1024; + +function jsonObjectBodyAtSize(bytes: number, field: "pad" | "error"): string { + const prefix = `{"${field}":"`; + const suffix = `"}`; + return `${prefix}${"x".repeat(bytes - prefix.length - suffix.length)}${suffix}`; +} + +function createTrackedResponse(params: { status: number; chunks: Uint8Array[] }): { + response: Response; + state: { emittedBytes: number; cancelled: boolean }; +} { + const state = { emittedBytes: 0, cancelled: false }; + let index = 0; + const body = new ReadableStream({ + pull(controller) { + const chunk = params.chunks[index++]; + if (!chunk) { + controller.close(); + return; + } + state.emittedBytes += chunk.byteLength; + controller.enqueue(chunk); + }, + cancel() { + state.cancelled = true; + }, + }); + return { + response: new Response(body, { + status: params.status, + headers: { "content-type": "application/json" }, + }), + state, + }; +} + +describe("ReefTransportClient response body bounds", () => { + it("accepts success JSON exactly at the byte limit", async () => { + const body = jsonObjectBodyAtSize(SUCCESS_RESPONSE_MAX_BYTES, "pad"); + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + cancel() { + cancelled = true; + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + const client = new ReefTransportClient( + "https://relay.example", + "alice", + keys, + async () => response, + () => ts, + ); + + const result = await client.pull(0); + const pad = (result as unknown as { pad: string }).pad; + expect(pad).toHaveLength(SUCCESS_RESPONSE_MAX_BYTES - 10); + expect(pad[0]).toBe("x"); + expect(pad.at(-1)).toBe("x"); + expect(Buffer.byteLength(body)).toBe(SUCCESS_RESPONSE_MAX_BYTES); + expect(cancelled).toBe(false); + }); + + it("cancels success JSON when a chunk crosses the byte limit", async () => { + const offered = createTrackedResponse({ + status: 200, + chunks: [ + new Uint8Array(SUCCESS_RESPONSE_MAX_BYTES - 1).fill(0x78), + new Uint8Array(2).fill(0x78), + new Uint8Array(1024).fill(0x78), + new Uint8Array(1024).fill(0x78), + ], + }); + const client = new ReefTransportClient( + "https://relay.example", + "alice", + keys, + async () => offered.response, + () => ts, + ); + + await expect(client.pull(0)).rejects.toThrow( + /reef\.relay: JSON response exceeds 16777216 bytes/, + ); + expect(offered.state.cancelled).toBe(true); + expect(offered.state.emittedBytes).toBeGreaterThan(SUCCESS_RESPONSE_MAX_BYTES); + expect(offered.state.emittedBytes).toBeLessThan(SUCCESS_RESPONSE_MAX_BYTES + 1 + 2048); + }); + + it("surfaces relay error JSON exactly at the error byte limit", async () => { + const body = jsonObjectBodyAtSize(ERROR_RESPONSE_MAX_BYTES, "error"); + const client = new ReefTransportClient( + "https://relay.example", + "alice", + keys, + async () => new Response(body, { status: 400 }), + () => ts, + ); + + const error = await client.requestFriend("bob", "code").catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(ReefRelayError); + expect(error).toMatchObject({ status: 400 }); + expect((error as Error).message).toHaveLength(ERROR_RESPONSE_MAX_BYTES - 12); + expect(Buffer.byteLength(body)).toBe(ERROR_RESPONSE_MAX_BYTES); + }); + + it("keeps status fallback and cancels oversized error bodies", async () => { + const offered = createTrackedResponse({ + status: 503, + chunks: Array.from({ length: 16 }, () => new Uint8Array(8 * 1024).fill(0x78)), + }); + const client = new ReefTransportClient( + "https://relay.example", + "alice", + keys, + async () => offered.response, + () => ts, + ); + + await expect(client.listFriends()).rejects.toMatchObject({ + name: "ReefRelayError", + status: 503, + message: "relay HTTP 503", + }); + expect(offered.state.cancelled).toBe(true); + expect(offered.state.emittedBytes).toBeGreaterThan(64 * 1024); + expect(offered.state.emittedBytes).toBeLessThan(128 * 1024); + }); + + it("keeps the typed status fallback for malformed error JSON", async () => { + const client = new ReefTransportClient( + "https://relay.example", + "alice", + keys, + async () => new Response("{", { status: 502 }), + () => ts, + ); + + await expect(client.requestFriend("bob", "code")).rejects.toMatchObject({ + name: "ReefRelayError", + status: 502, + message: "relay HTTP 502", + }); + }); +}); diff --git a/extensions/reef/src/transport.ts b/extensions/reef/src/transport.ts index 8dca3e470dad..450fd64bae1a 100644 --- a/extensions/reef/src/transport.ts +++ b/extensions/reef/src/transport.ts @@ -1,9 +1,16 @@ +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { sha256Hex, signDeviceRequest, utf8 } from "../protocol/index.js"; import type { Envelope, SignedReceipt } from "../protocol/index.js"; import type { InboxEntry, ReefKeys, RelayFriend } from "./types.js"; type FetchLike = typeof fetch; +// Relay JSON is untrusted network input. Cap success bodies at the shared +// provider default and keep error bodies smaller so a hostile relay cannot +// force unbounded allocation through response.json(). +const REEF_RELAY_JSON_MAX_BYTES = 16 * 1024 * 1024; +const REEF_RELAY_ERROR_JSON_MAX_BYTES = 64 * 1024; + export class ReefRelayError extends Error { constructor( readonly status: number, @@ -150,17 +157,26 @@ export class ReefTransportClient { if (!response.ok) { let message = `relay HTTP ${response.status}`; try { - const parsed = (await response.json()) as { error?: string }; - if (parsed.error) { + const parsed = await readProviderJsonResponse<{ error?: string }>( + response, + "reef.relay.error", + { maxBytes: REEF_RELAY_ERROR_JSON_MAX_BYTES }, + ); + if (typeof parsed.error === "string" && parsed.error) { message = parsed.error; } - } catch {} + } catch { + // Keep the status fallback when the error body is missing, malformed, + // or oversized; callers still get a typed ReefRelayError. + } throw new ReefRelayError(response.status, message); } if (response.status === 204) { return undefined as T; } - return (await response.json()) as T; + return await readProviderJsonResponse(response, "reef.relay", { + maxBytes: REEF_RELAY_JSON_MAX_BYTES, + }); } }