Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fifty-cars-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@naverpay/safe-html-react-parser": minor
---

[safe-html-react-parser] Replace isomorphic-dompurify with custom implementation supporting flexible DOM libraries

PR: [[safe-html-react-parser] Replace isomorphic-dompurify with custom implementation supporting flexible DOM libraries](https://github.com/NaverPayDev/pie/pull/203)
116 changes: 110 additions & 6 deletions packages/safe-html-react-parser/README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
# safe-html-react-parser

A secure wrapper for **html-react-parser** with **isomorphic-dompurify** that automatically sanitizes HTML before parsing.
A secure wrapper for **html-react-parser** with **DOMPurify** that automatically sanitizes HTML before parsing.

## What it does

- 🛡️ **Security**: Automatically sanitizes malicious HTML using DOMPurify
- ⚛️ **React**: Seamlessly integrates with html-react-parser
- 🌐 **Universal**: Works in both browser and Node.js (SSR) environments
- 🏷️ **Custom Tags**: Handles project-specific tags like `<custom>` safely

## Requirements

- Node.js >=20.19.5: isomorphic-dompurify@^2.30.1
- 🚀 **Flexible**: Choose your DOM implementation (jsdom, happy-dom, or linkedom)
- ⚡ **Optimized**: Built-in caching and memory management

## Installation

```bash
npm install @naverpay/safe-html-react-parser
```

### Choosing a DOM Implementation (Server-Side Only)

For server-side rendering, you need to install one of the following DOM implementations:

```bash
# Option 1: jsdom (most complete, heavier)
npm install jsdom

# Option 2: happy-dom (faster, lighter, recommended)
npm install happy-dom

# Option 3: linkedom (fastest, lightest)
npm install linkedom
```

The library will automatically detect and use the first available implementation in this order: jsdom → happy-dom → linkedom.

## Basic Usage

```tsx
Expand Down Expand Up @@ -119,6 +134,63 @@ const result = safeParse(html, {
})
```

### Configuring DOM Implementation (Server-Side)

You have two ways to configure the DOM implementation:

#### Method 1: Per-call configuration (Recommended)

Pass `domPurifyOptions` directly to `safeParse()`:

```tsx
import { safeParse } from '@naverpay/safe-html-react-parser'
import { Window } from 'happy-dom'

const result = safeParse(htmlString, {
domPurifyOptions: {
domWindowFactory: () => new Window(),
enableCache: true,
maxCacheSize: 100
}
})
```

#### Method 2: Global configuration

Configure once at app initialization:

```tsx
import { configureDOMPurify } from '@naverpay/safe-html-react-parser'

// Using jsdom
import { JSDOM } from 'jsdom'
configureDOMPurify({
domWindowFactory: () => new JSDOM('<!DOCTYPE html>'),
enableCache: true,
maxCacheSize: 100,
recreateInterval: 1000 // Recreate DOM instance every 1000 sanitizations
})

// Using happy-dom (recommended for better performance)
import { Window } from 'happy-dom'
configureDOMPurify({
domWindowFactory: () => new Window(),
enableCache: true,
recreateInterval: 500
})

// Using linkedom (fastest, minimal footprint)
import { parseHTML } from 'linkedom'
configureDOMPurify({
domWindowFactory: () => parseHTML('<!DOCTYPE html>'),
enableCache: true
})
```

> [!NOTE]
>
> If you don't configure anything, the library will automatically try jsdom → happy-dom → linkedom in that order.

## Default Allowed Tags

By default, the following HTML tags are allowed:
Expand All @@ -132,6 +204,37 @@ ALLOWED_TAGS: [
]
```

## Performance Optimization

### Caching

By default, caching is enabled to improve performance:

```tsx
configureDOMPurify({
enableCache: true, // Default: true
maxCacheSize: 100, // Default: 100
})
```

### Memory Management

The DOM instance is automatically recreated periodically to prevent memory leaks:

```tsx
configureDOMPurify({
recreateInterval: 1000 // Default: 1000 sanitization calls
})
```

### DOM Implementation Comparison

| Implementation | Speed | Memory | Completeness | Recommended For |
|----------------|-------|--------|--------------|-----------------|
| **jsdom** | Slower | Higher | Most complete | Maximum compatibility |
| **happy-dom** | Fast | Medium | Good | **Balanced (Recommended)** |
| **linkedom** | Fastest | Lowest | Basic | Performance-critical apps |

## Security Notes

- All HTML is sanitized by DOMPurify before parsing
Expand All @@ -142,7 +245,8 @@ ALLOWED_TAGS: [
## Built with

- [html-react-parser@^5.2.7](https://github.com/remarkablemark/html-react-parser) - HTML string to React element parser
- [isomorphic-dompurify@^2.30.1](https://github.com/kkomelin/isomorphic-dompurify) - Universal XSS sanitizer
- [dompurify@^3.3.0](https://github.com/cure53/DOMPurify) - XSS sanitizer
- Optional: [jsdom](https://github.com/jsdom/jsdom), [happy-dom](https://github.com/capricorn86/happy-dom), or [linkedom](https://github.com/WebReflection/linkedom)

## License

Expand Down
25 changes: 22 additions & 3 deletions packages/safe-html-react-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,39 @@
],
"author": "@NaverPayDev/frontend",
"dependencies": {
"html-react-parser": "^5.2.7",
"isomorphic-dompurify": "^2.30.1"
"dompurify": "^3.3.0",
"html-react-parser": "^5.2.7"
},
"devDependencies": {
"@types/jsdom": "^27.0.0",
"@types/react": "0.14 || 15 || 16 || 17 || 18 || 19",
"happy-dom": "^17.4.4",
"jsdom": "^27.2.0",
"linkedom": "^0.18.12",
"react": "0.14 || 15 || 16 || 17 || 18 || 19"
},
"peerDependencies": {
"@types/react": "0.14 || 15 || 16 || 17 || 18 || 19",
"happy-dom": "^17.4.4",
"jsdom": "^27.2.0",
"linkedom": "^0.18.12",
"react": "0.14 || 15 || 16 || 17 || 18 || 19"
},
"peerDependenciesMeta": {
"jsdom": {
"optional": true
},
"happy-dom": {
"optional": true
},
"linkedom": {
"optional": true
}
},
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && vite build"
"build": "npm run clean && vite build",
"test:memory": "vitest run --watch=false"
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.mjs",
Expand Down
37 changes: 30 additions & 7 deletions packages/safe-html-react-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
* Utilizes html-react-parser with DOMPurify for safe HTML parsing
*/
import * as htmlReactParser from 'html-react-parser'
import DOMPurify from 'isomorphic-dompurify'

import {sanitizeHtml, type SanitizerOptions as DOMPurifyOptionsType, type SanitizeConfig} from './utils/dompurify'

import type {DOMNode, HTMLReactParserOptions} from 'html-react-parser'

// html-react-parser가 esm에서 cjs 모듈을 re-export 하는 문제 처리
// Re-export configuration function
export {configureDOMPurify} from './utils/dompurify'
export type {DOMWindow, DOMWindowFactory, SanitizerOptions as DOMPurifyOptions} from './utils/dompurify'

// Solving the issue of html-react-parser re-exporting cjs modules in esm
// In CJS: htmlReactParser.default.default is the actual function
// In ESM: htmlReactParser.default is the function
const parse = ((htmlReactParser as any).default?.default ||
Expand All @@ -16,16 +21,30 @@ const parse = ((htmlReactParser as any).default?.default ||

export interface SafeParseOptions extends HTMLReactParserOptions {
/**
* DOMPurify Options
* DOMPurify sanitization configuration
*/
sanitizeConfig?: DOMPurify.Config
sanitizeConfig?: SanitizeConfig
/**
* Custom tag preservation option (temporary conversion before and after DOMPurify processing)
*/
preserveCustomTags?: string[]
/**
* Server-side DOMPurify options (DOM implementation, caching, etc.)
* Only used on server-side. Ignored on client-side.
*
* @example
* import { Window } from 'happy-dom'
* safeParse(html, {
* domPurifyOptions: {
* domWindowFactory: () => new Window(),
* enableCache: true
* }
* })
*/
domPurifyOptions?: DOMPurifyOptionsType
}

export const DEFAULT_SANITIZE_CONFIG: DOMPurify.Config = {
export const DEFAULT_SANITIZE_CONFIG: SanitizeConfig = {
ALLOWED_TAGS: [
'p',
'br',
Expand Down Expand Up @@ -61,7 +80,7 @@ export const DEFAULT_SANITIZE_CONFIG: DOMPurify.Config = {
* @returns Parsed React elements
*/
export function safeParse(htmlString: string, options: SafeParseOptions = {}) {
const {sanitizeConfig = DEFAULT_SANITIZE_CONFIG, preserveCustomTags, ...parserOptions} = options
const {sanitizeConfig = DEFAULT_SANITIZE_CONFIG, preserveCustomTags, domPurifyOptions, ...parserOptions} = options

// Temporarily convert custom tags to safe tags to preserve them during DOMPurify processing
const processedHtml =
Expand All @@ -73,7 +92,11 @@ export function safeParse(htmlString: string, options: SafeParseOptions = {}) {
htmlString,
) || htmlString

const sanitizedHtml = DOMPurify.sanitize(processedHtml, sanitizeConfig)
const sanitizedHtml = sanitizeHtml(processedHtml, sanitizeConfig, domPurifyOptions)

if (!sanitizedHtml) {
return null
}

return parse(sanitizedHtml, {
...parserOptions,
Expand Down
Loading