From f3419d3e79fe24042bb8922dec88b4210662c4b5 Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:19:21 +0700 Subject: [PATCH 1/3] fix(relay): harden pair body limit, challenge rate-limit, allowlist parse (#61) Close P2 availability gaps from M3.2 audit: bound POST /pair body size, rate-limit GET inbox challenge issuance, and fail-closed allowlist JSON parse. --- packages/relay/src/routes/allowlist.test.ts | 75 +++++++++++++++++++++ packages/relay/src/routes/allowlist.ts | 12 +++- packages/relay/src/routes/inbox.test.ts | 44 ++++++++++++ packages/relay/src/routes/inbox.ts | 2 +- packages/relay/src/routes/pair.test.ts | 27 ++++++++ packages/relay/src/routes/pair.ts | 32 ++++++--- 6 files changed, 179 insertions(+), 13 deletions(-) diff --git a/packages/relay/src/routes/allowlist.test.ts b/packages/relay/src/routes/allowlist.test.ts index f181fc0..62d60e4 100644 --- a/packages/relay/src/routes/allowlist.test.ts +++ b/packages/relay/src/routes/allowlist.test.ts @@ -2,11 +2,14 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + createOuterEnvelope, decodeBase64UrlStrict, encodeAllowlistPush, generateKeyPair, publicKeyToAgentId, + serializeOuterEnvelope, } from "@agentpair/protocol"; +import { utf8ToBytes } from "@noble/ciphers/utils.js"; import { afterEach, describe, expect, it } from "vitest"; import { createRelayApp } from "../server.js"; import { isSenderAllowed, signChallenge } from "./allowlist.js"; @@ -260,4 +263,76 @@ describe("allowlist relay routes — sign-the-blob cutover", () => { const payload = (await res.json()) as { error: string }; expect(payload.error).toBe("invalid_signature"); }); + + it("returns false when allowed_json is corrupted (fail-closed)", async () => { + const { app, db } = createRelayApp(); + const body = encodeAllowlistPush(ownerId, [peerId], owner.secretKey); + + const res = await app.request(`/allowlist/${ownerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(204); + + db.prepare("UPDATE allowlists SET allowed_json = ? WHERE agent_id = ?").run( + "not-json", + ownerId, + ); + expect(isSenderAllowed(db, ownerId, peerId)).toBe(false); + }); + + it("returns false when allowed_json parses to non-array (fail-closed)", async () => { + const { app, db } = createRelayApp(); + const body = encodeAllowlistPush(ownerId, [peerId], owner.secretKey); + + const res = await app.request(`/allowlist/${ownerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(204); + + db.prepare("UPDATE allowlists SET allowed_json = ? WHERE agent_id = ?").run( + JSON.stringify({ peer: peerId }), + ownerId, + ); + expect(isSenderAllowed(db, ownerId, peerId)).toBe(false); + }); + + it("POST inbox returns 403 recipient_not_allowed when allowlist JSON is corrupted", async () => { + const { app, db } = createRelayApp(); + const body = encodeAllowlistPush(ownerId, [peerId], owner.secretKey); + + const putRes = await app.request(`/allowlist/${ownerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(putRes.status).toBe(204); + + db.prepare("UPDATE allowlists SET allowed_json = ? WHERE agent_id = ?").run( + "not-json", + ownerId, + ); + + const envelope = createOuterEnvelope({ + sender: peer, + recipientAgentId: ownerId, + type: "core.msg", + thread: "550e8400-e29b-41d4-a716-446655440000", + seq: 1, + ttl: Math.floor(Date.now() / 1000) + 3600, + payload: utf8ToBytes("hello"), + }); + + const postRes = await app.request(`/inbox/${ownerId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: serializeOuterEnvelope(envelope), + }); + expect(postRes.status).toBe(403); + const payload = (await postRes.json()) as { error: string }; + expect(payload.error).toBe("recipient_not_allowed"); + }); }); diff --git a/packages/relay/src/routes/allowlist.ts b/packages/relay/src/routes/allowlist.ts index 96d69db..ae01b7b 100644 --- a/packages/relay/src/routes/allowlist.ts +++ b/packages/relay/src/routes/allowlist.ts @@ -35,7 +35,17 @@ export function isSenderAllowed( return false; } - const allowed = JSON.parse(row.allowed_json) as string[]; + let allowed: unknown; + try { + allowed = JSON.parse(row.allowed_json); + } catch { + return false; + } + + if (!Array.isArray(allowed)) { + return false; + } + return allowed.includes(senderAgentId); } diff --git a/packages/relay/src/routes/inbox.test.ts b/packages/relay/src/routes/inbox.test.ts index 62a9d17..5363d52 100644 --- a/packages/relay/src/routes/inbox.test.ts +++ b/packages/relay/src/routes/inbox.test.ts @@ -2588,3 +2588,47 @@ describe("POST /inbox §10 error alignment (M1.5)", () => { expect(noRowBody.error).toBe("recipient_not_allowed"); }); }); + +const GET_INBOX_RL_PORT = 13012; +const GET_INBOX_RL_BASE = `http://127.0.0.1:${GET_INBOX_RL_PORT}`; + +describe("GET inbox challenge rate limit (isolated db)", () => { + let server: ServerType; + const bob = generateKeyPair(); + const bobId = publicKeyToAgentId(bob.publicKey); + + beforeAll(async () => { + const relay = createRelayApp({ + rateLimitWindowMs: 60_000, + rateLimitMax: 2, + }); + + await new Promise((resolve) => { + server = serve({ fetch: relay.app.fetch, port: GET_INBOX_RL_PORT }, resolve); + }); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + + it("returns 429 when challenge issuance exceeds rate limit", async () => { + for (let i = 0; i < 2; i++) { + const res = await fetch(`${GET_INBOX_RL_BASE}/inbox/${bobId}?since=0`); + expect(res.status).toBe(401); + } + + const blocked = await fetch(`${GET_INBOX_RL_BASE}/inbox/${bobId}?since=0`); + expect(blocked.status).toBe(429); + const body = (await blocked.json()) as { error: string }; + expect(body.error).toBe("rate_limit_exceeded"); + }); +}); diff --git a/packages/relay/src/routes/inbox.ts b/packages/relay/src/routes/inbox.ts index eda9950..2eb59e1 100644 --- a/packages/relay/src/routes/inbox.ts +++ b/packages/relay/src/routes/inbox.ts @@ -398,7 +398,7 @@ export function createInboxRoutes( }, ); - routes.get("/inbox/:agentId", (c) => { + routes.get("/inbox/:agentId", rateLimit, (c) => { maybeGarbageCollectInbox(db, inboxGcState); const agentId = c.req.param("agentId"); const since = normalizeSince(Number(c.req.query("since") ?? "0")); diff --git a/packages/relay/src/routes/pair.test.ts b/packages/relay/src/routes/pair.test.ts index 75155c3..0c94f14 100644 --- a/packages/relay/src/routes/pair.test.ts +++ b/packages/relay/src/routes/pair.test.ts @@ -1,3 +1,4 @@ +import { MAX_ENVELOPE_WIRE_BYTES } from "@agentpair/protocol"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRelayApp } from "../server.js"; @@ -95,4 +96,30 @@ describe("pair relay routes — fixed TTL and §10 error codes", () => { const missingBody = (await missingRes.json()) as { error: string }; expect(missingBody.error).toBe("pair_not_found"); }); + + it("returns 413 payload_too_large when POST body exceeds pair body limit", async () => { + const { app } = createRelayApp(); + const oversized = "a".repeat(MAX_ENVELOPE_WIRE_BYTES + 1); + + const res = await app.request("/pair/oversized-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: oversized, + }); + expect(res.status).toBe(413); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("payload_too_large"); + }); + + it("accepts POST body at exactly MAX_ENVELOPE_WIRE_BYTES", async () => { + const { app } = createRelayApp(); + const exact = "a".repeat(MAX_ENVELOPE_WIRE_BYTES); + + const res = await app.request("/pair/exact-size-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: exact, + }); + expect(res.status).toBe(204); + }); }); diff --git a/packages/relay/src/routes/pair.ts b/packages/relay/src/routes/pair.ts index 1f1d716..024e358 100644 --- a/packages/relay/src/routes/pair.ts +++ b/packages/relay/src/routes/pair.ts @@ -1,4 +1,6 @@ +import { MAX_ENVELOPE_WIRE_BYTES } from "@agentpair/protocol"; import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; import type { RelayDatabase } from "../db/index.js"; import type { createRateLimiter } from "../middleware/rate-limit.js"; @@ -10,21 +12,29 @@ export function createPairRoutes( ) { const routes = new Hono(); - routes.post("/pair/:sessionId", rateLimit, async (c) => { - const sessionId = c.req.param("sessionId"); - const messageJson = await c.req.text(); - const now = Date.now(); - const expiresAt = now + PAIR_TTL_MS; - - db.prepare( - `INSERT INTO pair_sessions (session_id, message_json, created_at, expires_at) + routes.post( + "/pair/:sessionId", + bodyLimit({ + maxSize: MAX_ENVELOPE_WIRE_BYTES, + onError: (c) => c.json({ error: "payload_too_large" }, 413), + }), + rateLimit, + async (c) => { + const sessionId = c.req.param("sessionId"); + const messageJson = await c.req.text(); + const now = Date.now(); + const expiresAt = now + PAIR_TTL_MS; + + db.prepare( + `INSERT INTO pair_sessions (session_id, message_json, created_at, expires_at) VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET message_json = excluded.message_json`, - ).run(sessionId, messageJson, now, expiresAt); + ).run(sessionId, messageJson, now, expiresAt); - return c.body(null, 204); - }); + return c.body(null, 204); + }, + ); routes.get("/pair/:sessionId", (c) => { const sessionId = c.req.param("sessionId"); From 75a48d464517cabd4a3ae634b9134dfb865d48f0 Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:07:44 +0700 Subject: [PATCH 2/3] fix(relay): rate-limit challenge issuance only, not inbox GET pulls (#61) GET inbox middleware shared the POST rate-limit bucket, so e2e suites exhausted the limit and session_open saw relay_unavailable (429 masked). Scope REL-2 to unauthenticated challenge minting with a separate bucket. --- packages/relay/src/middleware/rate-limit.ts | 45 ++++++++++++++------- packages/relay/src/routes/inbox.test.ts | 44 +++++++++++++++++++- packages/relay/src/routes/inbox.ts | 10 ++++- packages/relay/src/server.ts | 10 +++-- 4 files changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/relay/src/middleware/rate-limit.ts b/packages/relay/src/middleware/rate-limit.ts index 0a9c07e..022a644 100644 --- a/packages/relay/src/middleware/rate-limit.ts +++ b/packages/relay/src/middleware/rate-limit.ts @@ -102,29 +102,46 @@ export function evictStaleBuckets( } } -export function createRateLimiter(options: RateLimitOptions): MiddlewareHandler { +export interface RateLimitConsumer { + tryConsume(c: Context, bucketKey: string): boolean; +} + +export function createRateLimitConsumer(options: RateLimitOptions): RateLimitConsumer { const buckets = new Map(); const trustProxy = options.trustProxy ?? false; - return async (c: Context, next: Next) => { - const now = Date.now(); - evictStaleBuckets(buckets, now, options.windowMs); + return { + tryConsume(c: Context, bucketKey: string): boolean { + const now = Date.now(); + evictStaleBuckets(buckets, now, options.windowMs); - const routeKey = c.req.routePath || c.req.path; - const key = `${clientKey(c, trustProxy)}:${routeKey}`; - const bucket = buckets.get(key); + const key = `${clientKey(c, trustProxy)}:${bucketKey}`; + const bucket = buckets.get(key); - if (!bucket || now - bucket.windowStart >= options.windowMs) { - buckets.set(key, { count: 1, windowStart: now }); - await next(); - return; - } + if (!bucket || now - bucket.windowStart >= options.windowMs) { + buckets.set(key, { count: 1, windowStart: now }); + return true; + } - if (bucket.count >= options.maxRequests) { + if (bucket.count >= options.maxRequests) { + return false; + } + + bucket.count += 1; + return true; + }, + }; +} + +export function createRateLimiter(options: RateLimitOptions): MiddlewareHandler { + const consumer = createRateLimitConsumer(options); + + return async (c: Context, next: Next) => { + const routeKey = c.req.routePath || c.req.path; + if (!consumer.tryConsume(c, routeKey)) { return c.json({ error: "rate_limit_exceeded" }, 429); } - bucket.count += 1; await next(); }; } diff --git a/packages/relay/src/routes/inbox.test.ts b/packages/relay/src/routes/inbox.test.ts index 5363d52..61da871 100644 --- a/packages/relay/src/routes/inbox.test.ts +++ b/packages/relay/src/routes/inbox.test.ts @@ -14,7 +14,7 @@ import type { ServerType } from "@hono/node-server"; import { utf8ToBytes } from "@noble/ciphers/utils.js"; import Database from "better-sqlite3"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createRateLimiter } from "../middleware/rate-limit.js"; +import { createRateLimitConsumer, createRateLimiter } from "../middleware/rate-limit.js"; import { createRelayApp } from "../server.js"; import { padWireToSize, wireUtf8Length } from "../test/wire-padding.js"; import { signChallenge } from "./allowlist.js"; @@ -2198,7 +2198,11 @@ describe("inbox absolute unix ttl (M1.2)", () => { .run(rowId, bobId, wire, aliceId, thread, 1, "core.msg", receivedAt); const rateLimit = createRateLimiter({ windowMs: 60_000, maxRequests: 100 }); - createInboxRoutes(legacyDb, rateLimit); + const challengeIssueRateLimit = createRateLimitConsumer({ + windowMs: 60_000, + maxRequests: 100, + }); + createInboxRoutes(legacyDb, rateLimit, challengeIssueRateLimit); const row = legacyDb.prepare("SELECT expires_at FROM inbox WHERE id = ?").get(rowId) as { expires_at: number; @@ -2631,4 +2635,40 @@ describe("GET inbox challenge rate limit (isolated db)", () => { const body = (await blocked.json()) as { error: string }; expect(body.error).toBe("rate_limit_exceeded"); }); + + it("does not count authenticated inbox pulls against POST rate limit bucket", async () => { + const relay = createRelayApp({ + rateLimitWindowMs: 60_000, + rateLimitMax: 2, + }); + const port = 13013; + const base = `http://127.0.0.1:${port}`; + const server = serve({ fetch: relay.app.fetch, port }); + + try { + for (let i = 0; i < 2; i++) { + const challengeRes = await fetch(`${base}/inbox/${bobId}?since=0`); + expect(challengeRes.status).toBe(401); + } + const blockedChallenge = await fetch(`${base}/inbox/${bobId}?since=0`); + expect(blockedChallenge.status).toBe(429); + + const postRes = await fetch(`${base}/inbox/${bobId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(postRes.status).not.toBe(429); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); }); diff --git a/packages/relay/src/routes/inbox.ts b/packages/relay/src/routes/inbox.ts index 2eb59e1..b186273 100644 --- a/packages/relay/src/routes/inbox.ts +++ b/packages/relay/src/routes/inbox.ts @@ -15,7 +15,7 @@ import { utf8ToBytes } from "@noble/ciphers/utils.js"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import type { RelayDatabase } from "../db/index.js"; -import type { createRateLimiter } from "../middleware/rate-limit.js"; +import type { RateLimitConsumer, createRateLimiter } from "../middleware/rate-limit.js"; import { isSenderAllowed } from "./allowlist.js"; const CHALLENGE_TTL_MS = 60 * 1000; @@ -286,9 +286,12 @@ function filterVisibleInboxRows( return visibleRows; } +const INBOX_CHALLENGE_ISSUE_BUCKET = "/inbox/:agentId:challenge-issue"; + export function createInboxRoutes( db: RelayDatabase, rateLimit: ReturnType, + challengeIssueRateLimit: RateLimitConsumer, ) { ensureInboxSchema(db); const inboxGcState = { lastGcAt: 0 }; @@ -398,7 +401,7 @@ export function createInboxRoutes( }, ); - routes.get("/inbox/:agentId", rateLimit, (c) => { + routes.get("/inbox/:agentId", (c) => { maybeGarbageCollectInbox(db, inboxGcState); const agentId = c.req.param("agentId"); const since = normalizeSince(Number(c.req.query("since") ?? "0")); @@ -406,6 +409,9 @@ export function createInboxRoutes( const sig = c.req.query("sig"); if (!challenge || !sig) { + if (!challengeIssueRateLimit.tryConsume(c, INBOX_CHALLENGE_ISSUE_BUCKET)) { + return c.json({ error: "rate_limit_exceeded" }, 429); + } const body = issueChallenge(db, agentId); return c.json(body, 401); } diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 3d85cd2..ebc0d34 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { type RelayDatabase, createDatabase } from "./db/index.js"; -import { createRateLimiter } from "./middleware/rate-limit.js"; +import { createRateLimitConsumer, createRateLimiter } from "./middleware/rate-limit.js"; import { createAllowlistRoutes } from "./routes/allowlist.js"; import { createArtifactRoutes } from "./routes/artifact.js"; import { healthRoutes } from "./routes/health.js"; @@ -23,16 +23,18 @@ export function createRelayApp(config: RelayConfig = {}): RelayApp { const db = createDatabase(config.dbPath ?? ":memory:"); const app = new Hono(); - const rateLimit = createRateLimiter({ + const rateLimitOptions = { windowMs: config.rateLimitWindowMs ?? 60_000, maxRequests: config.rateLimitMax ?? 60, trustProxy: config.trustProxy ?? false, - }); + }; + const rateLimit = createRateLimiter(rateLimitOptions); + const challengeIssueRateLimit = createRateLimitConsumer(rateLimitOptions); app.route("/", healthRoutes); app.route("/", createAllowlistRoutes(db)); app.route("/", createPairRoutes(db, rateLimit)); - app.route("/", createInboxRoutes(db, rateLimit)); + app.route("/", createInboxRoutes(db, rateLimit, challengeIssueRateLimit)); app.route("/", createArtifactRoutes(db, rateLimit)); return { app, db }; From 99e0c862c0582c3a1803021e36fec42e8367f7e9 Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:46:00 +0700 Subject: [PATCH 3/3] test(relay): cover authenticated inbox pull under challenge rate limit (#64) Address PR review: wait for server listen, exhaust challenge bucket via another agent id, assert signed GET still returns 200 and POST stays unblocked. --- packages/relay/src/routes/inbox.test.ts | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/relay/src/routes/inbox.test.ts b/packages/relay/src/routes/inbox.test.ts index 61da871..cba78e7 100644 --- a/packages/relay/src/routes/inbox.test.ts +++ b/packages/relay/src/routes/inbox.test.ts @@ -2636,23 +2636,36 @@ describe("GET inbox challenge rate limit (isolated db)", () => { expect(body.error).toBe("rate_limit_exceeded"); }); - it("does not count authenticated inbox pulls against POST rate limit bucket", async () => { + it("keeps authenticated inbox pulls unthrottled when challenge issuance is exhausted", async () => { const relay = createRelayApp({ rateLimitWindowMs: 60_000, rateLimitMax: 2, }); const port = 13013; const base = `http://127.0.0.1:${port}`; - const server = serve({ fetch: relay.app.fetch, port }); + const otherId = publicKeyToAgentId(generateKeyPair().publicKey); + let isolationServer: ServerType; + + await new Promise((resolve) => { + isolationServer = serve({ fetch: relay.app.fetch, port }, resolve); + }); try { - for (let i = 0; i < 2; i++) { - const challengeRes = await fetch(`${base}/inbox/${bobId}?since=0`); - expect(challengeRes.status).toBe(401); - } - const blockedChallenge = await fetch(`${base}/inbox/${bobId}?since=0`); + const challengeRes = await fetch(`${base}/inbox/${bobId}?since=0`); + expect(challengeRes.status).toBe(401); + const { challenge } = (await challengeRes.json()) as { challenge: string }; + const sig = signChallenge(challenge, bob.secretKey); + + const secondChallenge = await fetch(`${base}/inbox/${otherId}?since=0`); + expect(secondChallenge.status).toBe(401); + const blockedChallenge = await fetch(`${base}/inbox/${otherId}?since=0`); expect(blockedChallenge.status).toBe(429); + const pullRes = await fetch( + `${base}/inbox/${bobId}?since=0&challenge=${encodeURIComponent(challenge)}&sig=${encodeURIComponent(sig)}`, + ); + expect(pullRes.status).toBe(200); + const postRes = await fetch(`${base}/inbox/${bobId}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2661,7 +2674,7 @@ describe("GET inbox challenge rate limit (isolated db)", () => { expect(postRes.status).not.toBe(429); } finally { await new Promise((resolve, reject) => { - server.close((error) => { + isolationServer.close((error) => { if (error) { reject(error); return;