cityssm/express-abuse-points

View on GitHub
index.ts

Summary

Maintainability
C
1 day
Test Coverage
import type express from 'express'
import sqlite3 from 'sqlite3'

import { getIP, getXForwardedFor } from './trackingValues.js'
import type { AbuseCheckOptions } from './types.js'

const OPTIONS_DEFAULT: AbuseCheckOptions = {
  byIP: true,
  byXForwardedFor: false,

  abusePoints: 1,
  expiryMillis: 5 * 60 * 1000, // 5 minutes

  abusePointsMax: 10,
  clearIntervalMillis: 60 * 60 * 1000 // 1 hour
}

Object.freeze(OPTIONS_DEFAULT)

type TABLENAME = 'AbusePoints_IP' | 'AbusePoints_XForwardedFor'
const TABLENAME_IP = 'AbusePoints_IP'
const TABLENAME_XFORWARDEDFOR = 'AbusePoints_XForwardedFor'

const TABLECOLUMNS_CREATE =
  '(trackingValue TEXT, expiryTimeMillis INT UNSIGNED, abusePoints TINYINT UNSIGNED)'
const TABLECOLUMNS_INSERT = '(trackingValue, expiryTimeMillis, abusePoints)'

let options: AbuseCheckOptions = OPTIONS_DEFAULT
let database: sqlite3.Database

let clearAbuseIntervalFunction: NodeJS.Timeout

export function shutdown(): void {
  try {
    if (clearAbuseIntervalFunction !== undefined) {
      clearInterval(clearAbuseIntervalFunction)
    }
  } catch {
    // ignore
  }

  try {
    if (database !== undefined) {
      database.close()
    }
  } catch {
    // ignore
  }
}

export function initialize(
  optionsUser?: Partial<AbuseCheckOptions>
): express.RequestHandler {
  options = Object.assign({}, OPTIONS_DEFAULT, optionsUser)

  if (database === undefined) {
    database = new sqlite3.Database(':memory:')

    if (options.byIP) {
      database.run(
        `CREATE TABLE IF NOT EXISTS ${TABLENAME_IP} ${TABLECOLUMNS_CREATE}`
      )
    }

    if (options.byXForwardedFor) {
      database.run(
        `CREATE TABLE IF NOT EXISTS ${TABLENAME_XFORWARDEDFOR} ${TABLECOLUMNS_CREATE}`
      )
    }

    clearAbuseIntervalFunction = setInterval(
      clearExpiredAbuse,
      options.clearIntervalMillis
    )

    const shutdownEvents = ['beforeExit', 'exit', 'SIGINT', 'SIGTERM']

    for (const shutdownEvent of shutdownEvents) {
      process.on(shutdownEvent, shutdown)
    }
  }

  return abuseCheckHandler as express.RequestHandler
}

function clearExpiredAbuse(): void {
  if (options.byIP && database !== undefined) {
    database.run(
      `DELETE FROM ${TABLENAME_IP} WHERE expiryTimeMillis <= ?`,
      Date.now()
    )
  }

  if (options.byXForwardedFor && database !== undefined) {
    database.run(
      `DELETE FROM ${TABLENAME_XFORWARDEDFOR} WHERE expiryTimeMillis <= ?`,
      Date.now()
    )
  }
}

async function getAbusePoints(
  tableName: TABLENAME,
  trackingValue: string
): Promise<number> {
  return await new Promise((resolve, reject) => {
    database.get(
      `select sum(abusePoints) as abusePointsSum
        from ${tableName}
        where trackingValue = ?
        and expiryTimeMillis > ?`,
      trackingValue,
      Date.now(),
      (error: unknown, row: { abusePointsSum?: number }) => {
        if (error !== null) {
          reject(error)
        }

        resolve(row?.abusePointsSum ?? 0)
      }
    )
  })
}

function clearAbusePoints(tableName: TABLENAME, trackingValue: string): void {
  database.run(
    `DELETE FROM ${tableName} WHERE trackingValue = ?`,
    trackingValue
  )
}

/**
 * Clears all abuse records from a requestor, expired or not.
 */
export function clearAbuse(request: Partial<express.Request>): void {
  if (options.byIP) {
    const ipAddress = getIP(request)

    if (ipAddress !== '') {
      clearAbusePoints(TABLENAME_IP, ipAddress)
    }
  }

  if (options.byXForwardedFor) {
    const ipAddress = getXForwardedFor(request)

    if (ipAddress !== '') {
      clearAbusePoints(TABLENAME_XFORWARDEDFOR, ipAddress)
    }
  }
}

/**
 * Checks if the current requestor is considered from an abusive source.
 */
export async function isAbuser(
  request: Partial<express.Request>
): Promise<boolean> {
  if (options.byIP) {
    const ipAddress = getIP(request)

    if (ipAddress !== '') {
      const abusePoints = await getAbusePoints(TABLENAME_IP, ipAddress)

      if (abusePoints >= options.abusePointsMax) {
        return true
      }
    }
  }

  if (options.byXForwardedFor) {
    const ipAddress = getXForwardedFor(request)

    if (ipAddress !== '') {
      const abusePoints = await getAbusePoints(
        TABLENAME_XFORWARDEDFOR,
        ipAddress
      )

      if (abusePoints >= options.abusePointsMax) {
        return true
      }
    }
  }

  return false
}

/**
 * Adds a new abuse record.
 */
export function recordAbuse(
  request: Partial<express.Request>,
  abusePoints: number = options.abusePoints,
  expiryMillis: number = options.expiryMillis
): void {
  const expiryTimeMillis = Date.now() + expiryMillis

  if (options.byIP) {
    const ipAddress = getIP(request)

    if (ipAddress !== '') {
      database.run(
        `INSERT INTO ${TABLENAME_IP} ${TABLECOLUMNS_INSERT} VALUES (?, ?, ?)`,
        ipAddress,
        expiryTimeMillis,
        abusePoints
      )
    }
  }

  if (options.byXForwardedFor) {
    const ipAddress = getXForwardedFor(request)

    if (ipAddress !== '') {
      database.run(
        `INSERT INTO ${TABLENAME_XFORWARDEDFOR} ${TABLECOLUMNS_INSERT} VALUES (?, ?, ?)`,
        ipAddress,
        expiryTimeMillis,
        abusePoints
      )
    }
  }
}

/**
 * Middleware setup function
 */

async function abuseCheckHandler(
  request: express.Request,
  response: express.Response,
  next: express.NextFunction
): Promise<void> {
  const isRequestAbuser = await isAbuser(request)

  if (isRequestAbuser) {
    response.status(403).send('Access temporarily restricted.')

    response.end()
  } else {
    next()
  }
}

export function abuseCheck(
  optionsUser?: AbuseCheckOptions
): express.RequestHandler {
  initialize(optionsUser)
  return abuseCheckHandler as express.RequestHandler
}

export default {
  initialize,
  shutdown,
  recordAbuse,
  isAbuser,
  clearAbuse,
  abuseCheck
}