fix(reef): bound relay HTTP JSON response body reads (#106456)

* fix(reef): bound relay HTTP JSON response body reads

* test(reef): cover relay response boundaries

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
pick-cat
2026-07-16 16:00:35 +08:00
committed by GitHub
parent 54f8957e12
commit 9026b9f4ac
2 changed files with 175 additions and 5 deletions
+155 -1
View File
@@ -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<Uint8Array>({
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<Uint8Array>({
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",
});
});
});
+20 -4
View File
@@ -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<T>(response, "reef.relay", {
maxBytes: REEF_RELAY_JSON_MAX_BYTES,
});
}
}