diff --git a/server/__tests__/rateLimit.test.ts b/server/__tests__/rateLimit.test.ts new file mode 100644 index 00000000..f1d84dab --- /dev/null +++ b/server/__tests__/rateLimit.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import type { Server } from "node:http"; +import { describe, it } from "node:test"; + +import express from "express"; + +import { createApiRateLimiter } from "../src/middlewares/rateLimit.js"; + +const LIMITED_MESSAGE = "Too many requests. Try again later."; + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function listen(app: express.Express): Promise<{ + server: Server; + url: string; +}> { + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Test server did not bind to a TCP port"); + } + return { server, url: `http://127.0.0.1:${address.port}` }; +} + +function post(url: string, forwardedFor: string): Promise { + return fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": forwardedFor, + }, + body: JSON.stringify({ email: "person@example.com" }), + }); +} + +describe("createApiRateLimiter", () => { + it("rejects a non-positive limit", () => { + assert.throws( + () => createApiRateLimiter({ windowMs: 60_000, limit: 0 }), + /positive integer/, + ); + }); + + it("returns 429 after the limit for one client IP", async () => { + const app = express(); + app.set("trust proxy", 1); + app.post( + "/api/waitlist", + createApiRateLimiter({ windowMs: 60_000, limit: 2 }), + (_req, res) => { + res.status(201).json({ message: "ok" }); + }, + ); + + const { server, url } = await listen(app); + try { + const first = await post(`${url}/api/waitlist`, "203.0.113.10"); + const second = await post(`${url}/api/waitlist`, "203.0.113.10"); + const third = await post(`${url}/api/waitlist`, "203.0.113.10"); + + assert.equal(first.status, 201); + assert.equal(second.status, 201); + assert.equal(third.status, 429); + assert.deepEqual(await third.json(), { error: LIMITED_MESSAGE }); + } finally { + await close(server); + } + }); + + it("counts each client IP separately", async () => { + const app = express(); + app.set("trust proxy", 1); + app.post( + "/api/sponsorships", + createApiRateLimiter({ windowMs: 60_000, limit: 1 }), + (_req, res) => { + res.status(200).json({ message: "ok" }); + }, + ); + + const { server, url } = await listen(app); + try { + const firstClient = await post(`${url}/api/sponsorships`, "203.0.113.10"); + const secondClient = await post( + `${url}/api/sponsorships`, + "203.0.113.11", + ); + const firstClientAgain = await post( + `${url}/api/sponsorships`, + "203.0.113.10", + ); + + assert.equal(firstClient.status, 200); + assert.equal(secondClient.status, 200); + assert.equal(firstClientAgain.status, 429); + } finally { + await close(server); + } + }); + + it("ignores a spoofed address ahead of the proxy-reported client IP", async () => { + const app = express(); + app.set("trust proxy", 1); + app.post( + "/api/waitlist", + createApiRateLimiter({ windowMs: 60_000, limit: 1 }), + (_req, res) => { + res.status(201).json({ message: "ok" }); + }, + ); + + const { server, url } = await listen(app); + try { + const first = await post( + `${url}/api/waitlist`, + "198.51.100.9, 203.0.113.10", + ); + const spoofedPrefix = await post( + `${url}/api/waitlist`, + "198.51.100.50, 203.0.113.10", + ); + + assert.equal(first.status, 201); + assert.equal(spoofedPrefix.status, 429); + } finally { + await close(server); + } + }); +}); diff --git a/server/bun.lock b/server/bun.lock index d6e7d273..c595a366 100644 --- a/server/bun.lock +++ b/server/bun.lock @@ -11,6 +11,7 @@ "dotenv": "^16.4.7", "drizzle-orm": "^0.39.3", "express": "^4.21.2", + "express-rate-limit": "^8.7.0", "fast-csv": "^5.0.5", "helmet": "^8.0.0", "json-bigint": "^1.0.0", @@ -482,6 +483,8 @@ "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + "fast-csv": ["fast-csv@5.0.5", "", { "dependencies": { "@fast-csv/format": "5.0.5", "@fast-csv/parse": "5.0.5" } }, "sha512-9//QpogDIPln5Dc8e3Q3vbSSLXlTeU7z1JqsUOXZYOln8EIn/OOO8+NS2c3ukR6oYngDd3+P1HXSkby3kNV9KA=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -554,6 +557,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ip-address": ["ip-address@10.7.2", "", {}, "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], @@ -910,6 +915,8 @@ "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "express-rate-limit/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], diff --git a/server/package.json b/server/package.json index 88c02add..902147b7 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ "dotenv": "^16.4.7", "drizzle-orm": "^0.39.3", "express": "^4.21.2", + "express-rate-limit": "^8.7.0", "fast-csv": "^5.0.5", "helmet": "^8.0.0", "json-bigint": "^1.0.0", diff --git a/server/src/app.ts b/server/src/app.ts index e71d3f01..32e99123 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -4,6 +4,7 @@ import helmet from "helmet"; import env from "./config/index.js"; import { corsHandler } from "./middlewares/cors.js"; +import { createApiRateLimiter } from "./middlewares/rateLimit.js"; import logger from "./lib/logger.js"; import db from "./config/db.js"; import emailTransporter from "./config/emailTransporter.js"; @@ -15,7 +16,6 @@ import { buildLoopsContactPayload, } from "./utils/loopsContact.js"; import { toSafeLogError } from "./utils/safeLogError.js"; -import { Inquiry } from "./interfaces/Inquiry.js"; import Mail from "nodemailer/lib/mailer/index.js"; import axios from "axios"; import { DatabaseError } from "pg"; @@ -25,11 +25,15 @@ const dbClient = await db(); const emailClient = emailTransporter(); const app = express(); +const apiRateLimiter = createApiRateLimiter(env.rateLimit); + // Middleware -app.set("trust proxy", true); +// Trust one proxy hop so the limiter keys off the client IP Cloudflare reports. +app.set("trust proxy", 1); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); app.use(express.json()); app.use(corsHandler); +app.use("/api", apiRateLimiter); // Routes app.get("/", async (_, res) => { @@ -86,37 +90,6 @@ app.post("/api/waitlist", async (req, res) => { res.status(500).json({ error: "Unknown internal server error" }); } }); -app.post("/api/inquiries", async (req, res) => { - const { email, message, name } = req.body as Inquiry; - - if (!name) { - res.status(400).json({ error: "Name is required!" }); - return; - } - if (!email) { - res.status(400).json({ error: "Email is required!" }); - return; - } - if (!message) { - res.status(400).json({ error: "Message is required!" }); - return; - } - - const contactUsMailOptions: Mail.Options = { - from: `Hello Quantus <${env.email.sender}>`, - to: env.email.receiver, - subject: "Quantus New Contact", - text: `${name} is contacting, \n\nemail: ${email}\nmessage:${message}`, - }; - - try { - emailClient.sendMail(contactUsMailOptions); - - res.status(200).json({ message: "Success sending!", email }); - } catch (error) { - res.status(400).json({ error: "Failed sending." }); - } -}); app.post("/api/send-email", async (req, res) => { const { from, to, subject, html } = req.body as EmailPayload; diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 0ad7fe58..523ed9a2 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -45,4 +45,8 @@ export default { launch: process.env.LAUNCH_MAILING_LIST_ID, }, }, + rateLimit: { + windowMs: 10 * 60 * 1000, + limit: 10, + }, }; diff --git a/server/src/interfaces/Inquiry.ts b/server/src/interfaces/Inquiry.ts deleted file mode 100644 index 756a2551..00000000 --- a/server/src/interfaces/Inquiry.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Inquiry { - name:string; - email:string; - message:string; -} \ No newline at end of file diff --git a/server/src/middlewares/rateLimit.ts b/server/src/middlewares/rateLimit.ts new file mode 100644 index 00000000..9517a16d --- /dev/null +++ b/server/src/middlewares/rateLimit.ts @@ -0,0 +1,24 @@ +import type { RequestHandler } from "express"; +import { rateLimit } from "express-rate-limit"; + +export function createApiRateLimiter(options: { + windowMs: number; + limit: number; +}): RequestHandler { + assertPositiveInteger(options.windowMs, "windowMs"); + assertPositiveInteger(options.limit, "limit"); + + return rateLimit({ + windowMs: options.windowMs, + limit: options.limit, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { error: "Too many requests. Try again later." }, + }); +} + +function assertPositiveInteger(value: number, name: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`rate limit ${name} must be a positive integer`); + } +} diff --git a/website/public/.well-known/openapi/website-api.json b/website/public/.well-known/openapi/website-api.json index 5532645e..b0a27bf4 100644 --- a/website/public/.well-known/openapi/website-api.json +++ b/website/public/.well-known/openapi/website-api.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "Quantus Website API", - "version": "1.0.0", - "description": "Public endpoints for contact inquiries, waitlist subscriptions, and sponsorship requests." + "version": "2.0.0", + "description": "Public endpoints for waitlist subscriptions and sponsorship requests. POST /api/inquiries was removed in 2.0.0." }, "servers": [{ "url": "https://api.quantus.com" }], "paths": { @@ -18,32 +18,6 @@ } } }, - "/api/inquiries": { - "post": { - "summary": "Submit a contact inquiry", - "operationId": "createInquiry", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["name", "email", "message"], - "properties": { - "name": { "type": "string" }, - "email": { "type": "string", "format": "email" }, - "message": { "type": "string" } - } - } - } - } - }, - "responses": { - "200": { "description": "Inquiry sent successfully" }, - "400": { "description": "Validation error" } - } - } - }, "/api/waitlist": { "post": { "summary": "Subscribe to the waitlist", diff --git a/website/src/api/client.ts b/website/src/api/client.ts index d39607dd..8c08ddc2 100644 --- a/website/src/api/client.ts +++ b/website/src/api/client.ts @@ -41,12 +41,6 @@ interface GraphQLResponse { }>; } -interface ContactData { - name: string; - email: string; - message: string; -} - interface SubscribeData { email: string; firstName: string; @@ -105,23 +99,6 @@ const createApiClient = () => { return (await data.json())?.data as EthereumAddressData | null; }, - /** - * Submit a contact form inquiry - */ - contact: (name: string, email: string, message: string): ApiResponse => { - return fetch(`${env.API_URL}/inquiries`, { - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: JSON.stringify({ - name, - email, - message, - } as ContactData), - }); - }, - /** * Subscribe to waitlist * @@ -222,7 +199,6 @@ export default apiClient; export type { ChainStatsData, GraphQLResponse, - ContactData, SubscribeData, NodeData, NodeRpcState,