Skip to content
Open
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
61 changes: 49 additions & 12 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,26 +222,63 @@ export default class Helpers {
return el
}

sanitize(html) {
if (typeof html === 'string' && html.trim() === '')
return ''
else
return DOMPurify.sanitize(html, { ADD_TAGS: ['use'] })
sanitize(data) {
if (data === null || data === undefined) {
return data
}

if (typeof data === 'string' && data.trim() !== '') {
let sanitized = DOMPurify.sanitize(data, { ADD_TAGS: ['use'] })

// Additional sanitization for patterns that DOMPurify doesn't catch in standalone strings
if (/^\s*(javascript|vbscript|data\s*:\s*text\/html)/i.test(sanitized)) {
return ''
}

return sanitized
}

if (Array.isArray(data)) {
return data.map(item => this.sanitize(item))
}

if (typeof data === 'object') {
const sanitized = {}
for (const [key, value] of Object.entries(data)) {
sanitized[key] = this.sanitize(value)
}
return sanitized
}

// Handle null and undefined values in output by replacing them with empty string
if (typeof data === 'string') {
return data.replace(/\bnull\b/g, '').replace(/\bundefined\b/g, '')
}

// For primitives (numbers, booleans, etc.), return as-is
return data
Comment thread
araluce marked this conversation as resolved.
}

// Templates
render(template, data) {
render(template, data, options = {}) {
const defaults = { sanitize: true }
options = Object.assign({}, defaults, options)

// Sanitize HTML for XSS protection
if (options.sanitize) {
data = sanitize(data)
}

return App.templates.render(template, data)
}

insertTemplate(query, template, data, position = null) {
const html = render(template, data)
insertTemplate(query, template, data, options = {}) {
const defaults = { sanitize: true }
Comment thread
araluce marked this conversation as resolved.
options = Object.assign({}, defaults, options)

const options = { sanitize: false }
if (position !== null) {
options.position = position
}
const html = render(template, data, options)

options.sanitize = false
insertHTML(query, html, options)
}

Expand Down
48 changes: 1 addition & 47 deletions src/templates.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import Helpers from './helpers'

const helpers = new Helpers()

export default class Templates {
constructor(templates) {
this.templates = templates || {}
Expand All @@ -11,51 +7,9 @@ export default class Templates {
const tmpl = this.templates[template]

if (tmpl) {
// Sanitize the input data to prevent XSS attacks in variables
const sanitizedData = this._sanitizeData(data)
const output = tmpl.call(this, sanitizedData)

// Handle null and undefined values in output by replacing them with empty string
if (typeof output === 'string') {
return output.replace(/\bnull\b/g, '').replace(/\bundefined\b/g, '')
}

return output
return tmpl.call(this, data)
} else {
throw new Error(`[Ralix] Template '${template}' not found`)
}
}

// Deep sanitize data object to prevent XSS in variables
_sanitizeData(data) {
if (data === null || data === undefined) {
return data
}

if (typeof data === 'string') {
let sanitized = helpers.sanitize(data)

// Additional sanitization for patterns that DOMPurify doesn't catch in standalone strings
if (/^\s*(javascript|vbscript|data\s*:\s*text\/html)/i.test(sanitized)) {
return ''
}

return sanitized
}

if (Array.isArray(data)) {
return data.map(item => this._sanitizeData(item))
}

if (typeof data === 'object') {
const sanitized = {}
for (const [key, value] of Object.entries(data)) {
sanitized[key] = this._sanitizeData(value)
}
return sanitized
}

// For primitives (numbers, booleans, etc.), return as-is
return data
}
}
25 changes: 0 additions & 25 deletions tests/fixtures/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,3 @@ export const template2 = ({ title }) => `<h1>${title}</h1>`

// XSS testing templates
export const attributeInjection = (data) => `<img src="${data.src}" alt="${data.alt}">`
export const structureTemplate = (data) => `
<script type="application/json">${JSON.stringify(data)}</script>
<div onclick="handleClick()">
<p>User content: ${data.userContent}</p>
<img src="${data.userImage}" onerror="fallback()">
</div>
`
export const nestedTemplate = (data) => `
<div>
<h1>${data.user.name}</h1>
<p>${data.user.profile.bio}</p>
<ul>
${data.items.map(item => `<li>${item}</li>`).join('')}
</ul>
</div>
`
export const primitiveTemplate = (data) => `
<div>
<p>${data.message}</p>
<span>Count: ${data.count}</span>
<span>Active: ${data.isActive}</span>
<span>Null: ${data.nullValue}</span>
<span>Undefined: ${data.undefinedValue}</span>
</div>
`
72 changes: 71 additions & 1 deletion tests/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,76 @@ describe('DOM', () => {
})
})

describe('sanitize', () => {
describe('XSS Protection', () => {
test('should sanitize javascript: URLs in attributes', () => {
const maliciousData = {
src: 'javascript:alert("XSS")',
alt: 'Test image'
}
const maliciousHTML = ExampleTemplates.attributeInjection(maliciousData)

const result = sanitize(maliciousHTML)

expect(result).not.toContain('javascript:')
expect(result).not.toContain('alert("XSS")')
expect(result).toContain('alt="Test image"')
})

test('should sanitize nested object variables', () => {
const data = {
user: {
name: '<script>alert("name")</script>John',
profile: {
bio: '<img onerror="alert(1)" src="x">Developer'
}
},
items: [
'<script>alert("item1")</script>Item 1',
'javascript:alert("item2")',
'Safe item'
]
}

const result = sanitize(data)

// Scripts should be removed from variables
expect(result.user.name).not.toContain('<script>')
expect(result.items[0]).not.toContain('<script>')
expect(result.user.name).not.toContain('alert(')
expect(result.user.profile.bio).not.toContain('alert(')
expect(result.items[0]).not.toContain('alert(')
expect(result.items[1]).not.toContain('alert(')
expect(result.items[1]).not.toContain('javascript:')

// Safe content should be preserved
expect(result.user.name).toContain('John')
expect(result.user.profile.bio).toContain('Developer')
expect(result.items[2]).toContain('Safe item')
expect(result.user.profile.bio).toContain('<img src="x">')
})

test('should handle primitive values in data', () => {
const data = {
message: '<script>alert("XSS")</script>Hello',
count: 42,
isActive: true,
nullValue: null,
undefinedValue: undefined
}

const result = sanitize(data)

expect(result.message).not.toContain('<script>')
expect(result.message).toContain('Hello')
expect(result.count).toBe(42)
expect(result.isActive).toBeTruthy()
expect(result.nullValue).toBeNull()
expect(result.undefinedValue).toBeUndefined()
})
})
})

describe('Templates', () => {
describe('insertTemplate', () => {
let container
Expand All @@ -500,7 +570,7 @@ describe('Templates', () => {
test('inserts template with different positions', () => {
container.innerHTML = '<p>original</p>'

insertTemplate('.test-container', 'template1', 'end content', 'end')
insertTemplate('.test-container', 'template1', 'end content', { position: 'end' })
expect(container.innerHTML).toBe('<p>original</p><div>end content</div>')
})

Expand Down
82 changes: 0 additions & 82 deletions tests/templates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,86 +22,4 @@ describe('render', () => {
templates.render('foo')
}).toThrow("[Ralix] Template 'foo' not found")
})

describe('XSS Protection', () => {
test('should sanitize javascript: URLs in attributes', () => {
const maliciousData = {
src: 'javascript:alert("XSS")',
alt: 'Test image'
}

const result = templates.render('attributeInjection', maliciousData)

expect(result).not.toContain('javascript:')
expect(result).not.toContain('alert("XSS")')
expect(result).toContain('alt="Test image"')
})

test('should preserve template structure while sanitizing only variables', () => {
const data = {
userContent: '<script>alert("XSS")</script>Safe content',
userImage: 'javascript:alert("XSS")'
}

const result = templates.render('structureTemplate', data)

// Template structure should be preserved
expect(result).toContain('<script type="application/json">')
expect(result).toContain('onclick="handleClick()"')
expect(result).toContain('onerror="fallback()"')

// But user variables should be sanitized
expect(result).not.toContain('alert("XSS")')
expect(result).toContain('Safe content')
expect(result).toContain('<img src="" onerror="fallback()">')
})

test('should sanitize nested object variables', () => {
const data = {
user: {
name: '<script>alert("name")</script>John',
profile: {
bio: '<img onerror="alert(1)" src="x">Developer'
}
},
items: [
'<script>alert("item1")</script>Item 1',
'javascript:alert("item2")',
'Safe item'
]
}

const result = templates.render('nestedTemplate', data)

// Scripts should be removed from variables
expect(result).not.toContain('<script>')
expect(result).not.toContain('alert(')
expect(result).not.toContain('javascript:')

// Safe content should be preserved
expect(result).toContain('John')
expect(result).toContain('Developer')
expect(result).toContain('Safe item')
expect(result).toContain('<img src="x">')
})

test('should handle primitive values in data', () => {
const data = {
message: '<script>alert("XSS")</script>Hello',
count: 42,
isActive: true,
nullValue: null,
undefinedValue: undefined
}

const result = templates.render('primitiveTemplate', data)

expect(result).not.toContain('<script>')
expect(result).toContain('Hello')
expect(result).toContain('Count: 42')
expect(result).toContain('Active: true')
expect(result).toContain('Null: ')
expect(result).toContain('Undefined: ')
})
})
})
Loading