Compare commits

..
Author SHA1 Message Date
Chris Troutner eb0fba6924 Bumping internal rate limits 2021-03-04 19:27:31 -08:00
Chris Troutner ed6c612be9 Merge branch 'master' into basic-auth-only 2021-03-04 19:12:17 -08:00
Chris Troutner 9969ccafac Testing proLimits in usrObj 2021-02-25 07:19:30 -08:00
Chris Troutner 5f1e7d88bb Merge branch 'master' into basic-auth-only 2021-02-25 07:08:16 -08:00
Chris Troutner 1a115a0012 Removing debugging line 2021-02-11 13:55:45 -08:00
Chris Troutner 442b802b79 Merge branch 'master' into basic-auth-only 2021-02-11 13:55:27 -08:00
Chris Troutner 9e895bb0bd Debugging validateTxid2 2021-02-11 13:41:08 -08:00
Chris Troutner 0a0644d05c Merge branch 'master' into basic-auth-only 2021-02-11 12:57:08 -08:00
Chris Troutner 1a57177e4a Merge branch 'master' into basic-auth-only 2021-02-11 12:52:24 -08:00
Chris Troutner 039348aa24 Merge branch 'master' into basic-auth-only 2021-02-11 12:11:13 -08:00
Chris Troutner 2c147d518e Changing Dockerfile to basic-auth-only branch 2021-02-06 23:17:58 +01:00
Chris Troutner fb78033a67 Merging from master 2021-01-26 12:41:06 -08:00
Chris Troutner 33d1c74143 Merge branch 'master' into basic-auth-only 2020-12-25 06:07:38 -08:00
Chris Troutner 7b927da53b Merge branch 'master' into basic-auth-only 2020-12-13 09:33:01 -08:00
Chris Troutner 74c8ceb962 Merge branch 'master' into basic-auth-only 2020-12-07 13:11:05 -08:00
Chris Troutner 83a5db5ac7 Merge branch 'master' into basic-auth-only 2020-11-22 10:25:51 -08:00
Chris Troutner a1366bf46f Increasing anon_limits to effectively disable JWT 2020-11-12 09:12:53 -08:00
22 changed files with 1053 additions and 1080 deletions
+2 -7
View File
@@ -10,18 +10,13 @@ const config = {
: 'secret-jwt-token', : 'secret-jwt-token',
// Rate Limits // Rate Limits
anonRateLimit: process.env.ANON_RATE_LIMIT anonRateLimit: process.env.ANON_RATE_LIMIT ? Number(process.env.ANON_RATE_LIMIT) : 50,
? Number(process.env.ANON_RATE_LIMIT)
: 500,
whitelistRateLimit: process.env.WHITELIST_RATE_LIMIT whitelistRateLimit: process.env.WHITELIST_RATE_LIMIT
? Number(process.env.WHITELIST_RATE_LIMIT) ? Number(process.env.WHITELIST_RATE_LIMIT)
: 10, : 10,
pointsPerMinute: process.env.POINTS_PER_MINUTE
? Number(process.env.POINTS_PER_MINUTE)
: 10000,
whitelistDomains: process.env.WHITELIST_DOMAINS whitelistDomains: process.env.WHITELIST_DOMAINS
? process.env.WHITELIST_DOMAINS.split(',') ? process.env.WHITELIST_DOMAINS.split(',')
: ['fullstack.cash', 'psfoundation.cash', '10.0.'] : ['fullstack.cash', 'psfoundation.cash']
} }
module.exports = config module.exports = config
+1
View File
@@ -18,6 +18,7 @@ USER safeuser
WORKDIR /home/safeuser WORKDIR /home/safeuser
RUN git clone https://github.com/Permissionless-Software-Foundation/bch-api RUN git clone https://github.com/Permissionless-Software-Foundation/bch-api
WORKDIR /home/safeuser/bch-api WORKDIR /home/safeuser/bch-api
RUN git checkout basic-auth-only
RUN npm install --silent RUN npm install --silent
# Generate documentation # Generate documentation
+6 -12
View File
@@ -395,9 +395,9 @@
} }
}, },
"@psf/bch-js": { "@psf/bch-js": {
"version": "4.16.1", "version": "4.15.21",
"resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.16.1.tgz", "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.15.21.tgz",
"integrity": "sha512-0rAOLGwxuzCrpe6dbz5nQ9KQTJIJNGzihSVSVfv0PXIeBKQKq+MuEG7u6rZXNeJlLWokosGwf6aysK2EbDGcqA==", "integrity": "sha512-htwod6Xa9Gbn21/EOJe2BhqcrD8bJaEOCHpLC9L6FcWPt4/sFxaRqeiPt55uljOCZwCIQk9KLIhIDWY0sgRycA==",
"requires": { "requires": {
"@psf/bip21": "^2.0.1", "@psf/bip21": "^2.0.1",
"@psf/bip32-utils": "^1.0.0", "@psf/bip32-utils": "^1.0.0",
@@ -5592,12 +5592,6 @@
"integrity": "sha1-+CbJtOKoUR2E46yinbBeGk87cqk=", "integrity": "sha1-+CbJtOKoUR2E46yinbBeGk87cqk=",
"dev": true "dev": true
}, },
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
"dev": true
},
"lodash.defaults": { "lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -12890,9 +12884,9 @@
} }
}, },
"tape": { "tape": {
"version": "5.2.2", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/tape/-/tape-5.2.2.tgz", "resolved": "https://registry.npmjs.org/tape/-/tape-5.2.1.tgz",
"integrity": "sha512-grXrzPC1ly2kyTMKdqxh5GiLpb0BpNctCuecTB0psHX4Gu0nc+uxWR4xKjTh/4CfQlH4zhvTM2/EXmHXp6v/uA==", "integrity": "sha512-pjrC4M7OUCndgKNJ9AEy/WCfOd8Voux6pD/WlzRi0855ZZa66nPFlisCtPixA5Phh/V/tu6v8Q1cNRND9AcYMA==",
"requires": { "requires": {
"call-bind": "^1.0.2", "call-bind": "^1.0.2",
"deep-equal": "^2.0.5", "deep-equal": "^2.0.5",
+3 -4
View File
@@ -22,14 +22,14 @@
"coverage": "nyc report --reporter=text-lcov | coveralls", "coverage": "nyc report --reporter=text-lcov | coveralls",
"coverage:report": "export NETWORK=mainnet && nyc --reporter=html mocha --timeout 25000 test/v4/", "coverage:report": "export NETWORK=mainnet && nyc --reporter=html mocha --timeout 25000 test/v4/",
"docs": "./node_modules/.bin/apidoc -i src/routes/v4 -o docs", "docs": "./node_modules/.bin/apidoc -i src/routes/v4 -o docs",
"test:temp1": "export NETWORK=mainnet && export TEST=integration && mocha --exit --timeout 25000 -g '#hydrateUtxos-' test/v4/integration/", "test:temp1": "export NETWORK=mainnet && export TEST=integration && mocha --exit --timeout 25000 -g '#hydrateUtxosWL' test/v4/integration/",
"test:temp2": "mocha test/v4/rate-limit2-unit.js" "test:temp2": "mocha test/v4/rate-limits.js"
}, },
"engines": { "engines": {
"node": ">=10.15.1" "node": ">=10.15.1"
}, },
"dependencies": { "dependencies": {
"@psf/bch-js": "^4.16.1", "@psf/bch-js": "^4.15.21",
"apidoc": "^0.26.0", "apidoc": "^0.26.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"bitcore-lib-cash": "^8.23.1", "bitcore-lib-cash": "^8.23.1",
@@ -66,7 +66,6 @@
"eslint-plugin-prettier": "^3.1.0", "eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-standard": "^4.0.0", "eslint-plugin-standard": "^4.0.0",
"fs-extra": "^9.0.0", "fs-extra": "^9.0.0",
"lodash.clonedeep": "^4.5.0",
"nock": "^13.0.5", "nock": "^13.0.5",
"nyc": "^15.0.0", "nyc": "^15.0.0",
"prettier": "^2.0.0", "prettier": "^2.0.0",
+10 -28
View File
@@ -3,6 +3,7 @@
const express = require('express') const express = require('express')
// Middleware // Middleware
// const { routeRateLimit } = require("./middleware/route-ratelimit")
const RateLimits = require('./middleware/route-ratelimit') const RateLimits = require('./middleware/route-ratelimit')
const rateLimits = new RateLimits() const rateLimits = new RateLimits()
@@ -95,36 +96,17 @@ app.use('/', logReqInfo)
const v4prefix = 'v4' const v4prefix = 'v4'
// START Rate Limits // Inspect the header for a JWT token.
app.use(`/${v4prefix}/`, jwtAuth.getTokenFromHeaders)
// Instantiate the authorization middleware, used to implement pro-tier rate limiting.
// Handles Anonymous and Basic Authorization schemes used by passport.js
const auth = new AuthMW() const auth = new AuthMW()
app.use(`/${v4prefix}/`, auth.mw())
// Ensure req.locals and res.locals objects exist. // Rate limit on all v4 routes
app.use(`/${v4prefix}/`, rateLimits.populateLocals) // Establish and enforce rate limits.
app.use(`/${v4prefix}/`, rateLimits.rateLimitByResource)
// Allow users to turn off rate limits with an environment variable.
const DO_NOT_USE_RATE_LIMITS = process.env.DO_NOT_USE_RATE_LIMITS || false
console.log(`DO_NOT_USE_RATE_LIMITS: ${DO_NOT_USE_RATE_LIMITS}`)
if (!DO_NOT_USE_RATE_LIMITS) {
console.log('Rate limits are being used')
// Inspect the header for a JWT token.
app.use(`/${v4prefix}/`, jwtAuth.getTokenFromHeaders)
// Instantiate the authorization middleware, used to implement pro-tier rate limiting.
// Handles Anonymous and Basic Authorization schemes used by passport.js
app.use(`/${v4prefix}/`, auth.mw())
// Experimental rate limits
app.use(`/${v4prefix}/`, rateLimits.applyRateLimits)
// Rate limit on all v4 routes
// Establish and enforce rate limits.
// app.use(`/${v4prefix}/`, rateLimits.rateLimitByResource)
} else {
console.log('Rate limits are NOT being used')
}
// END Rate Limits
// Connect v4 routes // Connect v4 routes
app.use(`/${v4prefix}/` + 'health-check', healthCheckV4) app.use(`/${v4prefix}/` + 'health-check', healthCheckV4)
+8 -12
View File
@@ -1,25 +1,22 @@
/* /*
This library handles anonymous and Basic Authentication. CT 2/4/20 Note: This library handles anonymous and Basic auth. This library
can be phased out with the chage to JWT tokens and the new rate-limit library.
Handle authorization for bypassing rate limits.
1) Default is 'Anonymous Authentication', which unlocks the freemimum tier by 1) Default is 'Anonymous Authentication', which unlocks the freemimum tier by
default. default.
2) Hard-coded 'Basic Authentication' is a token that does not expire and is 2) Hard-coded 'Basic Authentication' is a token that does not expire and is
provided for clients who run their own isolated infrastructure without rate provided to buisiness partners.
limits, but still need a way from preventing the random public from using
their API.
3) JWT-based 'Local Authentication' is used for normal users that pay to 3) JWT-based 'Local Authentication' is used for normal users that pay to
access the premium pro-tier services. access the premium pro-tier services.
This file uses the passport npm library to check the header of each REST API This file uses the passport npm library to check the header of each REST API
call for the prescence of a Basic Authentication header: call for the prescence of a Basic authorization header:
https://en.wikipedia.org/wiki/Basic_access_authentication https://en.wikipedia.org/wiki/Basic_access_authentication
If the header is found and validated, the req.locals.proLimit Boolean value If the header is found and validated, the req.locals.proLimit Boolean value
is set and passed to the route-ratelimit.js middleware. route-ratelimit.js is set and passed to the route-ratelimits.ts middleware.
is for fine-grain JWT-based rate limits. If req.locals.proLimit is set to
true, then those rate limits will be skipped.
*/ */
'use strict' 'use strict'
@@ -79,9 +76,8 @@ class AuthMW {
req.locals.proLimit = false req.locals.proLimit = false
// Evaluate the username and password and set the rate limit accordingly. // Evaluate the username and password and set the rate limit accordingly.
// if (username === "BITBOX" && password === PRO_PASS) {
if (username === 'fullstackcash') { if (username === 'fullstackcash') {
// Can set several different passwords in the environment variable.
// Loop through each one to see if one matches.
for (let i = 0; i < PRO_PASS.length; i++) { for (let i = 0; i < PRO_PASS.length; i++) {
const thisPass = PRO_PASS[i] const thisPass = PRO_PASS[i]
+225 -318
View File
@@ -1,62 +1,55 @@
/* /*
This file will replace the original rate-limit.js file. 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)
Sets the rate limits for the anonymous and paid tiers. Current rate limits: Background:
- 10000 points in 60 seconds The rate limits below were originially coded with the idea of charging on a
- 500 points per call for anonymous tier (20 RPM) per-resource basis. However, that was confusing to end users trying to purchase
- 100 points per call for tier 40 (100 RPM) a subscription. So everything was simplied to two tiers: paid and anonymous
- 40 points per call for tier 50 (250 RPM)
- 16 points per call for tier 60 (625 RPM)
The rate limit handling is designed for these four use cases: CT 3/4/21: I increased the total points from 1,000 to 100,000 to prevent systems
- Users who want to buy a JWT token for 24 hour access. with Basic Authentication from hitting internal rate limits when calling
- Users who want to buy different RPM tiers: 100, 250, 600 hydrateUtxos().
- Basic Authentication which should not have any rate limits applied.
- Local installations that do not want any authentication or rate limits at all.
The Basic Auth use cases is considered when determining internal rate limits.
The internal rate limits should not be applied to calls from those users.
A lot of attention has been paid to passing rate-limit information for the user
when they trigger an endpoint that makes a lot of internal API calls. Examples
are hydrateUtxos() and getPublicKey(). These keeps things fair by charging the
same for 'light' API calls and 'heavy' API calls.
TODO:
- Add code for applying rate limits to whitelist domains.
*/ */
'use strict'
// Public npm libraries. // Public npm libraries.
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const Redis = require('ioredis')
const { RateLimiterRedis } = require('rate-limiter-flexible')
// local libraries. // local libraries.
const wlogger = require('../util/winston-logging') const wlogger = require('../util/winston-logging')
const config = require('../../config') const config = require('../../config')
let _this // Global pointer to instance of class, when 'this' context is lost. // Hard coding limits since basic-authentiation is assumed to be the primary access.
const ANON_LIMITS = 333
// Setup Redis to track rate limits for each user. const WHITELIST_RATE_LIMIT = config.whitelistRateLimit
const WHITELIST_DOMAINS = config.whitelistDomains
const INTERNAL_RATE_LIMIT = 1
// Redis
const redisOptions = { const redisOptions = {
enableOfflineQueue: false, enableOfflineQueue: false,
port: process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379, port: process.env.REDIS_PORT ? process.env.REDIS_PORT : 6379,
host: process.env.REDIS_HOST ? process.env.REDIS_HOST : '127.0.0.1' 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) const redisClient = new Redis(redisOptions)
// Rate limiter middleware lib.
const { RateLimiterRedis } = require('rate-limiter-flexible')
const rateLimitOptions = { const rateLimitOptions = {
storeClient: redisClient, storeClient: redisClient,
points: config.pointsPerMinute, // Number of points points: 1000, // Number of points
duration: 60 // Per minute (per 60 seconds) duration: 60 // Per minute (per 60 seconds)
} }
// Constants let _this
const ANON_LIMITS = config.anonRateLimit
// const WHITELIST_RATE_LIMIT = config.whitelistRateLimit
const WHITELIST_DOMAINS = config.whitelistDomains
const WHITELIST_POINTS_TO_CONSUME = config.whitelistRateLimit
const POINTS_PER_MINUTE = config.pointsPerMinute
const INTERNAL_POINTS_TO_CONSUME = 10
class RateLimits { class RateLimits {
constructor () { constructor () {
@@ -67,249 +60,248 @@ class RateLimits {
this.config = config this.config = config
} }
// This is the main middleware funciton of this library. All other functions // Used to disconnect from the Redis DB.
// support this function. // Called by unit tests so that node.js thread doesn't live forever.
async applyRateLimits (req, res, next) { 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 { try {
// Exit if the user has already authenticated with Basic Authentication. let userId
if (req.locals.proLimit) { let decoded = {}
console.log('External call, basic auth, skipping rate limits.')
wlogger.debug( // Create a req.locals object if not passed in.
'req.locals.proLimit = true; Using Basic Authentication instead of rate limits' if (!req.locals) {
) req.locals = {
return next() // default values
jwtToken: '',
proLimit: false,
apiLevel: 0
}
} }
// Determine if the call is an external or internal API call. // Create a res.locals object if it does not exist. This is used for
const isInternal = _this.checkInternalIp(req) // debugging.
console.log(`isInternal: ${isInternal}`) if (!res.locals) {
res.locals = {
rateLimitTriggered: false
}
}
// Determine if the call originates from another computer on the intranet. // Decode the JWT token if one exists.
const isWhitelistOrigin = _this.isInWhitelist(req) if (req.locals.jwtToken) {
console.log('isWhitelistOrigin: ', isWhitelistOrigin) try {
decoded = _this.jwt.verify(
req.locals.jwtToken,
_this.config.apiTokenSecret
)
// console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`)
// Handle the use case of internally-generated requests. userId = decoded.id
if (isInternal) { } catch (err) {
// Internal API calls should pass the authentication data in through the // This handler will be triggered if the JWT token does not match the
// the usrObj in the body. // token secret.
if (req.body && req.body.usrObj) { wlogger.error(
if (req.body.usrObj.proLimit) { `Last three letters of token secret: ${_this.config.apiTokenSecret.slice(
console.log('Internal call, basic auth, skipping rate limits.') -3
)}`
)
wlogger.error(
'Error trying to decode JWT token in route-ratelimit.js/newRateLimit(): ',
err
)
}
//
} else if (req.body && req.body.usrObj) {
// Same as above, but this code path is activated from internal calls to
// bch-js, like hydrateUtxo(), which passes the user object from the
// original API call.
// If this is an internal call that originated from a user using try {
// Basic Authentication, then skip rate-limits. decoded = _this.jwt.verify(
return next() req.body.usrObj.jwtToken,
} else { _this.config.apiTokenSecret
console.log( )
'Internal call, applying rate limits. Using JWT if available.' // console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`)
)
// Determine if user has exceeded their rate limits. Pass in the userId = decoded.id
// JWT token if one exists. } catch (err) {
const hasExceededRateLimit = await _this.trackRateLimits( // This handler will be triggered if the JWT token does not match the
req, // token secret.
res, wlogger.error(
req.body.usrObj.jwtToken 'Error in route-ratelimit.js trying to decode JWT token in usrObj'
) )
}
} else {
wlogger.debug('No JWT token found!')
}
if (!hasExceededRateLimit) { // Default value is 50 points per request = 20 RPM
// Rate limits have not been exceeded. Processing can continue. let rateLimit = ANON_LIMITS
return next()
} else {
// trackRateLimits() returns the 'res' object with an error message
// and status code.
return hasExceededRateLimit
}
}
} else {
// This should be a corner case. Calls should not be going into this
// code path, so the system should throw up big warning signs when they
// do.
// This code path happens when an internal call is made but does not
// pass the usrObj. Legacy code needs to be refactored to use the usrObj
// and avoid this code path. This code path is 'pooled': all users
// share the same rate limits. Even at 1000 RPM, this pool will get
// exhausted easily.
const warnMsg =
'Internal call. req.body.usrObj does not exist. Applying high-speed internal rate limits.'
console.log(warnMsg)
wlogger.info(warnMsg)
const defaultPayload = { // Only evaluate the JWT token if the user is not using Basic Authentication.
id: '98.76.54.32', if (!req.locals.proLimit && !req.body.usrObj.proLimit) {
email: 'internal@bchtest.net', // Code here for the rate limiter is adapted from this example:
apiLevel: 40, // https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#authorized-and-not-authorized-users
rateLimit: 100, try {
pointsToConsume: INTERNAL_POINTS_TO_CONSUME, // The resource being consumed: full node, indexer, SLPDB, etc.
duration: 30 const resource = _this.getResource(req.url)
wlogger.debug(`resource: ${resource}`)
// Key will be the JWT ID if it exists, otherwise the IP address of the caller.
let key = userId || req.ip
res.locals.key = key // Feedback for tests.
// console.log(`key: ${key}`)
// const pointsToConsume = userId ? 1 : 30
decoded.resource = resource
let pointsToConsume = _this.calcPoints(decoded)
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
// 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'
} }
// Default values, in case there is an error. wlogger.info(`origin: ${origin}`)
const defaultJwt = _this.generateJwtToken(defaultPayload)
// Track the rate limit for this user. Pass in the JWT token, if one // If the request originates from one of the approved wallet apps, then
// is available. // apply paid-access rate limits.
const hasExceededRateLimit = await _this.trackRateLimits( // console.log(`origin: ${JSON.stringify(origin, null, 2)}`)
req, // console.log(`whitelist: ${JSON.stringify(WHITELIST_DOMAINS, null, 2)}`)
res, const isInWhitelist = _this.isInWhitelist(origin)
defaultJwt if (isInWhitelist) {
pointsToConsume = WHITELIST_RATE_LIMIT
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
}
// For internal calls, increase rate limits to as fast as possible.
if (
// Comment out the line below when running bch-js e2e rate limit tests.
key.toString().indexOf('::ffff:127.0.0.1') > -1 ||
// Do not comment out this line.
key.toString().indexOf('172.17.') > -1
) {
pointsToConsume = INTERNAL_RATE_LIMIT
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
}
wlogger.info(
`User ${key} consuming ${pointsToConsume} point for resource ${resource}.`
) )
if (!hasExceededRateLimit) { rateLimit = Math.floor(100000 / pointsToConsume)
// Rate limits have not been exceeded. Processing can continue.
return next()
} else {
// trackRateLimits() returns the 'res' object with an error message
// and status code.
return hasExceededRateLimit
}
}
//
//
} else {
// Handle the normal use-case of external requests
console.log(
'External call, applying rate limits. Using JWT if available.'
)
// For calls originating from a whitelist domain, apply a high-RPM // Update the key so that rate limits track both the user and the resource.
// JWT token to the call. key = `${key}-${resource}`
if (isWhitelistOrigin) {
const defaultPayload = {
id: '77.77.77.77',
email: 'whitelist@bchtest.net',
apiLevel: 40,
rateLimit: 100,
pointsToConsume: WHITELIST_POINTS_TO_CONSUME,
duration: 30
}
// Inject the high-RPM JWT token into the call. await _this.rateLimiter.consume(key, pointsToConsume)
req.locals.jwtToken = _this.generateJwtToken(defaultPayload) } catch (err) {
} // console.log('err: ', err)
// Track the rate limit for this user. Pass in the JWT token, if one // Used for returning data for tests.
// is available. res.locals.rateLimitTriggered = true
const hasExceededRateLimit = await _this.trackRateLimits( // console.log('res.locals: ', res.locals)
req,
res,
req.locals.jwtToken
)
if (!hasExceededRateLimit) { // Rate limited was triggered
// Rate limits have not been exceeded. Processing can continue. res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return next() return res.json({
} else { error: `Too many requests. Your limits are currently ${rateLimit} requests per minute. Increase rate limits at https://fullstack.cash`
// trackRateLimits() returns the 'res' object with an error message })
// and status code.
return hasExceededRateLimit
} }
} }
} catch (err) { } catch (err) {
wlogger.error('Error in route-ratelimit2.js/applyRateLimits(): ', err) wlogger.error('Error in route-ratelimit.js/newRateLimit(): ', err)
// throw err
} }
// By default, move to the next middleware.
next() next()
} }
// A wrapper for Redis-based rate limiter. // Calculates the points consumed, based on the jwt information and the route
// Will return false if the user has not exceeded the rate limit. Otherwise // requested.
// it will return the 'res' object with an error status and message, which calcPoints (jwtInfo) {
// should be returned by the middleware. let retVal = ANON_LIMITS // By default, use anonymous tier.
async trackRateLimits (req, res, jwtToken) {
// Anonymous rate limits are used by default.
let pointsToConsume = ANON_LIMITS
let key = req.ip // Use the IP address as the key, by default.
try { try {
// Decode the JWT token if it exists // console.log(`jwtInfo: ${JSON.stringify(jwtInfo, null, 2)}`)
if (jwtToken) {
const decoded = _this.decodeJwtToken(jwtToken)
// console.log(`decoded: ${JSON.stringify(decoded, null, 2)}`)
// Preferentially use the decoded ID in the JWT payload, as the key. const apiLevel = jwtInfo.apiLevel
key = decoded.id const resource = jwtInfo.resource
pointsToConsume = decoded.pointsToConsume 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)) {
if (apiLevel >= 40) retVal = 10
// else if (apiLevel >= 10) retVal = 10
else retVal = ANON_LIMITS
// Normal indexer routes
} else if (level30Routes.includes(resource)) {
if (apiLevel >= 30) retVal = 10
else retVal = ANON_LIMITS
// Full node tier
} else if (apiLevel >= 20) {
retVal = 10
// Free tier, full node only.
} else {
retVal = ANON_LIMITS
}
} }
console.log(`rate limit key: ${key}`)
// This function will throw an error if the user exceeds the rate limit. return retVal
// The 429 error response is handled by the catch().
await _this.rateLimiter.consume(key, pointsToConsume)
res.locals.pointsToConsume = pointsToConsume // Feedback for tests.
// Signal that the user has not exceeded their rate limits.
return false
} catch (err) { } catch (err) {
console.log('err: ', err) wlogger.error('Error in route-ratelimit.js/calcPoints()')
// throw err
const rateLimit = Math.floor(POINTS_PER_MINUTE / pointsToConsume) retVal = ANON_LIMITS
res.locals.rateLimitTriggered = true
// console.log('res.locals: ', res.locals)
// 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`
})
} }
return retVal
} }
// Attempts to decode a JWT token. Returns default values if it fails. // This function parses the req.url property to identify what resource
decodeJwtToken (jwtToken) { // the user is requesting.
const defaultPayload = { // This was created as a function so that it can be unit tested. Not sure
id: '123.456.789.10', // what kind of variations will be seen in production.
email: 'test@bchtest.net', getResource (url) {
apiLevel: 10,
rateLimit: 3,
pointsToConsume: ANON_LIMITS,
duration: 30
}
try { try {
// Default values, in case there is an error. wlogger.debug(`url: ${JSON.stringify(url, null, 2)}`)
const defaultJwt = _this.generateJwtToken(defaultPayload)
// Generate a default payload to use, if the decoding of the user-provided const splitUrl = url.split('/')
// jwt fails. const resource = splitUrl[1]
let decoded = _this.jwt.verify(defaultJwt, _this.config.apiTokenSecret)
try { return resource
decoded = _this.jwt.verify(jwtToken, _this.config.apiTokenSecret)
} catch (err) {
wlogger.error('Error in route-ratelimit2.js/decodeJwtTokens(): ', err)
}
return decoded
} catch (err) { } catch (err) {
wlogger.error( wlogger.error('Error in getResource().')
'Unhandled error in route-ratelimit2.js/deocdeJwtToken: ', throw err
err
)
// Making sure there is an exp property. Not sure if this will cause an
// issue, using a hard-coded value.
defaultPayload.exp = 1574269450
return defaultPayload
} }
} }
// Returns a boolean if the origin of the request matches a domain in the // Returns a boolean if the origin of the request matches a domain in the
// whitelist. // whitelist.
isInWhitelist (req) { isInWhitelist (origin) {
try { try {
const retVal = false // Default value. const retVal = false // Default value.
// Retrieve the origin.
const origin = req.get('origin')
console.log(`origin: ${origin}`)
// If the origin is not determinable, return false.
if (!origin) return false if (!origin) return false
// console.log(`WHITELIST_DOMAINS: ${JSON.stringify(WHITELIST_DOMAINS, null, 2)}`) // console.log(`WHITELIST_DOMAINS: ${JSON.stringify(WHITELIST_DOMAINS, null, 2)}`)
@@ -317,7 +309,9 @@ class RateLimits {
for (let i = 0; i < WHITELIST_DOMAINS.length; i++) { for (let i = 0; i < WHITELIST_DOMAINS.length; i++) {
const thisDomain = WHITELIST_DOMAINS[i] const thisDomain = WHITELIST_DOMAINS[i]
if (origin.includes(thisDomain)) return true if (origin.toString().indexOf(thisDomain) > -1) {
return true
}
} }
return retVal return retVal
@@ -328,93 +322,6 @@ class RateLimits {
return false return false
} }
} }
// Checks the request object to see if it's IP address matches an internal
// IP address. That means the call is an internal API call and should be
// treated differently than an external API call.
checkInternalIp (req) {
try {
// Default value
let isInternal = false
const ip = req.ip
if (ip.includes('127.0.0.1')) isInternal = true
if (ip.includes('172.17.')) isInternal = true
// TODO: Add 192.168.
return isInternal
} catch (err) {
wlogger.error(
'Error in checkInternalIp(). Returning false be default. Err: ',
err
)
return false
}
}
// Used to disconnect from the Redis DB.
// Called by unit tests so that node.js thread doesn't live forever.
closeRedis () {
redisClient.disconnect()
}
// Clear the redis database. Used by unit tests.
async wipeRedis () {
await redisClient.flushdb()
}
// Generates a JWT token for testing purposes. This is not used in production.
// This function mirrors the kind of JWT token that would be generated by
// jwt-bch-api.
generateJwtToken (payload) {
try {
const jwtOptions = {
expiresIn: '30 days'
}
const token = _this.jwt.sign(
payload,
_this.config.apiTokenSecret,
jwtOptions
)
return token
} catch (err) {
console.error('Error in generateJwtToken()')
throw err
}
}
// Called when rate limits are not used.
populateLocals (req, res, next) {
try {
// Create a re*Q*.locals object if not passed in.
// req.locals.proLimit will be true if the user is using Basic Authentication.
if (!req.locals) {
req.locals = {
// default values
jwtToken: '',
proLimit: false,
apiLevel: 0
}
}
// Create a re*S*.locals object if it does not exist.
if (!res.locals) {
res.locals = {
rateLimitTriggered: false
}
}
next()
} catch (err) {
console.error('Error in populateLocals(): ', err)
throw err
}
}
} }
module.exports = RateLimits module.exports = RateLimits
+3 -3
View File
@@ -169,7 +169,7 @@ class Blockbook {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -333,7 +333,7 @@ class Blockbook {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -472,7 +472,7 @@ class Blockbook {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, txids)) { if (!_this.routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
+6 -6
View File
@@ -284,7 +284,7 @@ class Electrum {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -470,7 +470,7 @@ class Electrum {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, txids)) { if (!_this.routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
success: false, success: false,
error: 'Array too large.' error: 'Array too large.'
@@ -726,7 +726,7 @@ class Electrum {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, heights)) { if (!_this.routeUtils.validateArraySize(req, heights)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
success: false, success: false,
error: 'Array too large.' error: 'Array too large.'
@@ -895,7 +895,7 @@ class Electrum {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -1088,7 +1088,7 @@ class Electrum {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -1281,7 +1281,7 @@ class Electrum {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
+6 -6
View File
@@ -114,17 +114,16 @@ class Encryption {
}) })
} }
// console.log(
wlogger.debug( wlogger.debug(
'Executing encryption/getPublicKey with this address: ', 'Executing encryption/getPublicKey with this address: ',
cashAddr cashAddr
) )
const rawTxData = await _this.bchjs.Electrumx.transactions([cashAddr], usrObj) const rawTxData = await _this.bchjs.Electrumx.transactions(cashAddr, usrObj)
// console.log(`rawTxData: ${JSON.stringify(rawTxData, null, 2)}`) // console.log(`rawTxData: ${JSON.stringify(rawTxData, null, 2)}`)
// Extract just the TXIDs // Extract just the TXIDs
const txids = rawTxData.transactions[0].transactions.map((elem) => elem.tx_hash) const txids = rawTxData.transactions.map((elem) => elem.tx_hash)
// console.log(`txids: ${JSON.stringify(txids, null, 2)}`) // console.log(`txids: ${JSON.stringify(txids, null, 2)}`)
// throw error if there is no transaction history. // throw error if there is no transaction history.
@@ -136,14 +135,16 @@ class Encryption {
for (let i = 0; i < txids.length; i++) { for (let i = 0; i < txids.length; i++) {
const thisTx = txids[i] const thisTx = txids[i]
// CT 2/24/21: I might want to convert this to the POST call, to take
// advantage of the usrObj. It does not get passed in a GET call.
const txDetails = await _this.bchjs.RawTransactions.getRawTransaction( const txDetails = await _this.bchjs.RawTransactions.getRawTransaction(
[thisTx], thisTx,
true, true,
usrObj usrObj
) )
// console.log(`txDetails: ${JSON.stringify(txDetails, null, 2)}`) // console.log(`txDetails: ${JSON.stringify(txDetails, null, 2)}`)
const vin = txDetails[0].vin const vin = txDetails.vin
// Loop through each input. // Loop through each input.
for (let j = 0; j < vin.length; j++) { for (let j = 0; j < vin.length; j++) {
@@ -181,7 +182,6 @@ class Encryption {
publicKey: 'not found' publicKey: 'not found'
}) })
} catch (err) { } catch (err) {
console.log('Error in encryption.js/getPublicKey().', err)
wlogger.error('Error in encryption.js/getPublicKey().', err) wlogger.error('Error in encryption.js/getPublicKey().', err)
return _this.errorHandler(err, res) return _this.errorHandler(err, res)
+4 -4
View File
@@ -284,7 +284,7 @@ class Blockchain {
// Enforce array size rate limits // Enforce array size rate limits
if (!routeUtils.validateArraySize(req, hashes)) { if (!routeUtils.validateArraySize(req, hashes)) {
res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 res.status(429) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -457,7 +457,7 @@ class Blockchain {
// Enforce array size rate limits // Enforce array size rate limits
if (!routeUtils.validateArraySize(req, txids)) { if (!routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 res.status(429) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -794,7 +794,7 @@ class Blockchain {
// Enforce array size rate limits // Enforce array size rate limits
if (!routeUtils.validateArraySize(req, txids)) { if (!routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 res.status(429) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -888,7 +888,7 @@ class Blockchain {
// Enforce array size rate limits // Enforce array size rate limits
if (!routeUtils.validateArraySize(req, proofs)) { if (!routeUtils.validateArraySize(req, proofs)) {
res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 res.status(429) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
+12 -4
View File
@@ -118,7 +118,7 @@ class RawTransactions {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, hexes)) { if (!_this.routeUtils.validateArraySize(req, hexes)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -234,7 +234,7 @@ class RawTransactions {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, hexes)) { if (!_this.routeUtils.validateArraySize(req, hexes)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -324,12 +324,20 @@ class RawTransactions {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, txids)) { if (!_this.routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
} }
// stub response object
// const returnResponse = {
// status: 100,
// json: {
// error: ''
// }
// }
// Validate each txid in the array. // Validate each txid in the array.
for (let i = 0; i < txids.length; i++) { for (let i = 0; i < txids.length; i++) {
const txid = txids[i] const txid = txids[i]
@@ -440,7 +448,7 @@ class RawTransactions {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, hexes)) { if (!_this.routeUtils.validateArraySize(req, hexes)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
+192
View File
@@ -0,0 +1,192 @@
/*
A private library of utility functions used by several different routes.
*/
'use strict'
const axios = require('axios')
const wlogger = require('../../util/winston-logging')
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
const BCHJS = require('@psf/bch-js')
const bchjs = new BCHJS()
module.exports = {
validateNetwork, // Prevents a common user error
setEnvVars, // Allows RPC variables to be set dynamically based on changing env vars.
decodeError, // Extract and interpret error messages.
validateArraySize, // Ensure the passed array meets rate limiting requirements.
getAxiosOptions
}
// This function expects the Request Express.js object and an array as input.
// The array is then validated against freemium and pro-tier rate limiting
// requirements. A boolean is returned to indicate if the array size if valid
// or not.
function validateArraySize (req, array) {
const FREEMIUM_INPUT_SIZE = 20
const PRO_INPUT_SIZE = 20
if (req.locals && req.locals.proLimit) {
if (array.length <= PRO_INPUT_SIZE) return true
} else if (array.length <= FREEMIUM_INPUT_SIZE) {
return true
}
return false
}
// Returns true if user-provided cash address matches the correct network,
// mainnet or testnet. If NETWORK env var is not defined, it returns false.
// This prevent a common user-error issue that is easy to make: passing a
// testnet address into rest.bitcoin.com or passing a mainnet address into
// trest.bitcoin.com.
function validateNetwork (addr) {
try {
const network = process.env.NETWORK
// Return false if NETWORK is not defined.
if (!network || network === '') {
console.log('Warning: NETWORK environment variable is not defined!')
return false
}
// Convert the user-provided address to a cashaddress, for easy detection
// of the intended network.
const cashAddr = bchjs.Address.toCashAddress(addr)
// Return true if the network and address both match testnet
const addrIsTest = bchjs.Address.isTestnetAddress(cashAddr)
if (network === 'testnet' && addrIsTest) return true
// Return true if the network and address both match mainnet
const addrIsMain = bchjs.Address.isMainnetAddress(cashAddr)
if (network === 'mainnet' && addrIsMain) return true
return false
} catch (err) {
wlogger.error('Error in validateNetwork()')
return false
}
}
// Dynamically set these based on env vars. Allows unit testing.
function setEnvVars () {
const BitboxHTTP = axios.create({
baseURL: process.env.RPC_BASEURL,
timeout: 15000
})
const username = process.env.RPC_USERNAME
const password = process.env.RPC_PASSWORD
const requestConfig = {
method: 'post',
auth: {
username: username,
password: password
},
data: {
jsonrpc: '1.0'
}
}
return { BitboxHTTP, username, password, requestConfig }
}
// Axios options used when calling axios.post() to talk with a full node.
function getAxiosOptions () {
return {
method: 'post',
baseURL: process.env.RPC_BASEURL,
timeout: 15000,
auth: {
username: process.env.RPC_USERNAME,
password: process.env.RPC_PASSWORD
},
data: {
jsonrpc: '1.0'
}
}
}
// Error messages returned by a full node can be burried pretty deep inside the
// error object returned by Axios. This function attempts to extract and interpret
// error messages.
// Returns an object. If successful, obj.msg is a string.
// If there is a failure, obj.msg is false.
function decodeError (err) {
try {
// Attempt to extract the full node error message.
if (
err.response &&
err.response.data &&
err.response.data.error &&
err.response.data.error.message
) {
return { msg: err.response.data.error.message, status: 400 }
}
// Attempt to extract the Insight error message
if (err.response && err.response.data) {
return { msg: err.response.data, status: err.response.status }
}
// console.log(`err.message: ${err.message}`)
// console.log(`err: `, err)
// Attempt to detect a network connection error.
if (err.message && err.message.indexOf('ENOTFOUND') > -1) {
return {
msg:
'Network error: Could not communicate with full node or other external service.',
status: 503
}
}
// Different kind of network error
if (err.message && err.message.indexOf('ENETUNREACH') > -1) {
return {
msg:
'Network error: Could not communicate with full node or other external service.',
status: 503
}
}
// Different kind of network error
if (err.message && err.message.indexOf('EAI_AGAIN') > -1) {
return {
msg:
'Network error: Could not communicate with full node or other external service.',
status: 503
}
}
// Axios timeout (aborted) error, or service is down (connection refused).
if (
err.code &&
(err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')
) {
return {
msg:
'Network error: Could not communicate with full node or other external service.',
status: 503
}
}
// Handle general Error objects.
if (err.message) {
return {
message: err.message,
status: 422
}
}
return { msg: false, status: 500 }
} catch (err) {
console.error('unhandled error in route-utils.js/decodeError(): ', err)
wlogger.error('unhandled error in route-utils.js/decodeError(): ', err)
return { msg: false, status: 500 }
}
}
+15 -41
View File
@@ -104,13 +104,8 @@ class Slp {
// DRY error handler. // DRY error handler.
errorHandler (err, res) { errorHandler (err, res) {
// console.error('Entering slp.js/errorHandler(). err: ', err)
// Attempt to decode the error message. // Attempt to decode the error message.
const { msg, status } = _this.routeUtils.decodeError(err) const { msg, status } = _this.routeUtils.decodeError(err)
console.log('slp.js/errorHandler msg from decodeError: ', msg)
console.log('slp.js/errorHandler status from decodeError: ', status)
if (msg) { if (msg) {
res.status(status) res.status(status)
return res.json({ error: msg }) return res.json({ error: msg })
@@ -224,7 +219,7 @@ class Slp {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, tokenIds)) { if (!_this.routeUtils.validateArraySize(req, tokenIds)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -554,7 +549,7 @@ class Slp {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -923,7 +918,7 @@ class Slp {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, addresses)) { if (!_this.routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -984,7 +979,7 @@ class Slp {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, txids)) { if (!_this.routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -1222,10 +1217,13 @@ class Slp {
msg: '' msg: ''
} }
const path = `${process.env.SLP_API_URL}slp/validate/${txid}`
// console.log(`validate2Single path: ${path}`)
// Request options // Request options
const opt = { const opt = {
method: 'get', method: 'get',
baseURL: `${process.env.SLP_API_URL}slp/validate/${txid}`, baseURL: path,
timeout: 10000 // Exit after 10 seconds. timeout: 10000 // Exit after 10 seconds.
} }
const tokenRes = await _this.axios.request(opt) const tokenRes = await _this.axios.request(opt)
@@ -1412,7 +1410,7 @@ class Slp {
// Enforce array size rate limits // Enforce array size rate limits
if (!_this.routeUtils.validateArraySize(req, txids)) { if (!_this.routeUtils.validateArraySize(req, txids)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
@@ -1991,9 +1989,7 @@ class Slp {
// Extract a delay value if the user passed it in. // Extract a delay value if the user passed it in.
const usrObjIn = req.body.usrObj const usrObjIn = req.body.usrObj
let utxoDelay = 0 let utxoDelay = 0
if (usrObjIn && usrObjIn.utxoDelay) { if (usrObjIn && usrObjIn.utxoDelay) { utxoDelay = usrObjIn.utxoDelay }
utxoDelay = usrObjIn.utxoDelay
}
// console.log('req: ', req) // console.log('req: ', req)
// console.log(`req._remoteAddress: ${req._remoteAddress}`) // console.log(`req._remoteAddress: ${req._remoteAddress}`)
@@ -2042,10 +2038,7 @@ class Slp {
const theseUtxos = utxos[i].utxos const theseUtxos = utxos[i].utxos
// Get SLP token details. // Get SLP token details.
const details = await _this.bchjs.SLP.Utils.tokenUtxoDetails( const details = await _this.bchjs.SLP.Utils.tokenUtxoDetails(theseUtxos, usrObj)
theseUtxos,
usrObj
)
// console.log('details: ', details) // console.log('details: ', details)
// Replace the original UTXO data with the hydrated data. // Replace the original UTXO data with the hydrated data.
@@ -2056,13 +2049,12 @@ class Slp {
return res.json({ slpUtxos: utxos }) return res.json({ slpUtxos: utxos })
} catch (err) { } catch (err) {
wlogger.error('Error in slp.js/hydrateUtxos().', err) wlogger.error('Error in slp.js/hydrateUtxos().', err)
// console.error('Error in slp.js/hydrateUtxos().', err) console.error('Error in slp.js/hydrateUtxos().', err)
// Decode the error message. // Decode the error message.
const { msg, status } = routeUtils.decodeError(err) const { msg, status } = routeUtils.decodeError(err)
// console.log('msg: ', msg) console.log('msg: ', msg)
// console.log('status: ', status) console.log('status: ', status)
if (msg) { if (msg) {
res.status(status) res.status(status)
return res.json({ error: msg, message: msg, success: false }) return res.json({ error: msg, message: msg, success: false })
@@ -2097,23 +2089,6 @@ class Slp {
try { try {
const utxos = req.body.utxos const utxos = req.body.utxos
// Extract a delay value if the user passed it in.
const usrObjIn = req.body.usrObj
let utxoDelay = 0
if (usrObjIn && usrObjIn.utxoDelay) {
utxoDelay = usrObjIn.utxoDelay
}
// Generate a user object that can be passed along with internal calls
// from bch-js.
const usrObj = {
ip: req._remoteAddress,
jwtToken: req.locals.jwtToken,
proLimit: req.locals.proLimit,
apiLevel: req.locals.apiLevel,
utxoDelay
}
// Validate inputs // Validate inputs
if (!Array.isArray(utxos)) { if (!Array.isArray(utxos)) {
res.status(422) res.status(422)
@@ -2150,8 +2125,7 @@ class Slp {
// Get SLP token details. // Get SLP token details.
const details = await _this.bchjs.SLP.Utils.tokenUtxoDetailsWL( const details = await _this.bchjs.SLP.Utils.tokenUtxoDetailsWL(
theseUtxos, theseUtxos
usrObj
) )
// console.log('details : ', details) // console.log('details : ', details)
+2 -4
View File
@@ -4,9 +4,7 @@ const express = require('express')
const router = express.Router() const router = express.Router()
const axios = require('axios') const axios = require('axios')
const RouteUtils = require('../../util/route-utils') const routeUtils = require('./route-utils')
const routeUtils = new RouteUtils()
const wlogger = require('../../util/winston-logging') const wlogger = require('../../util/winston-logging')
const util = require('util') const util = require('util')
@@ -143,7 +141,7 @@ class UtilRoute {
// Enforce array size rate limits // Enforce array size rate limits
if (!routeUtils.validateArraySize(req, addresses)) { if (!routeUtils.validateArraySize(req, addresses)) {
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
return res.json({ return res.json({
error: 'Array too large.' error: 'Array too large.'
}) })
+2 -4
View File
@@ -5,10 +5,8 @@
'use strict' 'use strict'
const express = require('express') const express = require('express')
// const axios = require('axios')
const RouteUtils = require('../../util/route-utils') const routeUtils = require('./route-utils')
const routeUtils = new RouteUtils()
const wlogger = require('../../util/winston-logging') const wlogger = require('../../util/winston-logging')
// const router = express.Router() // const router = express.Router()
-40
View File
@@ -154,23 +154,6 @@ class RouteUtils {
} }
} }
// Handle 429 errors thrown by nginx
if (err.error) {
// console.log('decodeError: err: ', err)
if (err.error.includes('429 Too Many Requests')) {
const internalMsg =
'429 error thrown by nginx caught by route-utils.js/decodeError()'
console.error(internalMsg)
wlogger.error(internalMsg)
return {
msg: '429 Too Many Requests',
status: 429
}
}
}
// Handle general Error objects. // Handle general Error objects.
if (err.message) { if (err.message) {
return { return {
@@ -186,29 +169,6 @@ class RouteUtils {
return { msg: false, status: 500 } return { msg: false, status: 500 }
} }
} }
// Dynamically set these based on env vars. Allows unit testing.
setEnvVars () {
const BitboxHTTP = axios.create({
baseURL: process.env.RPC_BASEURL,
timeout: 15000
})
const username = process.env.RPC_USERNAME
const password = process.env.RPC_PASSWORD
const requestConfig = {
method: 'post',
auth: {
username: username,
password: password
},
data: {
jsonrpc: '1.0'
}
}
return { BitboxHTTP, username, password, requestConfig }
}
} }
module.exports = RouteUtils module.exports = RouteUtils
+4 -4
View File
@@ -594,7 +594,7 @@ describe('#Electrumx', () => {
assert.isArray(result.transactions) assert.isArray(result.transactions)
}) })
it('should throw 400 error if txid array is too large', async () => { it('should throw 429 error if txid array is too large', async () => {
const testArray = [] const testArray = []
for (var i = 0; i < 25; i++) testArray.push('') for (var i = 0; i < 25; i++) testArray.push('')
@@ -603,7 +603,7 @@ describe('#Electrumx', () => {
const result = await electrumxRoute.transactionDetailsBulk(req, res) const result = await electrumxRoute.transactionDetailsBulk(req, res)
// console.log(`result: ${util.inspect(result)}`) // console.log(`result: ${util.inspect(result)}`)
expectRouteError(res, result, 'Array too large', 400) expectRouteError(res, result, 'Array too large', 429)
}) })
it('should get details for a single txid', async () => { it('should get details for a single txid', async () => {
@@ -841,7 +841,7 @@ describe('#Electrumx', () => {
assert.isArray(result.headers) assert.isArray(result.headers)
}) })
it('should throw 400 error if heights array is too large', async () => { it('should throw 429 error if heights array is too large', async () => {
const testArray = [] const testArray = []
for (var i = 0; i < 25; i++) testArray.push('') for (var i = 0; i < 25; i++) testArray.push('')
@@ -849,7 +849,7 @@ describe('#Electrumx', () => {
const result = await electrumxRoute.blockHeadersBulk(req, res) const result = await electrumxRoute.blockHeadersBulk(req, res)
expectRouteError(res, result, 'Array too large', 400) expectRouteError(res, result, 'Array too large', 429)
}) })
it('should get details for a single height', async () => { it('should get details for a single height', async () => {
+3 -3
View File
@@ -82,7 +82,7 @@ describe('#Encryption Router', () => {
.resolves(mockData.mockFulcrumTxHistory) .resolves(mockData.mockFulcrumTxHistory)
sandbox sandbox
.stub(encryptionRoute.bchjs.RawTransactions, 'getRawTransaction') .stub(encryptionRoute.bchjs.RawTransactions, 'getRawTransaction')
.resolves([mockData.mockTxDetails2]) .resolves(mockData.mockTxDetails2)
} }
const result = await encryptionRoute.getPublicKey(req, res) const result = await encryptionRoute.getPublicKey(req, res)
@@ -110,7 +110,7 @@ describe('#Encryption Router', () => {
} }
const result = await encryptionRoute.getPublicKey(req, res) const result = await encryptionRoute.getPublicKey(req, res)
console.log(`result: ${JSON.stringify(result, null, 2)}`) // console.log(`result: ${JSON.stringify(result, null, 2)}`)
assert.property(result, 'success') assert.property(result, 'success')
assert.equal(result.success, false) assert.equal(result.success, false)
@@ -130,7 +130,7 @@ describe('#Encryption Router', () => {
.resolves(mockData.mockFulcrumNoSendBalance) .resolves(mockData.mockFulcrumNoSendBalance)
sandbox sandbox
.stub(encryptionRoute.bchjs.RawTransactions, 'getRawTransaction') .stub(encryptionRoute.bchjs.RawTransactions, 'getRawTransaction')
.resolves([mockData.mockNoSendTx]) .resolves(mockData.mockNoSendTx)
} }
const result = await encryptionRoute.getPublicKey(req, res) const result = await encryptionRoute.getPublicKey(req, res)
+62 -75
View File
@@ -55,68 +55,64 @@ const mockFulcrumTxHistory = {
success: true, success: true,
transactions: [ transactions: [
{ {
transactions: [ height: 511463,
{ tx_hash:
height: 511463, 'eff00a9538487ff44243c75fb13de19b5783454c42c81b9aff9afbfd09cbaec3'
tx_hash: },
'eff00a9538487ff44243c75fb13de19b5783454c42c81b9aff9afbfd09cbaec3' {
}, height: 511464,
{ tx_hash:
height: 511464, '7e9aa7a74de2b30200a2d6fc748ff35a0c753221444194f720bb7f61ef1d9153'
tx_hash: },
'7e9aa7a74de2b30200a2d6fc748ff35a0c753221444194f720bb7f61ef1d9153' {
}, height: 513373,
{ tx_hash:
height: 513373, '6960255abe64893073921e96bf3c053c82686e0fc22a565494fbe2a31e766975'
tx_hash: },
'6960255abe64893073921e96bf3c053c82686e0fc22a565494fbe2a31e766975' {
}, height: 513373,
{ tx_hash:
height: 513373, '9ea667bcfc9cd337bd6c5583d8094c1b1942bd2015d95b54189deac5070eeff0'
tx_hash: },
'9ea667bcfc9cd337bd6c5583d8094c1b1942bd2015d95b54189deac5070eeff0' {
}, height: 560481,
{ tx_hash:
height: 560481, 'ecc1b51bac767880382bf3190ff17abf78d0936843a022a943d871116ed50368'
tx_hash: },
'ecc1b51bac767880382bf3190ff17abf78d0936843a022a943d871116ed50368' {
}, height: 560615,
{ tx_hash:
height: 560615, 'b3792d28377b975560e1b6f09e48aeff8438d4c6969ca578bd406393bd50bd7d'
tx_hash: },
'b3792d28377b975560e1b6f09e48aeff8438d4c6969ca578bd406393bd50bd7d' {
}, height: 561568,
{ tx_hash:
height: 561568, '8bc2134c7e48e56e1769b3d7c4c1e3a0acc68e1e58160eee6fa67f3208c07262'
tx_hash: },
'8bc2134c7e48e56e1769b3d7c4c1e3a0acc68e1e58160eee6fa67f3208c07262' {
}, height: 561569,
{ tx_hash:
height: 561569, 'ceb0cab0e37b59caf3ca29e1a698d19ff47f2827dd09cb2f3b91b9100b1dad1c'
tx_hash: },
'ceb0cab0e37b59caf3ca29e1a698d19ff47f2827dd09cb2f3b91b9100b1dad1c' {
}, height: 561572,
{ tx_hash:
height: 561572, '0f9b49cafeb9ae1d741cdb12137c92816aa8470944c270a78ba2e610bd59190d'
tx_hash: },
'0f9b49cafeb9ae1d741cdb12137c92816aa8470944c270a78ba2e610bd59190d' {
}, height: 561582,
{ tx_hash:
height: 561582, 'e4a0ac48ff3f42fc342717a2a3d34248e5e85bae79d59bd20e1b60e61b1c500f'
tx_hash: },
'e4a0ac48ff3f42fc342717a2a3d34248e5e85bae79d59bd20e1b60e61b1c500f' {
}, height: 562106,
{ tx_hash:
height: 562106, '1afcc63b244182647909539ebe3f4a44b8ea4120a95edb8d9eebe5347b9491bb'
tx_hash: },
'1afcc63b244182647909539ebe3f4a44b8ea4120a95edb8d9eebe5347b9491bb' {
}, height: 562106,
{ tx_hash:
height: 562106, 'c42f8f16d3baa2ee343ea89ef110dfe094992379d08edd30887b8ca7ee671c9a'
tx_hash:
'c42f8f16d3baa2ee343ea89ef110dfe094992379d08edd30887b8ca7ee671c9a'
}
]
} }
] ]
} }
@@ -163,25 +159,16 @@ const mockTxDetails2 = {
const mockFulcrumNoTxHistory = { const mockFulcrumNoTxHistory = {
success: true, success: true,
transactions: [ transactions: []
{
transactions: [],
address: 'bitcoincash:qrgqqkky28jdkv3w0ctrah0mz3jcsnsklc34gtukrh'
}
]
} }
const mockFulcrumNoSendBalance = { const mockFulcrumNoSendBalance = {
success: true, success: true,
transactions: [ transactions: [
{ {
transactions: [ height: 633578,
{ tx_hash:
height: 633578, 'a3b62cd4f4c56ba52139179db14bffd4ab22a2e077f3c62bd5cf0541bfcaf023'
tx_hash:
'a3b62cd4f4c56ba52139179db14bffd4ab22a2e077f3c62bd5cf0541bfcaf023'
}
]
} }
] ]
} }
-505
View File
@@ -1,505 +0,0 @@
/*
Unit tests for the route-ratelimit2.js middleware.
*/
'use strict'
// Public npm libraries.
const assert = require('chai').assert
const sinon = require('sinon')
const cloneDeep = require('lodash.clonedeep')
const config = require('../../config')
// Mocking data.
const { mockReq, mockRes, mockNext } = require('./mocks/express-mocks')
// Libraries under test
const RateLimits = require('../../src/middleware/route-ratelimit')
let uut = new RateLimits()
let req, res, next
describe('#rate-routelimit', () => {
let sandbox
before(async () => {
if (!process.env.JWT_AUTH_SERVER) {
process.env.JWT_AUTH_SERVER = 'http://fakeurl.com/'
}
// Wipe the Redis DB, which prevents false negatives when running integration
// tests back-to-back.
await uut.wipeRedis()
})
// Setup the mocks before each test.
beforeEach(() => {
// Mock the req and res objects used by Express routes.
req = cloneDeep(mockReq)
res = cloneDeep(mockRes)
next = mockNext
// Explicitly reset the parmas and body.
req.params = {}
req.body = {}
req.query = {}
req.locals = {}
sandbox = sinon.createSandbox()
uut = new RateLimits()
})
afterEach(() => {
sandbox.restore()
})
after(() => {
uut.closeRedis()
})
describe('#checkInternalIp', () => {
it('should return true for a request from localhost', () => {
req.ip = '::ffff:127.0.0.1'
const result = uut.checkInternalIp(req)
assert.equal(result, true)
})
it('should return true for a request from a Docker container', () => {
req.ip = '172.17.0.3'
const result = uut.checkInternalIp(req)
assert.equal(result, true)
})
it('should return false for a random ip address', () => {
req.ip = '123.456.7.8'
const result = uut.checkInternalIp(req)
assert.equal(result, false)
})
it('should return false when an error is encountered', () => {
req.ip = 4
const result = uut.checkInternalIp(req)
assert.equal(result, false)
})
})
describe('#isInWhitelist', () => {
it('should return false when no argument is passed in', () => {
const result = uut.isInWhitelist()
assert.equal(result, false)
})
it('should return false when origin is not in the whitelist', () => {
req.origin = 'blah.com'
req.get = sandbox.stub().returns(req.origin)
const result = uut.isInWhitelist(req)
assert.equal(result, false)
// Used to appease linter. Remove these.
res.blah = 4
next()
})
it('should return true when origin is in the whitelist', () => {
req.origin = 'message.fullstack.cash'
req.get = sandbox.stub().returns(req.origin)
const result = uut.isInWhitelist(req)
assert.equal(result, true)
})
})
describe('#decodeJwtToken', () => {
it('should return the default JWT payload if decoding fails', () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVlODhhY2JmMDIyMWMxMDAxMmFkOTNmZiIsImVtYWlsIjoiY2hyaXMudHJvdXRuZXJAZ21haWwuY29tIiwiYXBpTGV2ZWwiOjQwLCJyYXRlTGltaXQiOjMsImlhdCI6MTYxNTE1NzA4NywiZXhwIjoxNjE3NzQ5MDg3fQ.RLNGuYAa-CcLdhTGD27tDeaxT6-GIdeR8T4JWZZLDZA'
const result = uut.decodeJwtToken(jwt)
console.log('result: ', result)
assert.property(result, 'id')
// assert.equal(result.id, '123.456.789.10')
assert.property(result, 'email')
// assert.equal(result.email, 'test@bchtest.net')
// assert.property(result, 'pointsToConsume')
// assert.equal(result.pointsToConsume, config.anonRateLimit)
assert.property(result, 'duration')
// assert.equal(result.duration, 30)
assert.property(result, 'exp')
})
it('should return the default JWT payload if no input is given', () => {
const result = uut.decodeJwtToken()
// console.log('result: ', result)
assert.property(result, 'id')
assert.equal(result.id, '123.456.789.10')
assert.property(result, 'email')
assert.equal(result.email, 'test@bchtest.net')
assert.property(result, 'pointsToConsume')
assert.equal(result.pointsToConsume, config.anonRateLimit)
assert.property(result, 'duration')
assert.equal(result.duration, 30)
assert.property(result, 'exp')
})
it('should correctly decode a JWT token', () => {
// Generate a new JWT token for the test.
const jwtPayload = {
id: '5dade3f5739e6c0ff034b9a1',
pointsToConsume: 10,
email: 'gooduser@test.com',
apiLevel: 40,
rateLimit: 100,
duration: 30
}
const jwtToken = uut.generateJwtToken(jwtPayload)
const result = uut.decodeJwtToken(jwtToken)
// console.log('result: ', result)
assert.property(result, 'id')
assert.equal(result.id, jwtPayload.id)
assert.property(result, 'email')
assert.equal(result.email, jwtPayload.email)
assert.property(result, 'pointsToConsume')
assert.equal(result.pointsToConsume, jwtPayload.pointsToConsume)
assert.property(result, 'duration')
assert.equal(result.duration, jwtPayload.duration)
assert.property(result, 'exp')
})
it('should return the default payload if there is an unhandled error', () => {
// Force an error.
sandbox.stub(uut, 'generateJwtToken').throws(new Error('test error'))
const result = uut.decodeJwtToken()
// console.log('result: ', result)
assert.property(result, 'id')
assert.equal(result.id, '123.456.789.10')
assert.property(result, 'email')
assert.equal(result.email, 'test@bchtest.net')
assert.property(result, 'pointsToConsume')
assert.equal(result.pointsToConsume, config.anonRateLimit)
assert.property(result, 'duration')
assert.equal(result.duration, 30)
assert.property(result, 'exp')
})
})
describe('#trackRateLimits', () => {
it('should apply anonymous rate limits if no JWT token is provided', async () => {
req.ip = '127.0.0.1'
const result = await uut.trackRateLimits(req, res)
// console.log(`result: `, result)
// console.log('res.locals.pointsToConsume: ', res.locals.pointsToConsume)
assert.equal(result, false, 'Rate limits not exceeded')
assert.equal(
res.locals.pointsToConsume,
config.anonRateLimit,
'Anonymous rate limits applied'
)
})
it('should apply 100 RPM rate limits when JWT token is provided', async () => {
// Generate a new JWT token for the test.
const jwtPayload = {
id: '5dade3f5739e6c0ff034b9a1',
pointsToConsume: 10
}
const jwtToken = uut.generateJwtToken(jwtPayload)
const result = await uut.trackRateLimits(req, res, jwtToken)
// console.log(`result: `, result)
// console.log('res.locals.pointsToConsume: ', res.locals.pointsToConsume)
assert.equal(result, false, 'Rate limits not exceeded')
assert.equal(res.locals.pointsToConsume, 10, '100 RPM limits applied')
})
})
describe('#applyRateLimits', () => {
it('should skip rate limits if basic auth token is used', async () => {
req.locals.proLimit = true
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
})
it('should skip rate limits if internal call passes basic auth token', async () => {
req.ip = '127.0.0.1'
req.body.usrObj = {
proLimit: true
}
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
})
it('should apply rate limits to anonymous users', async () => {
req.ip = '123.456.7.8'
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
assert.equal(
res.locals.pointsToConsume,
config.anonRateLimit,
'Anonymous rate limits applied'
)
})
it('should return 429 error when anonymous users exceed rate limit', async () => {
req.ip = '123.456.7.8'
// force req.locals.jwtToken to be empty.
req.locals.jwtToken = undefined
let val
for (let i = 0; i < 25; i++) {
console.log('req.locals: ', req.locals)
val = await uut.applyRateLimits(req, res, next)
}
console.log('val: ', val)
assert.property(val, 'error')
assert.include(
val.error,
'Too many requests. Your limits are currently 20 requests per minute.'
)
assert.equal(res.locals.rateLimitTriggered, true, 'Rate limits triggered')
assert.equal(
res.locals.pointsToConsume,
config.anonRateLimit,
'Anonymous rate limits applied'
)
})
it('should apply rate limits when JWT token is provided', async () => {
// Generate a new JWT token for the test.
const jwtPayload = {
id: '5dade3f5739e6c0ff034b9a1',
pointsToConsume: 10
}
const jwtToken = uut.generateJwtToken(jwtPayload)
req.ip = '123.456.7.8'
req.locals.jwtToken = jwtToken
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
assert.equal(
res.locals.pointsToConsume,
10,
'Anonymous rate limits applied'
)
})
it('should apply internal rate limits to internal calls', async () => {
req.ip = '127.0.0.1'
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
assert.equal(
res.locals.pointsToConsume,
10,
'Internal rate limits applied'
)
})
it('should return 429 error when internal calls exceed interal rate limit', async () => {
req.ip = '127.0.0.1'
let val
for (let i = 0; i < 1025; i++) {
val = await uut.applyRateLimits(req, res, next)
}
assert.property(val, 'error')
assert.include(
val.error,
'Too many requests. Your limits are currently 1000 requests per minute.'
)
assert.equal(res.locals.rateLimitTriggered, true, 'Rate limits triggered')
assert.equal(
res.locals.pointsToConsume,
10,
'Internal rate limits applied'
)
})
it('should apply JWT rate limits to internal calls when JWT passes through', async () => {
// Generate a new JWT token for the test.
const jwtPayload = {
id: '5dade3f5739e6c0ff034b9a1',
pointsToConsume: 10
}
const jwtToken = uut.generateJwtToken(jwtPayload)
req.ip = '127.0.0.1'
req.body.usrObj = {
jwtToken
}
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
assert.equal(
res.locals.pointsToConsume,
10,
'User JWT rate limits applied'
)
})
it('should return 429 error when internal calls using JWT pass-through exceeds rate limit', async () => {
// Generate a new JWT token for the test.
const jwtPayload = {
id: '5dade3f5739e6c0ff034b9a1',
pointsToConsume: 10
}
const jwtToken = uut.generateJwtToken(jwtPayload)
req.ip = '127.0.0.1'
req.body.usrObj = {
jwtToken
}
try {
let val
for (let i = 0; i < 120; i++) {
val = await uut.applyRateLimits(req, res, next)
}
console.log('val: ', val)
assert.property(val, 'error')
assert.include(
val.error,
'Too many requests. Your limits are currently 100 requests per minute.'
)
assert.equal(
res.locals.pointsToConsume,
10,
'User JWT rate limits applied'
)
} catch (err) {
console.log('err: ', err)
}
})
it('should move to the next middleware when encountering an unexpected internal error', async () => {
// Force the creation of the res and req locals property. Covers an
// otherwise untested code path.
req.locals = undefined
res.locals = undefined
// Force an error
sandbox.stub(uut, 'checkInternalIp').throws(new Error('test error'))
// console.log('next.callCount: ', next.callCount)
const startCallCount = next.callCount
await uut.applyRateLimits(req, res, next)
// console.log('next.callCount: ', next.callCount)
const endCallCount = next.callCount
assert.isAbove(
endCallCount,
startCallCount,
'Expecting next() to be called'
)
})
})
})
+487
View File
@@ -0,0 +1,487 @@
'use strict'
const chai = require('chai')
const assert = chai.assert
const sinon = require('sinon')
// Used for debugging.
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
// Mocking data.
const { mockReq, mockRes, mockNext } = require('./mocks/express-mocks')
// Libraries under test
const RateLimits = require('../../src/middleware/route-ratelimit')
let rateLimits = new RateLimits()
// const controlRoute = require('../../src/routes/v4/full-node/control')
const jwtAuth = require('../../src/middleware/jwt-auth')
let req, res, next
// let originalEnvVars // Used during transition from integration to unit tests.
// JWT token used in tests.
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVkYWRlM2Y1NzM5ZTZjMGZmMDM0YjlhMSIsImlhdCI6MTU3MTY3NzQ1MCwiZXhwIjoxNTc0MjY5NDUwfQ.SSz7F7ETyBB3eoNG2VKCzPOhddtB-vrtmEoj7PxicrQ'
describe('#route-ratelimits & jwt-auth', () => {
let sandbox
before(async () => {
// Save existing environment variables.
// originalEnvVars = {
// BITCOINCOM_BASEURL: process.env.BITCOINCOM_BASEURL,
// RPC_BASEURL: process.env.RPC_BASEURL,
// RPC_USERNAME: process.env.RPC_USERNAME,
// RPC_PASSWORD: process.env.RPC_PASSWORD
// }
if (!process.env.JWT_AUTH_SERVER) { process.env.JWT_AUTH_SERVER = 'http://fakeurl.com/' }
// Wipe the Redis DB, which prevents false negatives when running integration
// tests back-to-back.
await rateLimits.wipeRedis()
})
// Setup the mocks before each test.
beforeEach(() => {
// Mock the req and res objects used by Express routes.
req = Object.assign({}, mockReq)
res = Object.assign({}, mockRes)
next = mockNext
// Explicitly reset the parmas and body.
req.params = {}
req.body = {}
req.query = {}
req.locals = {}
sandbox = sinon.createSandbox()
})
afterEach(() => {
sandbox.restore()
})
after(() => {
rateLimits.closeRedis()
})
describe('#jwt-auth.js', () => {
describe('#getTokenFromHeaders', () => {
it('should populate the req.locals object correctly', () => {
// Initialize req.locals
req.locals = {
proLimit: false,
apiLevel: 0
}
const header = `Token ${jwt}`
req.headers.authorization = header
jwtAuth.getTokenFromHeaders(req, res, next)
// console.log(`req.locals: ${JSON.stringify(req.locals, null, 2)}`)
assert.property(req.locals, 'proLimit')
assert.property(req.locals, 'apiLevel')
assert.property(req.locals, 'jwtToken')
assert.equal(req.locals.jwtToken, jwt)
})
})
})
describe('#getResource', () => {
it('should decode a blockchain request', () => {
const url =
'/blockchain/getTxOut/62a3ea958a463a372bc0caf2c374a7f60be9c624be63a0db8db78f05809df6d8/0?include_mempool=true'
const result = rateLimits.getResource(url)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
assert.equal(result, 'blockchain')
})
})
describe('#calcPoints', () => {
it('should return 50 points for anonymous user', () => {
const result = rateLimits.calcPoints()
// console.log(`result: ${result}`)
assert.equal(result, 50)
})
it('should return 50 points for free tier requesting full node access', () => {
const jwtInfo = {
apiLevel: 10,
resource: 'blockchain',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 50)
})
it('should return 50 points for free tier requesting indexer access', () => {
const jwtInfo = {
apiLevel: 10,
resource: 'blockbook',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 50)
})
it('should return 50 points for free tier requesting SLPDB access', () => {
const jwtInfo = {
apiLevel: 10,
resource: 'slp',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 50)
})
it('should return 10 points for full node tier requesting full node access', () => {
const jwtInfo = {
apiLevel: 20,
resource: 'blockchain',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 10)
})
it('should return 50 points for full-node tier requesting indexer access', () => {
const jwtInfo = {
apiLevel: 20,
resource: 'blockbook',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 50)
})
it('should return 50 points for full node tier requesting SLPDB access', () => {
const jwtInfo = {
apiLevel: 20,
resource: 'slp',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 50)
})
it('should return 10 point for indexer tier requesting full node access', () => {
const jwtInfo = {
apiLevel: 30,
resource: 'blockchain',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 10)
})
it('should return 10 points for indexer tier requesting indexer access', () => {
const jwtInfo = {
apiLevel: 30,
resource: 'blockbook',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 10)
})
it('should return 50 points for indexer tier requesting SLPDB access', () => {
const jwtInfo = {
apiLevel: 30,
resource: 'slp',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 50)
})
it('should return 10 point for SLP tier requesting full node access', () => {
const jwtInfo = {
apiLevel: 40,
resource: 'blockchain',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 10)
})
it('should return 10 points for SLP tier requesting indexer access', () => {
const jwtInfo = {
apiLevel: 40,
resource: 'blockbook',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 10)
})
it('should return 10 points for SLP tier requesting SLPDB access', () => {
const jwtInfo = {
apiLevel: 40,
resource: 'slp',
id: '5e3a0415eb29a962da2708b4'
}
const result = rateLimits.calcPoints(jwtInfo)
assert.equal(result, 10)
})
})
describe('#rateLimitByResource', () => {
// NOTE: this test will fail if you run multiple integration tests in a
// short period. Because it talks to the Redis DB.
it('should pass through rate-limit middleware', async () => {
req.baseUrl = '/v4'
req.path = '/control/getNetworkInfo'
req.url = req.path
req.method = 'GET'
// Call the route twice to trigger the rate handling.
await rateLimits.rateLimitByResource(req, res, next)
await rateLimits.rateLimitByResource(req, res, next)
// next() will be called if rate-limit is not triggered
assert.equal(next.called, true)
})
it('should trigger rate-limit handler if rate limits exceeds 5 request per minute', async () => {
req.baseUrl = '/v4'
req.path = '/control/getNetworkInfo'
req.url = req.path
req.method = 'GET'
for (let i = 0; i < 5; i++) {
next.reset() // reset the stubbed next() function.
await rateLimits.rateLimitByResource(req, res, next)
// console.log(`next() called: ${next.called}`)
}
// Note: next() will be called unless the rate-limit kicks in.
assert.equal(
next.called,
false,
'next should not be called if rate limit was triggered.'
)
})
it('should NOT trigger rate-limit for free-tier at 5 RPM', async () => {
// Create a new instance of the rate limit so we start with zeroed tracking.
rateLimits = new RateLimits()
req.baseUrl = '/v4'
req.path = '/control/getNetworkInfo'
req.url = req.path
req.method = 'GET'
req.locals.jwtToken = 'some-token'
const jwtInfo = {
apiLevel: 10,
id: '5e3a0415eb29a962da2708b1'
}
// Mock the call to the jwt library.
sandbox.stub(rateLimits.jwt, 'verify').returns(jwtInfo)
for (let i = 0; i < 5; i++) {
next.reset() // reset the stubbed next() function.
await rateLimits.rateLimitByResource(req, res, next)
// console.log(`next() called: ${next.called}`)
}
// console.log(`req.locals after test: ${util.inspect(req.locals)}`)
// Note: next() will be called unless the rate-limit kicks in.
assert.equal(
next.called,
true,
'next should be called if rate limit was not triggered.'
)
})
it('should trigger rate-limit for free tier after 20 RPM', async () => {
// Create a new instance of the rate limit so we start with zeroed tracking.
rateLimits = new RateLimits()
req.baseUrl = '/v4'
req.path = '/control/getNetworkInfo'
req.url = req.path
req.method = 'GET'
req.locals.jwtToken = 'some-token'
const jwtInfo = {
apiLevel: 10,
id: '5e3a0415eb29a962da2708b2'
}
// Mock the call to the jwt library.
sandbox.stub(rateLimits.jwt, 'verify').returns(jwtInfo)
for (let i = 0; i < 22; i++) {
next.reset() // reset the stubbed next() function.
await rateLimits.rateLimitByResource(req, res, next)
// console.log(`next() called: ${next.called}`)
}
// Note: next() will be called unless the rate-limit kicks in.
assert.equal(
next.called,
false,
'next should not be called if rate limit was triggered.'
)
})
it('should NOT trigger rate-limit handler for indexer-tier at 25 RPM', async () => {
// Create a new instance of the rate limit so we start with zeroed tracking.
rateLimits = new RateLimits()
req.baseUrl = '/v4'
req.path = '/control/getNetworkInfo'
req.url = req.path
req.method = 'GET'
req.locals.jwtToken = 'some-token'
const jwtInfo = {
apiLevel: 20,
id: '5e3a0415eb29a962da2708b3'
}
// Mock the call to the jwt library.
sandbox.stub(rateLimits.jwt, 'verify').returns(jwtInfo)
for (let i = 0; i < 25; i++) {
next.reset() // reset the stubbed next() function.
await rateLimits.rateLimitByResource(req, res, next)
// console.log(`next() called: ${next.called}`)
}
// console.log(`req.locals after test: ${util.inspect(req.locals)}`)
// Note: next() will be called unless the rate-limit kicks in.
assert.equal(
next.called,
true,
'next should be called if rate limit was not triggered.'
)
})
it('should still rate-limit at a higher RPM for pro-tier', async () => {
// Create a new instance of the rate limit so we start with zeroed tracking.
rateLimits = new RateLimits()
req.baseUrl = '/v4'
req.path = '/control/getNetworkInfo'
req.url = req.path
req.method = 'GET'
req.locals.jwtToken = 'some-token'
const jwtInfo = {
apiLevel: 20,
id: '5e3a0415eb29a962da2708b5'
}
// Mock the call to the jwt library.
sandbox.stub(rateLimits.jwt, 'verify').returns(jwtInfo)
for (let i = 0; i < 150; i++) {
next.reset() // reset the stubbed next() function.
await rateLimits.rateLimitByResource(req, res, next)
// console.log(`next() called: ${next.called}`)
}
// console.log(`req.locals after test: ${util.inspect(req.locals)}`)
// Note: next() will be called unless the rate-limit kicks in.
assert.equal(
next.called,
false,
'next should NOT be called if rate limit was triggered.'
)
})
// CT 2/24/21 This test may have been invalidated by the interal IP address
// passing that I implemented to get hydrateUtxos() working properly.
// I'm commenting this out until I can study the side effects of this change,
// and why exactly this test is breaking.
// it('should handle misconfigured token secret', async () => {
// // Create a new instance of the rate limit so we start with zeroed tracking.
// rateLimits = new RateLimits()
//
// req.baseUrl = '/v4'
// req.path = '/control/getNetworkInfo'
// req.url = req.path
// req.method = 'GET'
//
// req.locals.jwtToken = 'some-token'
//
// next.reset() // reset the stubbed next() function.
//
// await rateLimits.rateLimitByResource(req, res, next)
//
// // Issues with token secret should treat incoming requests as anonymous
// // calls with 50 points, or 20 RPM.
// assert.equal(res.locals.pointsToConsume, 50)
// })
})
describe('#isInWhitelist', () => {
it('should return false when no argument is passed in', () => {
const result = rateLimits.isInWhitelist()
assert.equal(result, false)
})
it('should return false when origin is not in the whitelist', () => {
const origin = 'blah.com'
const result = rateLimits.isInWhitelist(origin)
assert.equal(result, false)
})
it('should return true when origin is in the whitelist', () => {
const origin = 'message.fullstack.cash'
const result = rateLimits.isInWhitelist(origin)
assert.equal(result, true)
})
})
})
// Generates a Basic authorization header.
// function generateAuthHeader (pass) {
// // https://en.wikipedia.org/wiki/Basic_access_authentication
// const username = 'BITBOX'
// const combined = `${username}:${pass}`
//
// var base64Credential = Buffer.from(combined).toString('base64')
// var readyCredential = `Basic ${base64Credential}`
//
// return readyCredential
// }