Skip to content

Commit ec54474

Browse files
authored
Merge pull request #479 from edehvictor/feature/rate-limiting-and-input-sanitization
feat: implement input sanitization and rate limiting middleware
2 parents 689d6fd + 9085082 commit ec54474

5 files changed

Lines changed: 202 additions & 19 deletions

File tree

backend/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export const config = {
4545
),
4646
keepAliveTimeoutMs: parseInteger(process.env.KEEP_ALIVE_TIMEOUT_MS, 65_000),
4747
headersTimeoutMs: parseInteger(process.env.HEADERS_TIMEOUT_MS, 66_000),
48+
contractId: process.env.CONTRACT_ID ?? "",
49+
sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org:443",
4850
};
4951

5052
export const walletIntegrationReady = Boolean(

backend/src/index.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ type CampaignListItem = CampaignRecord & { progress: CampaignProgress };
6262

6363
const CAMPAIGN_STATUSES: CampaignStatus[] = ['open', 'funded', 'claimed', 'failed'];
6464
const CONTRACT_AMOUNT_DECIMALS = Number(process.env.CONTRACT_AMOUNT_DECIMALS ?? 2);
65-
const RATE_LIMIT_WINDOW_MS = 60_000;
66-
const RATE_LIMIT_MAX_REQUESTS = 120;
67-
const WRITE_RATE_LIMIT_MAX_REQUESTS = 40;
65+
const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000);
66+
const RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120);
67+
const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20);
6868
const CAMPAIGN_DETAIL_PLEDGE_PREVIEW_LIMIT = 5;
6969

7070
app.use(
@@ -103,33 +103,44 @@ if (process.env.NODE_ENV === "production") {
103103

104104
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
105105

106-
function applyRateLimit(maxRequests: number) {
106+
export function applyRateLimit(limitOverride?: number) {
107107
return (req: Request, res: Response, next: express.NextFunction) => {
108-
const key = `${req.ip}:${req.path}:${maxRequests}`;
108+
if ((req as any).rateLimitedProcessed) {
109+
return next();
110+
}
111+
(req as any).rateLimitedProcessed = true;
112+
113+
const isWrite = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);
114+
const maxRequests = limitOverride ?? (isWrite ? WRITE_RATE_LIMIT_MAX_REQUESTS : RATE_LIMIT_MAX_REQUESTS);
115+
116+
const key = `${req.ip}:${isWrite ? "write" : "read"}`;
109117
const now = Date.now();
110118
const current = rateLimitBuckets.get(key);
111119

112-
if (!current || now >= current.resetAt) {
113-
rateLimitBuckets.set(key, {
114-
count: 1,
115-
resetAt: now + RATE_LIMIT_WINDOW_MS,
116-
});
117-
return next();
120+
let count = 1;
121+
let resetAt = now + RATE_LIMIT_WINDOW_MS;
122+
123+
if (current && now < current.resetAt) {
124+
count = current.count + 1;
125+
resetAt = current.resetAt;
118126
}
119127

120-
if (current.count >= maxRequests) {
128+
res.setHeader("X-RateLimit-Limit", String(maxRequests));
129+
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count)));
130+
res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));
131+
132+
if (current && now < current.resetAt && current.count >= maxRequests) {
121133
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
122-
res.setHeader('Retry-After', String(retryAfterSec));
123-
throw new AppError('Rate limit exceeded. Please retry shortly.', 429, 'RATE_LIMITED');
134+
res.setHeader("Retry-After", String(retryAfterSec));
135+
throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED");
124136
}
125137

126-
current.count += 1;
127-
rateLimitBuckets.set(key, current);
138+
rateLimitBuckets.set(key, { count, resetAt });
128139
return next();
129140
};
130141
}
131142

132-
app.use(applyRateLimit(RATE_LIMIT_MAX_REQUESTS));
143+
app.use(applyRateLimit());
133144

134145
app.use(requestIdMiddleware);
135146

backend/src/rateLimiter.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
import { applyRateLimit } from "./index";
3+
import { Request, Response } from "express";
4+
5+
describe("Rate Limiter Middleware", () => {
6+
let mockReq: Partial<Request>;
7+
let mockRes: Partial<Response>;
8+
let nextCalled: boolean;
9+
let headers: Record<string, string>;
10+
11+
beforeEach(() => {
12+
nextCalled = false;
13+
headers = {};
14+
mockReq = {
15+
ip: "127.0.0.1",
16+
method: "GET",
17+
};
18+
mockRes = {
19+
setHeader: vi.fn((key: string, value: string) => {
20+
headers[key] = value;
21+
return mockRes as Response;
22+
}),
23+
};
24+
});
25+
26+
const next = () => {
27+
nextCalled = true;
28+
};
29+
30+
it("should set X-RateLimit headers for GET requests (Read limits)", () => {
31+
const middleware = applyRateLimit();
32+
middleware(mockReq as Request, mockRes as Response, next);
33+
34+
expect(nextCalled).toBe(true);
35+
expect(headers["X-RateLimit-Limit"]).toBe("120");
36+
expect(headers["X-RateLimit-Remaining"]).toBeDefined();
37+
expect(headers["X-RateLimit-Reset"]).toBeDefined();
38+
});
39+
40+
it("should set X-RateLimit headers for POST requests (Write limits)", () => {
41+
mockReq.method = "POST";
42+
const middleware = applyRateLimit();
43+
middleware(mockReq as Request, mockRes as Response, next);
44+
45+
expect(nextCalled).toBe(true);
46+
expect(headers["X-RateLimit-Limit"]).toBe("20");
47+
});
48+
49+
it("should enforce rate limiting and throw 429 when limit is exceeded", () => {
50+
mockReq.method = "POST";
51+
const middleware = applyRateLimit(2); // Set limit to 2 for testing
52+
53+
// First request
54+
middleware(mockReq as Request, mockRes as Response, next);
55+
expect(nextCalled).toBe(true);
56+
57+
// Second request
58+
nextCalled = false;
59+
middleware(mockReq as Request, mockRes as Response, next);
60+
expect(nextCalled).toBe(true);
61+
62+
// Third request - should exceed limit
63+
nextCalled = false;
64+
expect(() => {
65+
middleware(mockReq as Request, mockRes as Response, next);
66+
}).toThrow(/Rate limit exceeded/);
67+
expect(headers["Retry-After"]).toBeDefined();
68+
});
69+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createCampaignPayloadSchema } from "./schemas";
3+
4+
describe("createCampaignPayloadSchema - Input Sanitization", () => {
5+
const basePayload = {
6+
creator: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
7+
acceptedTokens: ["USDC"],
8+
targetAmount: 100,
9+
deadline: Math.floor(Date.now() / 1000) + 3600,
10+
};
11+
12+
it("should successfully validate and trim valid inputs", () => {
13+
const result = createCampaignPayloadSchema.safeParse({
14+
...basePayload,
15+
title: " Valid Campaign Title ",
16+
description: " This is a valid campaign description that meets the length requirements. ",
17+
});
18+
expect(result.success).toBe(true);
19+
if (result.success) {
20+
expect(result.data.title).toBe("Valid Campaign Title");
21+
expect(result.data.description).toBe("This is a valid campaign description that meets the length requirements.");
22+
}
23+
});
24+
25+
it("should reject titles with only whitespace", () => {
26+
const result = createCampaignPayloadSchema.safeParse({
27+
...basePayload,
28+
title: " ",
29+
description: "This is a valid campaign description that meets the length requirements.",
30+
});
31+
expect(result.success).toBe(false);
32+
});
33+
34+
it("should escape HTML tags in title and description during parsing", () => {
35+
const result = createCampaignPayloadSchema.safeParse({
36+
...basePayload,
37+
title: "<h1>Test</h1>",
38+
description: "<h1>Test</h1> with at least 20 characters",
39+
});
40+
expect(result.success).toBe(true);
41+
if (result.success) {
42+
expect(result.data.title).toBe("&lt;h1&gt;Test&lt;&sol;h1&gt;");
43+
expect(result.data.description).toBe("&lt;h1&gt;Test&lt;&sol;h1&gt; with at least 20 characters");
44+
}
45+
});
46+
47+
it("should reject script tags in title", () => {
48+
const result = createCampaignPayloadSchema.safeParse({
49+
...basePayload,
50+
title: "Campaign <script>alert(1)</script>",
51+
description: "This is a valid campaign description that meets the length requirements.",
52+
});
53+
expect(result.success).toBe(false);
54+
});
55+
56+
it("should reject SQL comment sequences in title", () => {
57+
const result1 = createCampaignPayloadSchema.safeParse({
58+
...basePayload,
59+
title: "Campaign -- SQL Injection",
60+
description: "This is a valid campaign description that meets the length requirements.",
61+
});
62+
expect(result1.success).toBe(false);
63+
64+
const result2 = createCampaignPayloadSchema.safeParse({
65+
...basePayload,
66+
title: "Campaign /* SQL Comment */",
67+
description: "This is a valid campaign description that meets the length requirements.",
68+
});
69+
expect(result2.success).toBe(false);
70+
});
71+
72+
it("should reject SQL comment sequences in description", () => {
73+
const result = createCampaignPayloadSchema.safeParse({
74+
...basePayload,
75+
title: "Valid Campaign",
76+
description: "This is a valid campaign description that meets the length requirements. -- SQL injection",
77+
});
78+
expect(result.success).toBe(false);
79+
});
80+
});

backend/src/validation/schemas.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,35 @@ export const unixTimestampSchema = z.coerce
4545
.int("deadline must be a valid UNIX timestamp in seconds.")
4646
.positive("deadline must be a valid UNIX timestamp in seconds.");
4747

48+
function sanitizeInput(val: string): string {
49+
return val
50+
.replace(/</g, "&lt;")
51+
.replace(/>/g, "&gt;")
52+
.replace(/\//g, "&sol;");
53+
}
54+
55+
const containsSqlComment = (val: string) => /--|\/\*|\*\//.test(val);
56+
const containsScriptTag = (val: string) => /<script/i.test(val);
57+
4858
export const createCampaignPayloadSchema = z.object({
4959
creator: stellarAccountIdSchema,
50-
title: z.string().trim().min(4, "Title must be at least 4 characters.").max(80),
60+
title: z
61+
.string()
62+
.trim()
63+
.min(4, "Title must be at least 4 characters.")
64+
.max(80)
65+
.refine((val) => val.trim().length >= 4, "Title cannot be only whitespace.")
66+
.refine((val) => !containsScriptTag(val), "Title cannot contain script tags.")
67+
.refine((val) => !containsSqlComment(val), "Title cannot contain SQL comment sequences.")
68+
.transform((val) => sanitizeInput(val)),
5169
description: z
5270
.string()
5371
.trim()
5472
.min(20, "Description must be at least 20 characters.")
55-
.max(500),
73+
.max(500)
74+
.refine((val) => !containsScriptTag(val), "Description cannot contain script tags.")
75+
.refine((val) => !containsSqlComment(val), "Description cannot contain SQL comment sequences.")
76+
.transform((val) => sanitizeInput(val)),
5677
acceptedTokens: z
5778
.array(assetCodeSchema)
5879
.min(1, "At least one accepted token is required."),

0 commit comments

Comments
 (0)