diff --git a/src/components/spatial/DeviceCard.tsx b/src/components/spatial/DeviceCard.tsx index 87b78df..32a7a1d 100644 --- a/src/components/spatial/DeviceCard.tsx +++ b/src/components/spatial/DeviceCard.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import { sanitize, validateLabel } from "@/utils/sanitize"; interface DeviceCardProps { label: string; @@ -9,20 +10,6 @@ interface DeviceCardProps { metrics?: Record; } -function sanitize(input: string): string { - return input - .replace(/[<>]/g, "") - .replace(/javascript:/gi, "") - .replace(/on\w+=/gi, "") - .trim(); -} - -function validateLabel(raw: string): string { - const cleaned = sanitize(raw); - if (cleaned.length > 64) return cleaned.slice(0, 64); - return cleaned; -} - export function DeviceCard({ label, location, status, metrics }: DeviceCardProps) { const safeLabel = useMemo(() => validateLabel(label), [label]); const safeLocation = useMemo(() => sanitize(location), [location]); @@ -53,7 +40,7 @@ export function DeviceCard({ label, location, status, metrics }: DeviceCardProps {Object.entries(metrics).map(([key, value]) => (
{sanitize(key)} - {String(value)} + {sanitize(String(value))}
))} diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 0000000..c68bace --- /dev/null +++ b/src/utils/sanitize.ts @@ -0,0 +1,12 @@ +export function sanitize(input: string): string { + return input + .replace(/[<>]/g, "") + .replace(/javascript:/gi, "") + .replace(/on\w+=/gi, "") + .trim(); +} + +export function validateLabel(raw: string): string { + const cleaned = sanitize(raw); + return cleaned.length > 64 ? cleaned.slice(0, 64) : cleaned; +} diff --git a/tests/unit/sanitize.test.ts b/tests/unit/sanitize.test.ts new file mode 100644 index 0000000..82d6702 --- /dev/null +++ b/tests/unit/sanitize.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { sanitize, validateLabel } from "@/utils/sanitize"; + +describe("sanitize()", () => { + it("strips < and > characters", () => { + expect(sanitize("")).toBe("scriptalert(1)/script"); + }); + + it("removes javascript: URI scheme (case-insensitive)", () => { + expect(sanitize("javascript:alert(1)")).toBe("alert(1)"); + expect(sanitize("JAVASCRIPT:alert(1)")).toBe("alert(1)"); + expect(sanitize("JavaScript:void(0)")).toBe("void(0)"); + }); + + it("strips onerror= and other on* event handlers", () => { + expect(sanitize("img src=x onerror=alert(1)")).toBe("img src=x alert(1)"); + expect(sanitize("onclick=doSomething()")).toBe("doSomething()"); + expect(sanitize("onmouseover=steal()")).toBe("steal()"); + }); + + it("leaves safe strings unchanged", () => { + expect(sanitize("Sensor A")).toBe("Sensor A"); + expect(sanitize("Floor 3 - Room 12")).toBe("Floor 3 - Room 12"); + expect(sanitize(" trim me ")).toBe("trim me"); + }); + + it("handles empty string", () => { + expect(sanitize("")).toBe(""); + }); + + it("handles combined payloads", () => { + const payload = ''; + const result = sanitize(payload); + expect(result).not.toContain("<"); + expect(result).not.toContain(">"); + expect(result).not.toContain("onerror="); + }); + + it("removes javascript: even when embedded in larger string", () => { + const payload = "click here: javascript:void(0)"; + expect(sanitize(payload)).not.toContain("javascript:"); + }); +}); + +describe("validateLabel()", () => { + it("truncates to 64 characters", () => { + const long = "a".repeat(100); + expect(validateLabel(long).length).toBe(64); + }); + + it("leaves short labels unchanged", () => { + expect(validateLabel("My Device")).toBe("My Device"); + }); + + it("sanitizes before truncating", () => { + const payload = "