UNPKG

@prizemates/http-firewall

Version:

HTTP Firewall based on Spring Security HttpFirewall

271 lines 12.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.httpFirewall = void 0; const types_1 = require("./types"); class RequestRejectedError extends Error { constructor(message) { super(message); this.name = 'RequestRejectedError'; Object.setPrototypeOf(this, new.target.prototype); } } const httpFirewall = (options) => { return async (req, res, next) => { return await new StrictHttpFirewall(options).firewall(req, res, next); }; }; exports.httpFirewall = httpFirewall; class StrictHttpFirewall { constructor(options) { // Pre-defined constraints. These can be overriden this.ALLOW_ANY_HTTP_METHOD = []; this.ENCODED_PERCENT = '%25'; this.PERCENT = '%'; this.FORBIDDEN_ENCODED_PERIOD = ['%2e', '%2E']; this.FORBIDDEN_SEMICOLON = [';', '%3b', '%3B']; this.FORBIDDEN_FORWARDSLASH = ['%2f', '%2F']; this.FORBIDDEN_DOUBLE_FORWARDSLASH = ['//', '%2f%2f', '%2f%2F', '%2F%2f', '%2F%2F']; this.FORBIDDEN_BACKSLASH = ['\\', '%5c', '%5C']; this.FORBIDDEN_NULL = ['\0', '%00']; this.FORBIDDEN_LF = ['\n', '%0a', '%0A']; this.FORBIDDEN_CR = ['\r', '%0d', '%0D']; this.FORBIDDEN_LINE_SEPARATOR = ['\u2028']; this.FORBIDDEN_PARAGRAPH_SEPARATOR = ['\u2029']; this.logToConsole = false; this.encodedUrlBlocklist = []; this.decodedUrlBlocklist = []; this.allowedHttpMethods = this.createDefaultAllowedHttpMethods(); this.allowedHostnames = new types_1.Predicate((hostName) => true); this.ASSIGNED_AND_NOT_ISO_CONTROL_PATTERN = new RegExp('[\\p{IsAssigned}&&[^\\p{IsControl}]]*', 'g'); this.ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE = new types_1.Predicate((testName) => this.ASSIGNED_AND_NOT_ISO_CONTROL_PATTERN.test(testName)); this.allowedHeaderNames = this.ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE; this.allowedHeaderValues = this.ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE; this.allowedParameterNames = this.ASSIGNED_AND_NOT_ISO_CONTROL_PREDICATE; this.allowedParameterValues = new types_1.Predicate((value) => true); this.firewall = async (req, res, next) => { await this.rejectForbiddenHttpMethod(req) .then(() => this.rejectedBlocklistedUrls(req)) .then(() => this.rejectedUntrustedHosts(req)) .then(() => this.rejectNonNormalizedRequests(req)) .then(() => this.rejectNonPrintableAsciiCharactersInFieldName(req, req.url, 'requestURI')) .then(() => next()) .catch((error) => { if (this.logToConsole === true) { console.warn(error.message); } this.reject(req, res); }); }; this.isNormalizedRequest = (req) => { if (!this.isNormalized(req.url)) { return false; } if (!this.isNormalized(req.originalUrl)) { return false; } if (!this.isNormalized(req.path)) { return false; } return this.isNormalized(req.route); }; /** * Checks whether a path is normalized (doesn't contain path traversal sequences like * "./", "/../" or "/.") * @param path the path to test * @return true if the path doesn't contain any path-traversal character sequences. */ this.isNormalized = (path) => { if (path === undefined || path === null) { return true; } for (let i = path.length; i > 0;) { let slashIndex = path.lastIndexOf('/', i - 1); let gap = i - slashIndex; if (gap === 2 && path.charAt(slashIndex + 1) === '.') { return false; // ".", "/./" or "/." } if (gap === 3 && path.charAt(slashIndex + 1) === '.' && path.charAt(slashIndex + 2) === '.') { return false; } i = slashIndex; } return true; }; this.rejectNonNormalizedRequests = async (req) => { if (!this.isNormalizedRequest(req)) { throw new RequestRejectedError(`The request was rejected because the URL was not normalized.`); } }; this.rejectNonPrintableAsciiCharactersInFieldName = async (req, toCheck, propertyName) => { if (!StrictHttpFirewall.containsOnlyPrintableAsciiCharacters(toCheck)) { throw new RequestRejectedError(`The ${propertyName} was rejected because it can only contain ` + `printable ASCII characters.`); } }; this.rejectedBlocklistedUrls = async (req) => { const errorMessage = `The request was rejected because the URL contained a potentially malicious String ${req.url}`; const error = new RequestRejectedError(errorMessage); for (const forbidden of this.encodedUrlBlocklist) { if (StrictHttpFirewall.encodedUrlContains(req, forbidden)) { throw error; } } for (const forbidden of this.decodedUrlBlocklist) { if (StrictHttpFirewall.decodedUrlContains(req, forbidden)) { throw error; } } }; this.rejectedUntrustedHosts = async (req) => { const serverName = req.hostname; if (serverName !== undefined && serverName !== null && !this.allowedHostnames.test(serverName)) { throw new RequestRejectedError(`The request was rejected because the domain ${serverName} is untrusted.`); } }; this.rejectForbiddenHttpMethod = async (req) => { if (this.allowedHttpMethods === this.ALLOW_ANY_HTTP_METHOD) { return; } const method = req.method.toUpperCase(); if (this.allowedHttpMethods.indexOf(method) === -1) { throw new RequestRejectedError(`The request was rejected because the HTTP method ${method} was not ` + `included within the list of allowed HTTP methods + ${this.allowedHttpMethods}`); } }; this.reject = (req, res) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('FORBIDDEN'); }; this.urlBlocklistsAddAll(this.FORBIDDEN_SEMICOLON); this.urlBlocklistsAddAll(this.FORBIDDEN_FORWARDSLASH); this.urlBlocklistsAddAll(this.FORBIDDEN_DOUBLE_FORWARDSLASH); this.urlBlocklistsAddAll(this.FORBIDDEN_BACKSLASH); this.urlBlocklistsAddAll(this.FORBIDDEN_NULL); this.urlBlocklistsAddAll(this.FORBIDDEN_LF); this.urlBlocklistsAddAll(this.FORBIDDEN_CR); this.encodedUrlBlocklist.push(this.ENCODED_PERCENT); this.encodedUrlBlocklist.push(...this.FORBIDDEN_ENCODED_PERIOD); this.decodedUrlBlocklist.push(this.PERCENT); this.encodedUrlBlocklist.push(...this.FORBIDDEN_LINE_SEPARATOR); this.encodedUrlBlocklist.push(...this.FORBIDDEN_PARAGRAPH_SEPARATOR); if (options !== undefined) { if (options.logToConsole === true) { this.logToConsole = true; } this.allowedHttpMethods = options.unsafeAllowAnyHttpMethod === true ? this.ALLOW_ANY_HTTP_METHOD : this.createDefaultAllowedHttpMethods(); if (options.allowedHttpMethods !== undefined) { this.allowedHttpMethods = options.allowedHttpMethods.length !== 0 ? options.allowedHttpMethods : this.ALLOW_ANY_HTTP_METHOD; } if (options.allowSemicolon === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_SEMICOLON); } if (options.allowUrlEncodedSlash === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_FORWARDSLASH); } if (options.allowUrlEncodedDoubleSlash === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_DOUBLE_FORWARDSLASH); } if (options.allowUrlEncodedPeriod === true) { this.removeItems(this.encodedUrlBlocklist, this.FORBIDDEN_ENCODED_PERIOD); } if (options.allowBackSlash === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_BACKSLASH); } if (options.allowNull === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_NULL); } if (options.allowUrlEncodedPercent === true) { this.removeItems(this.encodedUrlBlocklist, [this.ENCODED_PERCENT]); this.removeItems(this.decodedUrlBlocklist, [this.PERCENT]); } if (options.allowUrlEncodedCarriageReturn === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_CR); } if (options.allowUrlEncodedLineFeed === true) { this.urlBlocklistsRemoveAll(this.FORBIDDEN_LF); } if (options.allowUrlEncodedParagraphSeparator === true) { this.removeItems(this.encodedUrlBlocklist, this.FORBIDDEN_PARAGRAPH_SEPARATOR); } if (options.allowUrlEncodedLineSeparator === true) { this.removeItems(this.encodedUrlBlocklist, this.FORBIDDEN_LINE_SEPARATOR); } if (options.allowedHeaderNames !== undefined) { this.allowedHeaderNames = options.allowedHeaderNames; } if (options.allowedHeaderValues !== undefined) { this.allowedHeaderValues = options.allowedHeaderValues; } if (options.allowedParameterNames !== undefined) { this.allowedParameterNames = options.allowedParameterNames; } if (options.allowedParameterValues !== undefined) { this.allowedParameterValues = options.allowedParameterValues; } if (options.allowedHostnames !== undefined) { this.allowedHostnames = options.allowedHostnames; } if (options.decodedUrlBlockList !== undefined && options.decodedUrlBlockList.length !== 0) { this.decodedUrlBlocklist.push(...options.decodedUrlBlockList); } if (options.encodedUrlBlockList !== undefined && options.encodedUrlBlockList.length !== 0) { this.encodedUrlBlocklist.push(...options.encodedUrlBlockList); } } } urlBlocklistsAddAll(values) { this.encodedUrlBlocklist.push(...values); this.decodedUrlBlocklist.push(...values); } urlBlocklistsRemoveAll(values) { this.removeItems(this.encodedUrlBlocklist, values); this.removeItems(this.decodedUrlBlocklist, values); } removeItems(originalArray, itemsTobeRemoved) { for (const item of itemsTobeRemoved) { const index = originalArray.indexOf(item); if (index !== -1) { originalArray.splice(index, 1); } } } createDefaultAllowedHttpMethods() { return ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; } } StrictHttpFirewall.encodedUrlContains = (req, value) => { if (StrictHttpFirewall.valueContains(req.path, value)) { return true; } if (StrictHttpFirewall.valueContains(req.url, value)) { return true; } if (StrictHttpFirewall.valueContains(req.baseUrl, value)) { return true; } if (StrictHttpFirewall.valueContains(req.originalUrl, value)) { return true; } return StrictHttpFirewall.valueContains(req.route, value); }; StrictHttpFirewall.decodedUrlContains = (req, value) => { return StrictHttpFirewall.valueContains(req.path, value); }; StrictHttpFirewall.containsOnlyPrintableAsciiCharacters = (uri) => { if (uri === undefined || uri === null) { return true; } const length = uri.length; for (let i = 0; i < length; i++) { const ch = uri.charAt(i); if (ch < '\u0020' || ch > '\u007e') { return false; } } return true; }; StrictHttpFirewall.valueContains = (value, contains) => { return value !== undefined && value !== null && value.length > 0 && value.indexOf(contains) !== -1; }; //# sourceMappingURL=strict-http-firewall.js.map