Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions packages/relay/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Bucket>();
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();
};
}
75 changes: 75 additions & 0 deletions packages/relay/src/routes/allowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
});
});
12 changes: 11 additions & 1 deletion packages/relay/src/routes/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
101 changes: 99 additions & 2 deletions packages/relay/src/routes/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2588,3 +2592,96 @@ 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<void>((resolve) => {
server = serve({ fetch: relay.app.fetch, port: GET_INBOX_RL_PORT }, resolve);
});
});

afterAll(async () => {
await new Promise<void>((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");
});

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 otherId = publicKeyToAgentId(generateKeyPair().publicKey);
let isolationServer: ServerType;

await new Promise<void>((resolve) => {
isolationServer = serve({ fetch: relay.app.fetch, port }, resolve);
});

try {
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" },
body: "{}",
});
expect(postRes.status).not.toBe(429);
} finally {
await new Promise<void>((resolve, reject) => {
isolationServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
});
8 changes: 7 additions & 1 deletion packages/relay/src/routes/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -286,9 +286,12 @@ function filterVisibleInboxRows(
return visibleRows;
}

const INBOX_CHALLENGE_ISSUE_BUCKET = "/inbox/:agentId:challenge-issue";

export function createInboxRoutes(
db: RelayDatabase,
rateLimit: ReturnType<typeof createRateLimiter>,
challengeIssueRateLimit: RateLimitConsumer,
) {
ensureInboxSchema(db);
const inboxGcState = { lastGcAt: 0 };
Expand Down Expand Up @@ -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);
}
Expand Down
27 changes: 27 additions & 0 deletions packages/relay/src/routes/pair.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
Loading
Loading