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:
2026-02-03 21:47:32 -06:00
parent 3bba2f1c33
commit 01639fff95
24 changed files with 921 additions and 0 deletions

View File

@@ -6,6 +6,7 @@
import { IsString, IsObject, IsNotEmpty, IsNumber } from "class-validator";
import type { CommandMessage } from "../types/message.types";
import { SanitizeObject } from "../../common/decorators/sanitize.decorator";
/**
* DTO for sending a command to a remote instance
@@ -21,6 +22,7 @@ export class SendCommandDto {
@IsObject()
@IsNotEmpty()
@SanitizeObject()
payload!: Record<string, unknown>;
}
@@ -42,6 +44,7 @@ export class IncomingCommandDto implements CommandMessage {
@IsObject()
@IsNotEmpty()
@SanitizeObject()
payload!: Record<string, unknown>;
@IsNumber()

View File

@@ -5,6 +5,7 @@
*/
import { IsString, IsUrl, IsOptional, IsObject, IsNumber } from "class-validator";
import { Sanitize, SanitizeObject } from "../../common/decorators/sanitize.decorator";
/**
* DTO for initiating a connection
@@ -20,6 +21,7 @@ export class InitiateConnectionDto {
export class AcceptConnectionDto {
@IsOptional()
@IsObject()
@SanitizeObject()
metadata?: Record<string, unknown>;
}
@@ -28,6 +30,7 @@ export class AcceptConnectionDto {
*/
export class RejectConnectionDto {
@IsString()
@Sanitize()
reason!: string;
}
@@ -37,6 +40,7 @@ export class RejectConnectionDto {
export class DisconnectConnectionDto {
@IsOptional()
@IsString()
@Sanitize()
reason?: string;
}

View File

@@ -5,6 +5,7 @@
*/
import { IsString, IsEmail, IsOptional, IsObject, IsArray, IsNumber } from "class-validator";
import { SanitizeObject } from "../../common/decorators/sanitize.decorator";
/**
* DTO for verifying identity from remote instance
@@ -81,6 +82,7 @@ export class CreateIdentityMappingDto {
@IsOptional()
@IsObject()
@SanitizeObject()
metadata?: Record<string, unknown>;
@IsOptional()
@@ -94,5 +96,6 @@ export class CreateIdentityMappingDto {
export class UpdateIdentityMappingDto {
@IsOptional()
@IsObject()
@SanitizeObject()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,225 @@
/**
* DTO Sanitization Integration Tests
*
* Tests that DTOs properly sanitize XSS attempts.
*/
import { describe, it, expect } from "vitest";
import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import {
RejectConnectionDto,
DisconnectConnectionDto,
AcceptConnectionDto,
} from "./connection.dto";
import { CreateIdentityMappingDto, UpdateIdentityMappingDto } from "./identity-linking.dto";
import { SendCommandDto, IncomingCommandDto } from "./command.dto";
describe("DTO Sanitization Integration", () => {
describe("Connection DTOs", () => {
it("should sanitize rejection reason", async () => {
const dirty = {
reason: '<script>alert("XSS")</script>Connection rejected',
};
const dto = plainToInstance(RejectConnectionDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.reason).not.toContain("<script>");
expect(dto.reason).toContain("Connection rejected");
});
it("should sanitize disconnection reason", async () => {
const dirty = {
reason: '<img src=x onerror="alert(1)">Goodbye',
};
const dto = plainToInstance(DisconnectConnectionDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.reason).not.toContain("onerror");
expect(dto.reason).toContain("Goodbye");
});
it("should sanitize acceptance metadata", async () => {
const dirty = {
metadata: {
note: "<script>alert(1)</script>Important",
nested: {
value: "<img src=x onerror=\"fetch('/steal')\">",
},
},
};
const dto = plainToInstance(AcceptConnectionDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.metadata!.note).not.toContain("<script>");
expect(dto.metadata!.note).toContain("Important");
expect((dto.metadata!.nested as any).value).not.toContain("onerror");
});
});
describe("Identity Linking DTOs", () => {
it("should sanitize identity mapping metadata", async () => {
const dirty = {
remoteInstanceId: "instance-123",
remoteUserId: "user-456",
oidcSubject: "sub-789",
email: "test@example.com",
metadata: {
displayName: '<script>alert("XSS")</script>John Doe',
bio: '<img src=x onerror="alert(1)">Developer',
},
};
const dto = plainToInstance(CreateIdentityMappingDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.metadata!.displayName).not.toContain("<script>");
expect(dto.metadata!.displayName).toContain("John Doe");
expect(dto.metadata!.bio).not.toContain("onerror");
});
it("should sanitize update metadata", async () => {
const dirty = {
metadata: {
tags: ["<script>tag1</script>", "<b>tag2</b>"],
},
};
const dto = plainToInstance(UpdateIdentityMappingDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect((dto.metadata!.tags as any)[0]).not.toContain("<script>");
expect((dto.metadata!.tags as any)[1]).toContain("<b>");
});
});
describe("Command DTOs", () => {
it("should sanitize command payload", async () => {
const dirty = {
connectionId: "conn-123",
commandType: "execute",
payload: {
script: '<script>alert("XSS")</script>console.log("hello")',
params: {
arg1: '<img src=x onerror="alert(1)">',
},
},
};
const dto = plainToInstance(SendCommandDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.payload.script).not.toContain("<script>");
expect((dto.payload.params as any).arg1).not.toContain("onerror");
});
it("should sanitize incoming command payload", async () => {
const dirty = {
messageId: "msg-123",
instanceId: "instance-456",
commandType: "update",
payload: {
data: '<style>body{background:url("javascript:alert(1)")}</style>Data',
metadata: {
author: "<script>alert(1)</script>Admin",
},
},
timestamp: Date.now(),
signature: "sig-789",
};
const dto = plainToInstance(IncomingCommandDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.payload.data).not.toContain("<style>");
expect(dto.payload.data).not.toContain("javascript:");
expect((dto.payload.metadata as any).author).not.toContain("<script>");
});
it("should handle deeply nested XSS attempts", async () => {
const dirty = {
connectionId: "conn-123",
commandType: "complex",
payload: {
level1: {
level2: {
level3: {
xss: "<script>alert(1)</script>",
array: ['<img src=x onerror="alert(2)">', "safe"],
},
},
},
},
};
const dto = plainToInstance(SendCommandDto, dirty);
const errors = await validate(dto);
expect(errors).toHaveLength(0);
const level3 = (dto.payload.level1 as any).level2.level3;
expect(level3.xss).not.toContain("<script>");
expect(level3.array[0]).not.toContain("onerror");
expect(level3.array[1]).toBe("safe");
});
});
describe("XSS Attack Vectors", () => {
it("should prevent javascript: URL injection", async () => {
const dirty = {
metadata: {
link: '<a href="javascript:alert(1)">Click</a>',
},
};
const dto = plainToInstance(AcceptConnectionDto, dirty);
expect(dto.metadata!.link).not.toContain("javascript:");
});
it("should prevent data exfiltration attempts", async () => {
const dirty = {
reason: "<img src=x onerror=\"fetch('/api/steal?cookie='+document.cookie)\">",
};
const dto = plainToInstance(RejectConnectionDto, dirty);
expect(dto.reason).not.toContain("onerror");
expect(dto.reason).not.toContain("fetch");
expect(dto.reason).not.toContain("document.cookie");
});
it("should prevent CSS-based XSS", async () => {
const dirty = {
metadata: {
style: '<style>@import "http://evil.com/steal.css";</style>',
},
};
const dto = plainToInstance(AcceptConnectionDto, dirty);
expect(dto.metadata!.style).not.toContain("<style>");
expect(dto.metadata!.style).not.toContain("@import");
});
it("should prevent SVG-based XSS", async () => {
const dirty = {
reason: '<svg onload="alert(1)">Test</svg>',
};
const dto = plainToInstance(RejectConnectionDto, dirty);
expect(dto.reason).not.toContain("<svg");
expect(dto.reason).not.toContain("onload");
});
});
});