Major code quality improvements: Fix XSS vulnerabilities and replace browser dialogs

Security Fixes:
- Add DOMPurify HTML sanitization for all dangerouslySetInnerHTML usage
- Create comprehensive HTML sanitization utility with configurable security levels
- Implement safe template rendering for slide content and layouts
- Add input validation for image sources and dangerous attributes

UI/UX Improvements:
- Replace browser alert() and confirm() with proper React modal components
- Create reusable Modal, AlertDialog, and ConfirmDialog components
- Add useDialog hook for easy dialog state management
- Implement proper accessibility with keyboard navigation and focus management
- Add smooth animations and responsive design for dialogs

Components Added:
- src/utils/htmlSanitizer.ts - Comprehensive HTML sanitization
- src/components/ui/Modal.tsx - Base modal component
- src/components/ui/AlertDialog.tsx - Alert dialog component
- src/components/ui/ConfirmDialog.tsx - Confirmation dialog component
- src/hooks/useDialog.ts - Dialog state management hook

Updated Components:
- SlideEditor.tsx - Now uses sanitized HTML rendering
- LayoutPreviewPage.tsx - Now uses sanitized HTML rendering
- PresentationEditor.tsx - Now uses React dialogs instead of browser dialogs

Benefits:
✅ Eliminates XSS vulnerabilities from unsafe HTML rendering
✅ Provides consistent, accessible user interface
✅ Improves user experience with proper modal dialogs
✅ Maintains security while preserving slide formatting capabilities
✅ Follows React best practices for component composition

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-20 17:41:15 -05:00
parent 4ce9f225a6
commit d0e70f81e7
14 changed files with 874 additions and 7 deletions

View File

@ -53,6 +53,9 @@
# General Claude Guidelines
- Don't run npm commands, just tell me what to run and I'll run them myself
- generally tsx files should be used for React components and they should be less than 200 lines of code
- if a file I should be warned when I ask for a code audit
- if a function or method is more than 50 lines of code, I should be warned
# Architecture
## Themes

28
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "slideshare",
"version": "0.0.0",
"dependencies": {
"dompurify": "^3.2.6",
"loglevel": "^1.9.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
@ -16,6 +17,7 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
@ -1396,6 +1398,16 @@
"@babel/types": "^7.28.2"
}
},
"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",
@ -1440,6 +1452,13 @@
"@types/react": "^19.0.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/@typescript-eslint/eslint-plugin": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
@ -1997,6 +2016,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.207",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz",

View File

@ -11,6 +11,7 @@
"generate-manifest": "node scripts/generate-themes-manifest.js"
},
"dependencies": {
"dompurify": "^3.2.6",
"loglevel": "^1.9.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
@ -19,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",

View File

@ -12,5 +12,5 @@
"hasMasterSlide": true
}
},
"generated": "2025-08-20T22:06:06.798Z"
"generated": "2025-08-20T22:36:56.857Z"
}

View File

@ -4,6 +4,8 @@ import type { Presentation } from '../../types/presentation';
import type { Theme } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import { useDialog } from '../../hooks/useDialog';
import { AlertDialog, ConfirmDialog } from '../../components/ui';
import './PresentationEditor.css';
export const PresentationEditor: React.FC = () => {
@ -20,6 +22,20 @@ export const PresentationEditor: React.FC = () => {
const [saving, setSaving] = useState(false);
const currentSlideIndex = slideNumber ? parseInt(slideNumber, 10) - 1 : 0;
const {
isAlertOpen,
isConfirmOpen,
alertOptions,
confirmOptions,
showAlert,
showConfirm,
closeAlert,
closeConfirm,
handleConfirm,
showError,
confirmDelete
} = useDialog();
useEffect(() => {
const loadPresentationAndTheme = async () => {
@ -150,7 +166,8 @@ export const PresentationEditor: React.FC = () => {
confirmMessage += ` This will remove the slide and renumber all subsequent slides. This action cannot be undone.`;
}
if (!confirm(confirmMessage)) {
const confirmed = await confirmDelete(confirmMessage);
if (!confirmed) {
return;
}
@ -206,11 +223,15 @@ export const PresentationEditor: React.FC = () => {
// TODO: Implement presentation saving
console.log('Save presentation functionality to be implemented');
alert('Auto-save will be implemented. Changes are saved automatically.');
showAlert({
message: 'Auto-save will be implemented. Changes are saved automatically.',
type: 'info',
title: 'Auto-save Feature'
});
} catch (err) {
console.error('Error saving presentation:', err);
alert('Failed to save presentation');
showError('Failed to save presentation. Please try again.');
} finally {
setSaving(false);
}
@ -508,6 +529,28 @@ export const PresentationEditor: React.FC = () => {
</div>
)}
</main>
{/* Dialog Components */}
<AlertDialog
isOpen={isAlertOpen}
onClose={closeAlert}
title={alertOptions.title}
message={alertOptions.message}
type={alertOptions.type}
confirmText={alertOptions.confirmText}
/>
<ConfirmDialog
isOpen={isConfirmOpen}
onClose={closeConfirm}
onConfirm={handleConfirm}
title={confirmOptions.title}
message={confirmOptions.message}
type={confirmOptions.type}
confirmText={confirmOptions.confirmText}
cancelText={confirmOptions.cancelText}
isDestructive={confirmOptions.isDestructive}
/>
</div>
);
};

View File

@ -5,6 +5,7 @@ import type { Theme, SlideLayout } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
@ -272,7 +273,7 @@ export const SlideEditor: React.FC = () => {
<div
className="layout-rendered"
dangerouslySetInnerHTML={{
__html: renderTemplateWithSampleData(layout.htmlTemplate, layout)
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
}}
/>
</div>
@ -388,7 +389,7 @@ export const SlideEditor: React.FC = () => {
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{
__html: renderTemplateWithContent(selectedLayout, slideContent)
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
}}
/>
</div>

View File

@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import type { Theme, SlideLayout } from '../../types/theme';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
import './LayoutPreviewPage.css';
export const LayoutPreviewPage: React.FC = () => {
@ -152,7 +153,7 @@ export const LayoutPreviewPage: React.FC = () => {
</div>
<div
className="layout-rendered-content"
dangerouslySetInnerHTML={{ __html: renderedContent }}
dangerouslySetInnerHTML={{ __html: sanitizeSlideTemplate(renderedContent) }}
/>
<Link
to={`/themes/${theme.id}/layouts/${layout.id}`}

View File

@ -0,0 +1,122 @@
import React from 'react';
import { Modal } from './Modal';
interface AlertDialogProps {
isOpen: boolean;
onClose: () => void;
title?: string;
message: string;
type?: 'info' | 'warning' | 'error' | 'success';
confirmText?: string;
}
export const AlertDialog: React.FC<AlertDialogProps> = ({
isOpen,
onClose,
title,
message,
type = 'info',
confirmText = 'OK'
}) => {
const getIcon = () => {
switch (type) {
case 'error':
return '❌';
case 'warning':
return '⚠️';
case 'success':
return '✅';
default:
return 'ℹ️';
}
};
const getTitle = () => {
if (title) return title;
switch (type) {
case 'error':
return 'Error';
case 'warning':
return 'Warning';
case 'success':
return 'Success';
default:
return 'Information';
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="small" title={getTitle()}>
<div className="alert-dialog">
<div className="alert-content">
<div className="alert-icon">{getIcon()}</div>
<p className="alert-message">{message}</p>
</div>
<div className="alert-actions">
<button
type="button"
className="button primary"
onClick={onClose}
autoFocus
>
{confirmText}
</button>
</div>
</div>
<style jsx>{`
.alert-dialog {
text-align: center;
}
.alert-content {
margin-bottom: 2rem;
}
.alert-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.alert-message {
color: #374151;
font-size: 1rem;
line-height: 1.5;
margin: 0;
}
.alert-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.button.primary {
background: #3b82f6;
color: white;
}
.button.primary:hover {
background: #2563eb;
}
.button.primary:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
`}</style>
</Modal>
);
};

View File

@ -0,0 +1,179 @@
import React from 'react';
import { Modal } from './Modal';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
type?: 'info' | 'warning' | 'danger';
confirmText?: string;
cancelText?: string;
isDestructive?: boolean;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
type = 'info',
confirmText,
cancelText = 'Cancel',
isDestructive = false
}) => {
const getIcon = () => {
switch (type) {
case 'danger':
return '⚠️';
case 'warning':
return '⚠️';
default:
return '❓';
}
};
const getTitle = () => {
if (title) return title;
switch (type) {
case 'danger':
return 'Confirm Deletion';
case 'warning':
return 'Confirm Action';
default:
return 'Confirm';
}
};
const getConfirmText = () => {
if (confirmText) return confirmText;
switch (type) {
case 'danger':
return 'Delete';
case 'warning':
return 'Continue';
default:
return 'Confirm';
}
};
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="small"
title={getTitle()}
closeOnOverlayClick={false}
>
<div className="confirm-dialog">
<div className="confirm-content">
<div className="confirm-icon">{getIcon()}</div>
<p className="confirm-message">{message}</p>
</div>
<div className="confirm-actions">
<button
type="button"
className="button secondary"
onClick={onClose}
>
{cancelText}
</button>
<button
type="button"
className={`button ${isDestructive || type === 'danger' ? 'danger' : 'primary'}`}
onClick={handleConfirm}
autoFocus
>
{getConfirmText()}
</button>
</div>
</div>
<style jsx>{`
.confirm-dialog {
text-align: center;
}
.confirm-content {
margin-bottom: 2rem;
}
.confirm-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.confirm-message {
color: #374151;
font-size: 1rem;
line-height: 1.5;
margin: 0;
text-align: left;
}
.confirm-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.button.primary {
background: #3b82f6;
color: white;
}
.button.primary:hover {
background: #2563eb;
}
.button.secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.button.secondary:hover {
background: #e5e7eb;
}
.button.danger {
background: #dc2626;
color: white;
}
.button.danger:hover {
background: #b91c1c;
}
.button:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.button.danger:focus {
outline-color: #dc2626;
}
`}</style>
</Modal>
);
};

123
src/components/ui/Modal.css Normal file
View File

@ -0,0 +1,123 @@
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
box-sizing: border-box;
}
/* Modal Content */
.modal-content {
background: white;
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Modal Sizes */
.modal-small {
width: 100%;
max-width: 400px;
}
.modal-medium {
width: 100%;
max-width: 600px;
}
.modal-large {
width: 100%;
max-width: 800px;
}
/* Modal Header */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0 1.5rem;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 1rem;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.modal-close-button {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
font-size: 1.25rem;
padding: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.modal-close-button:hover {
background: #f3f4f6;
color: #374151;
}
/* Modal Body */
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
/* Responsive Design */
@media (max-width: 640px) {
.modal-overlay {
padding: 0.5rem;
}
.modal-content {
max-height: 95vh;
}
.modal-header {
padding: 1rem 1rem 0 1rem;
}
.modal-body {
padding: 1rem;
}
.modal-title {
font-size: 1.125rem;
}
}

View File

@ -0,0 +1,80 @@
import React, { useEffect } from 'react';
import './Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'small' | 'medium' | 'large';
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
size = 'medium',
closeOnOverlayClick = true,
closeOnEscape = true
}) => {
// Handle escape key
useEffect(() => {
if (!isOpen || !closeOnEscape) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, closeOnEscape, onClose]);
// Handle body scroll lock
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
const handleOverlayClick = (event: React.MouseEvent) => {
if (event.target === event.currentTarget && closeOnOverlayClick) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className={`modal-content modal-${size}`}>
{title && (
<header className="modal-header">
<h2 className="modal-title">{title}</h2>
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label="Close modal"
>
✕
</button>
</header>
)}
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export { Modal } from './Modal';
export { AlertDialog } from './AlertDialog';
export { ConfirmDialog } from './ConfirmDialog';

117
src/hooks/useDialog.ts Normal file
View File

@ -0,0 +1,117 @@
import { useState, useCallback } from 'react';
interface AlertOptions {
title?: string;
message: string;
type?: 'info' | 'warning' | 'error' | 'success';
confirmText?: string;
}
interface ConfirmOptions {
title?: string;
message: string;
type?: 'info' | 'warning' | 'danger';
confirmText?: string;
cancelText?: string;
isDestructive?: boolean;
}
interface DialogState {
isAlertOpen: boolean;
isConfirmOpen: boolean;
alertOptions: AlertOptions;
confirmOptions: ConfirmOptions;
confirmCallback: (() => void) | null;
}
export function useDialog() {
const [state, setState] = useState<DialogState>({
isAlertOpen: false,
isConfirmOpen: false,
alertOptions: { message: '' },
confirmOptions: { message: '' },
confirmCallback: null
});
const showAlert = useCallback((options: AlertOptions) => {
setState(prev => ({
...prev,
isAlertOpen: true,
alertOptions: options
}));
}, []);
const showConfirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
setState(prev => ({
...prev,
isConfirmOpen: true,
confirmOptions: options,
confirmCallback: () => resolve(true)
}));
});
}, []);
const closeAlert = useCallback(() => {
setState(prev => ({
...prev,
isAlertOpen: false
}));
}, []);
const closeConfirm = useCallback(() => {
setState(prev => ({
...prev,
isConfirmOpen: false,
confirmCallback: null
}));
}, []);
const handleConfirm = useCallback(() => {
if (state.confirmCallback) {
state.confirmCallback();
}
closeConfirm();
}, [state.confirmCallback, closeConfirm]);
const handleConfirmCancel = useCallback(() => {
closeConfirm();
}, [closeConfirm]);
return {
// State
isAlertOpen: state.isAlertOpen,
isConfirmOpen: state.isConfirmOpen,
alertOptions: state.alertOptions,
confirmOptions: state.confirmOptions,
// Actions
showAlert,
showConfirm,
closeAlert,
closeConfirm: handleConfirmCancel,
handleConfirm,
// Convenience methods
showError: useCallback((message: string, title?: string) => {
showAlert({ message, title, type: 'error' });
}, [showAlert]),
showSuccess: useCallback((message: string, title?: string) => {
showAlert({ message, title, type: 'success' });
}, [showAlert]),
showWarning: useCallback((message: string, title?: string) => {
showAlert({ message, title, type: 'warning' });
}, [showAlert]),
confirmDelete: useCallback((message: string, title?: string) => {
return showConfirm({
message,
title: title || 'Confirm Deletion',
type: 'danger',
isDestructive: true
});
}, [showConfirm])
};
}

165
src/utils/htmlSanitizer.ts Normal file
View File

@ -0,0 +1,165 @@
import DOMPurify from 'dompurify';
/**
* Configuration for HTML sanitization based on context
*/
interface SanitizeConfig {
/**
* Whether to allow basic HTML formatting tags (b, i, em, strong, etc.)
*/
allowFormatting?: boolean;
/**
* Whether to allow structural HTML elements (div, span, p, etc.)
*/
allowStructural?: boolean;
/**
* Whether to allow image tags (with strict src validation)
*/
allowImages?: boolean;
/**
* Custom allowed tags (overrides other options if provided)
*/
allowedTags?: string[];
/**
* Custom allowed attributes (overrides defaults if provided)
*/
allowedAttributes?: string[];
}
/**
* Default safe configuration for slide content
*/
const DEFAULT_SLIDE_CONFIG: Required<SanitizeConfig> = {
allowFormatting: true,
allowStructural: true,
allowImages: false,
allowedTags: [
// Text formatting
'b', 'i', 'em', 'strong', 'u', 'mark', 'small', 'sub', 'sup',
// Structure
'div', 'span', 'p', 'br', 'hr',
// Lists
'ul', 'ol', 'li',
// Headings
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Quotes
'blockquote', 'cite'
],
allowedAttributes: [
'class', 'id', 'style', 'data-*'
]
};
/**
* Sanitizes HTML content to prevent XSS attacks while preserving safe formatting
*
* @param html - The HTML string to sanitize
* @param config - Configuration options for sanitization
* @returns Sanitized HTML string safe for rendering
*/
export function sanitizeHtml(html: string, config: SanitizeConfig = {}): string {
if (!html || typeof html !== 'string') {
return '';
}
const finalConfig = { ...DEFAULT_SLIDE_CONFIG, ...config };
// Build DOMPurify configuration
const purifyConfig: any = {
ALLOWED_TAGS: finalConfig.allowedTags,
ALLOWED_ATTR: finalConfig.allowedAttributes,
// Remove any scripts or dangerous content
FORBID_TAGS: ['script', 'object', 'embed', 'base', 'link', 'meta', 'style'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
// Keep content structure but remove dangerous elements
KEEP_CONTENT: true,
// Allow data attributes for slots and styling
ALLOW_DATA_ATTR: true
};
// Add image support if requested
if (finalConfig.allowImages) {
purifyConfig.ALLOWED_TAGS.push('img');
purifyConfig.ALLOWED_ATTR.push('src', 'alt', 'width', 'height');
// Add hook to validate image sources
DOMPurify.addHook('beforeSanitizeAttributes', (node) => {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && !isValidImageSource(src)) {
node.removeAttribute('src');
}
}
});
}
const sanitized = DOMPurify.sanitize(html, purifyConfig);
// Clean up hooks to prevent memory leaks
DOMPurify.removeAllHooks();
return sanitized;
}
/**
* Validates image sources to ensure they're safe
*
* @param src - Image source URL
* @returns Whether the source is considered safe
*/
function isValidImageSource(src: string): boolean {
try {
const url = new URL(src, window.location.origin);
// Allow data URLs for base64 images
if (url.protocol === 'data:') {
return src.startsWith('data:image/');
}
// Allow HTTPS and HTTP from trusted domains
if (url.protocol === 'https:' || url.protocol === 'http:') {
// Add any domain restrictions here if needed
return true;
}
return false;
} catch {
return false;
}
}
/**
* Specific sanitization for slide template content
* Allows structural elements and formatting but restricts dangerous content
*
* @param html - Template HTML to sanitize
* @returns Sanitized HTML safe for slide rendering
*/
export function sanitizeSlideTemplate(html: string): string {
return sanitizeHtml(html, {
allowFormatting: true,
allowStructural: true,
allowImages: true
});
}
/**
* Specific sanitization for user-provided content
* More restrictive than template sanitization
*
* @param html - User content to sanitize
* @returns Sanitized HTML safe for user content
*/
export function sanitizeUserContent(html: string): string {
return sanitizeHtml(html, {
allowFormatting: true,
allowStructural: false,
allowImages: false,
allowedTags: ['b', 'i', 'em', 'strong', 'u', 'br'],
allowedAttributes: []
});
}