xss-defender
Version:
A standalone library for XSS sanitization and detection.
265 lines (264 loc) • 12.2 kB
JavaScript
import { DEFAULT_SANITIZATION_CONFIG } from "./config";
import { DANGEROUS_PATTERNS } from "./patterns";
import { Logger } from "./logger";
/**
* Main class for XSS sanitization and defense.
* Provides methods to sanitize strings, HTML content, objects, and URL parameters.
*/
export class XssDefender {
/**
* Initializes a new instance of the XssDefender.
* @param initialConfig Optional initial configuration to override defaults.
*/
constructor(initialConfig) {
this.currentConfig = { ...DEFAULT_SANITIZATION_CONFIG, ...initialConfig };
this.logger = new Logger();
if (this.currentConfig.enableLogging) {
this.logger.log("XssDefender initialized.", this.currentConfig);
}
}
/**
* Updates the current sanitization configuration.
* @param config Partial configuration object to merge with the current settings.
*/
setConfig(config) {
this.currentConfig = { ...this.currentConfig, ...config };
if (this.currentConfig.enableLogging) {
this.logger.log("Configuration updated.", this.currentConfig);
}
}
/**
* Retrieves the current sanitization configuration.
* @returns A read-only copy of the current configuration.
*/
getConfig() {
return this.currentConfig;
}
/**
* Performs basic sanitization of a string value based on the provided configuration.
* This includes stripping dangerous patterns, and handling allowed/disallowed tags and attributes.
* @param value The string to sanitize.
* @param config The sanitization configuration to use.
* @returns The sanitized string.
*/
_basicSanitization(value, config) {
let sanitized = this.decodeNumericEntities(value);
sanitized = sanitized.replace(/(\s|\u00A0)+on\w+(?:\/\*[\s\S]*?\*\/)?[\s\u00A0]*=\s*(?:(["']).*?\2|[^\s>]+)/gi, "$1");
// 1. Remove known dangerous patterns
DANGEROUS_PATTERNS.forEach((pattern) => {
sanitized = sanitized.replace(pattern, "");
});
// 2. Handle HTML tags
if (config.allowedTags && config.allowedTags.length > 0) {
// Pattern to find tags that are NOT in the allowed list
const tagsToRemovePattern = new RegExp(`</?(?!(?:${config.allowedTags.join("|")})(?:\\s|>|/))[a-zA-Z0-9]+[^>]*>`, "gi");
sanitized = sanitized.replace(tagsToRemovePattern, (match) => {
if (config.stripIgnoreTag) {
return ""; // Remove the disallowed tag
}
// Encode the disallowed tag
return match.replace(/</g, "<").replace(/>/g, ">");
});
}
else {
// No tags are allowed
if (config.stripIgnoreTag) {
// Remove all tags
sanitized = sanitized.replace(/<[^>]+>/gi, "");
}
else {
// Encode all tags
sanitized = sanitized.replace(/<(\/?[\w\d\s="/.'-]+?)>/gi, (m, tagContent) => `<${tagContent}>`);
}
}
// 3. Handle HTML attributes (only for tags that are allowed or were not stripped)
if (config.allowedAttributes && config.allowedAttributes.length > 0) {
const universalTagPattern = /<([a-zA-Z0-9]+)((?:\s+[^>]*)?)>/g;
sanitized = sanitized.replace(universalTagPattern, (match, tagName, attributesString) => {
const lowerTagName = tagName.toLowerCase();
// Skip if the tag itself is not allowed
if (config.allowedTags &&
!config.allowedTags.includes(lowerTagName)) {
return match;
}
const attributePattern = /\s*([a-zA-Z0-9\-_]+)\s*=\s*(?:(["'])(.*?)\2|([^>\s]+))/g;
let newAttributesString = "";
let attrMatch;
while ((attrMatch = attributePattern.exec(attributesString)) !== null) {
const attributeName = attrMatch[1].toLowerCase();
if (config.allowedAttributes?.includes(attributeName)) {
let attributeValue = attrMatch[3] !== undefined ? attrMatch[3] : attrMatch[4];
const escapedAttributeValue = attributeValue.replace(/"/g, """);
newAttributesString += ` ${attributeName}="${escapedAttributeValue}"`;
}
}
return `<${tagName}${newAttributesString}>`;
});
}
else {
// No attributes allowed, remove all attributes from all tags
const allAttributesPattern = /\s+[a-zA-Z0-9\-_]+\s*=\s*(?:(["']).*?\1|[^>\s]+)/g;
sanitized = sanitized.replace(/<([a-zA-Z0-9]+)((?:\s+[^>]*)?)>/g, (match, tagName, attributesString) => {
return `<${tagName}${attributesString.replace(allAttributesPattern, "")}>`;
});
}
return sanitized;
}
/**
* Sanitizes a string, removing potential XSS threats.
* @param value The string to sanitize. Can be null or undefined, in which case an empty string is returned.
* @returns The sanitized string.
*/
sanitizeString(value) {
if (value === null || value === undefined || value === "")
return "";
const originalValue = String(value);
let sanitizedValue = originalValue;
sanitizedValue = this._basicSanitization(sanitizedValue, this.currentConfig);
if (originalValue !== sanitizedValue) {
this._logDetection(originalValue, sanitizedValue);
}
return sanitizedValue;
}
/**
* Sanitizes an HTML string and sets it as the innerHTML of a given HTMLElement.
* @param element The HTMLElement whose innerHTML is to be set.
* @param unsafeHtml The potentially unsafe HTML string to sanitize and apply.
*/
sanitizeHtmlForElement(element, unsafeHtml) {
if (!element)
return;
const sanitizedHtml = this.sanitizeString(unsafeHtml);
element.innerHTML = sanitizedHtml;
}
/**
* Recursively sanitizes all string values within an object or an array.
* @param obj The object or array to sanitize.
* @returns The sanitized object or array.
*/
sanitizeObject(obj) {
if (obj === null || typeof obj !== "object") {
if (typeof obj === "string") {
return this.sanitizeString(obj);
}
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => this.sanitizeObject(item));
}
const result = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
if (typeof value === "string") {
result[key] = this.sanitizeString(value);
}
else if (typeof value === "object") {
result[key] = this.sanitizeObject(value);
}
else {
result[key] = value;
}
}
}
return result;
}
/**
* Checks URL parameters for potential XSS risks and returns sanitized versions.
* @param params A record of URL parameters.
* @returns An object indicating if all parameters are safe and a list of any issues found.
*/
checkUrlParams(params) {
const issues = [];
let isSafe = true;
for (const key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
const paramValue = params[key];
const valuesToCheck = Array.isArray(paramValue)
? paramValue
: paramValue
? [paramValue]
: [];
for (const originalSingleValue of valuesToCheck) {
if (typeof originalSingleValue !== "string")
continue;
const sanitizedSingleValue = this.sanitizeString(originalSingleValue);
// An issue is logged if sanitization changed the value OR if the original value had detectable XSS patterns
// (even if sanitizeString somehow missed it or the patterns are different)
if (originalSingleValue !== sanitizedSingleValue ||
this.hasXssRisks(originalSingleValue)) {
if (originalSingleValue !== sanitizedSingleValue) {
// Prefer logging if actual change occurred
isSafe = false;
issues.push({
key,
value: sanitizedSingleValue,
originalValue: originalSingleValue,
});
this.logger.warn(`Potential XSS risk in URL parameter "${key}" was sanitized.`, {
key,
originalValue: originalSingleValue,
sanitizedValue: sanitizedSingleValue,
timestamp: new Date().toISOString(),
}, this.currentConfig);
}
else if (this.hasXssRisks(originalSingleValue)) {
// Log if original had risk, even if sanitization resulted in same string (less likely)
isSafe = false; // Still mark as not entirely safe due to initial risk
issues.push({
key,
value: sanitizedSingleValue,
originalValue: originalSingleValue,
}); // Report original and "sanitized"
this.logger.warn(`Potential XSS risk detected in URL parameter "${key}". Input was already clean or sanitization was ineffective.`, {
key,
originalValue: originalSingleValue,
sanitizedValue: sanitizedSingleValue,
timestamp: new Date().toISOString(),
}, this.currentConfig);
}
}
}
}
}
return { isSafe, issues };
}
/**
* Checks if a given string contains known XSS risk patterns.
* This method tests the string against `DANGEROUS_PATTERNS`.
* @param value The string to check. Can be null or undefined.
* @returns `true` if XSS risks are found, `false` otherwise.
*/
hasXssRisks(value) {
if (!value)
return false;
const str = String(value);
return DANGEROUS_PATTERNS.some((p) => new RegExp(p.source, p.flags).test(str));
}
decodeNumericEntities(str) {
return (str
// &#xNNNN;
.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
// &#NNNN;
.replace(/&#(\d+);?/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))));
}
/**
* Logs a detection event when sanitization modifies the input string.
* @param originalValue The original, unsafe string.
* @param sanitizedValue The sanitized string.
*/
_logDetection(originalValue, sanitizedValue) {
const details = {
originalValue,
sanitizedValue,
configUsed: {
// Log only key aspects of config for brevity unless detailed
allowedTags: this.currentConfig.allowedTags,
allowedAttributes: this.currentConfig.allowedAttributes,
stripIgnoreTag: this.currentConfig.stripIgnoreTag,
},
timestamp: new Date().toISOString(),
};
this.logger.warn("Potential XSS detected and input sanitized.", details, this.currentConfig);
}
}