diff --git a/package.json b/package.json index 29a440be8..81cfe5d2f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "check:typescript": "./src/scripts/checkTypescript.sh", "check:types": "tsc -p . --noEmit", "check:lint": "eslint --ignore-path .eslintignore --ext .ts,.tsx,.js,.jsx ./src/client/app", - "test": "NODE_ENV=test mocha --timeout 15000 \"src/server/test/**/*.js\"", + "test": "npm run noLimitTest && npm run rateTest", + "noLimitTest": "NODE_ENV=test mocha --timeout 15000 \"src/server/test/**/*.js\"", + "rateTest": "NODE_ENV=ratetest mocha --timeout 15000 \"src/server/rateTest/**/*.js\"", "testsome": "NODE_ENV=test mocha --timeout 15000", "createdb": "node ./src/server/services/createDB.js", "developerdb": "node -e 'require(\"./src/server/util/developer.js\").createShiftReadingsFunction()'", diff --git a/src/server/app.js b/src/server/app.js index 16a69b8ad..15b84409f 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -41,8 +41,12 @@ const ciks = require('./routes/ciks'); // the goal is to avoid hitting rate limiting in testing. // Note that NODE_ENV of test should only be set for the testing environment and is done in // package.json in the script section for the test ones. -const isTestEnvironment = process.env.NODE_ENV === 'test'; -const testMultiplier = isTestEnvironment ? 100 : 1; +const env = process.env.NODE_ENV; + +const isTestEnvironment = env === 'test'; +const isRateTest = env === 'ratetest'; + +const testMultiplier = isTestEnvironment ? 1000 : 1; // Limit the rate of overall requests to OED // TODO Verify that user see the message returned, see https://express-rate-limit.mintlify.app/reference/configuration#message @@ -116,6 +120,23 @@ const exportRawLimiter = rateLimit({ // Apply the raw export limit app.use('/api/readings/line/raw/meters', exportRawLimiter); +// Limit the number of login attempts +const loginLimiter = rateLimit({ + // Window of 1 hour + windowMs: 60 * 60 * 1000, + /* Rationale: The login route requires a more specifc and strict rate limit that must be tested seperately. + This is to validate that the rate limit in place is functioning correctly. + If running in the rate test enviroment, limit to 1 request (1 per hour) + Otherwise, use the standard limit based on the configured multiplier. */ + limit: isRateTest ? 1 : 900 * testMultiplier, + // Return rate limit info in the `RateLimit-*` headers + standardHeaders: true, + // Disable the `X-RateLimit-*` headers + legacyHeaders: false, +}); +//Apply the login limit +app.use('/api/login', loginLimiter); + // If other logging is turned off, there's no reason to log HTTP requests either. // TODO: Potentially modify the Morgan logger to use the log API, thus unifying all our logging. @@ -131,7 +152,7 @@ app.use('/api/users', users); app.use('/api/meters', meters); app.use('/api/readings', readings); app.use('/api/preferences', preferences); -app.use('/api/login', login); +app.use('api/login', login); app.use('/api/groups', groups); app.use('/api/verification', verification); app.use('/api/version', version); diff --git a/src/server/rateTest/rateTest.js b/src/server/rateTest/rateTest.js new file mode 100644 index 000000000..efa18ac37 --- /dev/null +++ b/src/server/rateTest/rateTest.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* This file currently only tests the login route rate limiting functionality. + It ensures that repeated login attempts are blocked after + exceeding the configured rate limit. */ + +// TODO: Create additonal rate limits tests for other OED Routes + +const { HTTP_CODE } = require('../util/readingsUtils'); +const { chai, mocha, expect, app } = require('../test/common'); +const { todo } = require('node:test'); + +mocha.describe('Login Rate Limit', () => { + mocha.it('Should block repeated login attempts with 429', async () => { + const first = await chai.request(app) + .post('/api/login') + .send({ + username: 'invalidUser', + password: 'invalidPassword' + }); + + //First request that makes it through to authentication + expect(first).to.have.status(HTTP_CODE.UNAUTHORIZED); + + const second = await chai.request(app) + .post('/api/login') + .send({ + username: 'invalidUser', + password: 'invalidPassword' + }); + + //Second request that triggers the rate limit of 1 request per hour + expect(second).to.have.status(HTTP_CODE.TOO_MANY_REQUESTS); + expect(second.text).to.include('Too many requests'); + }); +});