UNPKG

crowdsec-http-middleware

Version:
580 lines (568 loc) 18.8 kB
// 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