diff --git a/.changeset/fix-banner-content-types.md b/.changeset/fix-banner-content-types.md new file mode 100644 index 0000000..61d33c0 --- /dev/null +++ b/.changeset/fix-banner-content-types.md @@ -0,0 +1,21 @@ +--- +'@prosdevlab/experience-sdk': patch +'@prosdevlab/experience-sdk-plugins': patch +--- + +Fix BannerContent type definition, add CSS customization support, and implement HTML sanitization: + +- Add `buttons` array property with variant and metadata support +- Add `position` property (top/bottom) +- Make `title` optional (message is the only required field) +- Add `className` and `style` props for banner and buttons +- Update banner plugin to use `.xp-*` CSS classes +- Provide minimal, functional default styles +- Add HTML sanitizer for XSS prevention in title and message fields +- Support safe HTML tags (strong, em, a, br, span, b, i, p) +- Block dangerous tags and event handlers +- Sanitize URLs to prevent javascript: and data: attacks +- Aligns core types with banner plugin implementation + +This enables users to customize banners with Tailwind, design systems, or CSS frameworks while maintaining SDK's focus on targeting logic. HTML sanitization ensures safe rendering of user-provided content. + diff --git a/docs/pages/demo/banner.mdx b/docs/pages/demo/banner.mdx index b3ed2fa..47b26dc 100644 --- a/docs/pages/demo/banner.mdx +++ b/docs/pages/demo/banner.mdx @@ -17,7 +17,7 @@ experiences.register('welcome-banner', { url: { contains: '/' } }, content: { - title: '👋 Welcome!', + title: 'Welcome!', message: 'Thanks for visiting our site', buttons: [ { @@ -115,6 +115,8 @@ const decisions = experiences.evaluateAll(); | `buttons` | `array` | Array of button configurations | | `dismissable` | `boolean` | Can user dismiss? (default: `true`) | | `position` | `'top' \| 'bottom'` | Banner position (default: `'top'`) | +| `className` | `string` | Custom CSS class for the banner | +| `style` | `Record` | Inline styles for the banner | ### Button Options @@ -125,6 +127,8 @@ buttons: [{ url?: string; // Navigate on click variant?: 'primary' | 'secondary' | 'link'; // Visual style (default: 'primary') metadata?: Record; // Custom metadata + className?: string; // Custom CSS class + style?: Record; // Inline styles }] ``` @@ -159,6 +163,40 @@ targeting: { } ``` +## Customization + +Customize banners with your own CSS using `className` or `style` props: + +```typescript +// With Tailwind classes +experiences.register('promo', { + type: 'banner', + content: { + message: 'Flash Sale: 50% Off!', + className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white', + buttons: [{ + text: 'Shop Now', + className: 'bg-white text-blue-600 hover:bg-gray-100' + }] + } +}); + +// With inline styles +experiences.register('custom', { + type: 'banner', + content: { + message: 'Custom styled banner', + style: { + background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)', + color: 'white', + padding: '24px' + } + } +}); +``` + +The banner plugin uses stable `.xp-*` CSS classes that you can target in your stylesheets. See the [Plugins documentation](/reference/plugins#customization) for complete customization guide. + ## Events Listen to banner interactions: diff --git a/docs/pages/reference/plugins.mdx b/docs/pages/reference/plugins.mdx index 268dcce..ae68969 100644 --- a/docs/pages/reference/plugins.mdx +++ b/docs/pages/reference/plugins.mdx @@ -138,6 +138,105 @@ experiences.on('experiences:dismissed', ({ experienceId }) => { See [Banner Examples](/demo/banner) for complete usage examples. +#### Customization + +The Experience SDK focuses on **targeting logic**, not visual design. The banner plugin provides minimal, functional default styles that you can customize using CSS. + +**CSS Classes** + +The banner plugin uses the `.xp-*` namespace for all CSS classes: + +- `.xp-banner` - Main container +- `.xp-banner--top` - Top positioned banner +- `.xp-banner--bottom` - Bottom positioned banner +- `.xp-banner__container` - Inner wrapper +- `.xp-banner__content` - Content section +- `.xp-banner__title` - Optional title +- `.xp-banner__message` - Main message text +- `.xp-banner__buttons` - Buttons container +- `.xp-banner__button` - Individual button +- `.xp-banner__button--primary` - Primary button variant +- `.xp-banner__button--secondary` - Secondary button variant +- `.xp-banner__button--link` - Link button variant +- `.xp-banner__close` - Close button + +**Use Case 1: User with Tailwind** + +Add Tailwind classes via the `className` property: + +```typescript +experiences.register('flash-sale', { + type: 'banner', + content: { + message: 'Flash Sale: 50% Off Everything!', + className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white', + buttons: [{ + text: 'Shop Now', + url: '/shop', + variant: 'primary', + className: 'bg-white text-blue-600 hover:bg-gray-100' + }] + }, + targeting: { url: { contains: '/shop' } } +}); +``` + +**Use Case 2: User with Design System** + +Build your own plugin using your design system components: + +```typescript +import { MyBannerComponent } from '@your-org/design-system'; + +const myBannerPlugin: PluginFunction = (plugin, instance, config) => { + instance.on('experiences:evaluated', ({ decision, experience }) => { + if (decision.show && experience.type === 'banner') { + // Render using your React component + ReactDOM.render( + , + document.getElementById('banner-root') + ); + } + }); +}; + +experiences.use(myBannerPlugin); +``` + +**Use Case 3: User with CSS Framework** + +Add Bootstrap, Material UI, or other framework classes: + +```typescript +experiences.register('alert', { + type: 'banner', + content: { + message: 'Important notice', + className: 'alert alert-warning', + buttons: [{ + text: 'Learn More', + className: 'btn btn-primary' + }] + }, + targeting: {} +}); +``` + +**Inline Styles** + +For quick overrides, use the `style` property: + +```typescript +content: { + message: 'Flash Sale!', + style: { + background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)', + color: 'white', + padding: '24px' + } +} +``` + --- ### Frequency Plugin diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0bff975..06df9c5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -78,52 +78,24 @@ export interface FrequencyConfig { per: 'session' | 'day' | 'week'; } +// Import plugin-specific content types from plugins package +// (Core depends on plugins, so plugins owns these definitions) +import type { + BannerContent, + ModalContent, + TooltipContent, +} from '@prosdevlab/experience-sdk-plugins'; + /** * Experience Content (type-specific) * * Union type for all possible experience content types. + * Content types are defined in the plugins package. */ export type ExperienceContent = BannerContent | ModalContent | TooltipContent; -/** - * Banner Content - * - * Content for banner-type experiences. - */ -export interface BannerContent { - /** Banner title/heading */ - title: string; - /** Banner message/body text */ - message: string; - /** Whether the banner can be dismissed */ - dismissable?: boolean; -} - -/** - * Modal Content - * - * Content for modal-type experiences. - */ -export interface ModalContent { - /** Modal title */ - title: string; - /** Modal body content */ - body: string; - /** Optional action buttons */ - actions?: ModalAction[]; -} - -/** - * Tooltip Content - * - * Content for tooltip-type experiences. - */ -export interface TooltipContent { - /** Tooltip text */ - text: string; - /** Position relative to target element */ - position?: 'top' | 'bottom' | 'left' | 'right'; -} +// Re-export plugin content types for convenience +export type { BannerContent, ModalContent, TooltipContent }; /** * Modal Action Button diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 47d4cb3..eb0f88f 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -16,6 +16,8 @@ Renders banner experiences in the DOM with automatic positioning, theming, and r - Automatic theme detection (light/dark mode) - Top/bottom positioning - Dismissable with close button +- **CSS customization** via `className` and `style` props +- Stable `.xp-*` CSS classes for styling ```typescript import { createInstance, bannerPlugin } from '@prosdevlab/experience-sdk-plugins'; @@ -38,6 +40,33 @@ sdk.banner.show({ }); ``` +**Customization:** + +The banner plugin uses `.xp-*` CSS classes and supports custom styling: + +```typescript +// With Tailwind +content: { + message: 'Flash Sale!', + className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white', + buttons: [{ + text: 'Shop Now', + className: 'bg-white text-blue-600 hover:bg-gray-100' + }] +} + +// With inline styles +content: { + message: 'Flash Sale!', + style: { + background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)', + color: 'white' + } +} +``` + +See the [Plugins documentation](https://prosdevlab.github.io/experience-sdk/reference/plugins#customization) for more customization examples. + ### Frequency Plugin Manages impression tracking and frequency capping with persistent storage. diff --git a/packages/plugins/src/banner/banner.test.ts b/packages/plugins/src/banner/banner.test.ts index a35ff3b..387f01a 100644 --- a/packages/plugins/src/banner/banner.test.ts +++ b/packages/plugins/src/banner/banner.test.ts @@ -92,8 +92,8 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.top).toBe('0px'); - expect(banner.style.bottom).toBe(''); + expect(banner.className).toContain('xp-banner--top'); + expect(banner.className).not.toContain('xp-banner--bottom'); }); it('should show banner at bottom position when configured', () => { @@ -113,8 +113,8 @@ describe('Banner Plugin', () => { customSdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.bottom).toBe('0px'); - expect(banner.style.top).toBe(''); + expect(banner.className).toContain('xp-banner--bottom'); + expect(banner.className).not.toContain('xp-banner--top'); }); it('should create banner with title and message', () => { @@ -500,8 +500,8 @@ describe('Banner Plugin', () => { const button = banner?.querySelector('button') as HTMLElement; expect(button).toBeTruthy(); - // Primary variant should have white text - expect(button.style.color).toContain('255'); // rgb(255, 255, 255) + // Primary variant should have the correct class + expect(button.className).toContain('xp-banner__button--primary'); }); it('should emit action event with variant and metadata', () => { @@ -667,7 +667,8 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.zIndex).toBe('10000'); + // Default z-index is set via CSS, check that banner has the base class + expect(banner.className).toContain('xp-banner'); }); it('should apply custom z-index', () => { @@ -704,7 +705,9 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.position).toBe('fixed'); + // Position is set via CSS class, check computed style + const computedStyle = window.getComputedStyle(banner); + expect(computedStyle.position).toBe('fixed'); }); it('should span full width', () => { @@ -721,8 +724,365 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.left).toBe('0px'); - expect(banner.style.right).toBe('0px'); + // Width and positioning are set via CSS class, check computed styles + const computedStyle = window.getComputedStyle(banner); + expect(computedStyle.left).toBe('0px'); + expect(computedStyle.right).toBe('0px'); + expect(computedStyle.width).toBe('100%'); + }); + + it('should apply custom className to banner', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + className: 'my-custom-banner custom-class', + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; + expect(banner.className).toContain('xp-banner'); + expect(banner.className).toContain('my-custom-banner'); + expect(banner.className).toContain('custom-class'); + }); + + it('should apply custom inline styles to banner', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + style: { + backgroundColor: '#ff0000', + padding: '24px', + borderRadius: '8px', + }, + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; + expect(banner.style.backgroundColor).toBe('rgb(255, 0, 0)'); + expect(banner.style.padding).toBe('24px'); + expect(banner.style.borderRadius).toBe('8px'); + }); + + it('should apply custom className to buttons', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + buttons: [ + { + text: 'Primary Button', + variant: 'primary', + className: 'my-primary-btn custom-btn', + }, + { + text: 'Secondary Button', + variant: 'secondary', + className: 'my-secondary-btn', + }, + ], + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]'); + const buttons = banner?.querySelectorAll('.xp-banner__button'); + + expect(buttons?.[0].className).toContain('xp-banner__button--primary'); + expect(buttons?.[0].className).toContain('my-primary-btn'); + expect(buttons?.[0].className).toContain('custom-btn'); + + expect(buttons?.[1].className).toContain('xp-banner__button--secondary'); + expect(buttons?.[1].className).toContain('my-secondary-btn'); + }); + + it('should apply custom inline styles to buttons', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + buttons: [ + { + text: 'Styled Button', + variant: 'primary', + style: { + backgroundColor: '#00ff00', + color: '#000000', + fontWeight: 'bold', + }, + }, + ], + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]'); + const button = banner?.querySelector('.xp-banner__button') as HTMLElement; + + expect(button.style.backgroundColor).toBe('rgb(0, 255, 0)'); + expect(button.style.color).toBe('rgb(0, 0, 0)'); + expect(button.style.fontWeight).toBe('bold'); + }); + + it('should combine className and style props', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + className: 'custom-banner', + style: { + backgroundColor: '#0000ff', + }, + buttons: [ + { + text: 'Button', + className: 'custom-button', + style: { + color: '#ffffff', + }, + }, + ], + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; + expect(banner.className).toContain('xp-banner'); + expect(banner.className).toContain('custom-banner'); + expect(banner.style.backgroundColor).toBe('rgb(0, 0, 255)'); + + const button = banner.querySelector('.xp-banner__button') as HTMLElement; + expect(button.className).toContain('xp-banner__button'); + expect(button.className).toContain('custom-button'); + expect(button.style.color).toBe('rgb(255, 255, 255)'); + }); + }); + + describe('HTML Sanitization', () => { + it('should sanitize HTML in title to prevent XSS', () => { + const experience: Experience = { + id: 'xss-test', + type: 'banner', + targeting: {}, + content: { + title: 'Safe Title', + message: 'Test message', + }, + }; + + sdk.emit('experiences:evaluated', { + decision: { + show: true, + experienceId: 'xss-test', + reasons: [], + trace: [], + context: {} as any, + metadata: {}, + }, + experience, + }); + + const banner = document.querySelector('[data-experience-id="xss-test"]') as HTMLElement; + expect(banner).toBeTruthy(); + + const title = banner.querySelector('.xp-banner__title') as HTMLElement; + expect(title).toBeTruthy(); + // Script tag should be stripped + expect(title.innerHTML).not.toContain('World', + }, + }; + + sdk.emit('experiences:evaluated', { + decision: { + show: true, + experienceId: 'xss-test', + reasons: [], + trace: [], + context: {} as any, + metadata: {}, + }, + experience, + }); + + const banner = document.querySelector('[data-experience-id="xss-test"]') as HTMLElement; + expect(banner).toBeTruthy(); + + const message = banner.querySelector('.xp-banner__message') as HTMLElement; + expect(message).toBeTruthy(); + // Script tag should be stripped + expect(message.innerHTML).not.toContain('')).toBe(''); + expect(sanitizeHTML('HelloWorld')).toBe('HelloWorld'); + }); + + it('should strip script tags with attributes', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip nested script tags', () => { + expect(sanitizeHTML('

')).toBe('

'); + expect(sanitizeHTML('')).toBe( + '' + ); + }); + + it('should strip script tags in mixed content', () => { + expect(sanitizeHTML('SafeText')).toBe( + 'SafeText' + ); + }); + }); + + describe('XSS Prevention - Event Handlers', () => { + it('should strip onclick attributes', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + expect(sanitizeHTML('Text')).toBe('Text'); + }); + + it('should strip onerror attributes', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('Text')).toBe('Text'); + }); + + it('should strip onload attributes', () => { + expect(sanitizeHTML('Text')).toBe('Text'); + }); + + it('should strip onmouseover attributes', () => { + expect(sanitizeHTML('Text')).toBe( + 'Text' + ); + }); + + it('should strip all event handler attributes', () => { + const eventHandlers = [ + 'onclick', + 'onerror', + 'onload', + 'onmouseover', + 'onmouseout', + 'onfocus', + 'onblur', + 'onchange', + 'onsubmit', + 'onkeydown', + 'onkeypress', + 'onkeyup', + ]; + + for (const handler of eventHandlers) { + expect(sanitizeHTML(`Text`)).toBe( + 'Text' + ); + } + }); + }); + + describe('XSS Prevention - Dangerous Tags', () => { + it('should strip iframe tags', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('TextMore')).toBe('TextMore'); + }); + + it('should strip object tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip embed tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip form tags', () => { + expect(sanitizeHTML('
')).toBe(''); + }); + + it('should strip input tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip img tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip style tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip link tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip meta tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip video tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip audio tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip svg tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + }); + + describe('XSS Prevention - javascript: URLs', () => { + it('should strip javascript: URLs in href', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + }); + + it('should strip javascript: URLs with encoded characters', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + }); + }); + + describe('XSS Prevention - data: URLs', () => { + it('should strip data: URLs in href', () => { + expect( + sanitizeHTML('Link') + ).toBe('Link'); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + }); + + describe('XSS Prevention - Style-based Attacks', () => { + it('should allow safe style attributes', () => { + expect(sanitizeHTML('Text')).toBe( + 'Text' + ); + }); + + it('should escape HTML in style attributes', () => { + // Quotes in style attributes are escaped for safety + const result = sanitizeHTML('Text'); + expect(result).toContain(''); + // The exact escaping format may vary, but quotes should be escaped + expect(result).not.toContain("color: 'red'"); + }); + }); + + describe('XSS Prevention - HTML Entities', () => { + it('should escape HTML entities in text content', () => { + expect(sanitizeHTML('<script>')).toBe( + '<script>' + ); + expect(sanitizeHTML('<script>alert("xss")</script>')).toBe( + '<script>alert("xss")</script>' + ); + }); + + it('should escape quotes in attributes', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + }); + + describe('XSS Prevention - Nested Attacks', () => { + it('should handle deeply nested dangerous tags', () => { + expect( + sanitizeHTML('

') + ).toBe('

'); + }); + + it('should handle mixed safe and dangerous content', () => { + expect( + sanitizeHTML( + 'SafeAlso Safe' + ) + ).toBe('SafeAlso Safe'); + }); + }); + + describe('XSS Prevention - Edge Cases', () => { + it('should handle malformed HTML', () => { + expect(sanitizeHTML('Unclosed tag')).toBe('Unclosed tag'); + expect(sanitizeHTML('Closing tag without opening')).toBe( + 'Closing tag without opening' + ); + }); + + it('should handle case variations', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('')).toBe(''); + }); + + it('should handle whitespace in tags', () => { + // Tags with whitespace are treated as text (safe) + // Browser normalizes tags, so "< script >" becomes text content + const result1 = sanitizeHTML('< script >alert("xss")'); + expect(result1).toContain('alert("xss")'); + expect(result1).not.toContain('')).toBe(''); + + // Iframe injection + expect(sanitizeHTML('')).toBe(''); + + // Event handler in allowed tag + expect(sanitizeHTML('Click')).toBe( + 'Click' + ); + + // Mixed attack + expect( + sanitizeHTML( + 'WelcomeLink' + ) + ).toBe('WelcomeLink'); + }); + + it('should prevent encoded XSS attacks', () => { + // HTML entity encoded + expect(sanitizeHTML('<script>alert("xss")</script>')).toBe( + '<script>alert("xss")</script>' + ); + + // URL encoded javascript: should be decoded and blocked + // Note: decodeURIComponent will decode %6A%61%76%61%73%63%72%69%70%74%3A to "javascript:" + const result = sanitizeHTML( + 'Link' + ); + expect(result).toBe('Link'); + }); + }); + + describe('URL Sanitization', () => { + it('should allow relative URLs', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + + it('should allow http and https URLs', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + + it('should allow mailto and tel URLs', () => { + expect(sanitizeHTML('Email')).toBe( + 'Email' + ); + expect(sanitizeHTML('Call')).toBe( + 'Call' + ); + }); + + it('should block javascript: URLs regardless of case', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + }); + + it('should block data: URLs', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + + it('should block other dangerous protocols', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + }); + }); + + describe('Complex Scenarios', () => { + it('should handle real-world banner content', () => { + const content = ` + Flash Sale! + Get 50% off everything. + Shop Now + `; + const sanitized = sanitizeHTML(content); + expect(sanitized).toContain('Flash Sale!'); + expect(sanitized).toContain('50% off'); + expect(sanitized).toContain('Shop Now'); + expect(sanitized).not.toContain(''); + * // Returns: 'Hello' + * ``` + */ +export function sanitizeHTML(html: string): string { + if (!html || typeof html !== 'string') { + return ''; + } + + // Create a temporary DOM element to parse HTML + const temp = document.createElement('div'); + temp.innerHTML = html; + + /** + * Recursively sanitize a DOM node + */ + function sanitizeNode(node: Node): string { + // Text nodes - escape HTML entities + if (node.nodeType === Node.TEXT_NODE) { + return escapeHTML(node.textContent || ''); + } + + // Element nodes + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + const tagName = element.tagName.toLowerCase(); + + // Handle tags with whitespace (malformed HTML like "< script >") + // Browser normalizes these, but if we see a tag that's not in our list, + // it might be a dangerous tag that was normalized + if (!tagName || tagName.includes(' ')) { + return ''; + } + + // If tag is not allowed, return empty string + if (!ALLOWED_TAGS.includes(tagName as any)) { + return ''; + } + + // Get allowed attributes for this tag + const allowedAttrs = ALLOWED_ATTRIBUTES[tagName] || []; + + // Build attribute string + const attrs: string[] = []; + for (const attr of allowedAttrs) { + const value = element.getAttribute(attr); + if (value !== null) { + // Sanitize attribute values + if (attr === 'href') { + // Only allow safe URLs (http, https, mailto, tel, relative) + const sanitizedHref = sanitizeURL(value); + if (sanitizedHref) { + attrs.push(`href="${escapeAttribute(sanitizedHref)}"`); + } + } else { + // For all other attributes (title, class, style), escape HTML entities + attrs.push(`${attr}="${escapeAttribute(value)}"`); + } + } + } + + const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : ''; + + // Process child nodes + let innerHTML = ''; + for (const child of Array.from(element.childNodes)) { + innerHTML += sanitizeNode(child); + } + + // Self-closing tags + if (tagName === 'br') { + return ``; + } + + return `<${tagName}${attrString}>${innerHTML}`; + } + + return ''; + } + + // Sanitize all nodes + let sanitized = ''; + for (const child of Array.from(temp.childNodes)) { + sanitized += sanitizeNode(child); + } + + return sanitized; +} + +/** + * Escape HTML entities to prevent XSS in text content + */ +function escapeHTML(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Escape HTML entities for use in attribute values + */ +function escapeAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Sanitize URL to prevent javascript: and data: XSS attacks + * + * @param url - URL to sanitize + * @returns Sanitized URL or empty string if unsafe + */ +function sanitizeURL(url: string): string { + if (!url || typeof url !== 'string') { + return ''; + } + + // Decode URL-encoded characters to check for encoded attacks + let decoded: string; + try { + decoded = decodeURIComponent(url); + } catch { + // If decoding fails, use original + decoded = url; + } + + const trimmed = decoded.trim().toLowerCase(); + + // Block javascript: and data: protocols (check both original and decoded) + if ( + trimmed.startsWith('javascript:') || + trimmed.startsWith('data:') || + url.toLowerCase().trim().startsWith('javascript:') || + url.toLowerCase().trim().startsWith('data:') + ) { + return ''; + } + + // Allow http, https, mailto, tel, and relative URLs + if ( + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + trimmed.startsWith('mailto:') || + trimmed.startsWith('tel:') || + trimmed.startsWith('/') || + trimmed.startsWith('#') || + trimmed.startsWith('?') + ) { + return url; // Return original (case preserved) + } + + // Allow relative paths without protocol + if (!trimmed.includes(':')) { + return url; + } + + // Block everything else + return ''; +} diff --git a/tsconfig.json b/tsconfig.json index b543e23..7a360e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,12 +12,8 @@ "lib": ["ES2022", "DOM"], "baseUrl": ".", "paths": { - "@monorepo/core": ["packages/core/src"], - "@monorepo/core/*": ["packages/core/src/*"], - "@monorepo/utils": ["packages/utils/src"], - "@monorepo/utils/*": ["packages/utils/src/*"], - "@monorepo/feature-a": ["packages/feature-a/src"], - "@monorepo/feature-a/*": ["packages/feature-a/src/*"] + "@prosdevlab/experience-sdk": ["packages/core/src"], + "@prosdevlab/experience-sdk/*": ["packages/core/src/*"] } }, "exclude": ["node_modules"],