0

I'm working on an api that includes sending emails for password resets and email confirmations. Along with functions like "user sign up".

I'm trying to include an action limiter that allows users to perform these actions a limited amount of times within a given time frame to prevent malicious use.

At first I thought using IP addresses would be fine because even malicious users run out of ip address eventually (at least that im aware) but then I realized this might block users who are in a large building and would possibly inconvenience VPN users.

What is the best way to uniquely identify a user who is not signed in in order to limit their actions on certain functions? Is this possible? How does FAANG handle this?

Here's an example I wrote in nodejs if anybody has any feedback and/or ideas on how to make this more unique I'd be all ears.

const db = require('../../common/database');
// const ActionLimiterEnum = require('../../enums/action-limiter').actionLimiterEnum;
const NumberUtil = require('../../utils/number');
const ObjectUtil = require('../../utils/object');


// !==========================================================================================!
// This module has been put on hold until I can think of a way to uniquely identify users
// Major problem about this is that it may deny large groups of people whom use the same ip 
// VPNS and/or large buildings
//
// Could cause more problems than it solves
// !==========================================================================================!



// Simple action limiter for how often a user can perform actions
// Needs to be saved to a database and not an instance because there may be multiple instances and/or they may be reset

// Object of action or "signIn"
async function actionLimiter(action,ip){
  const d = {err: {code:0,message:""},res:{}}; let r,sql,vars;

  r = await checkLimit(action,ip);
  if(r.err.code) return r;

  r = await incrementLimit(action,ip);
  if(r.err.code) return r;

  return d;
}

async function checkLimit(action,ip){
  const d = {err: {code:0,message:""},res:{}}; let r,sql,vars;

  if(action === "signIn"){
    r = await checkLimit(ActionLimiterEnum.signInShortTerm,ip); if(r.err.code) return r;
    r = await checkLimit(ActionLimiterEnum.signInMidTerm,ip); if(r.err.code) return r;
    r = await checkLimit(ActionLimiterEnum.signInLongTerm,ip); if(r.err.code) return r;
    return d;
  }

  const numberIp = NumberUtil.ipToNumber(ip);

  var deleteDate = new Date();
  deleteDate.setMilliseconds(deleteDate.getMilliseconds() - action.time);

  sql = "DELETE FROM m_admin_action_limiter WHERE action_id = ? AND created_date <= ?";
  vars = [action.id,deleteDate];
  r = await db.query(sql,vars);
  if(r.err.code) return r;

  sql = "SELECT * FROM m_admin_action_limiter WHERE action_id = ? AND ip = ?";
  vars = [action.id,numberIp];
  r = await db.query(sql,vars);
  if(r.err.code) return r;

  if(r.res.length){
    const results = ObjectUtil.toCamelCaseKeys(r.res[0]);
    if(results.actionCount >= action.maxCount){
      d.err.code = 1;
      d.err.message = "Sorry this ip has performed this action too often please try again later. ";

      switch(action.id){
        case ActionLimiterEnum.signInShortTerm.id:
        case ActionLimiterEnum.signInMidTerm.id:
        case ActionLimiterEnum.signInLongTerm.id:
          d.err.message += "If you're having trouble remembering your password you can reset it via email. ";
          break;
        default: 
          break;
      }

      d.err.actionLimited = true;
      return d;
    }
  }

  return d;
}

async function incrementLimit(action,ip){
  const d = {err: {code:0,message:""},res:{}}; let r,sql,vars;

  if(action === "signIn"){
    r = await incrementLimit(ActionLimiterEnum.signInShortTerm,ip); if(r.err.code) return r;
    r = await incrementLimit(ActionLimiterEnum.signInMidTerm,ip); if(r.err.code) return r;
    r = await incrementLimit(ActionLimiterEnum.signInLongTerm,ip); if(r.err.code) return r;
    return d;
  }

  const numberIp = NumberUtil.ipToNumber(ip);
  const timenow = new Date();

  sql = "SELECT admin_action_limiter_id FROM m_admin_action_limiter WHERE action_id = ? AND ip = ?";
  vars = [action.id,numberIp];
  r = await db.query(sql,vars);
  if(r.err.code) return r;

  if(r.res.length){
    // update
    const id = r.res[0]['admin_action_limiter_id']

    sql = "UPDATE m_admin_action_limiter SET action_count = action_count + 1 WHERE admin_action_limiter_id = ?";
    vars = [id];
    r = await db.query(sql,vars);
    if(r.err.code) return r;
  }else{
    // insert
    sql = "INSERT INTO m_admin_action_limiter (action_id,ip,action_count,created_date) VALUES(?,?,?,?)";
    vars = [action.id,numberIp,1,timenow];
    r = await db.query(sql,vars);
    if(r.err.code) return r;
  }

  return d;
}

module.exports = {
  actionLimiter, 
  checkLimit,
  incrementLimit,
  Enum: ActionLimiterEnum,
};



//  SQL

// -- -----------------------------------------------------
// -- Table `m_admin_action_limiter`
// -- -----------------------------------------------------
// CREATE TABLE IF NOT EXISTS `m_admin_action_limiter`(
// `admin_action_limiter_id` int(11) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
// `action_id` int(2) unsigned NOT NULL,
// `ip` int(11) unsigned NOT NULL,
// `action_count` unsigned int(11) DEFAULT 1,
// `created_date` DATETIME NOT NULL
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

// ALTER TABLE `m_admin_action_limiter`
//   ADD CONSTRAINT m_admin_action_limiter_unique UNIQUE (`action_id`,`ip`);
// CREATE INDEX `created_date_index` ON `m_admin_action_limiter` (`created_date`);




// Enums


// time: (days * hours * minutes * seconds * milliseconds)
// time - amount of times they can try within the alotted count
// const actionLimiterEnum = {
//   signInShortTerm: {
//     id: 1,
//     time: (1 * 1 * 60 * 60 * 1000), // 1 hour
//     maxCount: 24,
//   },
//   signInMidTerm: {
//     id: 2,
//     time: (7 * 24 * 60 * 60 * 1000), // 7 days
//     maxCount: 150,
//   },
//   signInLongTerm: {
//     id: 3,
//     time: (120 * 24 * 60 * 60 * 1000), // 120 days
//     maxCount: 840,
//   },
//   authToken: {
//     id: 4,
//     time: (1 * 24 * 60 * 60 * 1000), // 1 day
//     maxCount: 16,
//   },
//   createAccount: {
//     id: 5,
//     time: (90 * 24 * 60 * 60 * 1000), // 90 days
//     maxCount: 3,
//   },
//   passwordCheck: {
//     id: 6,
//     time: (7 * 24 * 60 * 60 * 1000), // 1 week
//     maxCount: 150,
//   },
// }

// module.exports = {
//   actionLimiterEnum,
// };
Trevor Wood
  • 2,347
  • 5
  • 31
  • 56
  • How about using session to store increments? – artfulbeest May 21 '20 at 16:39
  • Wouldn't using a database and functions on your node.js server to run logic on every single request be quite taxing? The functions you describe sound like it'd be for websites. Why not just use client cookie information? The only people that'll slip through are those who delete cookies or simply do not allow the storage of them. Wouldn't it put a lot of stress on the DB for thousands of people to be getting their request studied by the server and pulling and updating data on a DB at the same time? I think the method of this implementation would cause more problems than solve. – Keith Cronin May 22 '20 at 12:00
  • @KeithCronin I'm trying to prevent malicious users and/or bots designed to break in. I imagine they wouldn't allow cookies. It wouldn't be on every request, just the specified ones. Since the columns as indexed I don't think it would be so taxing. – Trevor Wood May 22 '20 at 12:12
  • @Danizavtz But he needs a way of uniquely identifying the client. The implementation isn't the problem. – Keith Cronin May 22 '20 at 12:43
  • uniquely identify a user is a hard problem, the best resource i saw about it was a class. I would recommend every resource related to the class `Oct 17: Fingerprinting and Privacy on the Web`. There is lots of insight on how to uniquely identify users. https://web.stanford.edu/class/cs253/ – Danizavtz May 22 '20 at 12:54

1 Answers1

1

Notify users that the site will not work correctly without cookies enabled. Create the timestamp cookie when they enter the sign-up or sign-on page if it doesn't exist.. If a user requests to sign-in or sign-up and your cookie doesn't exist upon trying to read their timestamp, we know that they have cookies disabled or that it could be a malicious user.. If it exists obviously you'd compare the timestamps and update their cookie timestamp after the request logic has ran. Now if it doesn't exist tell them to enable cookies or the website won't work. This would prevent malicious use and kick IP Addresses out of the equation. If malicious users are truly a problem/concern for you the only way around the IP Address problem is with cookies, or with much more complicated logic than you currently have that attempts to identify malicious intent(could shoot yourself in the foot if it takes action against a false positive though I wouldn't recommend this route). Make sure you take steps to secure your cookie as well.

If you don't want to go the cookie route, you can run logic over client data to try and identify users with things like timezone, fonts installed, screen resolution etc.

A lot of websites require the use of cookies for full website functionality these days anyway; probably for this reason as well.

You can set up a simple key value pair database on your server. When a user requests, take things like timezone, fonts installed, screen resolution etc, and change all that data into a string without spaces, then turn it into a strong hash(a hash that would change if just one character in the string was different). The resulting hash would be the key used to identify the user. The value associated with said key would be their unique timestamp that represents the last time they accessed the server. Additionally make sure you are pulling the width and height of the actual screen, not the browser viewport.. otherwise they could resize the screen to make themself seem like a unique user if they were able to even figure out how the server identifies you. Obviously if the hash is different upon client request it's relatively safe to assume it's a new user.

With this method you wouldn't even need to use cookies. Additionally there are also loads of JS libraries that give more comprehensive client data, I suggest you check those out to build stronger unique hashes for a stronger identification of the client. To make it even better place the key value pair database on a proxy server and allow the request to the server if the conditions are met that are processed on the proxy server. Additionally you can use a service like Cloudflare to place in front of your proxy in case someone tries to DDoS the proxy server. If that happens you can get a new IP for the proxy and change it to the new IP on your DNS.

Keith Cronin
  • 373
  • 2
  • 9
  • Couldn't they just delete the cookie and get a new one after they've used it too many times? It's not the everyday user that I'm worried about. It's more-so potential hackers. The timezone, fonts installed, screen resolution part is interesting though, thank you. – Trevor Wood May 23 '20 at 00:37
  • @TrevorWood What are you afraid of them doing exactly? Regardless of your security set up they could still flood the website with requests. The server still has to process the request whether it'll deny or allow the request. If you are worried about flooding, consider using a proxy that'll process requests and route the allowed requests to the actual server. – Keith Cronin May 23 '20 at 01:51
  • 1
    @TrevorWood and to answer you question, yes they could. However if you had a way to uniquely identify the cookie user when they request to the server, you could then access their real server stored timestamp. You'd identify them with things like timezone, fonts installed, screen resolution etc. I have an idea ill update my answer. – Keith Cronin May 23 '20 at 01:55
  • Yeah I'm mostly concerned about 2 things. Brute force logging in (currently have a 2.5 second delay for login responses b/c of this) and email harassment. Creating an Account sends an email from my server to an unknown email address. If for whatever reason somebody decides to create a bot that spams this process, my server would be held accountable for the spam those users receive... granted it would just be "Thanks for creating an account" or "Reset your password" emails. Still I think it would lead to issues with AWS & Google mail – Trevor Wood May 23 '20 at 02:04
  • @TrevorWood well if you built a strong user identification system that was purely on the backend you could majorly mitigate that problem. – Keith Cronin May 23 '20 at 02:05
  • @TrevorWood updated the answer again. I think that method would be strong – Keith Cronin May 23 '20 at 02:09
  • Thanks for all the ideas Keith. I think the the cloudflare one would probably solve my issue. I think the user identification backend we have is sufficiently secure, so that just leaves the emails. We'll just add in a log and monitor how many times emails are being sent out to see if it actually is an issue. – Trevor Wood May 23 '20 at 02:13
  • @TrevorWood no problem glad I could help! – Keith Cronin May 23 '20 at 02:14