From 1d824d5f07710db77f1833e590b5fd3385f24aab Mon Sep 17 00:00:00 2001 From: Maxime Cannoodt Date: Sun, 10 Jul 2022 23:00:32 +0200 Subject: [PATCH] rate limiting on get, more unit test config --- docker-compose.yml | 6 ++++- server/.env.example | 12 +++++---- server/.env.test | 4 ++- server/package.json | 4 +-- server/src/app.integration.test.ts | 41 +++++++++++++++++++++++------- server/src/app.ts | 39 ++++++++++++++++------------ 6 files changed, 72 insertions(+), 34 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9cbbbfe..d3f91fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,9 +37,13 @@ services: - DATABASE_URL=file:/database/db.sqlite - FRONTEND_URL=http://localhost:5000 - CLEANUP_INTERVAL_SECONDS=600 + - NODE_ENV=production + # Rate limit for uploading notes - POST_LIMIT_WINDOW_SECONDS=86400 - POST_LIMIT=50 - - NODE_ENV=production + # Rate limit for downloading notes + - GET_LIMIT_WINDOW_SECONDS=60 + - GET_LIMIT=20 depends_on: migrate: condition: service_completed_successfully diff --git a/server/.env.example b/server/.env.example index f66e8e1..3b34315 100644 --- a/server/.env.example +++ b/server/.env.example @@ -5,7 +5,7 @@ # See the documentation for all the connection string options: https://pris.ly/d/connection-strings # If ENVIRONMENT=dev, CORS is enabled. -ENVIRONMENT=dev +ENVIRONMENT=test # Port at which the server is hosted. PORT=8080 @@ -20,8 +20,10 @@ DATABASE_URL="file:./dev.sqlite" # and expired notes are deleted. CLEANUP_INTERVAL_SECONDS=60 -# Rate limit window after which post limit is reset -POST_LIMIT_WINDOW="86400 # 24 hours" +# Rate limit for uploading notes. +POST_LIMIT_WINDOW=86400 +POST_LIMIT=50 -# Max. posted notes within rate limit window -POST_LIMIT=50 \ No newline at end of file +# Rate limit for downloading notes. +GET_LIMIT_WINDOW=60 +GET_LIMIT=20 \ No newline at end of file diff --git a/server/.env.test b/server/.env.test index ba0679f..0c9bfd8 100644 --- a/server/.env.test +++ b/server/.env.test @@ -1,5 +1,7 @@ DATABASE_URL="file:./test.sqlite" POST_LIMIT=50 -POST_LIMIT_WINDOW=1 +POST_LIMIT_WINDOW_SECONDS=0.1 +GET_LIMIT=20 +GET_LIMIT_WINDOW_SECONDS=0.1 LOG_LEVEL=warn \ No newline at end of file diff --git a/server/package.json b/server/package.json index 35e245a..6f45e4b 100644 --- a/server/package.json +++ b/server/package.json @@ -7,8 +7,8 @@ "test": "run-s test:db:reset test:test", "coverage": "run-s test:db:reset test:coverage", "test-watch": "dotenv -e .env.test -- vitest unit --coverage", - "test:test": "dotenv -e .env.test -- vitest run ", - "test:coverage": "dotenv -e .env.test -- vitest run --coverage", + "test:test": "dotenv -e .env.test -- vitest run --no-threads", + "test:coverage": "dotenv -e .env.test -- vitest run --no-threads --coverage", "test:db:reset": "dotenv -e .env.test -- npx prisma migrate reset -f", "build": "npx tsc", "dev": "npx nodemon ./server.ts | npx pino-colada" diff --git a/server/src/app.integration.test.ts b/server/src/app.integration.test.ts index e6ebb4e..bc5298c 100644 --- a/server/src/app.integration.test.ts +++ b/server/src/app.integration.test.ts @@ -31,13 +31,33 @@ describe("GET /api/note", () => { }); it("responds 404 for invalid ID", async () => { - // Insert a note // Make get request const res = await request(app).get(`/api/note/NaN`); // Validate returned note expect(res.statusCode).toBe(404); }); + + it("Applies rate limits to endpoint", async () => { + // Insert a note + const { id } = await prisma.encryptedNote.create({ + data: testNote, + }); + + // Make get requests + const requests = []; + for (let i = 0; i < 51; i++) { + requests.push(request(app).get(`/api/note/${id}`)); + } + const responses = await Promise.all(requests); + const responseCodes = responses.map((res) => res.statusCode); + + // at least one response should be 429 + expect(responseCodes).toContain(429); + + // sleep for 100 ms to allow rate limiter to reset + await new Promise((resolve) => setTimeout(resolve, 100)); + }); }); describe("POST /api/note", () => { @@ -85,6 +105,15 @@ describe("POST /api/note", () => { expect(res.body.hmac).toEqual(testNote.hmac); }); + it("Applies upload limit to endpoint of 400kb", async () => { + const largeNote = { + ciphertext: "a".repeat(400 * 1024), + hmac: "sample_hmac", + }; + const res = await request(app).post("/api/note").send(largeNote); + expect(res.statusCode).toBe(413); + }); + it("Applies rate limits to endpoint", async () => { // make more requests than the post limit set in .env.test const requests = []; @@ -96,14 +125,8 @@ describe("POST /api/note", () => { // at least one response should be 429 expect(responseCodes).toContain(429); - }); - it("Applies upload limit to endpoint of 400kb", async () => { - const largeNote = { - ciphertext: "a".repeat(400 * 1024), - hmac: "sample_hmac", - }; - const res = await request(app).post("/api/note").send(largeNote); - expect(res.statusCode).toBe(413); + // sleep for 100 ms to allow rate limiter to reset + await new Promise((resolve) => setTimeout(resolve, 100)); }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 439a334..754ff57 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -31,15 +31,37 @@ app.use( // Apply rate limiting const postLimiter = rateLimit({ - windowMs: parseInt(process.env.POST_LIMIT_WINDOW_SECONDS as string) * 1000, + windowMs: parseFloat(process.env.POST_LIMIT_WINDOW_SECONDS as string) * 1000, max: parseInt(process.env.POST_LIMIT as string), // Limit each IP to X requests per window standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); +const getLimiter = rateLimit({ + windowMs: parseFloat(process.env.GET_LIMIT_WINDOW_SECONDS as string) * 1000, + max: parseInt(process.env.GET_LIMIT as string), // Limit each IP to X requests per window + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers +}); + // Apply 400kB upload limit on POST app.use(bodyParser.json({ limit: "400k" })); +// Get encrypted note +app.get("/api/note/:id", getLimiter, (req, res, next) => { + prisma.encryptedNote + .findUnique({ + where: { id: req.params.id }, + }) + .then((note) => { + if (note != null) { + res.send(note); + } + res.status(404).send(); + }) + .catch(next); +}); + // Post new encrypted note app.post( "/api/note/", @@ -60,21 +82,6 @@ app.post( } ); -// Get encrypted note -app.get("/api/note/:id", (req, res, next) => { - prisma.encryptedNote - .findUnique({ - where: { id: req.params.id }, - }) - .then((note) => { - if (note != null) { - res.send(note); - } - res.status(404).send(); - }) - .catch(next); -}); - // For testing purposes app.get("/api/test", (req, res, next) => { res.status(200).send("Hello world!");