fix(#M5-QA): address security findings from code review

Fixes 2 important-level security issues identified in M5 QA:

1. XSS Protection (SearchResults.tsx):
   - Add DOMPurify sanitization for search result snippets
   - Configure to allow only <mark> tags for highlighting
   - Provides defense-in-depth against potential XSS

2. Error State (SearchPage):
   - Add user-facing error message when search fails
   - Display friendly error notification instead of silent failure
   - Improves UX by informing users of temporary issues

Testing:
- All 32 search component tests passing
- TypeScript typecheck passing
- DOMPurify properly sanitizes HTML while preserving highlighting

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-02 16:50:38 -06:00
parent 0e64dc8525
commit 6e63508f97
4 changed files with 60 additions and 5 deletions

View File

@@ -21,9 +21,11 @@
"@mosaic/shared": "workspace:*", "@mosaic/shared": "workspace:*",
"@mosaic/ui": "workspace:*", "@mosaic/ui": "workspace:*",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@types/dompurify": "^3.2.0",
"@xyflow/react": "^12.5.3", "@xyflow/react": "^12.5.3",
"better-auth": "^1.4.17", "better-auth": "^1.4.17",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"elkjs": "^0.9.3", "elkjs": "^0.9.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mermaid": "^11.4.1", "mermaid": "^11.4.1",

View File

@@ -23,6 +23,7 @@ export default function SearchPage(): React.JSX.Element {
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [totalResults, setTotalResults] = useState(0); const [totalResults, setTotalResults] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string | undefined>(); const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
const [availableTags, setAvailableTags] = useState<Tag[]>([]); const [availableTags, setAvailableTags] = useState<Tag[]>([]);
@@ -51,6 +52,7 @@ export default function SearchPage(): React.JSX.Element {
} }
setIsLoading(true); setIsLoading(true);
setError(null);
try { try {
// Build query params // Build query params
const params = new URLSearchParams({ q: query }); const params = new URLSearchParams({ q: query });
@@ -67,6 +69,7 @@ export default function SearchPage(): React.JSX.Element {
setTotalResults(response.pagination.total); setTotalResults(response.pagination.total);
} catch (error) { } catch (error) {
console.error("Search failed:", error); console.error("Search failed:", error);
setError("Search temporarily unavailable. Please try again in a moment.");
setResults([]); setResults([]);
setTotalResults(0); setTotalResults(0);
} finally { } finally {
@@ -99,8 +102,21 @@ export default function SearchPage(): React.JSX.Element {
</div> </div>
</div> </div>
{/* Error state */}
{error && (
<div className="container mx-auto max-w-6xl mt-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 flex items-start gap-3">
<span className="text-2xl"></span>
<div className="flex-1">
<h3 className="font-semibold text-yellow-900 mb-1">Search Unavailable</h3>
<p className="text-yellow-800 text-sm">{error}</p>
</div>
</div>
</div>
)}
{/* Results area */} {/* Results area */}
{query && ( {query && !error && (
<div className="container mx-auto max-w-7xl"> <div className="container mx-auto max-w-7xl">
<SearchResults <SearchResults
results={results} results={results}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import DOMPurify from "dompurify";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { SearchFilters } from "./SearchFilters"; import { SearchFilters } from "./SearchFilters";
@@ -50,7 +51,12 @@ function SearchResultItem({ result }: { result: SearchResult }): React.JSX.Eleme
{result.headline && ( {result.headline && (
<div <div
className="text-sm text-gray-600 line-clamp-2" className="text-sm text-gray-600 line-clamp-2"
dangerouslySetInnerHTML={{ __html: result.headline }} dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(result.headline, {
ALLOWED_TAGS: ["mark"],
ALLOWED_ATTR: [],
}),
}}
/> />
)} )}

37
pnpm-lock.yaml generated
View File

@@ -271,6 +271,12 @@ importers:
bullmq: bullmq:
specifier: ^5.67.2 specifier: ^5.67.2
version: 5.67.2 version: 5.67.2
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.14.1
version: 0.14.3
dockerode: dockerode:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.9 version: 4.0.9
@@ -344,6 +350,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.90.20 specifier: ^5.90.20
version: 5.90.20(react@19.2.4) version: 5.90.20(react@19.2.4)
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@xyflow/react': '@xyflow/react':
specifier: ^12.5.3 specifier: ^12.5.3
version: 12.10.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.10.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -353,6 +362,9 @@ importers:
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
dompurify:
specifier: ^3.3.1
version: 3.3.1
elkjs: elkjs:
specifier: ^0.9.3 specifier: ^0.9.3
version: 0.9.3 version: 0.9.3
@@ -2780,6 +2792,10 @@ packages:
'@types/dockerode@3.3.47': '@types/dockerode@3.3.47':
resolution: {integrity: sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==} resolution: {integrity: sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6938,7 +6954,7 @@ snapshots:
chalk: 5.6.2 chalk: 5.6.2
commander: 12.1.0 commander: 12.1.0
dotenv: 17.2.3 dotenv: 17.2.3
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
open: 10.2.0 open: 10.2.0
pg: 8.17.2 pg: 8.17.2
prettier: 3.8.1 prettier: 3.8.1
@@ -9164,6 +9180,10 @@ snapshots:
'@types/node': 22.19.7 '@types/node': 22.19.7
'@types/ssh2': 1.15.5 '@types/ssh2': 1.15.5
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -9823,7 +9843,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2 better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -9848,7 +9868,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.6.2 better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -10596,6 +10616,16 @@ snapshots:
dotenv@17.2.3: {} dotenv@17.2.3: {}
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
'@types/pg': 8.16.0
better-sqlite3: 12.6.2
kysely: 0.28.10
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -10605,6 +10635,7 @@ snapshots:
kysely: 0.28.10 kysely: 0.28.10
pg: 8.17.2 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
optional: true
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies: