express-keenio
Version:
Express middleware for creating events from request-responses.
318 lines (262 loc) • 11.4 kB
JavaScript
"use strict";
var helpers = require('./core/helpers');
var KeenEventModule = (function (options, eventEmitter) {
this._ee = helpers.setDefaultEvents(eventEmitter, ['error', 'debug']);
var MAX_PROPERTY_HIERARCHY_DEPTH = options.defaults && options.defaults.MAX_PROPERTY_HIERARCHY_DEPTH || 10,
MAX_STRING_LENGTH = options.defaults && options.defaults.MAX_STRING_LENGTH || 1000,
MAX_PROPERTY_QUANTITY = options.defaults && options.defaults.MAX_PROPERTY_QUANTITY || 300;
this._setupAddons = function (keenEvent, addonSwitches) {
var keenAddons = [];
if (keenEvent.identity.ipAddress && addonSwitches.ipToGeo) {
keenAddons.push({
name: "keen:ip_to_geo",
input: {
"ip": "identity.ipAddress"
},
output: "identity.ipGeography"
});
}
if (keenEvent.identity.userAgent && addonSwitches.userAgentParser) {
keenAddons.push({
name: "keen:ua_parser",
input: {
"ua_string": "identity.userAgent"
},
output: "identity.parsedUserAgent"
});
}
return keenAddons;
};
this.generateEvent = function (identity, parsedRequestData, parsedResponseData, routeConfig) {
var keenEvent = {
identity: identity,
intention: {
method: parsedRequestData.method,
path: parsedRequestData.path,
params: parsedRequestData.params,
body: parsedRequestData.body,
query: parsedRequestData.query
},
reaction: parsedResponseData.body,
httpStatus: parsedResponseData.status,
environment: {
library: 'express-keenio',
ipAddress: '${keen.ip}',
userAgent: '${keen.user_agent}'
},
keen: {}
};
keenEvent.keen.addons = this._setupAddons(keenEvent, options.defaults.addons);
if (parsedRequestData.referer) {
keenEvent.intention.referer = parsedRequestData.referer;
}
if (routeConfig.tag) {
keenEvent.tag = routeConfig.tag;
}
return this.sanitize(keenEvent, routeConfig.whitelistProperties || {});
};
this.sanitize = function (data, whitelistPropertiesObject) {
this._checkPropertyDepth(data, true);
this._checkForNonWhitelist(data, whitelistPropertiesObject);
return this._sanitizeData(data);
};
// This allows us to specify a max-depth of an object and ensure that any properties deeper than
// this are stripped out of the object.
this._checkPropertyDepth = function (data, smite, level) {
level = level || 1;
smite = smite || false;
var self = this,
depth = level;
helpers.forEach(data, function (value, key, data) {
if (level > MAX_PROPERTY_HIERARCHY_DEPTH) {
var isSmiteMessage = '';
if (smite) {
isSmiteMessage = 'and has been smited.';
delete data[key];
}
self._ee.emit('debug', 'WARNING: The depth of the key (' + key + ') is greater than ' + MAX_PROPERTY_HIERARCHY_DEPTH + isSmiteMessage);
}
if (helpers.isEnumerable(value)) {
depth = self._checkPropertyDepth(value, smite, level + 1);
}
depth = Math.max(depth, level);
});
return depth;
};
// Unless the keys 'query', 'body' or 'reaction' have been specified this won't do anything.
this._checkForNonWhitelist = function (data, whitelistPropertiesObject) {
if (!whitelistPropertiesObject) {
return data;
}
if (whitelistPropertiesObject.query) {
data.intention.query = this._stripNonWhitelistedDeepProperties(data.intention.query, whitelistPropertiesObject.query);
}
if (whitelistPropertiesObject.body) {
data.intention.body = this._stripNonWhitelistedDeepProperties(data.intention.body, whitelistPropertiesObject.body);
}
if (whitelistPropertiesObject.reaction) {
data.reaction = this._stripNonWhitelistedDeepProperties(data.reaction, whitelistPropertiesObject.reaction);
}
return data;
};
// I think it makes sense to run this before the final sanitisation function, and not as a part of it.
// I originally assumed this was used by the query whitelisting, but it later turns out that Express parses
// queries like this ?ob[type]=man&obj[code]=seb into objects...
this._stripNonWhitelistedProperties = function (data, whitelistProperties) {
var self = this,
PROPERTY_WHITELIST = whitelistProperties;
// Genuinely in most cases if this was a query you wouldn't need to recurse, but...
if (!helpers.isEnumerable(data)) {
return data;
} else {
helpers.forEach(data, function (value, key) {
if (PROPERTY_WHITELIST.indexOf(key) === -1) {
self._ee.emit('debug', "WARNING: The property (" + key + ") is not in the whitelist and has been smited.");
delete data[key];
} else {
data[key] = self._stripNonWhitelistedProperties(value, whitelistProperties);
}
});
}
return data;
};
// This method allows the data to be stripped of deeep properties which exactly match those inside
// the whitelistProperties array. I apologise for how complicated this got.
this._stripNonWhitelistedDeepProperties = function (data, whitelistProperties, parentKey, level) {
parentKey = parentKey || '';
level = level || 0;
var self = this;
// If a deep property exists then that means its parent keys must be whitelisted!
// We need to make sure that we are not deleting the property 'deep' when we have been passed
// a whitelist containing properties like 'deep.property.is.here' and 'deep.key'.
// We do this by making a new property whitelist of properties up to the current level.
var PROPERTY_WHITELIST_AT_LEVEL = whitelistProperties.map(function (wp) {
return wp.split('.').slice(0, level + 1).join('.');
}).filter(helpers.identity); // Filter falsey properties.
if (!helpers.isEnumerable(data)) {
return data;
} else {
helpers.forEach(data, function (value, key) {
// The current key is not just the key we are looping through but prefixed by the parentKey.
// We do this because we only wish to whitelist a key that matches 'deep.property.here' and
// not a key in the root of the object like 'here'.
var keyUpToLevel = parentKey + key;
// If the value of the property is an array, we want to make sure it's marked as an array.
if (helpers.isArray(value)) {
keyUpToLevel = keyUpToLevel + '[]';
}
// If a key is numberic, we ignore it: the judgement is that it's part of an array
// and we wish to allow deep properties like 'deep.array[].name' to whitelist the names of all array elements.
if (helpers.isNumber(key)) {
data[key] = self._stripNonWhitelistedDeepProperties(value, whitelistProperties, parentKey, level + 1);
} else if (PROPERTY_WHITELIST_AT_LEVEL.indexOf(keyUpToLevel) === -1) {
self._ee.emit('debug', "WARNING: The property (" + key + ") is not in the whitelist and has been smited.");
delete data[key];
} else {
// We keep on recursing, passing in the currentKeyAtThisLevel + '.' into the new parentKey as well as increasing
// the level that we are recursing at.
data[key] = self._stripNonWhitelistedDeepProperties(value, whitelistProperties, keyUpToLevel + '.', level + 1);
}
});
}
return data;
};
// A list of rules are specified within, and these are acted upon the object's properties and values.
// It will strip out the properties which are valid or that contain invalid values.
this._sanitizeData = function (data) {
var self = this;
var propertyCount = 0;
function execute (data) {
if (!helpers.isEnumerable(data)) {
return data;
} else {
helpers.forEach(data, function (value, key) {
var isSmitedDueToMaxProperty = false;
if (propertyCount >= MAX_PROPERTY_QUANTITY) {
self._ee.emit('debug', "WARNING: The property (" + key + ") has been smited due to there being too many properties in the Keen Event.");
delete data[key];
isSmitedDueToMaxProperty = true;
}
var isSmitedDueToProperty = false;
if (!isSmitedDueToMaxProperty && helpers.isObject(data)) {
isSmitedDueToProperty = (self._checkBlacklist(value, key, data) || self._checkInvalidProperty(value, key, data));
}
// No point in smiting the value if you have previously smited the property.
var isSmitedDueToValue = false;
if (!isSmitedDueToMaxProperty && !isSmitedDueToProperty) {
isSmitedDueToValue = (self._checkForFunctions(value, key, data) ||
self._checkForExtremelyLongStrings(value, key, data) ||
self._checkForArraysOfObjects(value, key, data));
}
var isSmited = isSmitedDueToProperty || isSmitedDueToValue || isSmitedDueToMaxProperty;
if (!isSmited) {
propertyCount += 1;
data[key] = execute(value);
}
});
}
return data;
}
return execute(data);
};
this._checkBlacklist = function (value, key, data) {
var PROPERTY_BLACKLIST = ['password'].concat(options && options.blacklistProperties || []);
if (PROPERTY_BLACKLIST.indexOf(key) !== -1) {
this._ee.emit('debug', "WARNING: The property (" + key + ") is blacklisted and has been smited.");
delete data[key];
return true;
}
return false;
};
this._checkInvalidProperty = function (value, key, data) {
if (!this._isValidProperty(key)) {
this._ee.emit('debug', "WARNING: The property (" + key + ") is not a valid Keen.IO property and has been smited.");
delete data[key];
return true;
}
return false;
};
this._checkForArraysOfObjects = function (value, key, data) {
// @todo: https://github.com/sebinsua/express-keenio/issues/7
var isNotAddons = key !== 'addons';
if (helpers.isArrayOfObjects(value) && isNotAddons) {
this._ee.emit('debug', "WARNING: An array of objects was found at property (" + key + ") and has been SMITED from the event.");
delete data[key];
return true;
}
return false;
};
this._checkForFunctions = function (value, key, data) {
if (helpers.isFunction(value)) {
this._ee.emit('debug', "WARNING: The value found at property (" + key + ") is a function and has been smited.");
delete data[key];
return true;
}
return false;
};
this._checkForExtremelyLongStrings = function (value, key, data) {
if (helpers.isString(value) && value.length > MAX_STRING_LENGTH) {
this._ee.emit('debug', "WARNING: The string found at property (" + key + ") is huge and has been smited.");
delete data[key];
return true;
}
return false;
};
this._isValidProperty = function (potentialProperty) {
if (!potentialProperty) {
return false;
}
if (potentialProperty.length > 256) {
return false;
}
if (potentialProperty.charAt(0) === '$') {
return false;
}
if (potentialProperty.indexOf('.') !== -1) {
return false;
}
return true;
};
return this;
}).bind({});
exports = module.exports = KeenEventModule;