crowdsec-http-middleware
Version:
HTTP server middleware that can act as a crowdsec bouncer/watcher
580 lines (568 loc) • 18.8 kB
JavaScript
// src/pkg.ts
var pkg = { name: "crowdsec-http-middleware", version: "0.0.8" };
// src/Validate.ts
var stringToBooleanMap = /* @__PURE__ */ new Map([
["true", true],
["y", true],
["yes", true],
["oui", true],
["on", true],
["1", true],
["false", false],
["n", false],
["no", false],
["non", false],
["off", false],
["0", false]
]);
var Validate = class _Validate {
static mail(mail) {
return _Validate.isString(mail) && !!/^([\w.%+-]+)@([\w-]+\.)+(\w{2,})$/i.exec(mail);
}
static uuid(uuid) {
return _Validate.isString(uuid) && !!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89AB][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(uuid);
}
// Returns if a value is a string
static isString(value) {
return typeof value === "string" || value instanceof String;
}
// Returns if a value is really a number
static isNumber(value) {
return typeof value === "number" && isFinite(value);
}
// Returns if a value is a function
static isFunction(value) {
return typeof value === "function";
}
// Returns if a value is an object
static isObject(value) {
return value != void 0 && typeof value === "object" && value.constructor === Object;
}
// Returns if a value is null
static isNull(value) {
return value === null;
}
// Returns if a value is undefined
static isUndefined(value) {
return typeof value === "undefined";
}
// in typescript, the equivalent is
// const a = undefined ?? bar
static isDefinedNotNull(value) {
return value !== void 0 && value !== null;
}
// Returns if a value is a boolean
static isBoolean(value) {
return typeof value === "boolean";
}
// Returns if a value is a regexp
static isRegExp(value) {
return value != void 0 && typeof value === "object" && value.constructor === RegExp;
}
// Returns if value is an error object
static isError(value) {
return value instanceof Error && typeof value.message !== "undefined";
}
// Returns if value is a date
static isDate(value, acceptTimestamp = true) {
return value instanceof Date || _Validate.isString(value) && !Number.isNaN(Date.parse(value.toString())) || acceptTimestamp && _Validate.isNumber(value) && !Number.isNaN(new Date(value));
}
// Returns if value is a Buffer
static isBuffer(value) {
return Buffer.isBuffer(value);
}
// Returns if a Symbol
static isSymbol(value) {
return typeof value === "symbol";
}
static implementsTKeys(obj, keys) {
if (!obj || !Array.isArray(keys) || _Validate.isString(obj) || _Validate.isNumber(obj) || _Validate.isBoolean(obj)) {
return false;
}
return keys.reduce((impl, key) => impl && key in obj, true);
}
static isType(obj, condition) {
return condition;
}
/**
* Return a boolean depending on the string . ("true", "y", "yes", "oui", "on" return true, else it's return false)
* @param {string} str
* @param {boolean} strict return null instead of false if not in list
* @return {boolean}
*/
static stringToBoolean(str, strict = false) {
return stringToBooleanMap.get((str || "").toLowerCase()) ?? (strict ? null : false);
}
};
var Validate_default = Validate;
// src/CrowdSecHTTPBouncerMiddleware.ts
import {
BouncerClient
} from "crowdsec-client";
// src/IpObjectsCacher.ts
import { LRUCache } from "lru-cache";
// src/utils.ts
import createDebug from "debug";
import { Address4, Address6 } from "ip-address";
var debug = createDebug(pkg.name);
var createDebugger = (name) => {
if (!name) {
throw new Error("name is mandatory");
}
return debug.extend(name);
};
var getIpObject = (ip) => {
try {
return new Address4(ip);
} catch (e) {
if (e.name === "AddressError") {
return new Address6(ip);
} else {
throw e;
}
}
};
// src/globals.ts
var MAX_IP_CACHE = 5e4;
// src/IpObjectsCacher.ts
var IpObjectsCacher = class {
ipObjectCache;
constructor(max = MAX_IP_CACHE) {
this.ipObjectCache = new LRUCache({
max
});
}
getIpObjectWithCache(value) {
const cachedIp = this.ipObjectCache.get(value);
if (cachedIp) {
return cachedIp;
}
const ip = getIpObject(value);
this.ipObjectCache.set(value, ip);
return ip;
}
};
// src/CommonsMiddleware.ts
var CommonsMiddleware = class {
logger;
constructor(name, options) {
this.logger = this.getLogger(name, options);
}
getLogger(name, options, extendedName) {
const defaultExtend = (extendName) => this.getLogger(name, options, extendName);
if (!options) {
const _debug = createDebugger(name);
const debug2 = extendedName ? _debug.extend(extendedName) : _debug;
return {
warn: (...args) => debug2("warning : %s", ...args),
debug: debug2,
info: debug2,
error: (...args) => debug2("error : %s", ...args),
extend: defaultExtend
};
}
if (typeof options === "function") {
return {
extend: defaultExtend,
...options(name)
};
}
return {
extend: defaultExtend,
...options
};
}
};
// src/CrowdSecHTTPBouncerMiddleware.ts
var CrowdSecHTTPBouncerMiddleware = class extends CommonsMiddleware {
clientOptions;
client;
//TODO store this on updates
get decisionsCount() {
return Object.keys(this.decisions || {}).reduce((previousValue, key) => previousValue + (this.decisions[key]?.length || 0), 0);
}
decisions = {};
options;
ipObjectCache;
/**
* allow to listen to decision events
*/
get decisionStream() {
return this._decisionStream;
}
_decisionStream;
constructor(options, clientOptions, cache) {
super("CrowdSecHTTPBouncerMiddleware", options.logger);
this.logger.debug("construct");
this.options = options;
this.clientOptions = clientOptions;
const auth = this.getBouncerAuthentication(options);
this.client = new BouncerClient({
url: this.clientOptions.url,
userAgent: this.clientOptions.userAgent,
timeout: this.clientOptions.timeout,
strictSSL: this.clientOptions.strictSSL,
auth
});
this.ipObjectCache = cache ?? new IpObjectsCacher(options.maxIpCache);
}
getBouncerAuthentication(bouncerOptions) {
this.logger.debug("getBouncerAuthentication");
if (Validate_default.implementsTKeys(bouncerOptions, ["key", "ca", "cert"])) {
return {
cert: bouncerOptions.cert,
key: bouncerOptions.key,
ca: bouncerOptions.ca
};
}
if (Validate_default.implementsTKeys(bouncerOptions, ["apiKey"])) {
return {
apiKey: bouncerOptions.apiKey
};
}
throw new Error("bad client configuration");
}
async start() {
this.logger.info("start");
await this.client.login();
this._decisionStream = this.client.Decisions.getStream({
interval: this.options?.pollingInterval,
scopes: ["ip", "range"]
});
this._decisionStream.on("error", (e) => {
this.logger.error("client stream error", e);
});
let timeout;
this._decisionStream.on("added", (decision) => {
try {
const ipObject = this.ipObjectCache.getIpObjectWithCache(decision.value);
const decisionId = ipObject.parsedAddress[0];
if (!this.decisions[decisionId]) {
this.decisions[decisionId] = [];
}
if (!this.decisions[decisionId].find(({ decision: d }) => this.isSameDecision(d, decision))) {
this.decisions[decisionId].push({ decision, selector: this.ipObjectCache.getIpObjectWithCache(decision.value) });
}
} catch (e) {
this.logger.error("fail to add decision", e, decision);
}
});
this._decisionStream.on("deleted", (decision) => {
const ipObject = this.ipObjectCache.getIpObjectWithCache(decision.value);
const decisionId = ipObject.parsedAddress[0];
this.decisions[decisionId] = (this.decisions[decisionId] || []).filter(({ decision: d }) => !this.isSameDecision(d, decision));
});
this._decisionStream.resume();
}
isSameDecision(d1, d2) {
return d1.value === d2.value && d1.type === d2.type;
}
async stop() {
this.logger.info("stop");
return this.client.stop();
}
middleware(ip, req) {
const localDebug = this.logger.extend("bouncerMiddleware");
localDebug.debug("start");
const currentAddress = this.ipObjectCache.getIpObjectWithCache(ip);
localDebug.debug("bouncerMiddleware receive request from %s", currentAddress.addressMinusSuffix);
localDebug.debug("start decision loop");
const decision = (this.decisions[currentAddress.parsedAddress[0]] || []).find(
({ selector }) => currentAddress.isInSubnet(selector)
);
localDebug.debug("end decision loop");
if (decision) {
req.decision = decision.decision;
}
localDebug.debug("end");
}
getMiddleware(getIpFromRequest) {
return (req, res) => {
const ip = getIpFromRequest(req);
this.middleware(ip, req);
};
}
};
// src/CrowdSecHTTPWatcherMiddleware.ts
import { WatcherClient } from "crowdsec-client";
import { MAX_CONFIDENCE } from "crowdsec-client-scenarios";
var SCENARIOS_PACKAGE_NAME = "crowdsec-client-scenarios";
var CrowdSecHTTPWatcherMiddleware = class extends CommonsMiddleware {
clientOptions;
client;
scenarios = [];
defaultScenarios = [];
options;
ipObjectCache;
constructor(options, clientOptions, cache) {
super("CrowdSecHTTPWatcherMiddleware", options.logger);
this.logger.debug("construct");
this.options = options;
this.clientOptions = clientOptions;
const auth = this.getWatcherAuthentication(options);
this.client = new WatcherClient({
url: this.clientOptions.url,
userAgent: this.clientOptions.userAgent,
timeout: this.clientOptions.timeout,
strictSSL: this.clientOptions.strictSSL,
heartbeat: options?.heartbeat,
auth
});
this.ipObjectCache = cache ?? new IpObjectsCacher(options.maxIpCache);
}
getWatcherAuthentication(watcherOptions) {
this.logger.debug("getWatcherAuthentication");
if (Validate_default.implementsTKeys(watcherOptions, ["key", "ca", "cert"])) {
return {
cert: watcherOptions.cert,
key: watcherOptions.key,
ca: watcherOptions.ca
};
}
if (Validate_default.implementsTKeys(watcherOptions, ["password", "machineID"])) {
return {
machineID: watcherOptions.machineID,
password: watcherOptions.password,
autoRenew: watcherOptions.autoRenew ?? true
};
}
throw new Error("bad client configuration");
}
async start() {
this.logger.info("start");
await this.loadDefaultsScenarios(true);
if (this.options.scenarios) {
this.options.scenarios.forEach(
(scenario) => this.addScenario(typeof scenario === "string" ? scenario : this.initScenario(scenario))
);
} else {
this.logger.debug(`add (${this.defaultScenarios.length}) default scenarios`);
this.defaultScenarios.forEach((scenario) => this.addScenario(this.initScenario(scenario)));
}
if (this.scenarios?.length > 0) {
this.logger.debug(`start to load (${this.scenarios.length}) scenarios`);
await Promise.all(this.scenarios.map((s) => s.loaded === false && s.load ? s.load() : Promise.resolve()));
}
this.logger.debug("login");
await this.client.login();
}
async loadDefaultsScenarios(allowFail = false) {
this.logger.info("loadDefaultsScenarios");
let defaultScenariosPackage;
try {
defaultScenariosPackage = await import(SCENARIOS_PACKAGE_NAME);
} catch (e) {
this.logger.error("fail to load defaultScenarios", e);
if (allowFail) {
return;
}
throw new Error(`fail to load "${SCENARIOS_PACKAGE_NAME}". You need to install ${SCENARIOS_PACKAGE_NAME} first`);
}
if (!defaultScenariosPackage.scenarios) {
throw new Error("fail to correctly load default scenarios");
}
this.defaultScenarios = defaultScenariosPackage.scenarios;
}
_addScenario(scenario) {
this.logger.debug("_addScenario", scenario.name);
if (!this.client) {
throw new Error("client need to be configured to register scenario");
}
this.scenarios.push(scenario);
if (scenario.announceToLAPI) {
this.client.scenarios.push(scenario.name);
}
}
addScenario(scenario) {
if (!scenario) {
throw new Error("scenario is needed");
}
this.logger.debug("add scenario", typeof scenario === "string" ? scenario : scenario.name);
let currentScenario;
if (typeof scenario === "string") {
if (!this.defaultScenarios) {
throw new Error(`defaultScenarios are not loaded . Did you call loadDefaultsScenarios() before ?`);
}
const defaultScenario = this.defaultScenarios.find((s) => s.name === scenario);
if (!defaultScenario) {
throw new Error(`no scenario found with name "${scenario}"`);
}
currentScenario = this.initScenario(defaultScenario);
} else {
currentScenario = scenario;
}
this._addScenario(currentScenario);
}
initScenario(scenarioConstructor) {
const scenarioOptions = this.options?.scenariosOptions;
return new scenarioConstructor(scenarioOptions);
}
async stop() {
this.logger.info("stop");
return this.client.stop();
}
extractIp(req) {
const localDebug = this.logger.extend("extractIp");
localDebug.debug("start extracting ip from request");
const othersIpsFound = [];
const ip = this.scenarios.reduce((currentIp, currentScenario) => {
if (currentIp || !currentScenario.extractIp) {
return currentIp;
}
const ipExtracted = currentScenario.extractIp(req);
if (ipExtracted?.confidence === MAX_CONFIDENCE) {
return ipExtracted.ip;
}
if (ipExtracted) {
othersIpsFound.push(ipExtracted);
}
return void 0;
}, void 0);
if (!ip && othersIpsFound.length > 0) {
localDebug.debug("no ip found with max confidence, check with less confidence");
return [...othersIpsFound].sort((a, b) => b.confidence - a.confidence)[0]?.ip;
}
localDebug.debug("ip found");
return ip;
}
/**
* Middleware function that processes incoming requests and checks for alerts based on IP address and request data.
*
* @param {string} ip - The IP address of the incoming request.
* @param {IncomingMessage & { decision?: Decision<any> }} req - The incoming request object.
*/
middleware(ip, req) {
const localDebug = this.logger.extend("watcherMiddleware");
localDebug.debug("start");
try {
const currentAddress = this.ipObjectCache.getIpObjectWithCache(ip);
localDebug.debug("watcherMiddleware receive request from ", currentAddress.addressMinusSuffix);
localDebug.debug("start check");
const alerts = this.scenarios.map((scenario) => {
const alerts2 = scenario.check?.(currentAddress, req);
if (!alerts2) {
return void 0;
}
return Array.isArray(alerts2) ? alerts2 : [alerts2];
}).flat().filter((v) => !!v);
localDebug.debug("end check");
if (alerts.length === 0) {
return;
}
localDebug.debug("start enrich");
const enrichedAlerts = alerts.map((alert) => {
return this.scenarios.reduce((previousValue, scenario) => {
if (!scenario.enrich || !previousValue) {
return previousValue;
}
return scenario.enrich(previousValue, req);
}, alert);
}).filter((v) => !!v);
localDebug.debug("end enrich");
if (enrichedAlerts.length === 0) {
return;
}
this.logger.debug(`ip ${ip} triggers alerts on scenarios : ${enrichedAlerts.map(({ scenario }) => scenario).join(", ")}`);
this.client.Alerts.pushAlerts(enrichedAlerts).catch((e) => console.error("fail to push alert", e));
} finally {
localDebug.debug("end");
}
}
getMiddleware(getIpFromRequest) {
return (req) => {
const ip = getIpFromRequest(req);
this.middleware(ip, req);
};
}
};
// src/CrowdSecHTTPMiddleware.ts
var defaultClientOptions = {
userAgent: `${pkg.name}/v${pkg.version}`
};
var CrowdSecHTTPMiddleware = class extends CommonsMiddleware {
clientOptions;
options;
watcher;
bouncer;
ipObjectCache;
constructor(options) {
super("CrowdSecHTTPMiddleware", options.logger);
this.logger.debug("construct");
this.options = {
protectedByHeader: true,
...options
};
this.clientOptions = {
...defaultClientOptions,
...options.clientOptions,
url: options.url
};
this.ipObjectCache = new IpObjectsCacher(options.maxIpCache);
const commonsOptions = {
logger: options.logger,
maxIpCache: options.maxIpCache
};
if (options.watcher) {
this.watcher = new CrowdSecHTTPWatcherMiddleware({ ...commonsOptions, ...options.watcher }, this.clientOptions);
}
if (options.bouncer) {
this.bouncer = new CrowdSecHTTPBouncerMiddleware({ ...commonsOptions, ...options.bouncer }, this.clientOptions);
}
}
async start() {
this.logger.info("start");
this.logger.debug("login");
await Promise.all([
this.bouncer ? this.bouncer.start() : Promise.resolve(),
this.watcher ? this.watcher.start() : Promise.resolve()
]);
}
/**
* extract current ip from the request .
* First try the option getCurrentIp, second check if a scenario allow to extractIp
* @param req
* @private
*/
getCurrentIpFromRequest(req) {
if (this.options.getCurrentIp) {
return this.options.getCurrentIp(req);
}
const ip = this.watcher?.extractIp(req);
if (!ip) {
throw new Error('no scenario can extract the ip from this request . And no option "getCurrentIp" to extract it');
}
return ip;
}
getIpObjectFromReq(req) {
const ip = this.getCurrentIpFromRequest(req);
req.ip = ip;
return this.ipObjectCache.getIpObjectWithCache(ip);
}
middlewareFunction = (req, res) => {
const currentIp = this.getIpObjectFromReq(req);
if (!currentIp.addressMinusSuffix) {
throw new Error("fail to get address without suffix");
}
this.bouncer?.middleware(currentIp.addressMinusSuffix, req);
this.watcher?.middleware(currentIp.addressMinusSuffix, req);
if (this.options.protectedByHeader && res) {
res.appendHeader("X-Protected-By", "CrowdSec");
}
};
getMiddleware() {
this.logger.debug("getMiddleware");
return this.middlewareFunction;
}
async stop() {
this.logger.info("stop");
await Promise.all([this.bouncer?.stop(), this.watcher?.stop()]);
}
};
// src/index.ts
export * from "crowdsec-client";
var VERSION = pkg.version;
export {
CrowdSecHTTPMiddleware,
VERSION
};
//# sourceMappingURL=index.mjs.map