node.jsuniquelimituser-activity

Limit user actions on certain functions. Uniquely identify unsigned in users


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,
// };

Solution

  • 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.