Browse Source

add rate limit

Pouria Ezzati 1 year ago
parent
commit
752f5ba3bd

+ 3 - 15
.example.env

@@ -40,31 +40,16 @@ DISALLOW_REGISTRATION=false
 # Disable anonymous link creation
 DISALLOW_ANONYMOUS_LINKS=false
 
-# The daily limit for each user
-USER_LIMIT_PER_DAY=50
-
 # This would be shown to the user on the settings page
 # It's only for display purposes and has no other use
 SERVER_IP_ADDRESS=
 
-# Create a cooldown for non-logged in users in minutes
-# Would be ignored if DISALLOW_ANONYMOUS_LINKS is set to true
-# Set 0 to disable
-NON_USER_COOLDOWN=0
-
-# Max number of visits for each link to have detailed stats
-DEFAULT_MAX_STATS_PER_LINK=5000
-
 # Use HTTPS for links with custom domain
 CUSTOM_DOMAIN_USE_HTTPS=false
 
 # A passphrase to encrypt JWT. Use a long and secure key.
 JWT_SECRET=securekey
 
-# Admin emails so they can access admin actions on settings page
-# Comma seperated
-ADMIN_EMAILS=
-
 # Optional - Google Cloud API to prevent from users from submitting malware URLs.
 # Get it from https://developers.google.com/safe-browsing/v4/get-started
 GOOGLE_SAFE_BROWSING_KEY=
@@ -81,6 +66,9 @@ MAIL_USER=
 MAIL_FROM=
 MAIL_PASSWORD=
 
+# Enable rate limitting for some API routes
+ENABLE_RATE_LIMIT=false
+
 # The email address that will receive submitted reports.
 REPORT_EMAIL=
 

+ 29 - 0
package-lock.json

@@ -18,6 +18,7 @@
         "dotenv": "16.0.3",
         "envalid": "8.0.0",
         "express": "^4.21.1",
+        "express-rate-limit": "^7.4.1",
         "express-validator": "6.14.2",
         "geoip-lite": "1.4.10",
         "hbs": "4.2.0",
@@ -38,6 +39,7 @@
         "passport-localapikey-update": "0.6.0",
         "pg": "8.12.0",
         "pg-query-stream": "4.6.0",
+        "rate-limit-redis": "^4.2.0",
         "sqlite3": "5.1.7",
         "useragent": "2.3.0",
         "uuid": "10.0.0"
@@ -2714,6 +2716,21 @@
         "node": ">= 0.10.0"
       }
     },
+    "node_modules/express-rate-limit": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz",
+      "integrity": "sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": "4 || 5 || ^5.0.0-beta.1"
+      }
+    },
     "node_modules/express-validator": {
       "version": "6.14.2",
       "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.2.tgz",
@@ -5305,6 +5322,18 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/rate-limit-redis": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.0.tgz",
+      "integrity": "sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      },
+      "peerDependencies": {
+        "express-rate-limit": ">= 6"
+      }
+    },
     "node_modules/raw-body": {
       "version": "2.5.2",
       "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",

+ 2 - 0
package.json

@@ -35,6 +35,7 @@
     "dotenv": "16.0.3",
     "envalid": "8.0.0",
     "express": "^4.21.1",
+    "express-rate-limit": "^7.4.1",
     "express-validator": "6.14.2",
     "geoip-lite": "1.4.10",
     "hbs": "4.2.0",
@@ -55,6 +56,7 @@
     "passport-localapikey-update": "0.6.0",
     "pg": "8.12.0",
     "pg-query-stream": "4.6.0",
+    "rate-limit-redis": "^4.2.0",
     "sqlite3": "5.1.7",
     "useragent": "2.3.0",
     "uuid": "10.0.0"

+ 3 - 5
server/env.js

@@ -30,15 +30,11 @@ const env = cleanEnv(process.env, {
   REDIS_PORT: num({ default: 6379 }),
   REDIS_PASSWORD: str({ default: "" }),
   REDIS_DB: num({ default: 0 }),
-  USER_LIMIT_PER_DAY: num({ default: 50 }),
-  NON_USER_COOLDOWN: num({ default: 10 }),
-  DEFAULT_MAX_STATS_PER_LINK: num({ default: 5000 }),
   DISALLOW_ANONYMOUS_LINKS: bool({ default: false }),
   DISALLOW_REGISTRATION: bool({ default: false }),
   SERVER_IP_ADDRESS: str({ default: "" }),
   CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }),
   JWT_SECRET: str(),
-  ADMIN_EMAILS: str({ default: "" }),
   GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }),
   MAIL_ENABLED: bool({ default: false }),
   MAIL_HOST: str({ default: "" }),
@@ -47,8 +43,10 @@ const env = cleanEnv(process.env, {
   MAIL_USER: str({ default: "" }),
   MAIL_FROM: str({ default: "", example: "Kutt <support@kutt.it>" }),
   MAIL_PASSWORD: str({ default: "" }),
+  ENABLE_RATE_LIMIT: bool({ default: false }),
   REPORT_EMAIL: str({ default: "" }),
-  CONTACT_EMAIL: str({ default: "" })
+  CONTACT_EMAIL: str({ default: "" }),
+  NODE_APP_INSTANCE: num({ default: 0 }),
 });
 
 module.exports = env;

+ 0 - 21
server/handlers/auth.handler.js

@@ -81,26 +81,6 @@ const jwtLoose = authenticate("jwt", "Unauthorized.", false, "header");
 const jwtLoosePage = authenticate("jwt", "Unauthorized.", false, "page");
 const apikey = authenticate("localapikey", "API key is not correct.", false, null);
 
-async function cooldown(req, res, next) {
-  if (env.DISALLOW_ANONYMOUS_LINKS) return next();
-  const cooldownConfig = env.NON_USER_COOLDOWN;
-  if (req.user || !cooldownConfig) return next();
-  
-  const ip = await query.ip.find({
-    ip: req.realIP.toLowerCase(),
-    created_at: [">", utils.dateToUTC(subMinutes(new Date(), cooldownConfig))]
-  });
-  
-  if (ip) {
-    const timeToWait = cooldownConfig - differenceInMinutes(new Date(), utils.parseDatetime(ip.created_at));
-    throw new CustomError(
-      `Non-logged in users are limited. Wait ${timeToWait} minutes or log in.`,
-      400
-    );
-  }
-  next();
-}
-
 function admin(req, res, next) {
   if (req.user.admin) return next();
   throw new CustomError("Unauthorized", 401);
@@ -408,7 +388,6 @@ module.exports = {
   changeEmail,
   changeEmailRequest,
   changePassword,
-  cooldown,
   createAdminUser,
   featureAccess,
   featureAccessPage,

+ 44 - 1
server/handlers/helpers.handler.js

@@ -1,6 +1,9 @@
+const { RedisStore: RateLimitRedisStore } = require("rate-limit-redis");
+const { rateLimit: expressRateLimit } = require("express-rate-limit");
 const { validationResult } = require("express-validator");
 
 const { CustomError } = require("../utils");
+const redis = require("../redis");
 const env = require("../env");
 
 function error(error, req, res, _next) {
@@ -14,7 +17,8 @@ function error(error, req, res, _next) {
   const statusCode = error.statusCode ?? 500;
 
   if (req.isHTML && req.viewTemplate) {
-    res.render(req.viewTemplate, { error: message });
+    res.locals.error = message;
+    res.render(req.viewTemplate);
     return;
   }
 
@@ -80,8 +84,47 @@ function parseQuery(req, res, next) {
   next();
 };
 
+function rateLimit(params) {
+  if (!env.ENABLE_RATE_LIMIT) {
+    return function(req, res, next) {
+      return next();
+    }
+  }
+  
+  let store = undefined;
+  if (env.REDIS_ENABLED) {
+    store = new RateLimitRedisStore({
+      sendCommand: (...args) => redis.client.call(...args),
+    })
+  }
+  
+  return expressRateLimit({
+    windowMs: params.window * 1000,
+    validate: { trustProxy: false },
+    skipSuccessfulRequests: !!params.skipSuccess,
+    skipFailedRequests: !!params.skipFailed,
+    ...(store && { store }),
+    limit: function (req, res) {
+      if (params.user && req.user) {
+        return params.user;
+      }
+      return params.limit;
+    },
+    keyGenerator: function(req, res) {
+      return "rl:" + req.method + req.baseUrl + req.path + ":" + req.ip;
+    },
+    requestWasSuccessful: function(req, res) {
+      return !res.locals.error && res.statusCode < 400;
+    },
+    handler: function (req, res, next, options) {
+      throw new CustomError(options.message, options.statusCode);
+    },
+  });
+}
+
 module.exports = {
   error,
   parseQuery,
+  rateLimit,
   verify,
 }

+ 3 - 8
server/handlers/links.handler.js

@@ -107,7 +107,6 @@ async function create(req, res) {
   const tasks = await Promise.all([
     validators.cooldown(req.user),
     validators.malware(req.user, target),
-    validators.linksCount(req.user),
     reuse &&
       query.link.find({
         target,
@@ -126,19 +125,19 @@ async function create(req, res) {
   
   // if "reuse" is true, try to return
   // the existent URL without creating one
-  if (tasks[3]) {
+  if (tasks[2]) {
     return res.json(utils.sanitize.link(tasks[3]));
   }
   
   // Check if custom link already exists
-  if (tasks[4]) {
+  if (tasks[3]) {
     const error = "Custom URL is already in use.";
     res.locals.errors = { customurl: error };
     throw new CustomError(error);
   }
 
   // Create new link
-  const address = customurl || tasks[5];
+  const address = customurl || tasks[4];
   const link = await query.link.create({
     password,
     address,
@@ -148,10 +147,6 @@ async function create(req, res) {
     expire_in,
     user_id: req.user && req.user.id
   });
-  
-  if (!req.user && env.NON_USER_COOLDOWN) {
-    query.ip.add(req.realIP);
-  }
 
   link.domain = fetched_domain?.address;
   

+ 16 - 25
server/handlers/validators.handler.js

@@ -345,9 +345,9 @@ const createUser = [
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
-    .isEmail()
-    .isLength({ min: 0, max: 255 })
+    .isLength({ min: 1, max: 255 })
     .withMessage("Email length must be max 255.")
+    .isEmail()
     .custom(async (value, { req }) => {
       const user = await query.user.find({ email: value });
       if (user) 
@@ -389,17 +389,23 @@ const signup = [
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
-    .isEmail()
     .isLength({ min: 0, max: 255 })
     .withMessage("Email length must be max 255.")
+    .isEmail()
+];
+
+const signupEmailTaken = [
+  body("email", "Email is not valid.")
     .custom(async (value, { req }) => {
       const user = await query.user.find({ email: value });
 
-      if (user)
+      if (user) {
         req.user = user;
+      }
 
-      if (user?.verified) 
+      if (user?.verified) {
         return Promise.reject();
+      }
     })
     .withMessage("You can't use this email address.")
 ];
@@ -412,9 +418,9 @@ const login = [
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
-    .isEmail()
     .isLength({ min: 1, max: 255 })
     .withMessage("Email length must be max 255.")
+    .isEmail()
 ];
 
 const createAdmin = [
@@ -425,9 +431,9 @@ const createAdmin = [
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
-    .isEmail()
     .isLength({ min: 0, max: 255 })
     .withMessage("Email length must be max 255.")
+    .isEmail()
 ];
 
 const changePassword = [
@@ -449,18 +455,18 @@ const changeEmail = [
   body("email", "Email address is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
-    .isEmail()
     .isLength({ min: 1, max: 255 })
     .withMessage("Email length must be max 255.")
+    .isEmail()
 ];
 
 const resetPassword = [
   body("email", "Email is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
     .trim()
-    .isEmail()
     .isLength({ min: 0, max: 255 })
     .withMessage("Email length must be max 255.")
+    .isEmail()
 ];
 
 const deleteUser = [
@@ -547,21 +553,6 @@ async function malware(user, target) {
   );
 };
 
-async function linksCount(user) {
-  if (!user) return;
-
-  const count = await query.link.total({
-    user_id: user.id,
-    "links.created_at": [">", utils.dateToUTC(subDays(new Date(), 1))]
-  });
-
-  if (count > env.USER_LIMIT_PER_DAY) {
-    throw new utils.CustomError(
-      `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
-    );
-  }
-};
-
 async function bannedDomain(domain) {
   const isBanned = await query.domain.find({
     address: domain,
@@ -614,7 +605,6 @@ module.exports = {
   deleteUserByAdmin,
   editLink,
   getStats,
-  linksCount,
   login, 
   malware,
   redirectProtected,
@@ -623,4 +613,5 @@ module.exports = {
   reportLink,
   resetPassword,
   signup,
+  signupEmailTaken,
 }

+ 0 - 2
server/queries/index.js

@@ -3,12 +3,10 @@ const visit = require("./visit.queries");
 const link = require("./link.queries");
 const user = require("./user.queries");
 const host = require("./host.queries");
-const ip = require("./ip.queries");
 
 module.exports = {
   domain,
   host,
-  ip,
   link,
   user,
   visit

+ 0 - 54
server/queries/ip.queries.js

@@ -1,54 +0,0 @@
-const { subMinutes } = require("date-fns");
-
-const utils = require("../utils");
-const knex = require("../knex");
-const env = require("../env");
-
-async function add(ipToAdd) {
-  const ip = ipToAdd.toLowerCase();
-  
-  const currentIP = await knex("ips").where("ip", ip).first();
-  
-  if (currentIP) {
-    const currentDate = utils.dateToUTC(new Date());
-    await knex("ips")
-      .where({ ip })
-      .update({
-        created_at: currentDate,
-        updated_at: currentDate
-      });
-  } else {
-    await knex("ips").insert({ ip });
-  }
-  
-  return ip;
-}
-
-
-async function find(match) {
-  const query = knex("ips");
-  
-  Object.entries(match).forEach(([key, value]) => {
-    query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
-  });
-  
-  const ip = await query.first();
-  
-  return ip;
-}
-
-function clear() {
-  return knex("ips")
-  .where(
-    "created_at",
-    "<",
-    utils.dateToUTC(subMinutes(new Date(), env.NON_USER_COOLDOWN))
-  )
-  .delete();
-}
-
-module.exports = {
-  add,
-  clear,
-  find,
-}

+ 9 - 0
server/routes/auth.routes.js

@@ -15,6 +15,7 @@ router.post(
   locals.viewTemplate("partials/auth/form"),
   validators.login,
   asyncHandler(helpers.verify),
+  helpers.rateLimit({ window: 60, limit: 5 }),
   asyncHandler(auth.local),
   asyncHandler(auth.login)
 );
@@ -25,6 +26,9 @@ router.post(
   auth.featureAccess([!env.DISALLOW_REGISTRATION, env.MAIL_ENABLED]),
   validators.signup,
   asyncHandler(helpers.verify),
+  helpers.rateLimit({ window: 60, limit: 5 }),
+  validators.signupEmailTaken,
+  asyncHandler(helpers.verify),
   asyncHandler(auth.signup)
 );
 
@@ -33,6 +37,7 @@ router.post(
   locals.viewTemplate("partials/auth/form_admin"),
   validators.createAdmin,
   asyncHandler(helpers.verify),
+  helpers.rateLimit({ window: 60, limit: 5 }),
   asyncHandler(auth.createAdminUser)
 );
 
@@ -42,6 +47,7 @@ router.post(
   asyncHandler(auth.jwt),
   validators.changePassword,
   asyncHandler(helpers.verify),
+  helpers.rateLimit({ window: 60, limit: 5 }),
   asyncHandler(auth.changePassword)
 );
 
@@ -52,6 +58,7 @@ router.post(
   auth.featureAccess([env.MAIL_ENABLED]),
   validators.changeEmail,
   asyncHandler(helpers.verify),
+  helpers.rateLimit({ window: 60, limit: 3 }),
   asyncHandler(auth.changeEmailRequest)
 );
 
@@ -59,6 +66,7 @@ router.post(
   "/apikey",
   locals.viewTemplate("partials/settings/apikey"),
   asyncHandler(auth.jwt),
+  helpers.rateLimit({ window: 60, limit: 10 }),
   asyncHandler(auth.generateApiKey)
 );
 
@@ -68,6 +76,7 @@ router.post(
   auth.featureAccess([env.MAIL_ENABLED]),
   validators.resetPassword,
   asyncHandler(helpers.verify),
+  helpers.rateLimit({ window: 60, limit: 3 }),
   asyncHandler(auth.resetPasswordRequest)
 );
 

+ 0 - 1
server/routes/link.routes.js

@@ -37,7 +37,6 @@ router.post(
   locals.viewTemplate("partials/shortener"),
   asyncHandler(auth.apikey),
   asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
-  asyncHandler(auth.cooldown),
   locals.createLink,
   validators.createLink,
   asyncHandler(helpers.verify),

+ 2 - 7
server/utils/utils.js

@@ -1,13 +1,13 @@
 const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears, format } = require("date-fns");
 const nanoid = require("nanoid/generate");
-const knexUtils = require("./knex");
 const JWT = require("jsonwebtoken");
-const knex = require("../knex");
 const path = require("path");
 const hbs = require("hbs");
 const ms = require("ms");
 
 const { ROLES } = require("../consts");
+const knexUtils = require("./knex");
+const knex = require("../knex");
 const env = require("../env");
 
 class CustomError extends Error {
@@ -72,10 +72,6 @@ function getShortURL(address, domain) {
   return { address, link, url };
 }
 
-function getStatsLimit() {
-  return env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
-};
-
 function statsObjectToArray(obj) {
   const objToArr = (key) =>
     Array.from(Object.keys(obj[key]))
@@ -346,7 +342,6 @@ module.exports = {
   getDifferenceFunction,
   getInitStats,
   getShortURL,
-  getStatsLimit,
   getStatsPeriods,
   isAdmin,
   parseBooleanQuery,

+ 1 - 0
static/css/styles.css

@@ -1262,6 +1262,7 @@ main form p.error {
 main form .target-wrapper p.error {
   font-size: 15px;
   margin-left: 1rem;
+  margin-bottom: 0;
 }
 
 main form .target-wrapper {