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('

text

'); + 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