2020-11-08 08:07:16 -08:00
|
|
|
/*
|
|
|
|
|
Sets the rate limits for the anonymous and paid tiers. Current rate limits:
|
|
|
|
|
- 1000 points in 60 seconds
|
|
|
|
|
- 10 points per call for paid tier (100 RPM)
|
|
|
|
|
- 50 points per call for anonymous tier (20 RPM)
|
|
|
|
|
|
|
|
|
|
Background:
|
|
|
|
|
The rate limits below were originially coded with the idea of charging on a
|
|
|
|
|
per-resource basis. However, that was confusing to end users trying to purchase
|
|
|
|
|
a subscription. So everything was simplied to two tiers: paid and anonymous
|
2021-03-04 19:27:31 -08:00
|
|
|
|
|
|
|
|
CT 3/4/21: I increased the total points from 1,000 to 100,000 to prevent systems
|
|
|
|
|
with Basic Authentication from hitting internal rate limits when calling
|
|
|
|
|
hydrateUtxos().
|
2020-11-08 08:07:16 -08:00
|
|
|
*/
|
|
|
|
|
|
2020-07-27 21:25:31 -07:00
|
|
|
'use strict'
|
|
|
|
|
|
2021-01-03 18:20:49 -08:00
|
|
|
// Public npm libraries.
|
2020-11-12 09:10:35 -08:00
|
|
|
const jwt = require('jsonwebtoken')
|
|
|
|
|
|
2021-01-03 18:20:49 -08:00
|
|
|
// local libraries.
|
2020-07-27 21:25:31 -07:00
|
|
|
const wlogger = require('../util/winston-logging')
|
|
|
|
|
const config = require('../../config')
|
|
|
|
|
|
2021-01-26 12:41:06 -08:00
|
|
|
// Hard coding limits since basic-authentiation is assumed to be the primary access.
|
2020-11-12 09:12:53 -08:00
|
|
|
const ANON_LIMITS = 333
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2021-01-03 18:20:49 -08:00
|
|
|
const WHITELIST_RATE_LIMIT = config.whitelistRateLimit
|
|
|
|
|
const WHITELIST_DOMAINS = config.whitelistDomains
|
|
|
|
|
const INTERNAL_RATE_LIMIT = 1
|
2020-07-27 21:25:31 -07:00
|
|
|
|
|
|
|
|
// Redis
|
|
|
|
|
const redisOptions = {
|
|
|
|
|
enableOfflineQueue: false,
|
|
|
|
|
port: process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379,
|
|
|
|
|
host: process.env.REDIS_HOST ? process.env.REDIS_HOST : '127.0.0.1'
|
|
|
|
|
}
|
|
|
|
|
console.log(`redisOptions: ${JSON.stringify(redisOptions, null, 2)}`)
|
|
|
|
|
|
|
|
|
|
const Redis = require('ioredis')
|
|
|
|
|
const redisClient = new Redis(redisOptions)
|
|
|
|
|
|
|
|
|
|
// Rate limiter middleware lib.
|
|
|
|
|
const { RateLimiterRedis } = require('rate-limiter-flexible')
|
|
|
|
|
const rateLimitOptions = {
|
|
|
|
|
storeClient: redisClient,
|
2020-10-01 17:25:52 -07:00
|
|
|
points: 1000, // Number of points
|
2020-07-27 21:25:31 -07:00
|
|
|
duration: 60 // Per minute (per 60 seconds)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _this
|
|
|
|
|
|
|
|
|
|
class RateLimits {
|
|
|
|
|
constructor () {
|
|
|
|
|
_this = this
|
|
|
|
|
|
|
|
|
|
this.jwt = jwt
|
|
|
|
|
this.rateLimiter = new RateLimiterRedis(rateLimitOptions)
|
|
|
|
|
this.config = config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Used to disconnect from the Redis DB.
|
|
|
|
|
// Called by unit tests so that node.js thread doesn't live forever.
|
|
|
|
|
closeRedis () {
|
|
|
|
|
redisClient.disconnect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async wipeRedis () {
|
|
|
|
|
await redisClient.flushdb()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// This is the new rate limit function that uses the rate-limiter-flexible npm
|
|
|
|
|
// library. It uses fine-grain rate limiting based on the resources being
|
|
|
|
|
// consumed.
|
|
|
|
|
async rateLimitByResource (req, res, next) {
|
|
|
|
|
try {
|
|
|
|
|
let userId
|
|
|
|
|
let decoded = {}
|
|
|
|
|
|
|
|
|
|
// Create a req.locals object if not passed in.
|
|
|
|
|
if (!req.locals) {
|
|
|
|
|
req.locals = {
|
|
|
|
|
// default values
|
|
|
|
|
jwtToken: '',
|
|
|
|
|
proLimit: false,
|
|
|
|
|
apiLevel: 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a res.locals object if it does not exist. This is used for
|
|
|
|
|
// debugging.
|
|
|
|
|
if (!res.locals) {
|
|
|
|
|
res.locals = {
|
|
|
|
|
rateLimitTriggered: false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decode the JWT token if one exists.
|
|
|
|
|
if (req.locals.jwtToken) {
|
|
|
|
|
try {
|
|
|
|
|
decoded = _this.jwt.verify(
|
|
|
|
|
req.locals.jwtToken,
|
|
|
|
|
_this.config.apiTokenSecret
|
|
|
|
|
)
|
|
|
|
|
// console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`)
|
|
|
|
|
|
|
|
|
|
userId = decoded.id
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// This handler will be triggered if the JWT token does not match the
|
|
|
|
|
// token secret.
|
|
|
|
|
wlogger.error(
|
|
|
|
|
`Last three letters of token secret: ${_this.config.apiTokenSecret.slice(
|
|
|
|
|
-3
|
|
|
|
|
)}`
|
|
|
|
|
)
|
|
|
|
|
wlogger.error(
|
|
|
|
|
'Error trying to decode JWT token in route-ratelimit.js/newRateLimit(): ',
|
|
|
|
|
err
|
|
|
|
|
)
|
|
|
|
|
}
|
2021-02-24 17:07:24 -08:00
|
|
|
//
|
|
|
|
|
} else if (req.body && req.body.usrObj) {
|
|
|
|
|
// Same as above, but this code path is activated from internal calls to
|
2021-02-24 18:09:24 -08:00
|
|
|
// bch-js, like hydrateUtxo(), which passes the user object from the
|
2021-02-24 17:07:24 -08:00
|
|
|
// original API call.
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
decoded = _this.jwt.verify(
|
|
|
|
|
req.body.usrObj.jwtToken,
|
|
|
|
|
_this.config.apiTokenSecret
|
|
|
|
|
)
|
|
|
|
|
// console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`)
|
|
|
|
|
|
|
|
|
|
userId = decoded.id
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// This handler will be triggered if the JWT token does not match the
|
|
|
|
|
// token secret.
|
|
|
|
|
wlogger.error(
|
|
|
|
|
'Error in route-ratelimit.js trying to decode JWT token in usrObj'
|
|
|
|
|
)
|
|
|
|
|
}
|
2020-07-27 21:25:31 -07:00
|
|
|
} else {
|
|
|
|
|
wlogger.debug('No JWT token found!')
|
|
|
|
|
}
|
|
|
|
|
|
2020-11-08 08:07:16 -08:00
|
|
|
// Default value is 50 points per request = 20 RPM
|
2020-11-12 09:10:35 -08:00
|
|
|
let rateLimit = ANON_LIMITS
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
// Only evaluate the JWT token if the user is not using Basic Authentication.
|
2021-02-25 07:19:30 -08:00
|
|
|
if (!req.locals.proLimit && !req.body.usrObj.proLimit) {
|
2020-11-11 07:48:40 -08:00
|
|
|
// Code here for the rate limiter is adapted from this example:
|
|
|
|
|
// https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#authorized-and-not-authorized-users
|
|
|
|
|
try {
|
|
|
|
|
// The resource being consumed: full node, indexer, SLPDB, etc.
|
|
|
|
|
const resource = _this.getResource(req.url)
|
|
|
|
|
wlogger.debug(`resource: ${resource}`)
|
2020-11-08 08:07:16 -08:00
|
|
|
|
2021-02-24 14:14:36 -08:00
|
|
|
// Key will be the JWT ID if it exists, otherwise the IP address of the caller.
|
2020-11-11 07:48:40 -08:00
|
|
|
let key = userId || req.ip
|
|
|
|
|
res.locals.key = key // Feedback for tests.
|
2021-02-24 18:09:24 -08:00
|
|
|
// console.log(`key: ${key}`)
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
// const pointsToConsume = userId ? 1 : 30
|
|
|
|
|
decoded.resource = resource
|
|
|
|
|
let pointsToConsume = _this.calcPoints(decoded)
|
2020-09-28 18:32:43 -07:00
|
|
|
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
|
|
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
// Retrieve the origin.
|
|
|
|
|
let origin = req.get('origin')
|
|
|
|
|
|
|
|
|
|
// Handle calls coming from the intranet.
|
|
|
|
|
if (origin === undefined && key.indexOf('10.0.0.5') > -1) {
|
|
|
|
|
origin = 'slp-api'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wlogger.info(`origin: ${origin}`)
|
|
|
|
|
|
|
|
|
|
// If the request originates from one of the approved wallet apps, then
|
|
|
|
|
// apply paid-access rate limits.
|
2021-01-03 18:20:49 -08:00
|
|
|
// console.log(`origin: ${JSON.stringify(origin, null, 2)}`)
|
|
|
|
|
// console.log(`whitelist: ${JSON.stringify(WHITELIST_DOMAINS, null, 2)}`)
|
2021-01-07 10:04:25 -08:00
|
|
|
const isInWhitelist = _this.isInWhitelist(origin)
|
|
|
|
|
if (isInWhitelist) {
|
2021-01-03 18:20:49 -08:00
|
|
|
pointsToConsume = WHITELIST_RATE_LIMIT
|
2020-11-11 07:48:40 -08:00
|
|
|
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For internal calls, increase rate limits to as fast as possible.
|
|
|
|
|
if (
|
2020-11-11 14:56:24 -08:00
|
|
|
// Comment out the line below when running bch-js e2e rate limit tests.
|
2020-11-11 18:59:42 -08:00
|
|
|
key.toString().indexOf('::ffff:127.0.0.1') > -1 ||
|
|
|
|
|
// Do not comment out this line.
|
|
|
|
|
key.toString().indexOf('172.17.') > -1
|
2020-11-11 07:48:40 -08:00
|
|
|
) {
|
2021-01-03 18:20:49 -08:00
|
|
|
pointsToConsume = INTERNAL_RATE_LIMIT
|
2020-11-11 07:48:40 -08:00
|
|
|
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wlogger.info(
|
|
|
|
|
`User ${key} consuming ${pointsToConsume} point for resource ${resource}.`
|
|
|
|
|
)
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2021-03-04 19:27:31 -08:00
|
|
|
rateLimit = Math.floor(100000 / pointsToConsume)
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
// Update the key so that rate limits track both the user and the resource.
|
|
|
|
|
key = `${key}-${resource}`
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
await _this.rateLimiter.consume(key, pointsToConsume)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// console.log('err: ', err)
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
// Used for returning data for tests.
|
|
|
|
|
res.locals.rateLimitTriggered = true
|
|
|
|
|
// console.log('res.locals: ', res.locals)
|
2020-07-27 21:25:31 -07:00
|
|
|
|
2020-11-11 07:48:40 -08:00
|
|
|
// Rate limited was triggered
|
|
|
|
|
res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
|
|
|
|
|
return res.json({
|
|
|
|
|
error: `Too many requests. Your limits are currently ${rateLimit} requests per minute. Increase rate limits at https://fullstack.cash`
|
|
|
|
|
})
|
|
|
|
|
}
|
2020-07-27 21:25:31 -07:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
wlogger.error('Error in route-ratelimit.js/newRateLimit(): ', err)
|
|
|
|
|
// throw err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
next()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculates the points consumed, based on the jwt information and the route
|
|
|
|
|
// requested.
|
|
|
|
|
calcPoints (jwtInfo) {
|
2020-11-12 09:10:35 -08:00
|
|
|
let retVal = ANON_LIMITS // By default, use anonymous tier.
|
2020-07-27 21:25:31 -07:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// console.log(`jwtInfo: ${JSON.stringify(jwtInfo, null, 2)}`)
|
|
|
|
|
|
|
|
|
|
const apiLevel = jwtInfo.apiLevel
|
|
|
|
|
const resource = jwtInfo.resource
|
|
|
|
|
|
|
|
|
|
const level30Routes = ['insight', 'bitcore', 'blockbook', 'electrumx']
|
|
|
|
|
const level40Routes = ['slp']
|
|
|
|
|
|
|
|
|
|
wlogger.debug(`apiLevel: ${apiLevel}`)
|
|
|
|
|
|
|
|
|
|
// Only evaluate if user is using a JWT token.
|
|
|
|
|
if (jwtInfo.id) {
|
|
|
|
|
// SLP indexer routes
|
|
|
|
|
if (level40Routes.includes(resource)) {
|
2020-10-01 17:34:36 -07:00
|
|
|
if (apiLevel >= 40) retVal = 10
|
2020-07-27 21:25:31 -07:00
|
|
|
// else if (apiLevel >= 10) retVal = 10
|
2020-11-12 09:10:35 -08:00
|
|
|
else retVal = ANON_LIMITS
|
2020-07-27 21:25:31 -07:00
|
|
|
|
|
|
|
|
// Normal indexer routes
|
|
|
|
|
} else if (level30Routes.includes(resource)) {
|
2020-10-01 17:34:36 -07:00
|
|
|
if (apiLevel >= 30) retVal = 10
|
2020-11-12 09:10:35 -08:00
|
|
|
else retVal = ANON_LIMITS
|
2020-07-27 21:25:31 -07:00
|
|
|
|
|
|
|
|
// Full node tier
|
|
|
|
|
} else if (apiLevel >= 20) {
|
2020-10-01 17:25:52 -07:00
|
|
|
retVal = 10
|
2020-07-27 21:25:31 -07:00
|
|
|
|
|
|
|
|
// Free tier, full node only.
|
|
|
|
|
} else {
|
2020-11-12 09:10:35 -08:00
|
|
|
retVal = ANON_LIMITS
|
2020-07-27 21:25:31 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return retVal
|
|
|
|
|
} catch (err) {
|
|
|
|
|
wlogger.error('Error in route-ratelimit.js/calcPoints()')
|
|
|
|
|
// throw err
|
2020-11-12 09:10:35 -08:00
|
|
|
retVal = ANON_LIMITS
|
2020-07-27 21:25:31 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return retVal
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// This function parses the req.url property to identify what resource
|
|
|
|
|
// the user is requesting.
|
|
|
|
|
// This was created as a function so that it can be unit tested. Not sure
|
|
|
|
|
// what kind of variations will be seen in production.
|
|
|
|
|
getResource (url) {
|
|
|
|
|
try {
|
|
|
|
|
wlogger.debug(`url: ${JSON.stringify(url, null, 2)}`)
|
|
|
|
|
|
|
|
|
|
const splitUrl = url.split('/')
|
|
|
|
|
const resource = splitUrl[1]
|
|
|
|
|
|
|
|
|
|
return resource
|
|
|
|
|
} catch (err) {
|
|
|
|
|
wlogger.error('Error in getResource().')
|
|
|
|
|
throw err
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-01-03 18:20:49 -08:00
|
|
|
|
|
|
|
|
// Returns a boolean if the origin of the request matches a domain in the
|
|
|
|
|
// whitelist.
|
|
|
|
|
isInWhitelist (origin) {
|
|
|
|
|
try {
|
|
|
|
|
const retVal = false // Default value.
|
|
|
|
|
|
|
|
|
|
if (!origin) return false
|
|
|
|
|
|
2021-01-07 09:11:35 -08:00
|
|
|
// console.log(`WHITELIST_DOMAINS: ${JSON.stringify(WHITELIST_DOMAINS, null, 2)}`)
|
|
|
|
|
|
2021-01-03 18:20:49 -08:00
|
|
|
for (let i = 0; i < WHITELIST_DOMAINS.length; i++) {
|
|
|
|
|
const thisDomain = WHITELIST_DOMAINS[i]
|
|
|
|
|
|
2021-01-07 09:11:35 -08:00
|
|
|
if (origin.toString().indexOf(thisDomain) > -1) {
|
2021-01-03 18:20:49 -08:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return retVal
|
|
|
|
|
} catch (err) {
|
2021-02-24 16:50:38 -08:00
|
|
|
wlogger.error(
|
|
|
|
|
'Error in route-ratelimit.js/isInWhitelist(). Returning false by default.'
|
|
|
|
|
)
|
2021-01-07 09:11:35 -08:00
|
|
|
return false
|
2021-01-03 18:20:49 -08:00
|
|
|
}
|
|
|
|
|
}
|
2020-07-27 21:25:31 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = RateLimits
|