UNPKG

spartan-shield

Version:

nodejs project to package and configure common security middleware.

483 lines (479 loc) 23.5 kB
'use strict' /* assumption => will not be using this to manage clientID/clientSecret negotiations... this is only for requests TO the application's API */ let RateLimiter = require('limiter').RateLimiter, numberRequestsAllowed = 150, // this should ultimately come from security.json periodRequestsAllowed = 'hour', // this should ultimately come from security.json requestTable = {}, limiter, dotenv = require('dotenv').config(), path = require('path'), jwt = require('njwt'), fs = require('fs'), { LogWriter } = require('./logger'), apiLog = new LogWriter( { console: false, file: true, filters: { 'key': [/[A-Za-z0-9]{32}/, 'KEY REDACTED'], 'credit-card': [/^\d{4}-\d{4}-\d{4}-\d{4}$|\d{16}/, 'xxxx-xxxx-xxxx-0000'], 'ssn': [/^\d{3}-\d{2}-\d{4}$|\d{9}/, 'YYY-ZZ-XXXX'] } }), methOver = require(path.join(__dirname + '/security.json')).formProtection.config.allowMethodOverride apiLog.rotateLogs = 'weekly' const setRemaining = (key, remaining) => { // requestTable[key] = remaining } const getRemaining = (key) => { // takes the key and does a lookup of remaining requests return requestTable[key] } const getKey = (key) => { if (Object.keys(requestTable).includes(key)) { // this should come from the database return { success : true, expires : Math.floor(Date.now()/1000) + (24*60*60*1000), // in seconds, expires 24 hours by default decodedKey : key } } else { return { success : false, expires : null, decodedKey : null } } } const revokeKey = (key) => { return `Key ${key} has been removed` } const checkToken = (req, cb) => { // let's plan to move this to the authentication workflow let token = req.headers['x-access-token'] || req.headers['authorization'] if(token.startsWith('Bearer ')) { token = token.slice(7, token.length) } if(token) { jwt.verify(token, process.env.SECRET,(err, decoded) => { if (err) { let error = new Error('api/problem-decoding-token') error.message = err.message error.status = 500 // Server Error error.code = 'api/problem-decoding-token' return cb(error, false, null) } else { if (!decoded.body.aud.includes(req.path)) { let error = new Error('api/invalid-request') error.message = 'You do not have permission to access this resource' error.status = 403 // Not Authorized error.code = 'api/invalid-request' return cb(error, false, error.message) } else if ((req.method === 'GET' && !decoded.body.scope.includes('read')) || (req.method === 'POST' && !decoded.body.scope.includes('create')) || ((req.method === 'PUT' || req.method === 'PATCH') && !decoded.body.scope.includes('update')) || (req.method === 'DELETE' && !decoded.body.scope.includes('delete'))) { // if the method/action matches the scope/action let error = new Error('api/illegal-action') error.message = 'You do not have permission to perform that action on this resource' error.status = 403 error.code = 'api/illegal-action' return cb(error, false, error.message) } else { return cb(null, true, decoded) } } }) } else { return cb(null, null, 'nothing to send') } } const checkKey = (req, options, cb) => { // let's plan to move this to the authentication workflow let key if (options.key) { key = options.key } if (req.protocol.includes(':')) { let p = req.protocol.split(':') req.protocol = p[0] } if (req.protocol !== 'https') { let error = new Error('api/insecure-key-transmission') error.status = 400 // Bad Request error.code = 'api/insecure-key-transmission' if (options && options.revokeKey) { // automatically revoke keys revokeKey(key) error.message = `The API key was transmitted via insecure means: ${req.protocol}. Key ${key} has been revoked and a new one should be generated.` } else { error.message = `The API key was transmitted via insecure means: ${req.protocol}. You should revoke this key's access and generate a new one` } return cb(error, false, error.message) } if(key) { // check key validity for (let k = 0; k < key.length; k++) { // lookup key in database setRemaining(key[k], 1) // remove prior to publish let findKey = getKey(key[k]) // <-- this should be options.fetchKey method which returns key with expiry // if key doesn't exist in the database, return an error if (findKey.success === false || key[k].length < 32) { let error = new Error('api/invalid-key') error.status = 403 error.message = 'The key provided is invalid' error.code = 'api/invalid-key' return cb(error, false, error.message) } else if (findKey.expires !== 'never' && findKey.expires < Math.floor(Date.now() / 1000)) { // if key is expired (according to the database) return an error let error = new Error('api/expired-key') error.status = 403 error.message = 'The key provided is expired' error.code = 'api/expired-key' return cb(error, false, error.message) } else if (!options.allowed.includes(req.method.toLowerCase())) { // if the requested action is outside of what the key grants, return an error let error = new Error('api/illegal-action') error.message = 'You do not have permission to do the requested action with the provided key' error.status = 403 error.code = 'api/illegal-action' return cb(error, false, error.message) } else { return cb(null, true, 'yay!') } } } } module.exports = { apiAccessCtrl : (request, options, callback) => { // covers authentication/authorization api concerns // API Key Validation try { let context = { from: request.ip, method: request.method, url: request.url, route: request.route, browser: request.headers['user-agent'], params: () => { return request.params || 'no params' }, query: () => { return request.query || 'no query string' } } if (options.key) { checkKey(request, options, (err, success, message) => { if (err) { apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'ERROR', context: JSON.stringify(context), message: `${err.code} : ${err.message}, (${err.status})` }) return callback(err, success, message) } else { apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'SUCCESS', context: context, message: `Validated API Key, 200` }) return callback(null, success, message) } }) } else { checkToken(request, (err, success, message) => { if (err) { apiLog.writer({ class: 'API', subclass: 'TOKEN Validation', type: 'error', context: context, message: `${err.code} : ${err.message}, (${err.status})` }) return callback(err, success, message) } else { apiLog.writer({ class: 'API', subclass: 'TOKEN Validation', type: 'success', context: context, message: 'Validated Token' }) return callback(null, success, message) } }) } } catch (e) { return e } }, /** * @param {Request} request - The request object. Expects Express-style request object * @param {object} options - object containing allowed methods, keys * @param {Function} callback (err, success): function containing any errors, success (boolean) * @return {void} */ apiMethodCheck : (request, options, callback) => { let context = { from: request.ip, method: request.method, url: request.url, route: request.route, browser: request.headers['user-agent'], params: () => { return request.params || 'no params' }, query: () => { return request.query || 'no query string' } }, safeMethods = ['GET', 'HEAD', 'OPTIONS', 'POST'], e = [], overloadHeaders if (request.headers['X-HTTP-METHOD'] !== undefined) { overloadHeaders = request.headers['X-HTTP-METHOD'] } else if (request.headers['X-HTTP-METHOD-OVERRIDE'] !== undefined) { overloadHeaders = request.headers['X-HTTP-METHOD-OVERRIDE'] } else if (request.headers['X-METHOD-OVERRIDE'] !== undefined){ overloadHeaders = request.headers['X-METHOD-OVERRIDE'] } else { overloadHeaders = undefined } for (let i = 0; i < options.allowed.length; i++) { if (!safeMethods.includes(options.allowed[i].toUpperCase())) { e.push(options.allowed[i]) } } // if options.allowed is NOT one of the safe methods and there's no key, return an error if (options.key === undefined && e) { let error = new Error('api/missing-key') error.message = `An API key is required to use overload method ${e}` error.status = 400 // Bad Request error.code = 'api/missing-key' apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } if (methOver === false && !safeMethods.includes(request.method)) { let error = new Error('api/method-override-prohibited') error.message = `The method ${request.method} is not allowed by policy` error.status = 405 error.code = 'api/method-override-prohibited' apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } else if (methOver === false && overloadHeaders !== undefined) { let error = new Error('api/method-override-prohibited') if (request.headers['X-HTTP-METHOD']) { error.message = `The method ${request.headers['X-HTTP-METHOD']} is prohibited by policy` } else if (request.headers['X-HTTP-METHOD-OVERRIDE']) { error.message = `The method ${request.headers['X-HTTP-METHOD-OVERRIDE']} is prohibited by policy` } else { error.message = `The method ${request.headers['X-METHOD-OVERRIDE']} is prohibited by policy` } error.status = 405 error.code = 'api/method-override-prohibited' apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } else if (methOver === false && request.query['_method']) { let error = new Error('api/invalid-method-in-query-param') error.message = `Found method ${request.query['_method']} in query parameter. Method override is prohibited` error.status = 400 // Bad Request error.code = 'api/invalid-method-in-query-param' apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } else if (methOver && options) { if (!options.allowed.includes((request.method).toLowerCase())) { let error = new Error('api/invalid-method-on-resource') error.message = `The method ${request.method} was requested for this resource but is not allowed` error.status = 405 error.code = 'api/invalid-method-on-resource' apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } else { apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'SUCCESS', context: context, message: `Method ${request.method} allowed, 200` }) return callback(null, true) } } else { apiLog.writer({ class: 'API', subclass: 'METHOD Validation', type: 'SUCCESS', context: JSON.stringify(context), message: `Method ${request.method} allowed, 200` }) return callback(null, true) } }, apiRateLimit : (request, options, number, interval, callback) => { let context = { from: request.ip, method: request.method, url: request.url, route: request.route, browser: request.headers['user-agent'], params: () => { return request.params || 'no params' }, query: () => { return request.query || 'no query string' } } if (number && interval) { limiter = new RateLimiter(number, interval) } else { number = numberRequestsAllowed interval = periodRequestsAllowed } if (options.instaBlock) { if (!options.key || typeof options.key !== 'string') { // dos protection... let error = new Error('api/cannot-block-without-key') error.message = `Key is missing or null, even though rate-limit has been reached. Cannot apply block to this route` error.status = 400 // Bad request error.code = 'api/cannot-block-without-key' apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, null) } let startTime = options.instaBlock.from, currentTime = Date.now(), elapsedTime = currentTime - startTime if (elapsedTime < options.instaBlock.duration) { // apply the block limiter = new RateLimiter(number, interval, true) limiter.removeTokens(1, (err, remainingRequests) => { if (remainingRequests < 1) { let error = new Error('api/request-blocked') error.message = `Requests to this route are not allowed at this time. Please try again in ${((options.instaBlock.duration - elapsedTime)/(1000*60)).toFixed(0)} minutes` error.status = 429 error.code = 'api/request-blocked' apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } }) } else { // the block has expired and the request can proceed as normal apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'INFO', context: JSON.stringify(context), message: `Rate limiting access restrictions expired.` }) return (null, true) } } if (!options.key || typeof options.key !== 'string') { // no key, but throttling // Deprecate requests per route limiter.removeTokens(1, (err, remainingRequests) => { if (options.throttle) { if(remainingRequests >= options.throttle.within) { apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'INFO', context: JSON.stringify(context), message: `${remainingRequests} to ${request.url} from ${request.ip}` }) return callback(null, true) // let the request go through } else { limiter = new RateLimiter(options.throttle.slowDownTo, options.throttle.per) let message = `You are within the last few allowed requests for the allotted time period. Your rate limit has been changed to ${options.throttle.slowDownTo} requests per ${options.throttle.per}` apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'INFO', context: JSON.stringify(context), message: `Rate limiting access throttling in effect, (200)` }) return callback(null, true, message) } } else { if (remainingRequests < 1) { let error = new Error('api/too-many-requests') error.message = 'you have exceeded the number of requests in the time period allotted.' error.status = 429 // Too Many Requests error.code = 'api/too-many-requests' apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } else { // allow request to go through apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'SUCCESS', context: JSON.stringify(context), message: `API Access request for ${request.url} from ${request.ip}, (200)` }) return callback(null, true) } } }) } else if (options.key && options.throttle) { // we have a key & throttling // Deprecate requests per token & throttle requests for the key limiter.removeTokens(1, (err, remainingRequests) => { setRemaining(options.key, remainingRequests) if (getRemaining(options.key) > options.throttle.within) { let message = `User with key ${options.key} has ${remainingRequests} left for the time period ${interval}` apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'INFO', context: JSON.stringify(context), message: `${message}, (200)` }) return callback(null, true, message) } else { limiter = new RateLimiter(options.throttle.slowDownTo, options.throttle.per) let message = `User with key ${options.key} is within the last few allowed requests for the allotted time period. The rate limit has been changed to ${options.throttle.slowDownTo} requests per ${options.throttle.per}` apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'info', context: JSON.stringify(context), message: `${message}, (200)` }) return callback(null, true, message) } }) } else { // you have a key, but no throttling limiter.removeTokens(1, (err, remainingRequests) => { setRemaining(options.key, remainingRequests) if (getRemaining(options.key) < 1) { let error = new Error('api/too-many-requests') error.message = `User with key ${options.key} has exceeded the number of requests in the time period allotted.` error.status = 429 // Too Many Requests error.code = 'api/too-many-requests' apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'ERROR', context: JSON.stringify(context), message: `${error.code} : ${error.message}, (${error.status})` }) return callback(error, false) } else { // allow request to go through and set new remaining number for that key let message = `User with key ${options.key} has ${remainingRequests} left for the time period ${interval}` apiLog.writer({ class: 'API', subclass: 'Key Validation', type: 'ERROR', context: JSON.stringify(context), message: `${message}, (200)` }) return callback(null, true, message) } }) } }, apiKeyRevocation : () => { return revokeKey }, keyComber : (request) => { // combs through a request looking for api keys, returns the key if found, otherwise returns null let context = { from: request.ip, method: request.method, url: request.url, route: request.route, browser: request.headers['user-agent'], params: () => { return request.params || 'no params' }, query: () => { return request.query || 'no query string' } }, keysFound = [], keysLocated = {} // search the req.query if (request.query) { for (let q in request.query) { // for each key in the queries if ((q.toLowerCase().includes('key') || q.toLowerCase().startsWith('api')) && request.query[q].length >= 32) { keysLocated['query'] = request.query[q] keysFound.push(request.query[q]) } } } if (request.params['api'] || //need to handle upper/lowercase definitions request.params['key'] || request.params['apiKey']) { let p = request.params['api'] || request.params['key'] || request.params['apiKey'] keysLocated['params'] = p keysFound.push(p) } // search req.headers if (request.headers['authorization'].includes('API') || request.headers['x-api-key']) { let k = request.headers['authorization'] || request.headers['x-api-key'], words words = k.split(" ") keysLocated['headers'] = words[(words.length - 1)] keysFound.push(words[(words.length - 1)]) } // search req.path if (request.path) { let allQueries = request.path.split('?'), // [/users, apiKey=1234567&someData=qwerty] queries = allQueries[1].split('&'), // [apiKey=2456789, someData=sdfghjk] values for (let q = 0; q < queries.length; q++) { values = queries[q].split('=') // split each params into key and value pairs if (values[0].includes('api') || values[0].includes('key')) { keysLocated['path'] = values[1] keysFound.push(values[1]) } } } // search cookies if (request.cookies) { // let's start with unsigned cookies to begin with, then we'll handle signed cookies if (Object.keys(request.cookies).includes('apiKey')) { keysLocated['cookies'] = request.cookies['apiKey'] keysFound.push(request.cookies['apiKey']) } if (Object.keys(request.cookies).includes('key')) { keysLocated['cookies'] = request.cookies['key'] keysFound.push(request.cookies['key']) } if (Object.keys(request.cookies).includes('api')) { keysLocated['cookies'] = request.cookies['api'] keysFound.push(request.cookies['api']) } } if (keysFound.length > 0) { let results = { status: 200, foundAt: keysLocated, allKeys: keysFound, message: `Found ${keysFound.length} keys in ${Object.keys(keysLocated)}` } apiLog.writer({ class: 'API', subclass: 'Key Discovery', type: 'INFO', context: JSON.stringify(context), message: `${results.message}, (${results.status})` }) return results } else { let results = { status: 404, // Not Found foundAt: undefined, allKeys: undefined, message: 'no keys found' } apiLog.writer({ class: 'API', subclass: 'Key Discovery', type: 'INFO', context: JSON.stringify(context), message: `${results.message}, (${results.status})` }) return } }, apiDataCheck : () => { }, apiLogger : apiLog }