diff --git a/.env.example b/.env.example index 3906943..87dc6d6 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,9 @@ GRACEFUL_SHUTDOWN_TIMEOUT_MS=10000 # ── Rate limiting ─────────────────────────────────────────────────────────── # [optional] Shared store for multi-instance deployments (in-memory otherwise) REDIS_URL=redis://localhost:6379 +# Publisher registration (POST /publishers): 10 registrations per IP per hour +RATE_LIMIT_REGISTER_IP_MAX=10 +RATE_LIMIT_REGISTER_IP_WINDOW_MS=3600000 RATE_LIMIT_VERIFY_IP_MAX=10 RATE_LIMIT_VERIFY_IP_WINDOW_MS=60000 RATE_LIMIT_VERIFY_WALLET_MAX=5 diff --git a/README.md b/README.md index 4d68bc1..d6d55a9 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,23 @@ const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex Failures are retried with exponential backoff (`WEBHOOK_MAX_ATTEMPTS`). Private and loopback URLs are rejected. +## Rate limiting + +All rate limits use a sliding-window algorithm. Responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers. Blocked requests return `429` with `{ "error": "Too many requests", "code": "RATE_LIMITED", "retryAfterSeconds": N }` and a `Retry-After` header. + +| Limiter | Scope | Default | Env vars | +|---|---|---|---| +| Registration | IP | 10 per hour | `RATE_LIMIT_REGISTER_IP_MAX`, `RATE_LIMIT_REGISTER_IP_WINDOW_MS` | +| Verify-content | IP | 10 / min | `RATE_LIMIT_VERIFY_IP_MAX`, `RATE_LIMIT_VERIFY_IP_WINDOW_MS` | +| Verify-content | Wallet | 5 / hour | `RATE_LIMIT_VERIFY_WALLET_MAX`, `RATE_LIMIT_VERIFY_WALLET_WINDOW_MS` | +| Publish resource | IP | 20 / min | `RATE_LIMIT_PUBLISH_IP_MAX`, `RATE_LIMIT_PUBLISH_IP_WINDOW_MS` | +| Publish resource | Wallet | 10 / hour | `RATE_LIMIT_PUBLISH_WALLET_MAX`, `RATE_LIMIT_PUBLISH_WALLET_WINDOW_MS` | +| Publisher API routes | Per publisher | 60 RPM | `PUBLISHER_RATE_LIMIT_RPM_DEFAULT`, `PUBLISHER_RATE_LIMIT_MAX_RPM` | + +**Registration rate limit** (`POST /publishers`): at most `RATE_LIMIT_REGISTER_IP_MAX` (default **10**) registrations per IP within `RATE_LIMIT_REGISTER_IP_WINDOW_MS` (default **1 hour**). This prevents bulk account and API-key creation from a single IP address. + +Publishers can inspect and override their own per-publisher limit via `PATCH /publishers/me/rate-limit` (up to `PUBLISHER_RATE_LIMIT_MAX_RPM`). + ## Environment variables See [`.env.example`](./.env.example) for the full list and descriptions. diff --git a/src/config.ts b/src/config.ts index b0b8fe3..c2c013f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -226,6 +226,8 @@ const envSchema = z.object({ VERIFICATION_COMPLETION_COST_PER_1M: z.coerce.number().nonnegative().default(15), // Rate limiting (verify-content + publish) + RATE_LIMIT_REGISTER_IP_MAX: z.coerce.number().default(10), + RATE_LIMIT_REGISTER_IP_WINDOW_MS: z.coerce.number().default(3_600_000), RATE_LIMIT_VERIFY_IP_MAX: z.coerce.number().default(10), RATE_LIMIT_VERIFY_IP_WINDOW_MS: z.coerce.number().default(60_000), RATE_LIMIT_VERIFY_WALLET_MAX: z.coerce.number().default(5), diff --git a/src/middleware/rateLimiters.ts b/src/middleware/rateLimiters.ts index c7fc7aa..2fb9e61 100644 --- a/src/middleware/rateLimiters.ts +++ b/src/middleware/rateLimiters.ts @@ -17,6 +17,13 @@ export const verifyIpRateLimit: RequestHandler = createIpRateLimiter( "verify_ip", ); +export const registerIpRateLimit: RequestHandler = createIpRateLimiter( + store, + "register", + config.RATE_LIMIT_REGISTER_IP_MAX, + config.RATE_LIMIT_REGISTER_IP_WINDOW_MS, + "register_ip", +); export const verifyWalletRateLimit: RequestHandler = createWalletRateLimiter( store, "verify", diff --git a/src/routes/publishers.register-rate-limit.test.ts b/src/routes/publishers.register-rate-limit.test.ts new file mode 100644 index 0000000..3761f6c --- /dev/null +++ b/src/routes/publishers.register-rate-limit.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for the IP rate limit on POST /publishers (#38). + * + * Split into two layers: + * 1. Unit tests — exercise `createIpRateLimiter` directly with a controlled + * in-memory store and a fake handler, so window/clock/limit are all + * under our control. + * 2. Integration test — verify that publishers.ts wires the limiter; the + * limiter is replaced with a middleware that rejects on demand so the + * integration test is deterministic without touching real config. + */ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import { createIpRateLimiter, RATE_LIMITED } from "../middleware/rateLimit.js"; +import { MemorySlidingWindowStore } from "../lib/rateLimit/stores.js"; + +// ── 1. Unit tests — createIpRateLimiter behaviour ───────────────────────── + +describe("createIpRateLimiter — registration window", () => { + function buildApp(max: number, windowMs: number, clockFn?: () => number) { + const store = new MemorySlidingWindowStore(clockFn); + const app = express(); + app.use(createIpRateLimiter(store, "register", max, windowMs, "register_ip")); + app.post("/publishers", (_req, res) => res.status(201).json({ ok: true })); + return app; + } + + it("allows requests up to the limit and sets RateLimit-* headers", async () => { + const app = buildApp(3, 60_000); + + const r1 = await request(app).post("/publishers").send({}); + expect(r1.status).toBe(201); + expect(r1.headers["ratelimit-limit"]).toBe("3"); + expect(Number(r1.headers["ratelimit-remaining"])).toBe(2); + + const r2 = await request(app).post("/publishers").send({}); + expect(r2.status).toBe(201); + expect(Number(r2.headers["ratelimit-remaining"])).toBe(1); + + const r3 = await request(app).post("/publishers").send({}); + expect(r3.status).toBe(201); + expect(Number(r3.headers["ratelimit-remaining"])).toBe(0); + }); + + it("returns 429 after the limit is exceeded", async () => { + const app = buildApp(2, 60_000); + + await request(app).post("/publishers").send({}); // 1 — ok + await request(app).post("/publishers").send({}); // 2 — ok (limit reached) + + const res = await request(app).post("/publishers").send({}); // 3 — blocked + expect(res.status).toBe(429); + expect(res.body).toMatchObject({ + error: "Too many requests", + code: RATE_LIMITED, + retryAfterSeconds: expect.any(Number), + }); + }); + + it("429 response includes Retry-After and RateLimit-Remaining=0 headers", async () => { + const app = buildApp(1, 60_000); + + await request(app).post("/publishers").send({}); + const res = await request(app).post("/publishers").send({}); + + expect(res.status).toBe(429); + expect(Number(res.headers["retry-after"])).toBeGreaterThan(0); + expect(res.headers["ratelimit-remaining"]).toBe("0"); + expect(Number(res.headers["ratelimit-reset"])).toBeGreaterThan(0); + }); + + it("resets the counter after the window elapses", async () => { + let ts = 0; + const windowMs = 60_000; + const app = buildApp(1, windowMs, () => ts); + + const r1 = await request(app).post("/publishers").send({}); + expect(r1.status).toBe(201); + + const blocked = await request(app).post("/publishers").send({}); + expect(blocked.status).toBe(429); + + // Advance clock past the window — the old request drops out of the + // sliding window, so the limit resets. + ts += windowMs + 1; + + const r3 = await request(app).post("/publishers").send({}); + expect(r3.status).toBe(201); + }); + + it("tracks counters per IP independently (independent stores)", async () => { + // Confirm that two independent stores share no state (each represents a + // different server instance or IP namespace in this test). + const store1 = new MemorySlidingWindowStore(); + const app1 = express(); + app1.use(createIpRateLimiter(store1, "register", 1, 60_000, "register_ip")); + app1.post("/publishers", (_req, res) => res.status(201).json({ ok: true })); + + const store2 = new MemorySlidingWindowStore(); + const app2 = express(); + app2.use(createIpRateLimiter(store2, "register", 1, 60_000, "register_ip")); + app2.post("/publishers", (_req, res) => res.status(201).json({ ok: true })); + + // Exhaust app1. + await request(app1).post("/publishers").send({}); + const blocked = await request(app1).post("/publishers").send({}); + expect(blocked.status).toBe(429); + + // app2 has its own store — still has capacity. + const ok = await request(app2).post("/publishers").send({}); + expect(ok.status).toBe(201); + }); +}); + +// ── 2. Integration test — publishers router wires the limiter ────────────── +// +// Replace rateLimiters with a controlled implementation so we can verify the +// real route returns 429 when registerIpRateLimit fires. + +vi.mock("../db/schema.js", () => ({ + publishers: { __table: "publishers" }, + resources: { __table: "resources" }, + payments: { __table: "payments" }, +})); + +vi.mock("../db/client.js", () => ({ + db: { + select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }), + }, +})); + +vi.mock("../config.js", () => ({ + config: { + BASE_URL: "http://localhost:4021", + PUBLISHER_RATE_LIMIT_RPM_DEFAULT: 60, + PUBLISHER_RATE_LIMIT_MAX_RPM: 10_000, + PUBLISHER_RATE_LIMIT_WINDOW_MS: 60_000, + RATE_LIMIT_REGISTER_IP_MAX: 10, + RATE_LIMIT_REGISTER_IP_WINDOW_MS: 3_600_000, + }, +})); + +vi.mock("../services/publisherService.js", () => ({ + registerPublisher: vi.fn().mockResolvedValue({ + publisher: { + id: "pub-1", + name: "Alice", + email: "alice@example.com", + walletAddress: "GALICE", + createdAt: new Date("2026-01-01"), + }, + apiKey: "mv_freshkey", + }), + getPublisherResources: vi.fn().mockResolvedValue([]), + rotateApiKey: vi.fn().mockResolvedValue("mv_newkey"), +})); + +// Build the registerIpRateLimit inside the factory (no outer-scope variables). +vi.mock("../middleware/rateLimiters.js", async () => { + const { createIpRateLimiter: factory } = await import("../middleware/rateLimit.js"); + const { MemorySlidingWindowStore: Store } = await import("../lib/rateLimit/stores.js"); + // Limit of 2 so the integration test triggers 429 after two requests. + const integrationStore = new Store(); + return { + registerIpRateLimit: factory(integrationStore, "register", 2, 60_000, "register_ip"), + publisherRateLimit: (_req: unknown, _res: unknown, next: () => void) => next(), + }; +}); + +import publisherRouter from "./publishers.js"; +import { errorHandler } from "../middleware/errorHandler.js"; + +function createIntegrationApp() { + const app = express(); + app.use(express.json()); + app.use(publisherRouter); + app.use(errorHandler); + return app; +} + +const validBody = { + name: "Alice", + email: "alice@example.com", + walletAddress: "GALICE", +}; + +describe("POST /publishers — registerIpRateLimit wired into route (#38)", () => { + it("returns 429 once the per-IP limit is reached on the real route", async () => { + const app = createIntegrationApp(); + + await request(app).post("/publishers").send(validBody); // 1 — ok + await request(app).post("/publishers").send(validBody); // 2 — ok (limit reached) + + const res = await request(app).post("/publishers").send(validBody); // 3 — blocked + expect(res.status).toBe(429); + expect(res.body.code).toBe(RATE_LIMITED); + }); +}); diff --git a/src/routes/publishers.test.ts b/src/routes/publishers.test.ts index 4dde7a8..7bb94e6 100644 --- a/src/routes/publishers.test.ts +++ b/src/routes/publishers.test.ts @@ -96,13 +96,16 @@ describe("POST /publishers — registration (#294)", () => { mockRegisterPublisher.mockReset(); }); + // A real Stellar ed25519 public key (G..., 56 chars) so the schema passes. + const VALID_WALLET = "GDEJJLXV2CNHF5HEGX34MU4G6L4PRVUAGD4ANF77ZDYTQJKSDSDYVVI6"; + it("registers a publisher and returns the one-time API key", async () => { mockRegisterPublisher.mockResolvedValue({ publisher: { id: "pub-1", name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, createdAt: new Date("2026-01-01"), }, apiKey: "mv_freshkey", @@ -111,7 +114,7 @@ describe("POST /publishers — registration (#294)", () => { const res = await request(createTestApp()).post("/publishers").send({ name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); expect(res.status).toBe(201); @@ -119,13 +122,13 @@ describe("POST /publishers — registration (#294)", () => { id: "pub-1", name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, apiKey: "mv_freshkey", }); expect(mockRegisterPublisher).toHaveBeenCalledWith({ name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); }); @@ -133,7 +136,7 @@ describe("POST /publishers — registration (#294)", () => { const res = await request(createTestApp()).post("/publishers").send({ name: "Alice", email: "not-an-email", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); expect(res.status).toBe(400); @@ -143,7 +146,7 @@ describe("POST /publishers — registration (#294)", () => { it("returns 400 when a required field is missing", async () => { const res = await request(createTestApp()).post("/publishers").send({ email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); expect(res.status).toBe(400); @@ -154,7 +157,7 @@ describe("POST /publishers — registration (#294)", () => { const res = await request(createTestApp()).post("/publishers").send({ name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, isAdmin: true, }); @@ -170,7 +173,7 @@ describe("POST /publishers — registration (#294)", () => { const res = await request(createTestApp()).post("/publishers").send({ name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); expect(res.status).toBe(409); @@ -187,7 +190,7 @@ describe("POST /publishers — registration (#294)", () => { const res = await request(createTestApp()).post("/publishers").send({ name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); expect(res.status).toBe(409); @@ -200,7 +203,7 @@ describe("POST /publishers — registration (#294)", () => { const res = await request(createTestApp()).post("/publishers").send({ name: "Alice", email: "alice@example.com", - walletAddress: "GALICE", + walletAddress: VALID_WALLET, }); expect(res.status).toBe(500); diff --git a/src/routes/publishers.ts b/src/routes/publishers.ts index 979dd07..d0a689f 100644 --- a/src/routes/publishers.ts +++ b/src/routes/publishers.ts @@ -2,7 +2,7 @@ import { Router, type Router as RouterType } from "express"; import type { z } from "zod/v4"; import { AppError } from "../lib/errors.js"; import { apiKeyAuth } from "../middleware/apiKeyAuth.js"; -import { publisherRateLimit } from "../middleware/rateLimiters.js"; +import { publisherRateLimit, registerIpRateLimit } from "../middleware/rateLimiters.js"; import { validate } from "../middleware/validate.js"; import { publisherRegisterSchema, @@ -21,7 +21,7 @@ const router: RouterType = Router(); type UpdateWebhookConfig = z.infer; // POST /publishers — register a new publisher (public) -router.post("/publishers", validate(publisherRegisterSchema), async (req, res) => { +router.post("/publishers", registerIpRateLimit, validate(publisherRegisterSchema), async (req, res) => { try { const { publisher, apiKey } = await registerPublisher(req.body); res.status(201).json({ diff --git a/src/routes/publishers.webhooks.test.ts b/src/routes/publishers.webhooks.test.ts index a70eb31..50be320 100644 --- a/src/routes/publishers.webhooks.test.ts +++ b/src/routes/publishers.webhooks.test.ts @@ -55,6 +55,7 @@ vi.mock("../middleware/apiKeyAuth.js", () => ({ vi.mock("../middleware/rateLimiters.js", () => ({ publisherRateLimit: (_req: any, _res: any, next: any) => next(), + registerIpRateLimit: (_req: any, _res: any, next: any) => next(), })); vi.mock("../services/publisherService.js", () => ({