diff --git a/apps/api/src/common/decorators/sanitize.decorator.ts b/apps/api/src/common/decorators/sanitize.decorator.ts new file mode 100644 index 0000000..4819387 --- /dev/null +++ b/apps/api/src/common/decorators/sanitize.decorator.ts @@ -0,0 +1,52 @@ +/** + * Sanitize Decorator + * + * Custom class-validator decorator to sanitize string input and prevent XSS. + */ + +import { Transform } from "class-transformer"; +import { sanitizeString, sanitizeObject } from "../utils/sanitize.util"; + +/** + * Sanitize decorator for DTO properties + * Automatically sanitizes string values to prevent XSS attacks + * + * Usage: + * ```typescript + * class MyDto { + * @Sanitize() + * @IsString() + * userInput!: string; + * } + * ``` + */ +export function Sanitize(): PropertyDecorator { + return Transform(({ value }: { value: unknown }) => { + if (typeof value === "string") { + return sanitizeString(value); + } + return value; + }); +} + +/** + * SanitizeObject decorator for nested objects + * Recursively sanitizes all string values in an object + * + * Usage: + * ```typescript + * class MyDto { + * @SanitizeObject() + * @IsObject() + * metadata?: Record; + * } + * ``` + */ +export function SanitizeObject(): PropertyDecorator { + return Transform(({ value }: { value: unknown }) => { + if (typeof value === "object" && value !== null) { + return sanitizeObject(value as Record); + } + return value; + }); +} diff --git a/apps/api/src/common/providers/redis.provider.ts b/apps/api/src/common/providers/redis.provider.ts new file mode 100644 index 0000000..7489bbf --- /dev/null +++ b/apps/api/src/common/providers/redis.provider.ts @@ -0,0 +1,54 @@ +/** + * Redis Provider + * + * Provides Redis/Valkey client instance for the application. + */ + +import { Logger } from "@nestjs/common"; +import type { Provider } from "@nestjs/common"; +import Redis from "ioredis"; + +/** + * Factory function to create Redis client instance + */ +function createRedisClient(): Redis { + const logger = new Logger("RedisProvider"); + const valkeyUrl = process.env.VALKEY_URL ?? "redis://localhost:6379"; + + logger.log(`Connecting to Valkey at ${valkeyUrl}`); + + const client = new Redis(valkeyUrl, { + maxRetriesPerRequest: 3, + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + logger.warn( + `Valkey connection retry attempt ${times.toString()}, waiting ${delay.toString()}ms` + ); + return delay; + }, + reconnectOnError: (err) => { + logger.error("Valkey connection error:", err.message); + return true; + }, + }); + + client.on("connect", () => { + logger.log("Connected to Valkey"); + }); + + client.on("error", (err) => { + logger.error("Valkey error:", err.message); + }); + + return client; +} + +/** + * Redis Client Provider + * + * Provides a singleton Redis client instance for dependency injection. + */ +export const RedisProvider: Provider = { + provide: "REDIS_CLIENT", + useFactory: createRedisClient, +}; diff --git a/apps/api/src/common/utils/redact.util.spec.ts b/apps/api/src/common/utils/redact.util.spec.ts new file mode 100644 index 0000000..99bfd7c --- /dev/null +++ b/apps/api/src/common/utils/redact.util.spec.ts @@ -0,0 +1,142 @@ +/** + * Redaction Utility Tests + * + * Tests for sensitive data redaction in logs. + */ + +import { describe, it, expect } from "vitest"; +import { redactSensitiveData, redactUserId, redactInstanceId } from "./redact.util"; + +describe("Redaction Utilities", () => { + describe("redactUserId", () => { + it("should redact user IDs", () => { + expect(redactUserId("user-12345")).toBe("user-***"); + }); + + it("should handle null/undefined", () => { + expect(redactUserId(null)).toBe("user-***"); + expect(redactUserId(undefined)).toBe("user-***"); + }); + }); + + describe("redactInstanceId", () => { + it("should redact instance IDs", () => { + expect(redactInstanceId("instance-abc-def")).toBe("instance-***"); + }); + + it("should handle null/undefined", () => { + expect(redactInstanceId(null)).toBe("instance-***"); + expect(redactInstanceId(undefined)).toBe("instance-***"); + }); + }); + + describe("redactSensitiveData", () => { + it("should redact user IDs", () => { + const data = { userId: "user-123", name: "Test" }; + const redacted = redactSensitiveData(data); + + expect(redacted.userId).toBe("user-***"); + expect(redacted.name).toBe("Test"); + }); + + it("should redact instance IDs", () => { + const data = { instanceId: "instance-456", url: "https://example.com" }; + const redacted = redactSensitiveData(data); + + expect(redacted.instanceId).toBe("instance-***"); + expect(redacted.url).toBe("https://example.com"); + }); + + it("should redact metadata objects", () => { + const data = { + metadata: { secret: "value", public: "data" }, + other: "field", + }; + const redacted = redactSensitiveData(data); + + expect(redacted.metadata).toBe("[REDACTED]"); + expect(redacted.other).toBe("field"); + }); + + it("should redact payloads", () => { + const data = { + payload: { command: "execute", args: ["secret"] }, + type: "command", + }; + const redacted = redactSensitiveData(data); + + expect(redacted.payload).toBe("[REDACTED]"); + expect(redacted.type).toBe("command"); + }); + + it("should redact private keys", () => { + const data = { + privateKey: "-----BEGIN PRIVATE KEY-----\n...", + publicKey: "-----BEGIN PUBLIC KEY-----\n...", + }; + const redacted = redactSensitiveData(data); + + expect(redacted.privateKey).toBe("[REDACTED]"); + expect(redacted.publicKey).toBe("-----BEGIN PUBLIC KEY-----\n..."); + }); + + it("should redact tokens", () => { + const data = { + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + oidcToken: "token-value", + }; + const redacted = redactSensitiveData(data); + + expect(redacted.token).toBe("[REDACTED]"); + expect(redacted.oidcToken).toBe("[REDACTED]"); + }); + + it("should handle nested objects", () => { + const data = { + user: { + id: "user-789", + email: "test@example.com", + }, + metadata: { nested: "data" }, + }; + const redacted = redactSensitiveData(data); + + expect(redacted.user.id).toBe("user-***"); + expect(redacted.user.email).toBe("test@example.com"); + expect(redacted.metadata).toBe("[REDACTED]"); + }); + + it("should handle arrays", () => { + const data = { + users: [{ userId: "user-1" }, { userId: "user-2" }], + }; + const redacted = redactSensitiveData(data); + + expect(redacted.users[0].userId).toBe("user-***"); + expect(redacted.users[1].userId).toBe("user-***"); + }); + + it("should preserve non-sensitive data", () => { + const data = { + id: "conn-123", + status: "active", + createdAt: "2024-01-01", + remoteUrl: "https://remote.com", + }; + const redacted = redactSensitiveData(data); + + expect(redacted).toEqual(data); + }); + + it("should handle null/undefined", () => { + expect(redactSensitiveData(null)).toBeNull(); + expect(redactSensitiveData(undefined)).toBeUndefined(); + }); + + it("should handle primitives", () => { + expect(redactSensitiveData("string")).toBe("string"); + expect(redactSensitiveData(123)).toBe(123); + expect(redactSensitiveData(true)).toBe(true); + }); + }); +}); diff --git a/apps/api/src/common/utils/redact.util.ts b/apps/api/src/common/utils/redact.util.ts new file mode 100644 index 0000000..952c8a7 --- /dev/null +++ b/apps/api/src/common/utils/redact.util.ts @@ -0,0 +1,107 @@ +/** + * Redaction Utilities + * + * Provides utilities to redact sensitive data from logs to prevent PII leakage. + */ + +/** + * Sensitive field names that should be redacted + */ +const SENSITIVE_FIELDS = new Set([ + "privateKey", + "token", + "oidcToken", + "accessToken", + "refreshToken", + "password", + "secret", + "apiKey", + "metadata", + "payload", + "signature", +]); + +/** + * Redact a user ID to prevent PII leakage + * @param _userId - User ID to redact + * @returns Redacted user ID + */ +export function redactUserId(_userId: string | null | undefined): string { + return "user-***"; +} + +/** + * Redact an instance ID to prevent system information leakage + * @param _instanceId - Instance ID to redact + * @returns Redacted instance ID + */ +export function redactInstanceId(_instanceId: string | null | undefined): string { + return "instance-***"; +} + +/** + * Recursively redact sensitive data from an object + * @param data - Data to redact + * @returns Redacted data (creates a new object/array) + */ +export function redactSensitiveData(data: T): T { + // Handle primitives and null/undefined + if (data === null || data === undefined) { + return data; + } + + if (typeof data !== "object") { + return data; + } + + // Handle arrays + if (Array.isArray(data)) { + const result = data.map((item: unknown) => redactSensitiveData(item)); + return result as unknown as T; + } + + // Handle objects + const redacted: Record = {}; + + for (const [key, value] of Object.entries(data)) { + // Redact sensitive fields + if (SENSITIVE_FIELDS.has(key)) { + redacted[key] = "[REDACTED]"; + continue; + } + + // Redact user IDs + if (key === "userId" || key === "remoteUserId" || key === "localUserId") { + redacted[key] = redactUserId(value as string); + continue; + } + + // Redact instance IDs + if (key === "instanceId" || key === "remoteInstanceId") { + redacted[key] = redactInstanceId(value as string); + continue; + } + + // Redact id field within user/instance context + if (key === "id" && typeof value === "string") { + // Check if this might be a user or instance ID based on value format + if (value.startsWith("user-") || value.startsWith("instance-")) { + if (value.startsWith("user-")) { + redacted[key] = redactUserId(value); + } else { + redacted[key] = redactInstanceId(value); + } + continue; + } + } + + // Recursively redact nested objects/arrays + if (typeof value === "object" && value !== null) { + redacted[key] = redactSensitiveData(value); + } else { + redacted[key] = value; + } + } + + return redacted as T; +} diff --git a/apps/api/src/common/utils/sanitize.util.spec.ts b/apps/api/src/common/utils/sanitize.util.spec.ts new file mode 100644 index 0000000..c181ce5 --- /dev/null +++ b/apps/api/src/common/utils/sanitize.util.spec.ts @@ -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 = 'Hello'; + const clean = sanitizeString(dirty); + + expect(clean).not.toContain("John', + description: "Safe text", + nested: { + value: '', + }, + }; + + const clean = sanitizeObject(dirty); + + expect(clean.name).not.toContain("safe", "another", + }, + }, + }, + }; + + const clean = sanitizeObject(input); + + expect(clean.level1.level2.level3.xss).not.toContain("safe", "clean", '']; + + const clean = sanitizeArray(dirty); + + expect(clean[0]).not.toContain("", 123, true, null, { key: "value" }]; + + const clean = sanitizeArray(input); + + expect(clean[0]).not.toContain("", "safe"], ['']]; + + const clean = sanitizeArray(input); + + expect(clean[0][0]).not.toContain("Connection rejected', + }; + + const dto = plainToInstance(RejectConnectionDto, dirty); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.reason).not.toContain("Important", + nested: { + value: "", + }, + }, + }; + + const dto = plainToInstance(AcceptConnectionDto, dirty); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.metadata!.note).not.toContain("John Doe', + bio: 'Developer', + }, + }; + + const dto = plainToInstance(CreateIdentityMappingDto, dirty); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.metadata!.displayName).not.toContain("", "tag2"], + }, + }; + + const dto = plainToInstance(UpdateIdentityMappingDto, dirty); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect((dto.metadata!.tags as any)[0]).not.toContain("console.log("hello")', + params: { + arg1: '', + }, + }, + }; + + const dto = plainToInstance(SendCommandDto, dirty); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.payload.script).not.toContain("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("', + }, + }; + + const dto = plainToInstance(AcceptConnectionDto, dirty); + + expect(dto.metadata!.style).not.toContain("