From d0e70f81e7437d713047d5681055efe805c2034f Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Wed, 20 Aug 2025 17:41:15 -0500 Subject: [PATCH] Major code quality improvements: Fix XSS vulnerabilities and replace browser dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 3 + package-lock.json | 28 +++ package.json | 2 + public/themes-manifest.json | 2 +- .../presentations/PresentationEditor.tsx | 49 ++++- src/components/presentations/SlideEditor.tsx | 5 +- src/components/themes/LayoutPreviewPage.tsx | 3 +- src/components/ui/AlertDialog.tsx | 122 ++++++++++++ src/components/ui/ConfirmDialog.tsx | 179 ++++++++++++++++++ src/components/ui/Modal.css | 123 ++++++++++++ src/components/ui/Modal.tsx | 80 ++++++++ src/components/ui/index.ts | 3 + src/hooks/useDialog.ts | 117 ++++++++++++ src/utils/htmlSanitizer.ts | 165 ++++++++++++++++ 14 files changed, 874 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/AlertDialog.tsx create mode 100644 src/components/ui/ConfirmDialog.tsx create mode 100644 src/components/ui/Modal.css create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/index.ts create mode 100644 src/hooks/useDialog.ts create mode 100644 src/utils/htmlSanitizer.ts diff --git a/CLAUDE.md b/CLAUDE.md index b9e2b42..0838ce4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 25c2317..1859a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 39b744a..a5efae2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/themes-manifest.json b/public/themes-manifest.json index 301b83d..e5faf01 100644 --- a/public/themes-manifest.json +++ b/public/themes-manifest.json @@ -12,5 +12,5 @@ "hasMasterSlide": true } }, - "generated": "2025-08-20T22:06:06.798Z" + "generated": "2025-08-20T22:36:56.857Z" } \ No newline at end of file diff --git a/src/components/presentations/PresentationEditor.tsx b/src/components/presentations/PresentationEditor.tsx index 9c5fbb9..d6fe1a5 100644 --- a/src/components/presentations/PresentationEditor.tsx +++ b/src/components/presentations/PresentationEditor.tsx @@ -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 = () => { )} + + {/* Dialog Components */} + + + ); }; \ No newline at end of file diff --git a/src/components/presentations/SlideEditor.tsx b/src/components/presentations/SlideEditor.tsx index b4514b5..a8a7b30 100644 --- a/src/components/presentations/SlideEditor.tsx +++ b/src/components/presentations/SlideEditor.tsx @@ -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 = () => {
@@ -388,7 +389,7 @@ export const SlideEditor: React.FC = () => {
diff --git a/src/components/themes/LayoutPreviewPage.tsx b/src/components/themes/LayoutPreviewPage.tsx index 1a21036..a009684 100644 --- a/src/components/themes/LayoutPreviewPage.tsx +++ b/src/components/themes/LayoutPreviewPage.tsx @@ -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 = () => {
void; + title?: string; + message: string; + type?: 'info' | 'warning' | 'error' | 'success'; + confirmText?: string; +} + +export const AlertDialog: React.FC = ({ + 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 ( + +
+
+
{getIcon()}
+

{message}

+
+
+ +
+
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..b8ba3ce --- /dev/null +++ b/src/components/ui/ConfirmDialog.tsx @@ -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 = ({ + 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 ( + +
+
+
{getIcon()}
+

{message}

+
+
+ + +
+
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/Modal.css b/src/components/ui/Modal.css new file mode 100644 index 0000000..f16b1ed --- /dev/null +++ b/src/components/ui/Modal.css @@ -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; + } +} \ No newline at end of file diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..e8e9814 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -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 = ({ + 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 ( +
+
+ {title && ( +
+

{title}

+ +
+ )} +
+ {children} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..692c325 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export { Modal } from './Modal'; +export { AlertDialog } from './AlertDialog'; +export { ConfirmDialog } from './ConfirmDialog'; \ No newline at end of file diff --git a/src/hooks/useDialog.ts b/src/hooks/useDialog.ts new file mode 100644 index 0000000..d988632 --- /dev/null +++ b/src/hooks/useDialog.ts @@ -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({ + 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 => { + 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]) + }; +} \ No newline at end of file diff --git a/src/utils/htmlSanitizer.ts b/src/utils/htmlSanitizer.ts new file mode 100644 index 0000000..1bea39b --- /dev/null +++ b/src/utils/htmlSanitizer.ts @@ -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 = { + 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: [] + }); +} \ No newline at end of file