Chore/Safe Parse HTML (#4905)

Refactor: Update pnpm-lock.yaml and enhance UI components for safe HTML rendering

- Updated pnpm-lock.yaml to improve dependency management and ensure consistency.
- Refactored the JSONViewer component to utilize a new JsonToken for syntax highlighting.
- Introduced SafeHTML component to sanitize and safely render HTML content in ViewMessagesDialog and NodeExecutionDetails.
- Replaced direct HTML rendering with SafeHTML in ChatMessage component for enhanced security.
This commit is contained in:
Henry Heng 2025-07-20 10:59:44 +01:00 committed by GitHub
parent 96a57a58e7
commit 9a06a85a8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 166 additions and 49 deletions

View File

@ -72,7 +72,7 @@ const getAllExecutions = async (filters: ExecutionFilters = {}): Promise<{ data:
const queryBuilder = appServer.AppDataSource.getRepository(Execution)
.createQueryBuilder('execution')
.leftJoinAndSelect('execution.agentflow', 'agentflow')
.orderBy('execution.createdDate', 'DESC')
.orderBy('execution.updatedDate', 'DESC')
.skip((page - 1) * limit)
.take(limit)

View File

@ -36,6 +36,7 @@
"@uiw/react-codemirror": "^4.21.21",
"axios": "1.7.9",
"clsx": "^1.1.1",
"dompurify": "^3.2.6",
"dotenv": "^16.0.0",
"flowise-embed": "latest",
"flowise-embed-react": "latest",

View File

@ -44,6 +44,7 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
// Project import
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { SafeHTML } from '@/ui-component/safe/SafeHTML'
import SourceDocDialog from '@/ui-component/dialog/SourceDocDialog'
import { MultiDropdown } from '@/ui-component/dropdown/MultiDropdown'
import { StyledButton } from '@/ui-component/button/StyledButton'
@ -860,7 +861,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
} else if (item.type === 'html') {
return (
<div style={{ marginTop: '20px' }}>
<div dangerouslySetInnerHTML={{ __html: item.data }}></div>
<SafeHTML html={item.data} />
</div>
)
} else {

View File

@ -3,31 +3,85 @@ import { Box } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import PropTypes from 'prop-types'
// Syntax highlighting function for JSON
function syntaxHighlight(json) {
if (!json) return '' // No JSON from response
const JsonToken = ({ type, children, isDarkMode }) => {
const getTokenStyle = (tokenType) => {
switch (tokenType) {
case 'string':
return { color: isDarkMode ? '#9cdcfe' : 'green' }
case 'number':
return { color: isDarkMode ? '#b5cea8' : 'darkorange' }
case 'boolean':
return { color: isDarkMode ? '#569cd6' : 'blue' }
case 'null':
return { color: isDarkMode ? '#d4d4d4' : 'magenta' }
case 'key':
return { color: isDarkMode ? '#ff5733' : '#ff5733' }
default:
return {}
}
}
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return <span style={getTokenStyle(type)}>{children}</span>
}
function parseJsonToElements(json, isDarkMode) {
if (!json) return []
const tokens = []
let index = 0
// Escape HTML characters for safety
const escapedJson = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return json.replace(
// eslint-disable-next-line
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
function (match) {
let cls = 'number'
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key'
const tokenRegex = /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g
let match
let lastIndex = 0
while ((match = tokenRegex.exec(escapedJson)) !== null) {
// Add any text before the match as plain text
if (match.index > lastIndex) {
const plainText = escapedJson.substring(lastIndex, match.index)
if (plainText) {
tokens.push(<span key={`plain-${index++}`}>{plainText}</span>)
}
}
// Determine token type
let tokenType = 'number'
const matchText = match[0]
if (/^"/.test(matchText)) {
if (/:$/.test(matchText)) {
tokenType = 'key'
} else {
cls = 'string'
tokenType = 'string'
}
} else if (/true|false/.test(match)) {
cls = 'boolean'
} else if (/null/.test(match)) {
cls = 'null'
}
return '<span class="' + cls + '">' + match + '</span>'
} else if (/true|false/.test(matchText)) {
tokenType = 'boolean'
} else if (/null/.test(matchText)) {
tokenType = 'null'
}
tokens.push(
<JsonToken key={`token-${index++}`} type={tokenType} isDarkMode={isDarkMode}>
{matchText}
</JsonToken>
)
lastIndex = match.index + match[0].length
}
// Add any remaining text
if (lastIndex < escapedJson.length) {
const remainingText = escapedJson.substring(lastIndex)
if (remainingText) {
tokens.push(<span key={`remaining-${index++}`}>{remainingText}</span>)
}
}
return tokens
}
export const JSONViewer = ({ data, maxHeight = '400px' }) => {
@ -35,6 +89,9 @@ export const JSONViewer = ({ data, maxHeight = '400px' }) => {
const customization = useSelector((state) => state.customization)
const isDarkMode = customization.isDarkMode
const jsonString = JSON.stringify(data, null, 2)
const jsonElements = parseJsonToElements(jsonString, isDarkMode)
return (
<Box
sx={{
@ -48,23 +105,6 @@ export const JSONViewer = ({ data, maxHeight = '400px' }) => {
maxHeight: maxHeight
}}
>
<style>{`
pre .string {
color: ${isDarkMode ? '#9cdcfe' : 'green'};
}
pre .number {
color: ${isDarkMode ? '#b5cea8' : 'darkorange'};
}
pre .boolean {
color: ${isDarkMode ? '#569cd6' : 'blue'};
}
pre .null {
color: ${isDarkMode ? '#d4d4d4' : 'magenta'};
}
pre .key {
color: ${isDarkMode ? '#ff5733' : '#ff5733'};
}
`}</style>
<pre
style={{
margin: 0,
@ -73,10 +113,9 @@ export const JSONViewer = ({ data, maxHeight = '400px' }) => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
dangerouslySetInnerHTML={{
__html: syntaxHighlight(JSON.stringify(data, null, 2), isDarkMode)
}}
/>
>
{jsonElements}
</pre>
</Box>
)
}
@ -85,3 +124,9 @@ JSONViewer.propTypes = {
data: PropTypes.object,
maxHeight: PropTypes.string
}
JsonToken.propTypes = {
type: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
isDarkMode: PropTypes.bool.isRequired
}

View File

@ -0,0 +1,58 @@
import PropTypes from 'prop-types'
import DOMPurify from 'dompurify'
/**
* SafeHTML component that sanitizes HTML content before rendering
*/
export const SafeHTML = ({ html, allowedTags, allowedAttributes, ...props }) => {
// Configure DOMPurify options
const config = {
ALLOWED_TAGS: allowedTags || [
'p',
'br',
'strong',
'em',
'u',
'i',
'b',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
'code',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'div',
'span'
],
ALLOWED_ATTR: allowedAttributes || ['href', 'title', 'alt', 'src', 'class', 'id', 'style'],
ALLOW_DATA_ATTR: false,
FORBID_SCRIPT: true,
FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
}
// Sanitize the HTML content
const sanitizedHTML = DOMPurify.sanitize(html || '', config)
return <div {...props} dangerouslySetInnerHTML={{ __html: sanitizedHTML }} />
}
SafeHTML.propTypes = {
html: PropTypes.string.isRequired,
allowedTags: PropTypes.arrayOf(PropTypes.string),
allowedAttributes: PropTypes.arrayOf(PropTypes.string)
}

View File

@ -29,6 +29,7 @@ import toolSVG from '@/assets/images/tool.svg'
// Project imports
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { SafeHTML } from '@/ui-component/safe/SafeHTML'
import { AGENTFLOW_ICONS, baseURL } from '@/store/constant'
import { JSONViewer } from '@/ui-component/json/JsonViewer'
import ReactJson from 'flowise-react-json-view'
@ -708,7 +709,7 @@ export const NodeExecutionDetails = ({ data, label, status, metadata, isPublic,
backgroundColor: theme.palette.background.paper
}}
>
<div dangerouslySetInnerHTML={{ __html: artifact.data }}></div>
<SafeHTML html={artifact.data} />
</Box>
)
} else {

View File

@ -49,6 +49,7 @@ import audioUploadSVG from '@/assets/images/wave-sound.jpg'
// project import
import NodeInputHandler from '@/views/canvas/NodeInputHandler'
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { SafeHTML } from '@/ui-component/safe/SafeHTML'
import SourceDocDialog from '@/ui-component/dialog/SourceDocDialog'
import ChatFeedbackContentDialog from '@/ui-component/dialog/ChatFeedbackContentDialog'
import StarterPromptsCard from '@/ui-component/cards/StarterPromptsCard'
@ -1659,7 +1660,7 @@ const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setP
} else if (item.type === 'html') {
return (
<div style={{ marginTop: '20px' }}>
<div dangerouslySetInnerHTML={{ __html: item.data }}></div>
<SafeHTML html={item.data} />
</div>
)
} else {

View File

@ -984,6 +984,9 @@ importers:
clsx:
specifier: ^1.1.1
version: 1.2.1
dompurify:
specifier: ^3.2.6
version: 3.2.6
dotenv:
specifier: ^16.0.0
version: 16.4.5
@ -9941,6 +9944,9 @@ packages:
resolution: { integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== }
engines: { node: '>= 4' }
dompurify@3.2.6:
resolution: { integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ== }
domutils@1.7.0:
resolution: { integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== }
@ -30197,6 +30203,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.6:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@1.7.0:
dependencies:
dom-serializer: 0.2.2