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
12 changes: 11 additions & 1 deletion frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
63 changes: 63 additions & 0 deletions frontend/lib/utils/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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 <script> tags and their content", () => {
const result = sanitize("<script>alert(1)</script>Safe text");
expect(result).not.toContain("<script>");
expect(result).not.toContain("alert(1)");
expect(result).toContain("Safe text");
});

it("strips inline event handler attributes", () => {
const result = sanitize('<b onclick="evil()">Bold</b>');
expect(result).not.toContain("onclick");
expect(result).toContain("Bold");
});

it("strips javascript: URI in anchor href", () => {
const result = sanitize('<a href="javascript:evil()">Click me</a>');
expect(result).not.toContain("javascript:");
});

it("strips data: URI attributes", () => {
const result = sanitize('<img src="data:text/html,<script>alert(1)</script>">');
expect(result).not.toContain("data:");
});

it("allows safe inline formatting tags", () => {
const input = "<b>Bold</b> and <em>italic</em> and <br>";
const result = sanitize(input);
expect(result).toContain("<b>Bold</b>");
expect(result).toContain("<em>italic</em>");
});

it("handles empty string without throwing", () => {
expect(sanitize("")).toBe("");
});

it("handles deeply nested XSS attempt", () => {
const result = sanitize('<div><p><span onmouseover="evil()">text</span></p></div>');
expect(result).not.toContain("onmouseover");
});
});

describe("stripTags()", () => {
it("removes all HTML tags leaving plain text", () => {
expect(stripTags("<b>Hello</b> world")).toBe("Hello world");
});

it("removes script tags", () => {
expect(stripTags("<script>alert(1)</script>text")).not.toContain("<script>");
});
});
68 changes: 68 additions & 0 deletions frontend/lib/utils/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* sanitize.ts
*
* Wraps DOMPurify with a strict allowlist.
* - No <script> tags
* - No event handler attributes (onclick, onload, etc.)
* - No javascript: URIs
* - Only safe inline formatting tags allowed
*
* Safe to call server-side (returns input unchanged when window is absent).
*/

import DOMPurify from "dompurify";

/**
* Allowed HTML tags — conservative list for user-generated content.
* Does NOT include <script>, <iframe>, <object>, <embed>, <form>, etc.
*/
const ALLOWED_TAGS = [
"b", "strong", "i", "em", "u", "s", "br", "p",
"ul", "ol", "li", "blockquote", "span",
];

/**
* Allowed attributes — explicitly no event handlers or src/href attributes
* that could carry javascript: URIs.
*/
const ALLOWED_ATTR: string[] = [];

/**
* Sanitize a string of user-generated content.
*
* @param input - Raw string that may contain HTML
* @returns Sanitized string safe for rendering
*
* @example
* sanitize('<script>alert(1)</script>Hello') // → 'Hello'
* sanitize('<b onclick="evil()">Bold</b>') // → '<b>Bold</b>'
* sanitize('<a href="javascript:evil()">x</a>') // → '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('<b>Hello</b> 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: [] });
}
67 changes: 65 additions & 2 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -52,4 +54,4 @@
"tailwindcss": "^4",
"typescript": "5.9.3"
}
}
}
Loading