express-jail
Version:
Express middleware which add malicious actors into a Fail2Ban jail
243 lines (221 loc) • 9.5 kB
JavaScript
var debug = require('debug')('express-jail');
var exec = require('@momsfriendlydevco/exec');
var eventer = require('@momsfriendlydevco/eventer');
var semverGte = require('semver/functions/gte')
/**
* Actual express middleware layer
* @param {Object} [options] Options to adjust behaviour, `expressJail.defaults` is used to populate this
* @returns {ExpressMiddleware} Express compatible middleware function
*
* @emits ban Emitted as `({ip, req?, res?})` before an IP is banned, if the async return is boolean `false` the adding operation is aborted
* @emits banned Emitted as `({ip, req?, res?})` after an IP is banned
*
* @emits unban Emitted as `({ip, req?, res?})` before an IP is unbanned, if the async return is boolean `false` the removal operation is aborted
* @emits unbanned Emitted as `({ip, req?, res?})` after an IP is unbanned
*/
var expressJail = module.exports = function expressJailMiddleware(options) {
var settings = {
...expressJail.defaults,
...options,
};
// Convert settings.paths into a Set for easier querying / dedupeing
settings.paths = new Set(settings.paths);
// Boot & prepare jail
var bootComplete = false;
var bootPromise = Promise.resolve()
// Fetch + check version {{{
.then(()=> jailMiddleware.version())
.then(f2bVersion => settings.minVersion && semverGte(f2bVersion, settings.minVersion) ? true : Promise.reject(`F2B version ${f2bVersion} is lower than required minimum of ${settings.minVersion}`))
// }}}
// Try pinging server {{{
.then(()=> exec([...settings.clientBinary, 'ping'], {buffer: true})
.catch(e => { throw new Error(`F2B-client ping error: ${e.toString()}`) })
.then(res => res != 'Server replied: pong' && Promise.reject(`Unexpected response when pinging F2B - ${res}`))
)
// }}}
// Query existing jails list {{{
.then(()=> exec([...settings.clientBinary, 'status'], {buffer: true})
.catch(e => { throw new Error(`F2B-client status error: ${e.toString()}`) })
)
// }}}
// Check jail exists or create it {{{
.then(res => {
var f2bRes = /\- Jail list:\s*(?<jails>.*)/ms.exec(res)?.groups;
f2bRes = {
jails: f2bRes?.jails
? f2bRes.jails.split(/\s*,\s*/)
: []
};
if (f2bRes.jails.includes(settings.jail)) {
debug(`F2B jail "${settings.jail}" already exists - skipping creation`);
} else {
debug(`F2B jail "${settings.jail}" doesnt exist - creating`);
return exec([...settings.clientBinary, 'add', settings.jail, 'auto'])
.catch(e => { throw new Error(`F2B-client jail creation error: ${e.toString()}`) })
}
})
// }}}
// F2B setup {{{
.then(()=> jailMiddleware.setup())
// }}}
// End {{{
.then(()=> bootComplete = true)
.catch(e => console.warn('express-jail - setup error', e))
// }}}
var jailMiddleware = function(req, res, next) {
Promise.resolve()
.then(()=> !bootComplete && bootPromise)
.then(()=> {
if (settings.paths.has(req.path)) {
return jailMiddleware.ban(req.ip, {req, res})
.then(()=> res.sendStatus(settings.responseCode))
} else {
next();
}
})
};
/**
* Setup / configure the F2B jail
* @returns {Promise} A promise which resolves when the operation has completed
*/
jailMiddleware.setup = function expressJailSetup() {
return Promise.resolve()
// Setup jail action iptables-multiport if its not already present {{{
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'addaction', 'iptables-multiport'])
.then(()=> Promise.resolve()
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'action', 'iptables-multiport', 'actionstart', `iptables -N f2b-${settings.jail}\niptables -A f2b-${settings.jail} -j RETURN\niptables -I INPUT -p tcp -m multiport --dports ${settings.jailPorts} -j f2b-${settings.jail}`])
.catch(e => { throw new Error(`F2B-client setup-action-start error: ${e.toString()}`) })
)
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'action', 'iptables-multiport', 'actionstop', `iptables -D INPUT -p tcp -m multiport --dports ${settings.jailPorts} -j f2b-${settings.jail}\niptables -F f2b-${settings.jail}\niptables -X f2b-${settings.jail}`])
.catch(e => { throw new Error(`F2B-client setup-action-stop error: ${e.toString()}`) })
)
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'action', 'iptables-multiport', 'actionflush', `iptables -F f2b-${settings.jail}`])
.catch(e => { throw new Error(`F2B-client setup-action-flush error: ${e.toString()}`) })
)
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'action', 'iptables-multiport', 'actioncheck', `iptables -n -L INPUT | grep -q 'f2b-${settings.jail}[ \\t]'`])
.catch(e => { throw new Error(`F2B-client setup-action-check error: ${e.toString()}`) })
)
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'action', 'iptables-multiport', 'actionban', `iptables -I f2b-${settings.jail} 1 -s <ip> -j REJECT --reject-with icmp-port-unreachable`])
.catch(e => { throw new Error(`F2B-client setup-action-ban error: ${e.toString()}`) })
)
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'action', 'iptables-multiport', 'actionunban', `iptables -D f2b-${settings.jail} -s <ip> -j REJECT --reject-with icmp-port-unreachable`])
.catch(e => { throw new Error(`F2B-client setup-action-unban error: ${e.toString()}`) })
)
)
.catch(e => {
if (e === 'Non-zero exit code: 255') return; // Skip already-exists errors
throw e;
})
)
// }}}
// Start jail - if not already started {{{
.then(()=> exec([...settings.clientBinary, 'start', settings.jail]))
// }}}
}
/**
* Retrieve the Fail2Ban version number
* @returns {Promise<string>} A promise which resolves when the operation has completed with the F2B version
*/
jailMiddleware.version = function expressJailVersion() {
return Promise.resolve()
.then(()=> exec([...settings.clientBinary, '--version'], {buffer: true})
.catch(e => { throw new Error(`F2B-client query-version error: ${e.toString()}`) })
)
.then(buf => /^Fail2Ban v(?<version>[\d\.]+).*/m.exec(buf)?.groups.version)
.then(version => version || Promise.reject('Unable to query Fail2Ban version - is client installed?'))
}
/**
* Ban an incomming IP address by adding it to the F2B jail
* @param {string} ip The IP address to ban
* @param {Object} [context] Optional additional named object parameters to pass to emitter
* @returns {Promise} A promise which resolves when the operation has completed
*
*/
jailMiddleware.ban = function expressJailBan(ip, context) {
return Promise.resolve()
.then(()=> jailMiddleware.emit('ban', {ip, ...context}))
.then(doBan => { if (doBan === false) throw 'SKIP' })
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'banip', ip], {buffer: true})
.catch(e => { throw new Error(`F2B-client add-to-jail error: ${e.toString()}`) })
)
.then(()=> jailMiddleware.emit('banned', {ip, ...context}))
.catch(e => {
if (e === 'SKIP') return;
throw e;
})
}
/**
* Unban an incomming IP address by removing it from the F2B jail
* @param {string} ip The IP address to unban
* @param {Object} [context] Optional additional named object parameters to pass to emitter
* @returns {Promise} A promise which resolves when the operation has completed
*
*/
jailMiddleware.unban = function expressJailUnban(ip, context) {
return Promise.resolve()
.then(()=> jailMiddleware.emit('unban', {ip, ...context}))
.then(doBan => { if (doBan === false) throw 'SKIP' })
.then(()=> exec([...settings.clientBinary, 'set', settings.jail, 'unbanip', ip], {buffer: true})
.catch(e => { throw new Error(`F2B-client remove-from-jail error: ${e.toString()}`) })
)
.then(()=> jailMiddleware.emit('unbanned', {ip, ...context}))
.catch(e => {
if (e === 'SKIP') return;
throw e;
})
}
/**
* Return all bans in the jail
*/
jailMiddleware.bans = function expressJailBans(ip) {
return Promise.resolve()
.then(()=> exec([...settings.clientBinary, 'get', settings.jail, 'banip', '--with-time'], {buffer: true})
.catch(e => { throw new Error(`F2B-client query-jail error: ${e.toString()}`) })
)
.then(bans => bans.split(/\n/)
.map(line => /^(?<ip>[\d\.]+)\s+(?<from>[\d\-\s:]+) \+ (?<time>\d+) = (?<to>[\d\-\s:]+)$/.exec(line)?.groups)
.filter(Boolean)
.map(ban => ({
ip: ban.ip,
from: new Date(ban.from),
time: parseInt(ban.time),
to: new Date(ban.to),
}))
)
};
/**
* Query if a given IP exists within the jail
* This is really just a lazy bans() + filter convenience function
* @param {string} ip The IP to query for
*/
jailMiddleware.hasBan = function expressJailHasBan(ip) {
return jailMiddleware.bans()
.then(bans => bans.some(ban => ban.ip == ip))
};
eventer.extend(jailMiddleware);
return jailMiddleware;
};
expressJail.defaults= {
paths: [
'/ 3ms',
'/.aws/config',
'/.env',
'//secure/ManageFilters.jspa',
'/.git/HEAD',
'/id_dsa',
'/id_rsa',
'//plugins/servlet/gadgets/makeRequest',
'/plugins/servlet/Wallboard/',
'/.svn/entries',
'/.ssh/id_rsa',
'/.ssh/id_dsa',
'/clients/2345/2345.js',
'/ecp/Current/exporttool/microsoft.exchange.ediscovery.exporttool.application',
],
responseCode: 404,
clientBinary: ['/usr/bin/sudo', '/usr/bin/fail2ban-client'],
jail: 'www',
jailPorts: 'http,https',
minVersion: '0.11.1',
setup: true,
};