feat(#285): Add input sanitization for XSS prevention
Security improvements: - Create sanitization utility using sanitize-html library - Add @Sanitize() and @SanitizeObject() decorators for DTOs - Apply sanitization to vulnerable fields: - Connection rejection/disconnection reasons - Connection metadata - Identity linking metadata - Command payloads - Remove script tags, event handlers, javascript: URLs - Prevent data exfiltration, CSS-based XSS, SVG-based XSS Changes: - Add sanitize.util.ts with recursive sanitization functions - Add sanitize.decorator.ts for class-transformer integration - Update connection.dto.ts with sanitization decorators - Update identity-linking.dto.ts with sanitization decorators - Update command.dto.ts with sanitization decorators - Add comprehensive test coverage including attack vectors Part of M7.1 Remediation Sprint P1 security fixes. Fixes #285 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
171
apps/api/src/common/utils/sanitize.util.spec.ts
Normal file
171
apps/api/src/common/utils/sanitize.util.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Sanitization Utility Tests
|
||||
*
|
||||
* Tests for HTML sanitization and XSS prevention.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeString, sanitizeObject, sanitizeArray } from "./sanitize.util";
|
||||
|
||||
describe("Sanitization Utilities", () => {
|
||||
describe("sanitizeString", () => {
|
||||
it("should remove script tags", () => {
|
||||
const dirty = '<script>alert("XSS")</script>Hello';
|
||||
const clean = sanitizeString(dirty);
|
||||
|
||||
expect(clean).not.toContain("<script>");
|
||||
expect(clean).not.toContain("alert");
|
||||
expect(clean).toContain("Hello");
|
||||
});
|
||||
|
||||
it("should remove javascript: URLs", () => {
|
||||
const dirty = '<a href="javascript:alert(1)">Click</a>';
|
||||
const clean = sanitizeString(dirty);
|
||||
|
||||
expect(clean).not.toContain("javascript:");
|
||||
expect(clean).toContain("Click");
|
||||
});
|
||||
|
||||
it("should remove on* event handlers", () => {
|
||||
const dirty = '<div onclick="alert(1)">Click me</div>';
|
||||
const clean = sanitizeString(dirty);
|
||||
|
||||
expect(clean).not.toContain("onclick");
|
||||
expect(clean).toContain("Click me");
|
||||
});
|
||||
|
||||
it("should allow safe HTML tags", () => {
|
||||
const input = "<p>Hello <b>world</b>!</p>";
|
||||
const clean = sanitizeString(input);
|
||||
|
||||
expect(clean).toContain("<p>");
|
||||
expect(clean).toContain("<b>");
|
||||
expect(clean).toContain("Hello");
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
expect(sanitizeString(null)).toBe("");
|
||||
expect(sanitizeString(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle non-string values", () => {
|
||||
expect(sanitizeString(123 as any)).toBe("123");
|
||||
expect(sanitizeString(true as any)).toBe("true");
|
||||
});
|
||||
|
||||
it("should prevent data exfiltration via img tags", () => {
|
||||
const dirty = '<img src="x" onerror="fetch(\'/steal?data=\'+document.cookie)">';
|
||||
const clean = sanitizeString(dirty);
|
||||
|
||||
expect(clean).not.toContain("onerror");
|
||||
expect(clean).not.toContain("fetch");
|
||||
});
|
||||
|
||||
it("should remove style tags with malicious CSS", () => {
|
||||
const dirty = '<style>body { background: url("javascript:alert(1)") }</style>Hello';
|
||||
const clean = sanitizeString(dirty);
|
||||
|
||||
expect(clean).not.toContain("<style>");
|
||||
expect(clean).not.toContain("javascript:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeObject", () => {
|
||||
it("should sanitize all string values in an object", () => {
|
||||
const dirty = {
|
||||
name: '<script>alert("XSS")</script>John',
|
||||
description: "Safe text",
|
||||
nested: {
|
||||
value: '<img src=x onerror="alert(1)">',
|
||||
},
|
||||
};
|
||||
|
||||
const clean = sanitizeObject(dirty);
|
||||
|
||||
expect(clean.name).not.toContain("<script>");
|
||||
expect(clean.name).toContain("John");
|
||||
expect(clean.description).toBe("Safe text");
|
||||
expect(clean.nested.value).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("should preserve non-string values", () => {
|
||||
const input = {
|
||||
string: "test",
|
||||
number: 123,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
};
|
||||
|
||||
const clean = sanitizeObject(input);
|
||||
|
||||
expect(clean.number).toBe(123);
|
||||
expect(clean.boolean).toBe(true);
|
||||
expect(clean.nullValue).toBeNull();
|
||||
expect(clean.undefinedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle arrays within objects", () => {
|
||||
const input = {
|
||||
tags: ["<script>alert(1)</script>safe", "another<script>"],
|
||||
};
|
||||
|
||||
const clean = sanitizeObject(input);
|
||||
|
||||
expect(clean.tags[0]).not.toContain("<script>");
|
||||
expect(clean.tags[0]).toContain("safe");
|
||||
expect(clean.tags[1]).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("should handle deeply nested objects", () => {
|
||||
const input = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
xss: "<script>alert(1)</script>",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const clean = sanitizeObject(input);
|
||||
|
||||
expect(clean.level1.level2.level3.xss).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeArray", () => {
|
||||
it("should sanitize all strings in an array", () => {
|
||||
const dirty = ["<script>alert(1)</script>safe", "clean", '<img src=x onerror="alert(2)">'];
|
||||
|
||||
const clean = sanitizeArray(dirty);
|
||||
|
||||
expect(clean[0]).not.toContain("<script>");
|
||||
expect(clean[0]).toContain("safe");
|
||||
expect(clean[1]).toBe("clean");
|
||||
expect(clean[2]).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("should handle arrays with mixed types", () => {
|
||||
const input = ["<script>test</script>", 123, true, null, { key: "<b>value</b>" }];
|
||||
|
||||
const clean = sanitizeArray(input);
|
||||
|
||||
expect(clean[0]).not.toContain("<script>");
|
||||
expect(clean[1]).toBe(123);
|
||||
expect(clean[2]).toBe(true);
|
||||
expect(clean[3]).toBeNull();
|
||||
expect((clean[4] as any).key).toContain("<b>");
|
||||
});
|
||||
|
||||
it("should handle nested arrays", () => {
|
||||
const input = [["<script>alert(1)</script>", "safe"], ['<img src=x onerror="alert(2)">']];
|
||||
|
||||
const clean = sanitizeArray(input);
|
||||
|
||||
expect(clean[0][0]).not.toContain("<script>");
|
||||
expect(clean[0][1]).toBe("safe");
|
||||
expect(clean[1][0]).not.toContain("onerror");
|
||||
});
|
||||
});
|
||||
});
|
||||
123
apps/api/src/common/utils/sanitize.util.ts
Normal file
123
apps/api/src/common/utils/sanitize.util.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Sanitization Utilities
|
||||
*
|
||||
* Provides HTML/XSS sanitization for user-controlled input.
|
||||
* Uses sanitize-html to prevent XSS attacks.
|
||||
*/
|
||||
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
/**
|
||||
* Sanitize options for strict mode (default)
|
||||
* Allows only safe tags and attributes, removes all scripts and dangerous content
|
||||
*/
|
||||
const STRICT_OPTIONS: sanitizeHtml.IOptions = {
|
||||
allowedTags: ["p", "b", "i", "em", "strong", "a", "br", "ul", "ol", "li"],
|
||||
allowedAttributes: {
|
||||
a: ["href"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
disallowedTagsMode: "discard",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize a string value to prevent XSS attacks
|
||||
* Removes dangerous HTML tags, scripts, and event handlers
|
||||
*
|
||||
* @param value - String to sanitize
|
||||
* @param options - Optional sanitize-html options (defaults to strict)
|
||||
* @returns Sanitized string
|
||||
*/
|
||||
export function sanitizeString(
|
||||
value: string | null | undefined,
|
||||
options: sanitizeHtml.IOptions = STRICT_OPTIONS
|
||||
): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Convert non-strings to strings
|
||||
const stringValue = typeof value === "string" ? value : String(value);
|
||||
|
||||
return sanitizeHtml(stringValue, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize all string values in an object recursively
|
||||
* Preserves object structure and non-string values
|
||||
*
|
||||
* @param obj - Object to sanitize
|
||||
* @param options - Optional sanitize-html options
|
||||
* @returns Sanitized object
|
||||
*/
|
||||
export function sanitizeObject<T extends Record<string, unknown> | null | undefined>(
|
||||
obj: T,
|
||||
options: sanitizeHtml.IOptions = STRICT_OPTIONS
|
||||
): T {
|
||||
// Handle null/undefined
|
||||
if (obj == null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item: unknown) => {
|
||||
if (typeof item === "string") {
|
||||
return sanitizeString(item, options);
|
||||
}
|
||||
if (typeof item === "object" && item !== null) {
|
||||
return sanitizeObject(item as Record<string, unknown>, options);
|
||||
}
|
||||
return item;
|
||||
}) as unknown as T;
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === "string") {
|
||||
sanitized[key] = sanitizeString(value, options);
|
||||
} else if (Array.isArray(value)) {
|
||||
sanitized[key] = sanitizeArray(value, options);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
sanitized[key] = sanitizeObject(value as Record<string, unknown>, options);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize all string values in an array recursively
|
||||
* Preserves array structure and non-string values
|
||||
*
|
||||
* @param arr - Array to sanitize
|
||||
* @param options - Optional sanitize-html options
|
||||
* @returns Sanitized array
|
||||
*/
|
||||
export function sanitizeArray<T extends unknown[] = unknown[]>(
|
||||
arr: T,
|
||||
options: sanitizeHtml.IOptions = STRICT_OPTIONS
|
||||
): T {
|
||||
if (!Array.isArray(arr)) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const result = arr.map((item: unknown) => {
|
||||
if (typeof item === "string") {
|
||||
return sanitizeString(item, options);
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
return sanitizeArray(item as unknown[], options);
|
||||
}
|
||||
if (typeof item === "object" && item !== null) {
|
||||
return sanitizeObject(item as Record<string, unknown>, options);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
Reference in New Issue
Block a user