@prizemates/http-firewall
Version:
HTTP Firewall based on Spring Security HttpFirewall
271 lines • 12.6 kB
JavaScript
"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