feat(security): Add XSS protection with DOMPurify sanitization
- Add sanitize.ts utility module with sanitizeText(), sanitizeUrl(), and sanitizeHtml() functions - Implement server-side HTML entity escaping and client-side DOMPurify sanitization - Add comprehensive test suite for sanitization functions in lib/__tests__/sanitize.test.ts - Secure marketing pages by sanitizing all CMS data (hero, features, challenges, advantages, footer) - Secure inquiries form by sanitizing field labels, placeholders, and button text - Add SECURITY.md documentation outlining XSS protection measures and best practices - Add USAGE_EXAMPLES.md with detailed examples of sanitization function usage - Add XSS_PROTECTION_SUMMARY.md with implementation overview - Update package.json to include dompurify (^3.3.0) dependency - Protects against common XSS attack vectors including script injection, event handlers, and malicious URLs
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
# Security - XSS Protection
|
||||
|
||||
This document outlines the XSS (Cross-Site Scripting) protection measures implemented in this application.
|
||||
|
||||
## Overview
|
||||
|
||||
All external data from the CMS API is sanitized using DOMPurify before being rendered to prevent XSS attacks.
|
||||
|
||||
## Sanitization Functions
|
||||
|
||||
Located in `lib/sanitize.ts`:
|
||||
|
||||
### `sanitizeText()`
|
||||
|
||||
Strips all HTML tags from text. Use this for plain text content that should never contain HTML.
|
||||
|
||||
```typescript
|
||||
import { sanitizeText } from "@/lib/sanitize";
|
||||
|
||||
// Example usage
|
||||
<h1>{sanitizeText(cmsData?.title)}</h1>;
|
||||
```
|
||||
|
||||
### `sanitizeHtml()`
|
||||
|
||||
Allows specific HTML tags while removing dangerous content. Use this when you need to render formatted text.
|
||||
|
||||
```typescript
|
||||
import { sanitizeHtml } from "@/lib/sanitize";
|
||||
|
||||
// Example usage with default allowed tags (b, i, em, strong, a, br, p)
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(cmsData?.content) }} />
|
||||
|
||||
// Example with custom allowed tags
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(cmsData?.content, ["p", "br"]) }} />
|
||||
```
|
||||
|
||||
### `sanitizeUrl()`
|
||||
|
||||
Sanitizes URLs to prevent javascript:, data:, and vbscript: URI attacks.
|
||||
|
||||
```typescript
|
||||
import { sanitizeUrl } from "@/lib/sanitize";
|
||||
|
||||
// Example usage
|
||||
<Link href={sanitizeUrl(cmsData?.link) || "#"}>Click here</Link>
|
||||
<Image src={sanitizeUrl(cmsData?.imageUrl) || "/fallback.svg"} />
|
||||
```
|
||||
|
||||
## Protected Areas
|
||||
|
||||
The following components have been secured:
|
||||
|
||||
1. **Marketing Pages** (`app/marketing/[id]/page.tsx`)
|
||||
|
||||
- Hero section (title, description, button text, links)
|
||||
- Features section (titles, descriptions, icons)
|
||||
- Challenges section (titles, descriptions, icons)
|
||||
- Advantages section (titles, descriptions, icons)
|
||||
- Footer (email, phone, location, tagline, copyright)
|
||||
|
||||
2. **Inquiries Form** (`app/_inquires/form.tsx`, `app/_inquires/page.tsx`)
|
||||
- Form field labels and placeholders
|
||||
- Submit button text
|
||||
- Section titles and descriptions
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always sanitize external data** - Any data from APIs, databases, or user input should be sanitized
|
||||
2. **Use the right function** - Use `sanitizeText()` for plain text, `sanitizeUrl()` for URLs, and `sanitizeHtml()` for formatted content
|
||||
3. **Validate on the server** - While client-side sanitization helps, always validate and sanitize on the server as well
|
||||
4. **Keep DOMPurify updated** - Regularly update the DOMPurify package to get the latest security fixes
|
||||
|
||||
## Server-Side Rendering
|
||||
|
||||
The sanitization functions work in both client and server environments:
|
||||
|
||||
- **Server-side**: Uses HTML entity escaping to prevent XSS attacks
|
||||
- **Client-side**: Uses DOMPurify for advanced HTML sanitization
|
||||
|
||||
This hybrid approach ensures:
|
||||
|
||||
1. Fast server-side rendering without heavy dependencies
|
||||
2. Robust client-side protection with DOMPurify
|
||||
3. No build issues with Node.js-specific modules
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `dompurify` (^3.3.0) - HTML sanitization library (client-side only)
|
||||
|
||||
## Testing XSS Protection
|
||||
|
||||
To verify XSS protection is working, try injecting malicious content through the CMS:
|
||||
|
||||
```javascript
|
||||
// These should be sanitized and rendered harmless:
|
||||
<script>alert('XSS')</script>
|
||||
<img src=x onerror=alert('XSS')>
|
||||
javascript:alert('XSS')
|
||||
<iframe src="javascript:alert('XSS')"></iframe>
|
||||
```
|
||||
|
||||
All of these should be stripped or neutralized by the sanitization functions.
|
||||
@@ -0,0 +1,222 @@
|
||||
# XSS Protection Usage Examples
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Sanitizing Text Content
|
||||
|
||||
```typescript
|
||||
import { sanitizeText } from "@/lib/sanitize";
|
||||
|
||||
// In a component
|
||||
function MyComponent({ cmsData }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{sanitizeText(cmsData.title)}</h1>
|
||||
<p>{sanitizeText(cmsData.description)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Sanitizing URLs
|
||||
|
||||
```typescript
|
||||
import { sanitizeUrl } from "@/lib/sanitize";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
function MyComponent({ cmsData }) {
|
||||
return (
|
||||
<div>
|
||||
{/* For links */}
|
||||
<Link href={sanitizeUrl(cmsData.link) || "#"}>Click here</Link>
|
||||
|
||||
{/* For images */}
|
||||
<Image
|
||||
src={sanitizeUrl(cmsData.imageUrl) || "/fallback.svg"}
|
||||
alt={sanitizeText(cmsData.imageAlt)}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Sanitizing HTML Content
|
||||
|
||||
```typescript
|
||||
import { sanitizeHtml } from "@/lib/sanitize";
|
||||
|
||||
function MyComponent({ cmsData }) {
|
||||
return (
|
||||
<div>
|
||||
{/* With default allowed tags (b, i, em, strong, a, br, p) */}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(cmsData.richContent),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* With custom allowed tags */}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(cmsData.richContent, ["p", "br", "strong"]),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Example 1: Dynamic Marketing Page
|
||||
|
||||
```typescript
|
||||
import { sanitizeText, sanitizeUrl } from "@/lib/sanitize";
|
||||
|
||||
export default function MarketingPage({ cmsData }) {
|
||||
return (
|
||||
<main>
|
||||
<section>
|
||||
<h1>{sanitizeText(cmsData.hero.title)}</h1>
|
||||
<p>{sanitizeText(cmsData.hero.description)}</p>
|
||||
<Link href={sanitizeUrl(cmsData.hero.buttonLink) || "#"}>
|
||||
{sanitizeText(cmsData.hero.buttonText)}
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{cmsData.features.map((feature) => (
|
||||
<article key={feature.id}>
|
||||
<Image
|
||||
src={sanitizeUrl(feature.icon) || "/default-icon.svg"}
|
||||
alt={sanitizeText(feature.title)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<h3>{sanitizeText(feature.title)}</h3>
|
||||
<p>{sanitizeText(feature.description)}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Dynamic Form Fields
|
||||
|
||||
```typescript
|
||||
import { sanitizeText } from "@/lib/sanitize";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export default function DynamicForm({ contactData }) {
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{contactData.fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={field.name}>{sanitizeText(field.label)}</label>
|
||||
<input
|
||||
id={field.name}
|
||||
placeholder={sanitizeText(field.placeholder)}
|
||||
{...register(field.name)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="submit">
|
||||
{sanitizeText(contactData.submitButtonText)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: User-Generated Content
|
||||
|
||||
```typescript
|
||||
import { sanitizeHtml } from "@/lib/sanitize";
|
||||
|
||||
export default function CommentSection({ comments }) {
|
||||
return (
|
||||
<div>
|
||||
{comments.map((comment) => (
|
||||
<article key={comment.id}>
|
||||
<h4>{sanitizeText(comment.author)}</h4>
|
||||
{/* Allow only basic formatting tags */}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(comment.content, [
|
||||
"p",
|
||||
"br",
|
||||
"em",
|
||||
"strong",
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common XSS Attack Vectors (All Blocked)
|
||||
|
||||
```typescript
|
||||
// These malicious inputs will be sanitized:
|
||||
|
||||
// 1. Script injection
|
||||
const xss1 = "<script>alert('XSS')</script>";
|
||||
sanitizeText(xss1); // Safe: <script>alert('XSS')</script>
|
||||
|
||||
// 2. Event handler injection
|
||||
const xss2 = "<img src=x onerror=alert('XSS')>";
|
||||
sanitizeText(xss2); // Safe: <img src=x onerror=alert('XSS')>
|
||||
|
||||
// 3. JavaScript URL
|
||||
const xss3 = "javascript:alert('XSS')";
|
||||
sanitizeUrl(xss3); // Returns: ""
|
||||
|
||||
// 4. Data URL with script
|
||||
const xss4 = "data:text/html,<script>alert('XSS')</script>";
|
||||
sanitizeUrl(xss4); // Returns: ""
|
||||
|
||||
// 5. Iframe injection
|
||||
const xss5 = "<iframe src='javascript:alert(1)'></iframe>";
|
||||
sanitizeText(xss5); // Safe: <iframe src='javascript:alert(1)'></iframe>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always sanitize external data** before rendering
|
||||
2. **Use the appropriate function** for the content type
|
||||
3. **Provide fallback values** for URLs and optional content
|
||||
4. **Never trust user input** or external API data
|
||||
5. **Keep dependencies updated** to get latest security patches
|
||||
|
||||
## When NOT to Sanitize
|
||||
|
||||
- Static content defined in your codebase
|
||||
- Environment variables (but validate them)
|
||||
- Data from trusted internal APIs (but still validate)
|
||||
- Constants and configuration values
|
||||
|
||||
## Testing Your Sanitization
|
||||
|
||||
```typescript
|
||||
// Test with malicious input
|
||||
const testData = {
|
||||
title: "<script>alert('XSS')</script>Hello",
|
||||
link: "javascript:alert('XSS')",
|
||||
description: "<img src=x onerror=alert('XSS')>",
|
||||
};
|
||||
|
||||
// All should be safe
|
||||
console.log(sanitizeText(testData.title)); // No script execution
|
||||
console.log(sanitizeUrl(testData.link)); // Empty string
|
||||
console.log(sanitizeText(testData.description)); // No image/script
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
# XSS Protection Implementation Summary
|
||||
|
||||
## What Was Done
|
||||
|
||||
Successfully implemented comprehensive XSS (Cross-Site Scripting) protection across the application using DOMPurify.
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **`lib/sanitize.ts`** - Core sanitization utilities
|
||||
|
||||
- `sanitizeText()` - Strips all HTML tags
|
||||
- `sanitizeHtml()` - Allows specific HTML tags
|
||||
- `sanitizeUrl()` - Validates and sanitizes URLs
|
||||
|
||||
2. **`SECURITY.md`** - Security documentation
|
||||
3. **`USAGE_EXAMPLES.md`** - Practical usage examples
|
||||
4. **`lib/__tests__/sanitize.test.ts`** - Unit tests
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **`app/marketing/[id]/page.tsx`**
|
||||
|
||||
- Sanitized all CMS data: titles, descriptions, URLs, icons
|
||||
- Protected hero section, features, challenges, advantages, footer
|
||||
|
||||
2. **`app/_inquires/form.tsx`**
|
||||
|
||||
- Sanitized form field labels and placeholders
|
||||
- Sanitized submit button text
|
||||
|
||||
3. **`app/_inquires/page.tsx`**
|
||||
- Sanitized section titles and descriptions
|
||||
|
||||
## Protected Data Points
|
||||
|
||||
### Marketing Page (`/marketing/[id]`)
|
||||
|
||||
- ✅ Hero title and description
|
||||
- ✅ Hero button text and link
|
||||
- ✅ Feature cards (titles, descriptions, icons)
|
||||
- ✅ Challenge cards (titles, descriptions, icons)
|
||||
- ✅ Advantage cards (titles, descriptions, icons)
|
||||
- ✅ Footer (email, phone, location, tagline, copyright)
|
||||
|
||||
### Inquiries Form
|
||||
|
||||
- ✅ Form field labels
|
||||
- ✅ Form field placeholders
|
||||
- ✅ Submit button text
|
||||
- ✅ Section titles and descriptions
|
||||
|
||||
## How It Works
|
||||
|
||||
### Server-Side (SSR)
|
||||
|
||||
- Uses HTML entity escaping
|
||||
- Fast and lightweight
|
||||
- No external dependencies needed
|
||||
|
||||
### Client-Side
|
||||
|
||||
- Uses DOMPurify library
|
||||
- Advanced HTML sanitization
|
||||
- Configurable allowed tags
|
||||
|
||||
## Security Features
|
||||
|
||||
1. **Script Injection Protection**
|
||||
|
||||
- Blocks `<script>` tags
|
||||
- Removes event handlers (onclick, onerror, etc.)
|
||||
|
||||
2. **URL Injection Protection**
|
||||
|
||||
- Blocks `javascript:` URLs
|
||||
- Blocks `data:` URLs
|
||||
- Blocks `vbscript:` URLs
|
||||
|
||||
3. **HTML Injection Protection**
|
||||
- Escapes dangerous characters
|
||||
- Removes malicious attributes
|
||||
- Strips unwanted tags
|
||||
|
||||
## Testing
|
||||
|
||||
Build successful: ✅
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# ✓ Compiled successfully
|
||||
# ✓ Linting and checking validity of types
|
||||
# ✓ Collecting page data
|
||||
# ✓ Generating static pages (7/7)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `dompurify` (^3.3.0) - Already installed
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { sanitizeText, sanitizeUrl, sanitizeHtml } from "@/lib/sanitize";
|
||||
|
||||
// Plain text
|
||||
<h1>{sanitizeText(cmsData.title)}</h1>
|
||||
|
||||
// URLs
|
||||
<Link href={sanitizeUrl(cmsData.link) || "#"}>Click</Link>
|
||||
|
||||
// HTML content
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(cmsData.content) }} />
|
||||
```
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
1. Add unit tests for sanitization functions
|
||||
2. Set up automated security scanning
|
||||
3. Implement Content Security Policy (CSP) headers
|
||||
4. Add rate limiting for form submissions
|
||||
5. Implement CSRF protection for forms
|
||||
|
||||
## Verification
|
||||
|
||||
To verify XSS protection is working, try injecting malicious content through the CMS:
|
||||
|
||||
```javascript
|
||||
// These will be sanitized:
|
||||
<script>alert('XSS')</script>
|
||||
<img src=x onerror=alert('XSS')>
|
||||
javascript:alert('XSS')
|
||||
```
|
||||
|
||||
All malicious content will be escaped or removed before rendering.
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { sanitizeText } from "@/lib/sanitize";
|
||||
import api from "@/service/api";
|
||||
import { CmsContact } from "@/types/TCms";
|
||||
import { useState } from "react";
|
||||
@@ -82,7 +83,8 @@ const InquiresForm = ({
|
||||
];
|
||||
|
||||
const fields = contactData?.fields || defaultFields;
|
||||
const submitText = contactData?.submit_button_text || buttonText || "Submit";
|
||||
const submitText =
|
||||
sanitizeText(contactData?.submit_button_text) || buttonText || "Submit";
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -100,8 +102,8 @@ const InquiresForm = ({
|
||||
<InputGroup
|
||||
key={field.name}
|
||||
id={field.name}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
label={sanitizeText(field.label)}
|
||||
placeholder={sanitizeText(field.placeholder)}
|
||||
register={register}
|
||||
type={inputType}
|
||||
error={errors[field.name]?.message}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sanitizeText } from "@/lib/sanitize";
|
||||
import { CmsContact } from "@/types/TCms";
|
||||
import InquiresForm from "./form";
|
||||
|
||||
@@ -6,10 +7,10 @@ const Inquires = ({ contactData }: { contactData?: CmsContact }) => {
|
||||
<div className="flex flex-col items-center mt-16 px-8 ">
|
||||
<div className="lg:w-2/3 m-auto flex flex-col gap-4 mb-12">
|
||||
<h2 className="font-bold text-4xl">
|
||||
{contactData?.title || "Unlock Your Growth Potential"}
|
||||
{sanitizeText(contactData?.title) || "Unlock Your Growth Potential"}
|
||||
</h2>
|
||||
<p className="font-normal text-[16px] text-gray-600">
|
||||
{contactData?.description ||
|
||||
{sanitizeText(contactData?.description) ||
|
||||
"Register for early access and a free consultation. Let's start winning together."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+34
-28
@@ -2,6 +2,7 @@ import ApexChart from "@/app/_components/ApexChart";
|
||||
import FooterForm from "@/app/_components/FooterForm";
|
||||
import ImageWithFallback from "@/app/_components/ImageWithFallback";
|
||||
import { cmsService } from "@/hooks/useCmsQueries";
|
||||
import { sanitizeText, sanitizeUrl } from "@/lib/sanitize";
|
||||
import { CmsResponse } from "@/types/TCms";
|
||||
import { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
@@ -93,27 +94,27 @@ export default async function Home({ params }: IProps) {
|
||||
<section className="text-left flex flex-col gap-6 bg-gradient-to-b from-[#011132] to-[#012B80] text-white rounded-[56px] relative p-6 lg:p-14 -mt-16 z-20 lg:ml-28 ">
|
||||
<hgroup>
|
||||
<h1 className="capitalize font-extrabold text-4xl mb-6 md:text-5xl">
|
||||
{cmsData?.data.hero.title}
|
||||
{sanitizeText(cmsData?.data.hero.title)}
|
||||
</h1>
|
||||
<p className="text-[16px] text-[#dadada]">
|
||||
{cmsData?.data.hero.description}
|
||||
{sanitizeText(cmsData?.data.hero.description)}
|
||||
</p>
|
||||
</hgroup>
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="w-full lg:w-fit capitalize bg-(--primary) rounded-4xl py-4 px-6"
|
||||
href={cmsData?.data.hero.button_link ?? "#"}>
|
||||
{cmsData?.data.hero.button_text}
|
||||
href={sanitizeUrl(cmsData?.data.hero.button_link) || "#"}>
|
||||
{sanitizeText(cmsData?.data.hero.button_text)}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<article className="mt-40">
|
||||
<hgroup>
|
||||
<h4 className="capitalize font-bold text-2xl lg:text-4xl">
|
||||
{cmsData?.data.features.title}
|
||||
{sanitizeText(cmsData?.data.features.title)}
|
||||
</h4>
|
||||
<small className="text-gray-600 mt-3">
|
||||
{cmsData?.data.features.description}
|
||||
{sanitizeText(cmsData?.data.features.description)}
|
||||
</small>
|
||||
</hgroup>
|
||||
</article>
|
||||
@@ -123,24 +124,26 @@ export default async function Home({ params }: IProps) {
|
||||
key={frame.description}
|
||||
className="bg-[#F7FAFF] border border-(--primary)/30 rounded-3xl my-4 py-10 px-6 flex flex-col gap-5 transition duration-300 transform scale-90 hover:bg-[#0164FF30] hover:scale-110">
|
||||
<ImageWithFallback
|
||||
src={frame.icon ?? "/magnifier.svg"}
|
||||
alt={frame.title}
|
||||
src={sanitizeUrl(frame.icon) || "/magnifier.svg"}
|
||||
alt={sanitizeText(frame.title)}
|
||||
width={36}
|
||||
height={36}
|
||||
unoptimized
|
||||
/>
|
||||
<h3 className="text-2xl font-bold">{frame.title}</h3>
|
||||
<p className="text-gray-600">{frame.description}</p>
|
||||
<h3 className="text-2xl font-bold">
|
||||
{sanitizeText(frame.title)}
|
||||
</h3>
|
||||
<p className="text-gray-600">{sanitizeText(frame.description)}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="mt-36 bg-[#F7FAFF] px-4 py-6 rounded-4xl flex flex-col gap-9 lg:p-16">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className=" font-bold text-4xl">
|
||||
{cmsData?.data.challenges.title}
|
||||
{sanitizeText(cmsData?.data.challenges.title)}
|
||||
</h2>
|
||||
<p className="font-normal text-sm">
|
||||
{cmsData?.data.challenges.description}
|
||||
{sanitizeText(cmsData?.data.challenges.description)}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-4 lg:flex-row lg:justify-between">
|
||||
@@ -150,15 +153,15 @@ export default async function Home({ params }: IProps) {
|
||||
key={card.title}>
|
||||
<div className="p-4">
|
||||
<ImageWithFallback
|
||||
src={card.icon ?? "/clock.svg"}
|
||||
alt={card.title}
|
||||
src={sanitizeUrl(card.icon) || "/clock.svg"}
|
||||
alt={sanitizeText(card.title)}
|
||||
width={36}
|
||||
height={36}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<h2 className="font-bold">{card.title}</h2>
|
||||
<p>{card.description}</p>
|
||||
<h2 className="font-bold">{sanitizeText(card.title)}</h2>
|
||||
<p>{sanitizeText(card.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -166,7 +169,7 @@ export default async function Home({ params }: IProps) {
|
||||
<section className="flex flex-col mt-36 gap-10 px-2 lg:w-3/4 m-auto">
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl lg:text-4xl">
|
||||
{cmsData?.data.advantages.title}
|
||||
{sanitizeText(cmsData?.data.advantages.title)}
|
||||
</h2>
|
||||
</div>
|
||||
<section className="flex flex-col gap-10 lg:flex-row">
|
||||
@@ -175,15 +178,17 @@ export default async function Home({ params }: IProps) {
|
||||
className="flex flex-col gap-7 bg-[#FFF7EB] p-10 rounded-3xl lg:pb-20"
|
||||
key={card.title}>
|
||||
<ImageWithFallback
|
||||
src={card.icon ?? "/people.svg"}
|
||||
src={sanitizeUrl(card.icon) || "/people.svg"}
|
||||
width={48}
|
||||
height={48}
|
||||
alt="people icon"
|
||||
alt={sanitizeText(card.title)}
|
||||
unoptimized
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-bold text-xl">{card.title}</h2>
|
||||
<p>{card.description}</p>
|
||||
<h2 className="font-bold text-xl">
|
||||
{sanitizeText(card.title)}
|
||||
</h2>
|
||||
<p>{sanitizeText(card.description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -210,10 +215,10 @@ export default async function Home({ params }: IProps) {
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white my-2">
|
||||
{cmsData?.data.footer.tagline}
|
||||
{sanitizeText(cmsData?.data.footer.tagline)}
|
||||
</p>
|
||||
<small className="text-white my-2">
|
||||
{cmsData?.data.footer.copyright}
|
||||
{sanitizeText(cmsData?.data.footer.copyright)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,8 +232,8 @@ export default async function Home({ params }: IProps) {
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a href={`tel:${cmsData?.data.footer.phone}`}>
|
||||
{cmsData?.data.footer.phone}
|
||||
<a href={`tel:${sanitizeText(cmsData?.data.footer.phone)}`}>
|
||||
{sanitizeText(cmsData?.data.footer.phone)}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex gap-3 my-3 text-white">
|
||||
@@ -239,8 +244,9 @@ export default async function Home({ params }: IProps) {
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a href={`mailto:${cmsData?.data.footer.email}`}>
|
||||
{cmsData?.data.footer.email}
|
||||
<a
|
||||
href={`mailto:${sanitizeText(cmsData?.data.footer.email)}`}>
|
||||
{sanitizeText(cmsData?.data.footer.email)}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex gap-3 my-3 text-white">
|
||||
@@ -251,7 +257,7 @@ export default async function Home({ params }: IProps) {
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>{cmsData?.data.footer.location}</p>
|
||||
<p>{sanitizeText(cmsData?.data.footer.location)}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</address>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { sanitizeHtml, sanitizeText, sanitizeUrl } from "../sanitize";
|
||||
|
||||
describe("Sanitization Functions", () => {
|
||||
describe("sanitizeText", () => {
|
||||
it("should remove HTML tags", () => {
|
||||
const dirty = "<script>alert('XSS')</script>Hello";
|
||||
const clean = sanitizeText(dirty);
|
||||
expect(clean).not.toContain("<script>");
|
||||
expect(clean).not.toContain("</script>");
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
expect(sanitizeText(null)).toBe("");
|
||||
expect(sanitizeText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should escape dangerous characters", () => {
|
||||
const dirty = "<img src=x onerror=alert('XSS')>";
|
||||
const clean = sanitizeText(dirty);
|
||||
expect(clean).not.toContain("<img");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUrl", () => {
|
||||
it("should block javascript: URLs", () => {
|
||||
const dirty = "javascript:alert('XSS')";
|
||||
const clean = sanitizeUrl(dirty);
|
||||
expect(clean).toBe("");
|
||||
});
|
||||
|
||||
it("should block data: URLs", () => {
|
||||
const dirty = "data:text/html,<script>alert('XSS')</script>";
|
||||
const clean = sanitizeUrl(dirty);
|
||||
expect(clean).toBe("");
|
||||
});
|
||||
|
||||
it("should allow safe URLs", () => {
|
||||
const safe = "https://example.com";
|
||||
const clean = sanitizeUrl(safe);
|
||||
expect(clean).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
expect(sanitizeUrl(null)).toBe("");
|
||||
expect(sanitizeUrl(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHtml", () => {
|
||||
it("should remove script tags", () => {
|
||||
const dirty = "<p>Hello</p><script>alert('XSS')</script>";
|
||||
const clean = sanitizeHtml(dirty);
|
||||
expect(clean).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
expect(sanitizeHtml(null)).toBe("");
|
||||
expect(sanitizeHtml(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Basic HTML entity encoding for server-side sanitization
|
||||
* Escapes dangerous characters to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"/": "/",
|
||||
};
|
||||
return text.replace(/[&<>"'/]/g, (char) => map[char]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks
|
||||
* @param dirty - The potentially unsafe HTML string
|
||||
* @param allowedTags - Optional array of allowed HTML tags (only works client-side)
|
||||
* @returns Sanitized HTML string safe for rendering
|
||||
*/
|
||||
export function sanitizeHtml(
|
||||
dirty: string | undefined | null,
|
||||
allowedTags?: string[]
|
||||
): string {
|
||||
if (!dirty) return "";
|
||||
|
||||
// Server-side: escape all HTML
|
||||
if (typeof window === "undefined") {
|
||||
return escapeHtml(dirty);
|
||||
}
|
||||
|
||||
// Client-side: use DOMPurify
|
||||
const DOMPurify = require("dompurify");
|
||||
const config = {
|
||||
ALLOWED_TAGS: allowedTags || ["b", "i", "em", "strong", "a", "br", "p"],
|
||||
ALLOWED_ATTR: ["href", "target", "rel"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
return DOMPurify.sanitize(dirty, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes plain text by stripping all HTML tags
|
||||
* Use this for text that should never contain HTML
|
||||
* @param dirty - The potentially unsafe string
|
||||
* @returns Plain text with all HTML removed
|
||||
*/
|
||||
export function sanitizeText(dirty: string | undefined | null): string {
|
||||
if (!dirty) return "";
|
||||
|
||||
// Server-side: escape HTML entities
|
||||
if (typeof window === "undefined") {
|
||||
return escapeHtml(dirty);
|
||||
}
|
||||
|
||||
// Client-side: use DOMPurify
|
||||
const DOMPurify = require("dompurify");
|
||||
return DOMPurify.sanitize(dirty, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a URL to prevent javascript: and data: URI XSS attacks
|
||||
* @param url - The potentially unsafe URL
|
||||
* @returns Sanitized URL or empty string if invalid
|
||||
*/
|
||||
export function sanitizeUrl(url: string | undefined | null): string {
|
||||
if (!url) return "";
|
||||
|
||||
// Check for dangerous protocols first
|
||||
const lowerUrl = url.toLowerCase().trim();
|
||||
if (
|
||||
lowerUrl.startsWith("javascript:") ||
|
||||
lowerUrl.startsWith("data:") ||
|
||||
lowerUrl.startsWith("vbscript:") ||
|
||||
lowerUrl.includes("<script")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Server-side: basic validation and escaping
|
||||
if (typeof window === "undefined") {
|
||||
return escapeHtml(url);
|
||||
}
|
||||
|
||||
// Client-side: use DOMPurify
|
||||
const DOMPurify = require("dompurify");
|
||||
const sanitized = DOMPurify.sanitize(url, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
Generated
+28
@@ -9,8 +9,10 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"apexcharts": "^5.3.5",
|
||||
"axios": "^1.12.2",
|
||||
"dompurify": "^3.3.0",
|
||||
"firebase": "^12.6.0",
|
||||
"next": "15.5.5",
|
||||
"react": "19.1.0",
|
||||
@@ -1746,6 +1748,16 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.21",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.21.tgz",
|
||||
@@ -1775,6 +1787,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@yr/monotone-cubic-spline": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
|
||||
@@ -1964,6 +1983,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"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.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"apexcharts": "^5.3.5",
|
||||
"axios": "^1.12.2",
|
||||
"dompurify": "^3.3.0",
|
||||
"firebase": "^12.6.0",
|
||||
"next": "15.5.5",
|
||||
"react": "19.1.0",
|
||||
|
||||
Reference in New Issue
Block a user