Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions src/components/spatial/DeviceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useMemo } from "react";
import { sanitize, validateLabel } from "@/utils/sanitize";

interface DeviceCardProps {
label: string;
Expand All @@ -9,20 +10,6 @@ interface DeviceCardProps {
metrics?: Record<string, string | number>;
}

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]);
Expand Down Expand Up @@ -53,7 +40,7 @@ export function DeviceCard({ label, location, status, metrics }: DeviceCardProps
{Object.entries(metrics).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-muted-foreground">{sanitize(key)}</span>
<span className="font-mono tabular-nums">{String(value)}</span>
<span className="font-mono tabular-nums">{sanitize(String(value))}</span>
</div>
))}
</div>
Expand Down
12 changes: 12 additions & 0 deletions src/utils/sanitize.ts
Original file line number Diff line number Diff line change
@@ -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;
}
66 changes: 66 additions & 0 deletions tests/unit/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { sanitize, validateLabel } from "@/utils/sanitize";

describe("sanitize()", () => {
it("strips < and > characters", () => {
expect(sanitize("<script>alert(1)</script>")).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 = '<img src=x onerror=alert(1) />';
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 = "<script>" + "x".repeat(100);
const result = validateLabel(payload);
expect(result).not.toContain("<");
expect(result).not.toContain(">");
expect(result.length).toBeLessThanOrEqual(64);
});

it("returns empty string for pure XSS label", () => {
expect(validateLabel("<><><>")).toBe("");
});
});
Loading