diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
index 62b87d1..51e2a42 100644
--- a/frontend/eslint.config.mjs
+++ b/frontend/eslint.config.mjs
@@ -24,8 +24,18 @@ const eslintConfig = [
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
+
+ /**
+ * Flag any new dangerouslySetInnerHTML usage for mandatory review.
+ * All user-generated content MUST be passed through sanitize() before
+ * using dangerouslySetInnerHTML. This rule ensures new usages are
+ * consciously reviewed rather than silently introduced.
+ *
+ * @see frontend/lib/utils/sanitize.ts
+ */
+ "react/no-danger": "error",
},
},
];
-export default eslintConfig;
+export default eslintConfig;
\ No newline at end of file
diff --git a/frontend/lib/utils/sanitize.test.ts b/frontend/lib/utils/sanitize.test.ts
new file mode 100644
index 0000000..893ef0f
--- /dev/null
+++ b/frontend/lib/utils/sanitize.test.ts
@@ -0,0 +1,63 @@
+/**
+ * sanitize.test.ts
+ *
+ * Unit tests for the sanitize() and stripTags() utilities.
+ * Tests run in jsdom (Next.js / Jest default) which provides window + DOMPurify DOM.
+ */
+
+import { sanitize, stripTags } from "./sanitize";
+
+describe("sanitize()", () => {
+ it("passes plain text through unchanged", () => {
+ expect(sanitize("Hello, world!")).toBe("Hello, world!");
+ });
+
+ it("strips Safe text");
+ expect(result).not.toContain("">');
+ expect(result).not.toContain("data:");
+ });
+
+ it("allows safe inline formatting tags", () => {
+ const input = "Bold and italic and
";
+ const result = sanitize(input);
+ expect(result).toContain("Bold");
+ expect(result).toContain("italic");
+ });
+
+ it("handles empty string without throwing", () => {
+ expect(sanitize("")).toBe("");
+ });
+
+ it("handles deeply nested XSS attempt", () => {
+ const result = sanitize('
');
+ expect(result).not.toContain("onmouseover");
+ });
+});
+
+describe("stripTags()", () => {
+ it("removes all HTML tags leaving plain text", () => {
+ expect(stripTags("Hello world")).toBe("Hello world");
+ });
+
+ it("removes script tags", () => {
+ expect(stripTags("text")).not.toContain("Hello') // → 'Hello'
+ * sanitize('Bold') // → 'Bold'
+ * sanitize('x') // → 'x'
+ */
+export function sanitize(input: string): string {
+ if (typeof window === "undefined") {
+ // Server-side: DOMPurify requires a DOM. Strip all tags as a safe fallback.
+ return input.replace(/<[^>]*>/g, "");
+ }
+
+ return DOMPurify.sanitize(input, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ // Block javascript: and data: URIs in any attribute value
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
+ // Prevent DOM clobbering
+ SANITIZE_DOM: true,
+ });
+}
+
+/**
+ * Strip ALL HTML tags — use this when you only need plain text output.
+ *
+ * @example
+ * stripTags('Hello world') // → 'Hello world'
+ */
+export function stripTags(input: string): string {
+ if (typeof window === "undefined") {
+ return input.replace(/<[^>]*>/g, "");
+ }
+ return DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
+}
\ No newline at end of file
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index e9ffa30..6e0b179 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,7 +1,70 @@
import type { NextConfig } from "next";
+/**
+ * Content-Security-Policy header.
+ *
+ * Policy rationale:
+ * - default-src 'self' — only same-origin resources by default
+ * - script-src 'self' — no inline scripts; no eval
+ * - style-src 'self' 'unsafe-inline' — Tailwind injects inline styles at runtime
+ * - img-src 'self' data: blob: https: — allow remote images and data URIs for avatars
+ * - font-src 'self' https://fonts.gstatic.com — Google Fonts
+ * - connect-src 'self' https: — API calls to same origin + any HTTPS endpoint
+ * - frame-ancestors 'none' — block clickjacking
+ * - object-src 'none' — block Flash / plugins
+ * - base-uri 'self' — prevent base tag injection
+ * - form-action 'self' — prevent form hijacking
+ */
+const CSP = [
+ "default-src 'self'",
+ "script-src 'self'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: https:",
+ "font-src 'self' https://fonts.gstatic.com",
+ "connect-src 'self' https:",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+].join("; ");
+
+const SECURITY_HEADERS = [
+ {
+ key: "Content-Security-Policy",
+ value: CSP,
+ },
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+ {
+ key: "X-Frame-Options",
+ value: "DENY",
+ },
+ {
+ key: "X-XSS-Protection",
+ value: "1; mode=block",
+ },
+ {
+ key: "Referrer-Policy",
+ value: "strict-origin-when-cross-origin",
+ },
+ {
+ key: "Permissions-Policy",
+ value: "camera=(), microphone=(), geolocation=()",
+ },
+];
+
const nextConfig: NextConfig = {
- /* config options here */
+ async headers() {
+ return [
+ {
+ // Apply security headers to all routes
+ source: "/(.*)",
+ headers: SECURITY_HEADERS,
+ },
+ ];
+ },
};
-export default nextConfig;
+export default nextConfig;
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2d3bcc3..1288004 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,6 +17,7 @@
"@vercel/speed-insights": "^1.2.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.2.6",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24",
"jose": "^5.9.6",
@@ -41,6 +42,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
+ "@types/dompurify": "^3.0.5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1633,6 +1635,16 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1685,6 +1697,13 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -3048,6 +3067,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.12",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+ "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e038c2c..3829e6e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
"@vercel/speed-insights": "^1.2.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dompurify": "^3.2.6",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24",
"jose": "^5.9.6",
@@ -43,6 +44,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
+ "@types/dompurify": "^3.0.5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -52,4 +54,4 @@
"tailwindcss": "^4",
"typescript": "5.9.3"
}
-}
+}
\ No newline at end of file