xss-defender
Version:
A standalone library for XSS sanitization and detection.
347 lines (318 loc) • 12 kB
text/typescript
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, "<").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.
*/
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,
);
}
}