UNPKG

xss-defender

Version:

A standalone library for XSS sanitization and detection.

347 lines (318 loc) 12 kB
import { SanitizationConfig, 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 { private currentConfig: SanitizationConfig; private readonly logger: Logger; /** * Initializes a new instance of the XssDefender. * @param initialConfig Optional initial configuration to override defaults. */ constructor(initialConfig?: Partial<SanitizationConfig>) { 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. */ public setConfig(config: Partial<SanitizationConfig>): void { 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. */ public getConfig(): Readonly<SanitizationConfig> { 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. */ private _basicSanitization( value: string, config: SanitizationConfig, ): string { let sanitized = this.decodeNumericEntities(value); sanitized = sanitized.replace( /(\s|\u00A0)+on\w+(?:\/\*[\s\S]*?\*\/)?[\s\u00A0]*=\s*(?:(["']).*?\2|[^\s>]+)/gi, "$1", // оставляем первый блок пробелов/NBSP ); // 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, "&lt;").replace(/>/g, "&gt;"); }); } 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) => `&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; 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, "&quot;", ); 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. */ public sanitizeString(value: string | null | undefined): string { 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. */ public sanitizeHtmlForElement( element: HTMLElement, unsafeHtml: string, ): void { 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. */ public sanitizeObject<T extends Record<string, any> | Array<any>>(obj: T): T { if (obj === null || typeof obj !== "object") { if (typeof obj === "string") { return this.sanitizeString(obj) as any; } return obj; } if (Array.isArray(obj)) { return obj.map((item) => this.sanitizeObject(item as any)) as any; } const result: Record<string, any> = {}; 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 as any); } else { result[key] = value; } } } return result as T; } /** * 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. */ public checkUrlParams( params: Record<string, string | string[] | undefined>, ): { isSafe: boolean; issues: Array<{ key: string; value: string; originalValue: string }>; } { const issues: Array<{ key: string; value: string; originalValue: string }> = []; let isSafe = true; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { const paramValue = params[key]; const valuesToCheck: string[] = 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. */ public hasXssRisks(value: string | null | undefined): boolean { if (!value) return false; const str = String(value); return DANGEROUS_PATTERNS.some((p) => new RegExp(p.source, p.flags).test(str), ); } private decodeNumericEntities(str: string): string { 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. */ private _logDetection(originalValue: string, sanitizedValue: string): void { 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, ); } }