Skip to content

Commit e28411c

Browse files
authored
Merge pull request #719 from Yodomite/fix/124-contact-rate-limit
fix(contact): apply rate limiting in contact router to prevent spam
2 parents c9ac0ad + b20821b commit e28411c

3 files changed

Lines changed: 81 additions & 37 deletions

File tree

src/index.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,6 @@ const strictLimiter = makeLimiter({
122122
message: "Too many requests from this IP, please try again later.",
123123
});
124124

125-
// Contact form limiter (stricter) - prevents spam on the public contact form.
126-
const contactLimiter = makeLimiter({
127-
name: "contact",
128-
windowMs: env.RATE_LIMIT_CONTACT_WINDOW_MS,
129-
max: env.RATE_LIMIT_CONTACT_MAX,
130-
message: "Too many contact form submissions. Please try again later.",
131-
});
132-
133125
// Analytics limiter - specific limit for analytics endpoints
134126
const analyticsLimiter = makeLimiter({
135127
name: "analytics",
@@ -165,8 +157,6 @@ app.use("/api/v1", indexerStatusRouter);
165157
app.use("/api/v1", reprocessEventsRouter);
166158
app.use("/api/v1", diagnosticsRouter);
167159
app.use("/api/v1", backfillEventsRouter);
168-
// Apply contact-specific rate limiting to contact endpoint
169-
app.use("/api/v1/contact", contactLimiter);
170160
app.use("/api/v1", contactRouter);
171161
app.use("/api/v1", billingRouter);
172162
app.use("/api/v1", apiV1NotFoundHandler);

src/routes/contact.test.ts

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,12 @@ vi.mock("nodemailer", () => ({
2121
default: { createTransport: () => ({ sendMail }) },
2222
}));
2323

24-
import { makeLimiter } from "../middleware/rate-limit";
2524
import { contactRouter } from "./contact";
2625

2726
function makeApp() {
2827
const app = express();
2928
app.set("trust proxy", 1);
3029
app.use(express.json());
31-
const contactLimiter = makeLimiter({
32-
name: "contact",
33-
windowMs: envMock.RATE_LIMIT_CONTACT_WINDOW_MS,
34-
max: envMock.RATE_LIMIT_CONTACT_MAX,
35-
message: "Too many contact form submissions. Please try again later.",
36-
});
37-
app.use("/api/v1/contact", contactLimiter);
3830
app.use("/api/v1", contactRouter);
3931
return app;
4032
}
@@ -58,6 +50,7 @@ describe("POST /contact/send-message", () => {
5850
it("rejects missing fields with 400 and does not send", async () => {
5951
const res = await request(makeApp())
6052
.post("/api/v1/contact/send-message")
53+
.set("X-Forwarded-For", "10.0.0.1")
6154
.send({ firstName: "Ada" });
6255
expect(res.status).toBe(400);
6356
expect(sendMail).not.toHaveBeenCalled();
@@ -66,6 +59,7 @@ describe("POST /contact/send-message", () => {
6659
it("rejects an oversized message with 400", async () => {
6760
const res = await request(makeApp())
6861
.post("/api/v1/contact/send-message")
62+
.set("X-Forwarded-For", "10.0.0.2")
6963
.send({ ...valid, message: "x".repeat(5001) });
7064
expect(res.status).toBe(400);
7165
expect(sendMail).not.toHaveBeenCalled();
@@ -74,12 +68,16 @@ describe("POST /contact/send-message", () => {
7468
it("rejects an invalid email with 400", async () => {
7569
const res = await request(makeApp())
7670
.post("/api/v1/contact/send-message")
71+
.set("X-Forwarded-For", "10.0.0.3")
7772
.send({ ...valid, email: "not-an-email" });
7873
expect(res.status).toBe(400);
7974
});
8075

8176
it("dev-mode without credentials returns success without sending", async () => {
82-
const res = await request(makeApp()).post("/api/v1/contact/send-message").send(valid);
77+
const res = await request(makeApp())
78+
.post("/api/v1/contact/send-message")
79+
.set("X-Forwarded-For", "10.0.0.4")
80+
.send(valid);
8381
expect(res.status).toBe(200);
8482
expect(res.body.success).toBe(true);
8583
expect(sendMail).not.toHaveBeenCalled();
@@ -90,7 +88,10 @@ describe("POST /contact/send-message", () => {
9088
envMock.EMAIL_USER = "sender@gmail.com";
9189
envMock.EMAIL_PASSWORD = "app-password";
9290
// CONTACT_RECIPIENT_EMAIL intentionally left unset
93-
const res = await request(makeApp()).post("/api/v1/contact/send-message").send(valid);
91+
const res = await request(makeApp())
92+
.post("/api/v1/contact/send-message")
93+
.set("X-Forwarded-For", "10.0.0.5")
94+
.send(valid);
9495
expect(res.status).toBe(503);
9596
expect(sendMail).not.toHaveBeenCalled();
9697
});
@@ -101,6 +102,7 @@ describe("POST /contact/send-message", () => {
101102
envMock.CONTACT_RECIPIENT_EMAIL = "team@stellopay.com";
102103
const res = await request(makeApp())
103104
.post("/api/v1/contact/send-message")
105+
.set("X-Forwarded-For", "10.0.0.6")
104106
.send({ ...valid, message: "<script>alert(1)</script>" });
105107

106108
expect(res.status).toBe(200);
@@ -116,7 +118,10 @@ describe("POST /contact/send-message", () => {
116118
envMock.EMAIL_PASSWORD = "app-password";
117119
envMock.CONTACT_RECIPIENT_EMAIL = "team@stellopay.com";
118120
sendMail.mockRejectedValueOnce(new Error("smtp down"));
119-
const res = await request(makeApp()).post("/api/v1/contact/send-message").send(valid);
121+
const res = await request(makeApp())
122+
.post("/api/v1/contact/send-message")
123+
.set("X-Forwarded-For", "10.0.0.7")
124+
.send(valid);
120125
expect(res.status).toBe(500);
121126
expect(res.body.error).toMatch(/Failed to send/);
122127
});
@@ -127,7 +132,10 @@ describe("POST /contact/send-message", () => {
127132
envMock.EMAIL_PASSWORD = "app-password";
128133
envMock.CONTACT_RECIPIENT_EMAIL = "team@stellopay.com";
129134
sendMail.mockRejectedValueOnce(new Error("smtp down"));
130-
const res = await request(makeApp()).post("/api/v1/contact/send-message").send(valid);
135+
const res = await request(makeApp())
136+
.post("/api/v1/contact/send-message")
137+
.set("X-Forwarded-For", "10.0.0.8")
138+
.send(valid);
131139
expect(res.status).toBe(500);
132140
expect(res.body.details).toBeUndefined();
133141
});
@@ -137,33 +145,54 @@ describe("POST /contact/send-message", () => {
137145
envMock.EMAIL_PASSWORD = "app-password";
138146
envMock.CONTACT_RECIPIENT_EMAIL = "team@stellopay.com";
139147
sendMail.mockRejectedValueOnce("smtp exploded"); // a thrown string, not an Error
140-
const res = await request(makeApp()).post("/api/v1/contact/send-message").send(valid);
148+
const res = await request(makeApp())
149+
.post("/api/v1/contact/send-message")
150+
.set("X-Forwarded-For", "10.0.0.9")
151+
.send(valid);
141152
expect(res.status).toBe(500);
142153
});
143154
});
144155

145156
describe("Contact endpoint rate limiting", () => {
146157
it("allows requests up to the configured limit", async () => {
147-
envMock.RATE_LIMIT_CONTACT_MAX = 3;
148158
const app = makeApp();
149159

150-
// First 3 requests should succeed
160+
// First 3 requests should succeed (default max). Use a unique IP
161+
// to avoid cross-test contamination from the module-scoped limiter.
151162
for (let i = 0; i < 3; i++) {
152-
const res = await request(app).post("/api/v1/contact/send-message").send(valid);
163+
const res = await request(app)
164+
.post("/api/v1/contact/send-message")
165+
.set("X-Forwarded-For", "10.0.1.1")
166+
.send(valid);
153167
expect(res.status).toBe(200);
154168
}
155169
});
156170

157171
it("returns 429 with standard error envelope when limit is exceeded", async () => {
158-
envMock.RATE_LIMIT_CONTACT_MAX = 2;
159172
const app = makeApp();
160173

161-
// First 2 requests succeed
162-
await request(app).post("/api/v1/contact/send-message").send(valid).expect(200);
163-
await request(app).post("/api/v1/contact/send-message").send(valid).expect(200);
174+
// First 3 requests succeed (default max) from a unique IP
175+
await request(app)
176+
.post("/api/v1/contact/send-message")
177+
.set("X-Forwarded-For", "10.0.1.2")
178+
.send(valid)
179+
.expect(200);
180+
await request(app)
181+
.post("/api/v1/contact/send-message")
182+
.set("X-Forwarded-For", "10.0.1.2")
183+
.send(valid)
184+
.expect(200);
185+
await request(app)
186+
.post("/api/v1/contact/send-message")
187+
.set("X-Forwarded-For", "10.0.1.2")
188+
.send(valid)
189+
.expect(200);
164190

165-
// Third request exceeds limit
166-
const res = await request(app).post("/api/v1/contact/send-message").send(valid);
191+
// Fourth request exceeds the default limit (max=3)
192+
const res = await request(app)
193+
.post("/api/v1/contact/send-message")
194+
.set("X-Forwarded-For", "10.0.1.2")
195+
.send(valid);
167196
expect(res.status).toBe(429);
168197
expect(res.body).toEqual({
169198
error: "Too many contact form submissions. Please try again later.",
@@ -172,10 +201,19 @@ describe("Contact endpoint rate limiting", () => {
172201
});
173202

174203
it("enforces rate limiting per IP address", async () => {
175-
envMock.RATE_LIMIT_CONTACT_MAX = 1;
176204
const app = makeApp();
177205

178-
// Client A exhausts their single request
206+
// Client A exhausts their 3 requests
207+
await request(app)
208+
.post("/api/v1/contact/send-message")
209+
.set("X-Forwarded-For", "198.51.100.1")
210+
.send(valid)
211+
.expect(200);
212+
await request(app)
213+
.post("/api/v1/contact/send-message")
214+
.set("X-Forwarded-For", "198.51.100.1")
215+
.send(valid)
216+
.expect(200);
179217
await request(app)
180218
.post("/api/v1/contact/send-message")
181219
.set("X-Forwarded-For", "198.51.100.1")
@@ -197,11 +235,14 @@ describe("Contact endpoint rate limiting", () => {
197235
});
198236

199237
it("preserves existing contact endpoint behaviour for valid requests", async () => {
200-
envMock.RATE_LIMIT_CONTACT_MAX = 10;
201238
const app = makeApp();
202239

203-
// Valid request should still succeed with rate limiter applied
204-
const res = await request(app).post("/api/v1/contact/send-message").send(valid);
240+
// Valid request should still succeed with rate limiter applied.
241+
// Use a unique IP to avoid cross-test contamination.
242+
const res = await request(app)
243+
.post("/api/v1/contact/send-message")
244+
.set("X-Forwarded-For", "10.0.1.3")
245+
.send(valid);
205246
expect(res.status).toBe(200);
206247
expect(res.body.success).toBe(true);
207248
});

src/routes/contact.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@ import express from "express";
22
import nodemailer from "nodemailer";
33
import { z } from "zod";
44
import { env } from "../config.js";
5+
import { makeLimiter } from "../middleware/rate-limit.js";
56

67
const contactRouter = express.Router();
78

9+
// Rate limit the public contact form to prevent spam.
10+
// Default: 3 submissions per hour per IP (configurable via RATE_LIMIT_CONTACT_WINDOW_MS
11+
// and RATE_LIMIT_CONTACT_MAX env vars).
12+
contactRouter.use(
13+
makeLimiter({
14+
name: "contact",
15+
windowMs: env.RATE_LIMIT_CONTACT_WINDOW_MS,
16+
max: env.RATE_LIMIT_CONTACT_MAX,
17+
message: "Too many contact form submissions. Please try again later.",
18+
}),
19+
);
20+
821
/**
922
* Validation schema for the contact form. Trims input and caps each field's
1023
* length to prevent unbounded, unchecked input and large-payload abuse.

0 commit comments

Comments
 (0)