fix: XSS vulnerability in block-input and support-var-input components (#24835)
This commit is contained in:
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* XSS Fix Verification Test
|
||||
*
|
||||
* This test verifies that the XSS vulnerability in check-code pages has been
|
||||
* properly fixed by replacing dangerouslySetInnerHTML with safe React rendering.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock i18next with the new safe translation structure
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key === 'login.checkCode.tipsPrefix')
|
||||
return 'We send a verification code to '
|
||||
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Next.js useSearchParams
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'email')
|
||||
return 'test@example.com<script>alert("XSS")</script>'
|
||||
return null
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Fixed CheckCode component implementation (current secure version)
|
||||
const SecureCheckCodeComponent = ({ email }: { email: string }) => {
|
||||
const { t } = require('react-i18next').useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Check Code</h1>
|
||||
<p>
|
||||
<span>
|
||||
{t('login.checkCode.tipsPrefix')}
|
||||
<strong>{email}</strong>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vulnerable implementation for comparison (what we fixed)
|
||||
const VulnerableCheckCodeComponent = ({ email }: { email: string }) => {
|
||||
const mockTranslation = (key: string, params?: any) => {
|
||||
if (key === 'login.checkCode.tips' && params?.email)
|
||||
return `We send a verification code to <strong>${params.email}</strong>`
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Check Code</h1>
|
||||
<p>
|
||||
<span dangerouslySetInnerHTML={{ __html: mockTranslation('login.checkCode.tips', { email }) }}></span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('XSS Fix Verification - Check Code Pages Security', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const maliciousEmail = 'test@example.com<script>alert("XSS")</script>'
|
||||
|
||||
it('should securely render email with HTML characters as text (FIXED VERSION)', () => {
|
||||
console.log('\n🔒 Security Fix Verification Report')
|
||||
console.log('===================================')
|
||||
|
||||
const { container } = render(<SecureCheckCodeComponent email={maliciousEmail} />)
|
||||
|
||||
const spanElement = container.querySelector('span')
|
||||
const strongElement = container.querySelector('strong')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
|
||||
console.log('\n✅ Fixed Implementation Results:')
|
||||
console.log('- Email rendered in strong tag:', strongElement?.textContent)
|
||||
console.log('- HTML tags visible as text:', strongElement?.textContent?.includes('<script>'))
|
||||
console.log('- Script elements created:', scriptElements.length)
|
||||
console.log('- Full text content:', spanElement?.textContent)
|
||||
|
||||
// Verify secure behavior
|
||||
expect(strongElement?.textContent).toBe(maliciousEmail) // Email rendered as text
|
||||
expect(strongElement?.textContent).toContain('<script>') // HTML visible as text
|
||||
expect(scriptElements).toHaveLength(0) // No script elements created
|
||||
expect(spanElement?.textContent).toBe(`We send a verification code to ${maliciousEmail}`)
|
||||
|
||||
console.log('\n🎯 Security Status: SECURE - HTML automatically escaped by React')
|
||||
})
|
||||
|
||||
it('should demonstrate the vulnerability that was fixed (VULNERABLE VERSION)', () => {
|
||||
const { container } = render(<VulnerableCheckCodeComponent email={maliciousEmail} />)
|
||||
|
||||
const spanElement = container.querySelector('span')
|
||||
const strongElement = container.querySelector('strong')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
|
||||
console.log('\n⚠️ Previous Vulnerable Implementation:')
|
||||
console.log('- HTML content:', spanElement?.innerHTML)
|
||||
console.log('- Strong element text:', strongElement?.textContent)
|
||||
console.log('- Script elements created:', scriptElements.length)
|
||||
console.log('- Script content:', scriptElements[0]?.textContent)
|
||||
|
||||
// Verify vulnerability exists in old implementation
|
||||
expect(scriptElements).toHaveLength(1) // Script element was created
|
||||
expect(scriptElements[0]?.textContent).toBe('alert("XSS")') // Contains malicious code
|
||||
expect(spanElement?.innerHTML).toContain('<script>') // Raw HTML in DOM
|
||||
|
||||
console.log('\n❌ Security Status: VULNERABLE - dangerouslySetInnerHTML creates script elements')
|
||||
})
|
||||
|
||||
it('should verify all affected components use the secure pattern', () => {
|
||||
console.log('\n📋 Component Security Audit')
|
||||
console.log('============================')
|
||||
|
||||
// Test multiple malicious inputs
|
||||
const testCases = [
|
||||
'user@test.com<img src=x onerror=alert(1)>',
|
||||
'test@evil.com<div onclick="alert(2)">click</div>',
|
||||
'admin@site.com<script>document.cookie="stolen"</script>',
|
||||
'normal@email.com',
|
||||
]
|
||||
|
||||
testCases.forEach((testEmail, index) => {
|
||||
const { container } = render(<SecureCheckCodeComponent email={testEmail} />)
|
||||
|
||||
const strongElement = container.querySelector('strong')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
const imgElements = container.querySelectorAll('img')
|
||||
const divElements = container.querySelectorAll('div:not([data-testid])')
|
||||
|
||||
console.log(`\n📧 Test Case ${index + 1}: ${testEmail.substring(0, 20)}...`)
|
||||
console.log(` - Script elements: ${scriptElements.length}`)
|
||||
console.log(` - Img elements: ${imgElements.length}`)
|
||||
console.log(` - Malicious divs: ${divElements.length - 1}`) // -1 for container div
|
||||
console.log(` - Text content: ${strongElement?.textContent === testEmail ? 'SAFE' : 'ISSUE'}`)
|
||||
|
||||
// All should be safe
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
expect(imgElements).toHaveLength(0)
|
||||
expect(strongElement?.textContent).toBe(testEmail)
|
||||
})
|
||||
|
||||
console.log('\n✅ All test cases passed - secure rendering confirmed')
|
||||
})
|
||||
|
||||
it('should validate the translation structure is secure', () => {
|
||||
console.log('\n🔍 Translation Security Analysis')
|
||||
console.log('=================================')
|
||||
|
||||
const { t } = require('react-i18next').useTranslation()
|
||||
const prefix = t('login.checkCode.tipsPrefix')
|
||||
|
||||
console.log('- Translation key used: login.checkCode.tipsPrefix')
|
||||
console.log('- Translation value:', prefix)
|
||||
console.log('- Contains HTML tags:', prefix.includes('<'))
|
||||
console.log('- Pure text content:', !prefix.includes('<') && !prefix.includes('>'))
|
||||
|
||||
// Verify translation is plain text
|
||||
expect(prefix).toBe('We send a verification code to ')
|
||||
expect(prefix).not.toContain('<')
|
||||
expect(prefix).not.toContain('>')
|
||||
expect(typeof prefix).toBe('string')
|
||||
|
||||
console.log('\n✅ Translation structure is secure - no HTML content')
|
||||
})
|
||||
|
||||
it('should confirm React automatic escaping works correctly', () => {
|
||||
console.log('\n⚡ React Security Mechanism Test')
|
||||
console.log('=================================')
|
||||
|
||||
// Test React's automatic escaping with various inputs
|
||||
const dangerousInputs = [
|
||||
'<script>alert("xss")</script>',
|
||||
'<img src="x" onerror="alert(1)">',
|
||||
'"><script>alert(2)</script>',
|
||||
'\'>alert(3)</script>',
|
||||
'<div onclick="alert(4)">click</div>',
|
||||
]
|
||||
|
||||
dangerousInputs.forEach((input, index) => {
|
||||
const TestComponent = () => <strong>{input}</strong>
|
||||
const { container } = render(<TestComponent />)
|
||||
|
||||
const strongElement = container.querySelector('strong')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
|
||||
console.log(`\n🧪 Input ${index + 1}: ${input.substring(0, 30)}...`)
|
||||
console.log(` - Rendered as text: ${strongElement?.textContent === input}`)
|
||||
console.log(` - No script execution: ${scriptElements.length === 0}`)
|
||||
|
||||
expect(strongElement?.textContent).toBe(input)
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
})
|
||||
|
||||
console.log('\n🛡️ React automatic escaping is working perfectly')
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
76
web/__tests__/xss-prevention.test.tsx
Normal file
76
web/__tests__/xss-prevention.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* XSS Prevention Test Suite
|
||||
*
|
||||
* This test verifies that the XSS vulnerabilities in block-input and support-var-input
|
||||
* components have been properly fixed by replacing dangerouslySetInnerHTML with safe React rendering.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import BlockInput from '../app/components/base/block-input'
|
||||
import SupportVarInput from '../app/components/workflow/nodes/_base/components/support-var-input'
|
||||
|
||||
// Mock styles
|
||||
jest.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
|
||||
item: 'mock-item-class',
|
||||
}))
|
||||
|
||||
describe('XSS Prevention - Block Input and Support Var Input Security', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('BlockInput Component Security', () => {
|
||||
it('should safely render malicious variable names without executing scripts', () => {
|
||||
const testInput = 'user@test.com{{<script>alert("XSS")</script>}}'
|
||||
const { container } = render(<BlockInput value={testInput} readonly={true} />)
|
||||
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('<script>')
|
||||
})
|
||||
|
||||
it('should preserve legitimate variable highlighting', () => {
|
||||
const legitimateInput = 'Hello {{userName}} welcome to {{appName}}'
|
||||
const { container } = render(<BlockInput value={legitimateInput} readonly={true} />)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('userName')
|
||||
expect(textContent).toContain('appName')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SupportVarInput Component Security', () => {
|
||||
it('should safely render malicious variable names without executing scripts', () => {
|
||||
const testInput = 'test@evil.com{{<img src=x onerror=alert(1)>}}'
|
||||
const { container } = render(<SupportVarInput value={testInput} readonly={true} />)
|
||||
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
const imgElements = container.querySelectorAll('img')
|
||||
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
expect(imgElements).toHaveLength(0)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('<img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('React Automatic Escaping Verification', () => {
|
||||
it('should confirm React automatic escaping works correctly', () => {
|
||||
const TestComponent = () => <span>{'<script>alert("xss")</script>'}</span>
|
||||
const { container } = render(<TestComponent />)
|
||||
|
||||
const spanElement = container.querySelector('span')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
|
||||
expect(spanElement?.textContent).toBe('<script>alert("xss")</script>')
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
@@ -16,19 +16,26 @@ const VarHighlight: FC<IVarHighlightProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`${s.item} ${className} mb-2 flex h-5 items-center justify-center rounded-md px-1 text-xs font-medium text-primary-600`}
|
||||
className={`${s.item} ${className} mb-2 inline-flex h-5 items-center justify-center rounded-md px-1 text-xs font-medium text-primary-600`}
|
||||
>
|
||||
<span className='opacity-60'>{'{{'}</span>
|
||||
<span>{name}</span>
|
||||
<span className='opacity-60'>{'}}'}</span>
|
||||
<span className='opacity-60'>{'{{'}</span><span>{name}</span><span className='opacity-60'>{'}}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DEPRECATED: This function is vulnerable to XSS attacks and should not be used
|
||||
// Use the VarHighlight React component instead
|
||||
export const varHighlightHTML = ({ name, className = '' }: IVarHighlightProps) => {
|
||||
const escapedName = name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const html = `<div class="${s.item} ${className} inline-flex mb-2 items-center justify-center px-1 rounded-md h-5 text-xs font-medium text-primary-600">
|
||||
<span class='opacity-60'>{{</span>
|
||||
<span>${name}</span>
|
||||
<span>${escapedName}</span>
|
||||
<span class='opacity-60'>}}</span>
|
||||
</div>`
|
||||
return html
|
||||
|
@@ -3,7 +3,7 @@
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { varHighlightHTML } from '../../app/configuration/base/var-highlight'
|
||||
import VarHighlight from '../../app/configuration/base/var-highlight'
|
||||
import Toast from '../toast'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
@@ -66,11 +66,24 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
'block-input--editing': isEditing,
|
||||
})
|
||||
|
||||
const coloredContent = (currentValue || '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(regex, varHighlightHTML({ name: '$1' })) // `<span class="${highLightClassName}">{{$1}}</span>`
|
||||
.replace(/\n/g, '<br />')
|
||||
const renderSafeContent = (value: string) => {
|
||||
const parts = value.split(/(\{\{[^}]+\}\}|\n)/g)
|
||||
return parts.map((part, index) => {
|
||||
const variableMatch = part.match(/^\{\{([^}]+)\}\}$/)
|
||||
if (variableMatch) {
|
||||
return (
|
||||
<VarHighlight
|
||||
key={`var-${index}`}
|
||||
name={variableMatch[1]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (part === '\n')
|
||||
return <br key={`br-${index}`} />
|
||||
|
||||
return <span key={`text-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Not use useCallback. That will cause out callback get old data.
|
||||
const handleSubmit = (value: string) => {
|
||||
@@ -96,11 +109,11 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
|
||||
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
|
||||
const TextAreaContentView = () => {
|
||||
return <div
|
||||
className={classNames(style, className)}
|
||||
dangerouslySetInnerHTML={{ __html: coloredContent }}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
return (
|
||||
<div className={classNames(style, className)}>
|
||||
{renderSafeContent(currentValue || '')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const placeholder = ''
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { varHighlightHTML } from '@/app/components/app/configuration/base/var-highlight'
|
||||
import VarHighlight from '@/app/components/app/configuration/base/var-highlight'
|
||||
type Props = {
|
||||
isFocus?: boolean
|
||||
onFocus?: () => void
|
||||
@@ -22,11 +22,24 @@ const SupportVarInput: FC<Props> = ({
|
||||
textClassName,
|
||||
readonly,
|
||||
}) => {
|
||||
const withHightContent = (value || '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\{\{([^}]+)\}\}/g, varHighlightHTML({ name: '$1', className: '!mb-0' })) // `<span class="${highLightClassName}">{{$1}}</span>`
|
||||
.replace(/\n/g, '<br />')
|
||||
const renderSafeContent = (inputValue: string) => {
|
||||
const parts = inputValue.split(/(\{\{[^}]+\}\}|\n)/g)
|
||||
return parts.map((part, index) => {
|
||||
const variableMatch = part.match(/^\{\{([^}]+)\}\}$/)
|
||||
if (variableMatch) {
|
||||
return (
|
||||
<VarHighlight
|
||||
key={`var-${index}`}
|
||||
name={variableMatch[1]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (part === '\n')
|
||||
return <br key={`br-${index}`} />
|
||||
|
||||
return <span key={`text-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -42,9 +55,9 @@ const SupportVarInput: FC<Props> = ({
|
||||
<div
|
||||
className={cn(textClassName, 'h-full w-0 grow truncate whitespace-nowrap')}
|
||||
title={value}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: withHightContent,
|
||||
}}></div>
|
||||
>
|
||||
{renderSafeContent(value || '')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
Reference in New Issue
Block a user