-
Notifications
You must be signed in to change notification settings - Fork 173
feat: implement input sanitization and rate limiting middleware #479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -34,7 +34,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reconcileOnChainPledge, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| refundContributor, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SortOrder, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| updateCampaign, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check failure on line 37 in backend/src/index.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from './services/campaignStore'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { checkDbHealth } from './services/db'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { listCampaignHistory } from './services/eventHistory'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -62,9 +62,9 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CAMPAIGN_STATUSES: CampaignStatus[] = ['open', 'funded', 'claimed', 'failed']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CONTRACT_AMOUNT_DECIMALS = Number(process.env.CONTRACT_AMOUNT_DECIMALS ?? 2); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const RATE_LIMIT_WINDOW_MS = 60_000; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const RATE_LIMIT_MAX_REQUESTS = 120; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const WRITE_RATE_LIMIT_MAX_REQUESTS = 40; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_READ_LIMIT ?? process.env.RATE_LIMIT_MAX_REQUESTS ?? 120); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const WRITE_RATE_LIMIT_MAX_REQUESTS = Number(process.env.RATE_LIMIT_WRITE_LIMIT ?? process.env.WRITE_RATE_LIMIT_MAX_REQUESTS ?? 20); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CAMPAIGN_DETAIL_PLEDGE_PREVIEW_LIMIT = 5; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.use( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -103,33 +103,44 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function applyRateLimit(maxRequests: number) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function applyRateLimit(limitOverride?: number) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return (req: Request, res: Response, next: express.NextFunction) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const key = `${req.ip}:${req.path}:${maxRequests}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ((req as any).rateLimitedProcessed) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check failure on line 108 in backend/src/index.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return next(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (req as any).rateLimitedProcessed = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check failure on line 111 in backend/src/index.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isWrite = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const maxRequests = limitOverride ?? (isWrite ? WRITE_RATE_LIMIT_MAX_REQUESTS : RATE_LIMIT_MAX_REQUESTS); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const key = `${req.ip}:${isWrite ? "write" : "read"}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const current = rateLimitBuckets.get(key); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!current || now >= current.resetAt) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rateLimitBuckets.set(key, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| count: 1, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resetAt: now + RATE_LIMIT_WINDOW_MS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return next(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let count = 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let resetAt = now + RATE_LIMIT_WINDOW_MS; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (current && now < current.resetAt) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| count = current.count + 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resetAt = current.resetAt; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (current.count >= maxRequests) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader("X-RateLimit-Limit", String(maxRequests)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader("X-RateLimit-Remaining", String(Math.max(0, maxRequests - count))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (current && now < current.resetAt && current.count >= maxRequests) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader('Retry-After', String(retryAfterSec)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new AppError('Rate limit exceeded. Please retry shortly.', 429, 'RATE_LIMITED'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.setHeader("Retry-After", String(retryAfterSec)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| current.count += 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rateLimitBuckets.set(key, current); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rateLimitBuckets.set(key, { count, resetAt }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
117
to
+138
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Prune expired buckets to avoid unbounded memory growth. Each distinct IP/type key remains in Suggested fix+let lastRateLimitSweep = 0;
+
+function sweepExpiredRateLimitBuckets(now: number): void {
+ if (now - lastRateLimitSweep < RATE_LIMIT_WINDOW_MS) return;
+ lastRateLimitSweep = now;
+
+ for (const [bucketKey, bucket] of rateLimitBuckets.entries()) {
+ if (now >= bucket.resetAt) {
+ rateLimitBuckets.delete(bucketKey);
+ }
+ }
+}
+
const key = `${req.ip}:${isWrite ? "write" : "read"}`;
const now = Date.now();
+ sweepExpiredRateLimitBuckets(now);
const current = rateLimitBuckets.get(key);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return next(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.use(applyRateLimit(RATE_LIMIT_MAX_REQUESTS)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.use(applyRateLimit()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.use(requestIdMiddleware); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -596,7 +607,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| app.use((err: any, req: Request, res: Response, _next: express.NextFunction) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check failure on line 610 in backend/src/index.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (err.type === 'entity.too.large') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return res.status(413).json({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| success: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { describe, expect, it, vi, beforeEach } from "vitest"; | ||
| import { applyRateLimit } from "./index"; | ||
| import { Request, Response } from "express"; | ||
|
|
||
| describe("Rate Limiter Middleware", () => { | ||
| let mockReq: Partial<Request>; | ||
| let mockRes: Partial<Response>; | ||
| let nextCalled: boolean; | ||
| let headers: Record<string, string>; | ||
|
|
||
| beforeEach(() => { | ||
| nextCalled = false; | ||
| headers = {}; | ||
| mockReq = { | ||
| ip: "127.0.0.1", | ||
| method: "GET", | ||
| }; | ||
|
Comment on lines
+11
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Use fresh request objects for each simulated HTTP request.
Suggested fix describe("Rate Limiter Middleware", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let nextCalled: boolean;
let headers: Record<string, string>;
+ let testIpCounter = 0;
+ let testIp: string;
beforeEach(() => {
nextCalled = false;
headers = {};
+ testIp = `127.0.0.${++testIpCounter}`;
mockReq = {
- ip: "127.0.0.1",
+ ip: testIp,
method: "GET",
};
@@
it("should enforce rate limiting and throw 429 when limit is exceeded", () => {
- mockReq.method = "POST";
+ const makePostReq = () => ({ ip: testIp, method: "POST" }) as Request;
const middleware = applyRateLimit(2); // Set limit to 2 for testing
// First request
- middleware(mockReq as Request, mockRes as Response, next);
+ middleware(makePostReq(), mockRes as Response, next);
expect(nextCalled).toBe(true);
// Second request
nextCalled = false;
- middleware(mockReq as Request, mockRes as Response, next);
+ middleware(makePostReq(), mockRes as Response, next);
expect(nextCalled).toBe(true);
// Third request - should exceed limit
nextCalled = false;
expect(() => {
- middleware(mockReq as Request, mockRes as Response, next);
+ middleware(makePostReq(), mockRes as Response, next);
}).toThrow(/Rate limit exceeded/);Also applies to: 53-66 🤖 Prompt for AI Agents |
||
| mockRes = { | ||
| setHeader: vi.fn((key: string, value: string) => { | ||
| headers[key] = value; | ||
| return mockRes as Response; | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| const next = () => { | ||
| nextCalled = true; | ||
| }; | ||
|
|
||
| it("should set X-RateLimit headers for GET requests (Read limits)", () => { | ||
| const middleware = applyRateLimit(); | ||
| middleware(mockReq as Request, mockRes as Response, next); | ||
|
|
||
| expect(nextCalled).toBe(true); | ||
| expect(headers["X-RateLimit-Limit"]).toBe("120"); | ||
| expect(headers["X-RateLimit-Remaining"]).toBeDefined(); | ||
| expect(headers["X-RateLimit-Reset"]).toBeDefined(); | ||
| }); | ||
|
|
||
| it("should set X-RateLimit headers for POST requests (Write limits)", () => { | ||
| mockReq.method = "POST"; | ||
| const middleware = applyRateLimit(); | ||
| middleware(mockReq as Request, mockRes as Response, next); | ||
|
|
||
| expect(nextCalled).toBe(true); | ||
| expect(headers["X-RateLimit-Limit"]).toBe("20"); | ||
| }); | ||
|
|
||
| it("should enforce rate limiting and throw 429 when limit is exceeded", () => { | ||
| mockReq.method = "POST"; | ||
| const middleware = applyRateLimit(2); // Set limit to 2 for testing | ||
|
|
||
| // First request | ||
| middleware(mockReq as Request, mockRes as Response, next); | ||
| expect(nextCalled).toBe(true); | ||
|
|
||
| // Second request | ||
| nextCalled = false; | ||
| middleware(mockReq as Request, mockRes as Response, next); | ||
| expect(nextCalled).toBe(true); | ||
|
|
||
| // Third request - should exceed limit | ||
| nextCalled = false; | ||
| expect(() => { | ||
| middleware(mockReq as Request, mockRes as Response, next); | ||
| }).toThrow(/Rate limit exceeded/); | ||
| expect(headers["Retry-After"]).toBeDefined(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { createCampaignPayloadSchema } from "./schemas"; | ||
|
|
||
| describe("createCampaignPayloadSchema - Input Sanitization", () => { | ||
| const basePayload = { | ||
| creator: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", | ||
| acceptedTokens: ["USDC"], | ||
| targetAmount: 100, | ||
| deadline: Math.floor(Date.now() / 1000) + 3600, | ||
| }; | ||
|
|
||
| it("should successfully validate and trim valid inputs", () => { | ||
| const result = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: " Valid Campaign Title ", | ||
| description: " This is a valid campaign description that meets the length requirements. ", | ||
| }); | ||
| expect(result.success).toBe(true); | ||
| if (result.success) { | ||
| expect(result.data.title).toBe("Valid Campaign Title"); | ||
| expect(result.data.description).toBe("This is a valid campaign description that meets the length requirements."); | ||
| } | ||
| }); | ||
|
|
||
| it("should reject titles with only whitespace", () => { | ||
| const result = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: " ", | ||
| description: "This is a valid campaign description that meets the length requirements.", | ||
| }); | ||
| expect(result.success).toBe(false); | ||
| }); | ||
|
|
||
| it("should escape HTML tags in title and description during parsing", () => { | ||
| const result = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: "<h1>Test</h1>", | ||
| description: "<h1>Test</h1> with at least 20 characters", | ||
| }); | ||
| expect(result.success).toBe(true); | ||
| if (result.success) { | ||
| expect(result.data.title).toBe("<h1>Test</h1>"); | ||
| expect(result.data.description).toBe("<h1>Test</h1> with at least 20 characters"); | ||
| } | ||
| }); | ||
|
|
||
| it("should reject script tags in title", () => { | ||
| const result = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: "Campaign <script>alert(1)</script>", | ||
| description: "This is a valid campaign description that meets the length requirements.", | ||
| }); | ||
| expect(result.success).toBe(false); | ||
| }); | ||
|
|
||
| it("should reject SQL comment sequences in title", () => { | ||
| const result1 = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: "Campaign -- SQL Injection", | ||
| description: "This is a valid campaign description that meets the length requirements.", | ||
| }); | ||
| expect(result1.success).toBe(false); | ||
|
|
||
| const result2 = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: "Campaign /* SQL Comment */", | ||
| description: "This is a valid campaign description that meets the length requirements.", | ||
| }); | ||
| expect(result2.success).toBe(false); | ||
| }); | ||
|
|
||
| it("should reject SQL comment sequences in description", () => { | ||
| const result = createCampaignPayloadSchema.safeParse({ | ||
| ...basePayload, | ||
| title: "Valid Campaign", | ||
| description: "This is a valid campaign description that meets the length requirements. -- SQL injection", | ||
| }); | ||
| expect(result.success).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,14 +45,35 @@ export const unixTimestampSchema = z.coerce | |
| .int("deadline must be a valid UNIX timestamp in seconds.") | ||
| .positive("deadline must be a valid UNIX timestamp in seconds."); | ||
|
|
||
| function sanitizeInput(val: string): string { | ||
| return val | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/\//g, "/"); | ||
| } | ||
|
Comment on lines
+48
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical Incomplete HTML escaping creates an entity-based XSS bypass
Additionally:
🧰 Tools🪛 ast-grep (0.44.0)[warning] 48-50: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html. (manual-sanitization-typescript) [warning] 48-49: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html. (manual-sanitization-typescript) 🤖 Prompt for AI Agents |
||
|
|
||
| const containsSqlComment = (val: string) => /--|\/\*|\*\//.test(val); | ||
| const containsScriptTag = (val: string) => /<script/i.test(val); | ||
|
|
||
| export const createCampaignPayloadSchema = z.object({ | ||
| creator: stellarAccountIdSchema, | ||
| title: z.string().trim().min(4, "Title must be at least 4 characters.").max(80), | ||
| title: z | ||
| .string() | ||
| .trim() | ||
| .min(4, "Title must be at least 4 characters.") | ||
| .max(80) | ||
| .refine((val) => val.trim().length >= 4, "Title cannot be only whitespace.") | ||
| .refine((val) => !containsScriptTag(val), "Title cannot contain script tags.") | ||
| .refine((val) => !containsSqlComment(val), "Title cannot contain SQL comment sequences.") | ||
| .transform((val) => sanitizeInput(val)), | ||
| description: z | ||
| .string() | ||
| .trim() | ||
| .min(20, "Description must be at least 20 characters.") | ||
| .max(500), | ||
| .max(500) | ||
| .refine((val) => !containsScriptTag(val), "Description cannot contain script tags.") | ||
| .refine((val) => !containsSqlComment(val), "Description cannot contain SQL comment sequences.") | ||
| .transform((val) => sanitizeInput(val)), | ||
| acceptedTokens: z | ||
| .array(assetCodeSchema) | ||
| .min(1, "At least one accepted token is required."), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Validate env limits before using them.
Number(...)accepts invalid config asNaNor0, which can make headers emitNaNand effectively bypass throttling. Parse positive safe integers and fall back when invalid.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents