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
135 changes: 135 additions & 0 deletions server/__tests__/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Response> {
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);
}
});
});
7 changes: 7 additions & 0 deletions server/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 6 additions & 33 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions server/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@ export default {
launch: process.env.LAUNCH_MAILING_LIST_ID,
},
},
rateLimit: {
windowMs: 10 * 60 * 1000,
limit: 10,
},
};
5 changes: 0 additions & 5 deletions server/src/interfaces/Inquiry.ts

This file was deleted.

24 changes: 24 additions & 0 deletions server/src/middlewares/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
30 changes: 2 additions & 28 deletions website/public/.well-known/openapi/website-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
24 changes: 0 additions & 24 deletions website/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,6 @@ interface GraphQLResponse<T = any> {
}>;
}

interface ContactData {
name: string;
email: string;
message: string;
}

interface SubscribeData {
email: string;
firstName: string;
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -222,7 +199,6 @@ export default apiClient;
export type {
ChainStatsData,
GraphQLResponse,
ContactData,
SubscribeData,
NodeData,
NodeRpcState,
Expand Down