Prevent XSS attacks

This commit is contained in:
Moshfegh Hamedani 2026-02-25 10:21:30 -08:00
parent 5bf969ae7b
commit ed22c0ff88
8 changed files with 36 additions and 6 deletions

View file

@ -22,6 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core": "workspace:*",
"dompurify": "^3.3.1",
"lucide-react": "^0.563.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
@ -37,6 +38,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.2.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -616,6 +618,8 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
@ -642,6 +646,8 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="],
@ -858,6 +864,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],

View file

@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core": "workspace:*",
"dompurify": "^3.3.1",
"lucide-react": "^0.563.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
@ -36,6 +37,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.2.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View file

@ -1,3 +1,4 @@
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { type Ticket } from "core/constants/ticket.ts";
@ -9,6 +10,7 @@ import ErrorAlert from "@/components/ErrorAlert";
interface Reply {
id: number;
body: string;
bodyHtml: string | null;
senderType: SenderType;
user: { id: string; name: string } | null;
createdAt: string;
@ -67,7 +69,16 @@ export default function ReplyThread({ ticket }: ReplyThreadProps) {
</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{reply.body}</p>
{reply.bodyHtml ? (
<div
className="text-sm"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(reply.bodyHtml),
}}
/>
) : (
<p className="whitespace-pre-wrap text-sm">{reply.body}</p>
)}
</CardContent>
</Card>
);

View file

@ -1,3 +1,4 @@
import DOMPurify from "dompurify";
import { type Ticket } from "core/constants/ticket.ts";
import {
Card,
@ -39,7 +40,11 @@ export default function TicketDetail({ ticket }: TicketDetailProps) {
</CardHeader>
<CardContent>
{ticket.bodyHtml ? (
<div dangerouslySetInnerHTML={{ __html: ticket.bodyHtml }} />
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(ticket.bodyHtml),
}}
/>
) : (
<p className="whitespace-pre-wrap">{ticket.body}</p>
)}

View file

@ -4,10 +4,10 @@ import { ticketCategories } from "../constants/ticket-category";
export const inboundEmailSchema = z.object({
from: z.email("Invalid email address"),
fromName: z.string().trim().min(1, "Sender name is required"),
subject: z.string().trim().min(1, "Subject is required"),
body: z.string().min(1, "Body is required"),
bodyHtml: z.string().optional(),
fromName: z.string().trim().min(1, "Sender name is required").max(255, "Sender name is too long"),
subject: z.string().trim().min(1, "Subject is required").max(255, "Subject is too long"),
body: z.string().min(1, "Body is required").max(1000, "Body is too long"),
bodyHtml: z.string().max(2000, "HTML body is too long").optional(),
});
export type InboundEmailInput = z.infer<typeof inboundEmailSchema>;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reply" ADD COLUMN "bodyHtml" TEXT;

View file

@ -107,6 +107,7 @@ model Ticket {
model Reply {
id Int @id @default(autoincrement())
body String
bodyHtml String?
senderType SenderType
ticketId Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)

View file

@ -29,6 +29,7 @@ router.post("/inbound-email", requireWebhookSecret, async (req, res) => {
await prisma.reply.create({
data: {
body: data.body,
bodyHtml: data.bodyHtml ?? null,
senderType: "customer",
ticketId: existingTicket.id,
userId: null,