morphit/node_modules/launder/index.js

538 lines
16 KiB
JavaScript

const dayjs = require('dayjs');
// Strip characters browsers ignore inside URLs (control chars and
// embedded HTML comments) that are commonly used to sneak XSS
// payloads past simple scheme checks.
function cleanHref(href) {
// Browsers ignore character codes of 32 (space) and below in a surprising
// number of situations. Start reading here:
// https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab
// eslint-disable-next-line no-control-regex
href = href.replace(/[\x00-\x20]+/g, '');
// Clobber any comments in URLs, which the browser might
// interpret inside an XML data island, allowing
// a javascript: URL to be snuck through
while (true) {
const firstIndex = href.indexOf('<!--');
if (firstIndex === -1) {
break;
}
const lastIndex = href.indexOf('-->', firstIndex + 4);
if (lastIndex === -1) {
break;
}
href = href.substring(0, firstIndex) + href.substring(lastIndex + 3);
}
return href;
}
// Returns true if `href` should be rejected as unsafe. Used to block
// `javascript:`, `data:` and other dangerous schemes both when
// validating URL fields and when sanitizing HTML attribute values.
//
// `options.allowedSchemes` is an array of permitted URL schemes
// (defaults to the schemes long permitted by `launder().url`).
// `options.allowProtocolRelative` controls whether protocol-relative
// URLs (e.g. `//example.com`) are permitted. Defaults to true.
function naughtyHref(href, options) {
options = options || {};
const allowedSchemes = options.allowedSchemes ||
[ 'http', 'https', 'ftp', 'mailto', 'tel', 'sms' ];
const allowProtocolRelative = (options.allowProtocolRelative !== false);
if (typeof href !== 'string') {
return false;
}
href = cleanHref(href);
// Case insensitive so we don't get faked out by JAVASCRIPT #1
// Allow more characters after the first so we don't get faked
// out by certain schemes browsers accept
const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/);
if (!matches) {
// Protocol-relative URL starting with any combination of '/' and '\'
if (href.match(/^[/\\]{2}/)) {
return !allowProtocolRelative;
}
// No scheme
return false;
}
const scheme = matches[1].toLowerCase();
return allowedSchemes.indexOf(scheme) === -1;
}
module.exports = function(options) {
const self = {};
self.options = options || {};
self.filterTag = self.options.filterTag || function(tag) {
tag = tag.trim();
return tag.toLowerCase();
};
self.string = function(s, def) {
if (typeof (s) !== 'string') {
if ((typeof (s) === 'number') || (typeof (s) === 'boolean')) {
s += '';
} else {
s = '';
}
}
s = s.trim();
if (def !== undefined) {
if (s === '') {
s = def;
}
}
return s;
};
self.strings = function(strings) {
if (!Array.isArray(strings)) {
return [];
}
return strings.map(function(s) {
return self.string(s);
});
};
self.integer = function(i, def, min, max) {
if (def === undefined) {
def = 0;
}
if (typeof (i) === 'number') {
i = Math.floor(i);
} else {
try {
i = parseInt(i, 10);
if (isNaN(i)) {
i = def;
}
} catch (e) {
i = def;
}
}
if ((typeof (min) === 'number') && (i < min)) {
i = min;
}
if ((typeof (max) === 'number') && (i > max)) {
i = max;
}
return i;
};
self.padInteger = function(i, places) {
let s = i + '';
while (s.length < places) {
s = '0' + s;
}
return s;
};
self.float = function(i, def, min, max) {
if (def === undefined) {
def = 0;
}
if (!(typeof (i) === 'number')) {
try {
i = parseFloat(i, 10);
if (isNaN(i)) {
i = def;
}
} catch (e) {
i = def;
}
}
if ((typeof (min) === 'number') && (i < min)) {
i = min;
}
if ((typeof (max) === 'number') && (i > max)) {
i = max;
}
return i;
};
self.naughtyHref = naughtyHref;
self.url = function(s, def, httpsFix) {
s = self.string(s, def);
// Allow the default to be undefined, null, false, etc.
if (s === def) {
return s;
}
// Strip characters browsers ignore that are commonly used to
// smuggle dangerous schemes past validation.
s = cleanHref(s);
// Reject naughty URLs (e.g. `javascript:`, `data:`) before fixUrl
// runs. Otherwise fixUrl could mask such schemes by prepending
// `https://` to anything containing a `.`, turning
// `javascript:alert(document.domain)` into a syntactically odd
// but accepted `https://...` URL.
if (naughtyHref(s)) {
return def;
}
s = fixUrl(s);
if (s === null) {
return def;
}
return s;
function fixUrl(href) {
if (href.match(/^(((https?|ftp):\/\/)|((mailto|tel|sms):)|#|([^/.]+)?\/|[^/.]+$)/)) {
// All good - no change required
return href;
} else if (href.match(/^[^/.]+\.[^/.]+/)) {
// Smells like a domain name. Educated guess: they left off http://
const protocol = httpsFix ? 'https://' : 'http://';
return protocol + href;
} else {
return null;
}
};
};
self.select = function(s, choices, def) {
s = self.string(s);
if (!choices || !choices.length) {
return def;
}
let choice;
if (typeof (choices[0]) === 'object') {
choice = choices.find(function(choice) {
if ((choice.value === null) || (choice.value === undefined)) {
// Don't crash on invalid choices
return false;
}
return choice.value.toString() === s;
});
if (choice != null) {
return choice.value;
}
return def;
}
choice = choices.find(function(choice) {
if ((choice === null) || (choice === undefined)) {
// Don't crash on invalid choices
return false;
}
return choice.toString() === s;
});
if (choice !== undefined) {
return choice;
}
return def;
};
self.boolean = function(b, def) {
if (b === true) {
return true;
}
if (b === false) {
return false;
}
b = self.string(b, def);
if (b === def) {
if (b === undefined) {
return false;
}
return b;
}
b = b.toLowerCase().charAt(0);
if ((b === '') || (b === 'n') || (b === '0') || (b === 'f')) {
return false;
}
if ((b === 't') || (b === 'y') || (b === '1')) {
return true;
}
return false;
};
// Given an `options` object in which options[name] is a string
// set to '0', '1', or 'any', this method adds mongodb criteria
// to the `criteria` object.
//
// '0' or false means "the property must be false or absent," '1' or true
// means "the property must be true," and 'any' or null means "we don't care
// what the property is."
//
// See `booleanOrNull` for additional synonyms accepted for the
// three possible values.
//
// An empty string is considered equivalent to '0'.
//
// This is not the same as apos.sanitizeBoolean which is concerned only with
// true or false and does not address "any."
//
// `def` defaults to `any`.
//
// This method is most often used with REST API parameters and forms.
self.addBooleanFilterToCriteria = function(options, name, criteria, def) {
// if any or null, we aren't changing criteria
if (def === undefined) {
def = null;
}
// allow object or boolean
let value = (typeof (options) === 'object' && options !== null) ? options[name] : options;
value = (value === undefined) ? def : value;
value = self.booleanOrNull(value);
if (value === null) {
// Don't care, show all
} else if (!value) {
// Must be absent or false. Hooray for $ne
criteria[name] = { $ne: true };
} else {
// Must be true
criteria[name] = true;
}
};
// This method is used for tristate filters, i.e. "published,"
// "unpublished", and "show me both".
//
// Accepts `true`, `false`, or `null` and returns them exactly
// as such; if the parameter is none of those or their synonyms
// below, returns `def`.
//
// Also accepts the strings `'yes'` (or starting with y), `'no'` (or
// starting with n), `'true'` (or starting with t), `'false'`
// (or starting with f), `'1'`, `'0'` and the strings `'any'` and
// `'null'`. The string `'null'` must be an exact match, anything
// else starting with `n` is taken as `no` (false).
//
// These various synonyms are useful for string input, such as from
// a user friendly query string.
self.booleanOrNull = function(b, def) {
if (b === true) {
return b;
}
if (b === false) {
return b;
}
if (b === null) {
return b;
}
b = self.string(b, def);
if (b === def) {
if (def === undefined) {
return null;
}
return b;
}
// String 'null' must match as a full string to disambiguate from string 'n' for 'no'
if (b === 'null') {
return null;
}
b = b.toLowerCase().charAt(0);
if ((b === '') || (b === 'n') || (b === '0') || (b === 'f')) {
return false;
}
if ((b === 't') || (b === 'y') || (b === '1')) {
return true;
}
if ((b === 'a')) {
return null;
}
return def;
};
// Accept a user-entered string in YYYY-MM-DD, MM/DD, MM/DD/YY, or MM/DD/YYYY format
// (tolerates missing leading zeroes on MM and DD). Also accepts a Date object.
// Returns YYYY-MM-DD.
//
// The current year is assumed when MM/DD is used. If there is no explicit default
// any unparseable date is returned as today's date.
//
// If the default is explicitly `null` (not `undefined`) then `null` is returned for
// any unparseable date.
//
// The `now` argument can be passed for performance if you prefer to call
// `new Date()` just once before many calls to this method and pass that single
// value to all of them.
self.date = function(date, def, now) {
let components;
function returnDefault() {
if (def === undefined) {
def = dayjs().format('YYYY-MM-DD');
}
return def;
}
if (typeof (date) === 'string') {
if (date.match(/\//)) {
components = date.split('/');
if (components.length === 2) {
// Convert mm/dd to yyyy-mm-dd
return (now || new Date()).getFullYear() + '-' + self.padInteger(components[0], 2) + '-' + self.padInteger(components[1], 2);
} else if (components.length === 3) {
// Convert mm/dd/yy to mm/dd/yyyy
if (components[2] < 100) {
// Add the current century. If the result is more than
// 50 years in the future, assume they meant the
// previous century. Thus in 2015, we find that
// we get the intuitive result for both 1/1/75,
// 1/1/99 and 1/1/25. It's a nasty habit among
// us imprecise humans. -Tom
const d = (now || new Date());
const nowYear = d.getFullYear() % 100;
const nowCentury = d.getFullYear() - nowYear;
let theirYear = parseInt(components[2]) + nowCentury;
if (theirYear - d.getFullYear() > 50) {
theirYear -= 100;
}
components[2] = theirYear;
}
// Convert mm/dd/yyyy to yyyy-mm-dd
return self.padInteger(components[2], 4) + '-' + self.padInteger(components[0], 2) + '-' + self.padInteger(components[1], 2);
} else {
return returnDefault();
}
} else if (date.match(/-/)) {
components = date.split('-');
if (components.length === 2) {
// Convert mm-dd to yyyy-mm-dd
return (now || new Date()).getFullYear() + '-' + self.padInteger(components[0], 2) + '-' + self.padInteger(components[1], 2);
} else if (components.length === 3) {
// Convert yyyy-mm-dd (with questionable padding) to yyyy-mm-dd
return self.padInteger(components[0], 4) + '-' + self.padInteger(components[1], 2) + '-' + self.padInteger(components[2], 2);
} else {
return returnDefault();
}
}
}
try {
if (date === null) {
return returnDefault();
}
date = (now || new Date(date));
if (isNaN(date.getTime())) {
return returnDefault();
}
return date.getFullYear() + '-' + self.padInteger(date.getMonth() + 1, 2) + '-' + self.padInteger(date.getDate(), 2);
} catch (e) {
return returnDefault();
}
};
// This is likely not relevent to you unless you're using Apostrophe
// Given a date object, return a date string in Apostrophe's preferred sortable,
// comparable, JSON-able format, which is YYYY-MM-DD. If `date` is undefined
// the current date is used.
self.formatDate = function(date) {
return dayjs(date).format('YYYY-MM-DD');
};
// Accepts a user-entered string in 12-hour or 24-hour time and returns a string
// in 24-hour time. This method is tolerant of syntax such as `4pm`; minutes and
// seconds are optional.
//
// If `def` is not set the default is the current time.
self.time = function(time, def) {
time = self.string(time).toLowerCase();
time = time.trim();
const components = time.match(/^(\d+)([:|.](\d+))?([:|.](\d+))?\s*(am|pm|AM|PM|a|p|A|M)?$/);
if (components) {
let hours = parseInt(components[1], 10);
const minutes = (components[3] !== undefined) ? parseInt(components[3], 10) : 0;
const seconds = (components[5] !== undefined) ? parseInt(components[5], 10) : 0;
let ampm = (components[6]) ? components[6].toLowerCase() : components[6];
ampm = ampm && ampm.charAt(0);
if ((hours === 12) && (ampm === 'a')) {
hours -= 12;
} else if ((hours === 12) && (ampm === 'p')) {
// Leave it be
} else if (ampm === 'p') {
hours += 12;
}
if ((hours === 24) || (hours === '24')) {
hours = 0;
}
return self.padInteger(hours, 2) + ':' + self.padInteger(minutes, 2) + ':' + self.padInteger(seconds, 2);
} else {
if (def !== undefined) {
return def;
}
return dayjs().format('HH:mm');
}
};
// This is likely not relevent to you unless you're using Apostrophe
// Given a JavaScript Date object, return a time string in
// Apostrophe's preferred sortable, comparable, JSON-able format:
// 24-hour time, with seconds.
//
// If `date` is missing the current time is used.
self.formatTime = function(date) {
return dayjs(date).format('HH:mm:ss');
};
// Sanitize tags. Tags should be submitted as an array of strings,
// or a comma-separated string.
//
// This method ensures the input is an array or string and, if
// an array, that the elements of the array are strings.
//
// If a filterTag function is passed as an option when initializing
// Launder, then all tags are passed through it (as individual
// strings, one per call) and the return value is used instead. You
// may also pass a filterTag when calling this function
self.tags = function(tags, filter) {
if (typeof (tags) === 'string') {
tags = tags.split(/,\s*/);
}
if (!Array.isArray(tags)) {
return [];
}
const strings = tags.map(tag => self.string(tag));
const rewritten = strings.map(filter || self.filterTag);
const filtered = rewritten.filter(tag => tag.length > 0);
return filtered;
};
// Sanitize an id. IDs must consist solely of upper and lower case
// letters, numbers, and digits unless options.idRegExp is set.
self.idRegExp = self.options.idRegExp || /^[A-Za-z0-9_]+$/;
self.id = function(s, def) {
const id = self.string(s, def);
if (id === def) {
return id;
}
if (!id.match(self.idRegExp)) {
return def;
}
return id;
};
// Sanitize an array of IDs. IDs must consist solely of upper and lower case
// letters and numbers, digits, and underscores. Any elements that are not
// IDs are omitted from the final array.
self.ids = function(ids) {
if (!Array.isArray(ids)) {
return [];
}
const result = ids.filter(function(id) {
return (self.id(id) !== undefined);
});
return result;
};
return self;
};
// Expose `naughtyHref` statically so callers (notably sanitize-html)
// can use it without instantiating a launder.
module.exports.naughtyHref = naughtyHref;