UNPKG

xss-defender

Version:

A standalone library for XSS sanitization and detection.

271 lines (270 loc) 12.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.XssDefender = void 0; const config_1 = require("./config"); const patterns_1 = require("./patterns"); const logger_1 = require("./logger"); /** * Main class for XSS sanitization and defense. * Provides methods to sanitize strings, HTML content, objects, and URL parameters. */ class XssDefender { /** * Initializes a new instance of the XssDefender. * @param initialConfig Optional initial configuration to override defaults. */ constructor(initialConfig) { this.currentConfig = { ...config_1.DEFAULT_SANITIZATION_CONFIG, ...initialConfig }; this.logger = new logger_1.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 = value; // 1. Remove known dangerous patterns patterns_1.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 // This matches opening tags, closing tags, and self-closing tags. const tagsToRemovePattern = new RegExp(`</?(?!${config.allowedTags.join("|")})[a-zA-Z0-9]+[^>]*>`, "gi"); sanitized = sanitized.replace(tagsToRemovePattern, (match) => { if (config.stripIgnoreTag) { return ""; // Remove the disallowed tag } // Encode the disallowed tag (e.g., <badtag> becomes &lt;badtag&gt;) return match.replace(/</g, "&lt;").replace(/>/g, "&gt;"); }); } else { // No tags are allowed if (config.stripIgnoreTag) { // Remove all tags sanitized = sanitized.replace(/<[^>]+>/gi, ""); } else { // Encode all tags (e.g., <tag> becomes &lt;tag&gt;) sanitized = sanitized.replace(/<(\/?[\w\d\s="/.'-]+?)>/gi, (m, tagContent) => `&lt;${tagContent}&gt;`); } } // 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; // Matches any tag and its attributes string sanitized = sanitized.replace(universalTagPattern, (match, tagName, attributesString) => { const lowerTagName = tagName.toLowerCase(); // Skip if the tag itself is not allowed (it might have been encoded, so check original allowedTags) if (config.allowedTags && !config.allowedTags.includes(lowerTagName) && !config.stripIgnoreTag) { // If tags are encoded, this attribute stripping logic might not apply as expected // This part primarily works if disallowed tags were stripped or if the tag is allowed. return match; } if (config.allowedTags && !config.allowedTags.includes(lowerTagName) && config.stripIgnoreTag) { // If tag was supposed to be stripped but wasn't caught by previous step (e.g. complex/malformed) // this check might be redundant if previous step was perfect. return match; // Or consider stripping the match if tag is definitively disallowed } 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)) { // Reconstruct the attribute, ensuring its value is not re-processed by dangerous patterns here // as those were globally applied. Value itself is not deeply sanitized here beyond initial pass. newAttributesString += ` ${attrMatch[0]}`; } } 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; return patterns_1.DANGEROUS_PATTERNS.some((pattern) => pattern.test(String(value))); } /** * 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); } } exports.XssDefender = XssDefender;