@small-tech/jsdb
Version:
A zero-dependency, transparent, in-memory, streaming write-on-update JavaScript database for Small Web applications that persists to a JavaScript transaction log.
318 lines (273 loc) • 10.1 kB
JavaScript
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// QuerySanitiser class.
//
// Sanitises a query and returns it or returns an empty array either if
// the query could not be sanitised or if there are no results.
//
// Usage:
//
// QuerySanitiser.sanitiseAndExecuteQueryOnData(query, data)
//
// Like this? Fund us!
// https://small-tech.org/fund-us
//
// Copyright ⓒ 2020-2021 Aral Balkan. Licensed under AGPLv3 or later.
// Shared with ♥ by the Small Technology Foundation.
//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
import { log } from './Util.js'
import QueryOperators from './QueryOperators.js'
function globalRegExp () {
return new RegExp(Array.from(arguments).join(''), 'g')
}
class RegularExpressionLiterals {
static dot = '\\.'
static semicolon = ';'
static backslash = '\\\\'
static plugSign = '\\+'
static backtick = '\\`'
static openingCurlyBracket = '\\{'
static closingCurlyBracket = '\\}'
static openingSquareBracket = '\\['
static closingSquareBracket = '\\]'
static dollarSign = '\\$'
static singleQuote = '\''
static doubleQuote = '"'
static openingParenthesis = '\\('
static closingParenthesis = '\\)'
static verticalBar = '\\|'
static ampersand = '\\&'
static underscore = '_'
static false = 'false'
static true = 'true'
static valueOfDot = 'valueOf\\.'
static toLowerCaseFunctionCall = 'toLowerCase()'
static join () { return Array.from(arguments).join('') } // concatenates into a string.
// Collections of characters.
static get logicalOr () {
return this.join(this.verticalBar, this.verticalBar) // '\\|\\|'
}
static get logicalAnd () {
return this.join(this.ampersand, this.ampersand) // '\\&\\&`
}
}
// Regular expression snippets.
class RE {
static join() { return Array.from(arguments).join('') } // concatenates into a string.
static anyOf() { return `(${Array.from(arguments).join('|')})` } // creates an alternation (e.g., (x|y)).
static setOf() { return `[${Array.from(arguments).join('')}]` } // creates set (e.g., [xy]).
static negatedSetOf() { return `[^${Array.from(arguments).join('')}]` } // creates negated set (e.g., [^xy])
// Literal characters and words.
static literal = RegularExpressionLiterals
static whitespace = '\\s' // spaces, tabs, line breaks.
static anyDigit = '\\d' // 0-9.
static anyCharacter = '.'
static oneOrMore = '+'
static nonGreedy = '?'
static zeroOrMore = '?'
// Note: we use set here to mean a regular expression set (expressed as a string), not a JavaScript set.
// [;\\\+\`\{\}\[\]\$]
static get setOfDangerousCharacters () {
return this.setOf(
this.literal.semicolon,
this.literal.backslash,
this.literal.plugSign,
this.literal.backtick,
this.literal.openingCurlyBracket,
this.literal.closingCurlyBracket,
this.literal.openingSquareBracket,
this.literal.closingSquareBracket,
this.literal.dollarSign
)
}
// ['"\\(\\)\\s]
static get setOfAllowedCharacters () {
return this.setOf(
this.literal.singleQuote,
this.literal.doubleQuote,
this.literal.openingParenthesis,
this.literal.closingParenthesis,
this.whitespace
)
}
// .+?
static get oneOrMoreCharactersNonGreedy () {
return this.join(this.anyCharacter, this.oneOrMore, this.nonGreedy)
}
// (.+?)
static get oneOrMoreCharactersNonGreedyBetweenLiteralParentheses () {
return this.join(
this.literal.openingParenthesis,
this.oneOrMoreCharactersNonGreedy,
this.literal.closingParenthesis
)
}
// This should match string representations of numbers, including floating point ones and
// ones where digits are separated by underscores for readability.
// [\\d\\._]+?
static get oneOrMoreDigitsLiteralDotsOrLiteralUnderscoresUntilZeroOrMoreWhitespaces () {
return this.join(
this.setOf(
this.anyDigit,
this.literal.dot,
this.literal.underscore
),
this.oneOrMore,
this.whitespace,
this.zeroOrMore
)
}
// '.+?'
static get oneOrMoreCharactersNonGreedyBetweenLiteralSingleQuotes () {
return this.join(
this.literal.singleQuote,
this.oneOrMoreCharactersNonGreedy,
this.literal.singleQuote
)
}
// ".+?"
static get oneOrMoreCharactersNonGreedyBetweenLiteralDoubleQuotes () {
return this.join(
this.literal.doubleQuote,
this.oneOrMoreCharactersNonGreedy,
this.literal.doubleQuote
)
}
// This should match any valid right-hand-side value on a relational operation.
// ([\\d\\._]+?|'.+?'|".+?"|false|true)
static get anyValidRelationalOperationValue () {
return this.anyOf(
this.oneOrMoreDigitsLiteralDotsOrLiteralUnderscoresUntilZeroOrMoreWhitespaces,
this.oneOrMoreCharactersNonGreedyBetweenLiteralSingleQuotes,
this.oneOrMoreCharactersNonGreedyBetweenLiteralDoubleQuotes,
this.literal.false,
this.literal.true
)
}
// [^\\.]+?
static get oneOrMoreCharactersNonGreedyThatAreNotDots () {
return this.join(
this.negatedSetOf(
this.literal.dot
),
this.oneOrMore,
this.nonGreedy
)
}
static get zeroOrMoreWhitespace () {
return this.join(
this.whitespace,
this.zeroOrMore
)
}
static anyFunctionalOperator = this.anyOf(...QueryOperators.FUNCTIONAL_OPERATORS)
static anyRelationalOperator = this.anyOf(...QueryOperators.uniqueListOfRelationalOperators)
}
//
// Allowed elements.
//
class Allowed {
// Statements in the form valueOf.toLowerCase().startsWith(…), etc.
// /valueOf\..+?\.toLowerCase()\.(startsWith|endsWith|includes|startsWithCaseInsensitive|endsWithCaseInsensitive|includesCaseInsensitive)\(.+?\)/g
static functionalOperationsCaseInsensitive = globalRegExp(
RE.literal.valueOfDot,
RE.oneOrMoreCharactersNonGreedy,
RE.literal.dot,
RE.literal.toLowerCaseFunctionCall,
RE.literal.dot,
RE.anyFunctionalOperator,
RE.oneOrMoreCharactersNonGreedyBetweenLiteralParentheses
)
// Statements in the form valueOf.startsWith(…), etc.
// /valueOf\..+?\.(startsWith|endsWith|includes|startsWithCaseInsensitive|endsWithCaseInsensitive|includesCaseInsensitive)\(.+?\)/g
static functionalOperations = globalRegExp(
RE.literal.valueOfDot,
RE.oneOrMoreCharactersNonGreedy,
RE.literal.dot,
RE.anyFunctionalOperator,
RE.oneOrMoreCharactersNonGreedyBetweenLiteralParentheses
)
// Statements in the form valueOf.<property> === <value>, etc.
// /valueOf\.[^\.]+?\s?(===|!==|>|>=|<|<=)\s?([\d\._]+\s?|'.+?'|".+?"|false|true)/g
static relationalOperations = globalRegExp(
RE.literal.valueOfDot,
RE.oneOrMoreCharactersNonGreedyThatAreNotDots,
RE.zeroOrMoreWhitespace,
RE.anyRelationalOperator,
RE.zeroOrMoreWhitespace,
RE.anyValidRelationalOperationValue
)
// Logical OR.
// /\|\|/g
static logicalOr = globalRegExp(RE.literal.logicalOr)
// Logical AND.
// /\&\&/g
static logicalAnd = globalRegExp(RE.literal.logicalAnd)
// Single and double quotation marks, parentheses, and whitespace.
// /['"\(\)\s]/g
static allowedCharacters = globalRegExp(RE.setOfAllowedCharacters)
}
//
// Disallowed elements.
//
class Disallowed {
// /[;\\\+\`\{\}\$]/g
static dangerousCharacters = globalRegExp(RE.setOfDangerousCharacters)
}
//
// Main class.
//
export default class QuerySanitiser {
static Allowed = Allowed
static Disallowed = Disallowed
// Sanitises the provided query and runs it on the provided data.
// If there is a sanitisation issue or if there are no results,
// an empty array is returned.
static sanitiseAndExecuteQueryOnData (queryString, data) {
// First, remove any disallowed dangerous characters.
if (Disallowed.dangerousCharacters.test(queryString)) {
// Disallowed character(s) found, reject.
// log(' 💾 ❨JSDB❩ Warning: disallowed character(s) found in query string; rejecting.', queryString)
return []
}
// Next, let’s see if there’s anything nefarious left after
// we strip away everything that we expect to be there.
let sieve = queryString
.replace(Allowed.functionalOperationsCaseInsensitive, '')
.replace(Allowed.functionalOperations, '')
.replace(Allowed.relationalOperations, '')
.replace(Allowed.logicalOr, '')
.replace(Allowed.logicalAnd,'')
.replace(Allowed.allowedCharacters, '')
// Initialise the result array.
let result = []
// Only run the query if the sieve is empty.
if (sieve === '') {
// Write out the final query string.
// To further restrict it, specify that it must be run in strict mode.
const finalQueryString = `'use strict'; return (${queryString});`
// Create the query function.
let query
try {
query = new Function('valueOf', finalQueryString)
} catch (error) {
// Syntax error in query string, reject.
// log(' 💾 ❨JSDB❩ Warning: syntax error in query string; rejecting.', queryString, error)
return []
}
// OK, the query should now be safe to run.
result = data.filter(function(value) {
try {
return query(value)
} catch (error) /* c8 ignore start */ {
// Unexpected error: something went wrong while executing the query.
// (This should not happen as all errors should have been caught before this point.)
log(' 💾 ❨JSDB❩ Warning: query function threw unexpected error; rejecting.', queryString, error)
return false
} /* c8 ignore stop */
})
}
return result
}
}