diff --git a/packages/csp/src/parser.js b/packages/csp/src/parser.js index 2a79bdcf1..6cf67a9bc 100644 --- a/packages/csp/src/parser.js +++ b/packages/csp/src/parser.js @@ -794,6 +794,10 @@ class Evaluator { const prop = node.argument.computed ? this.evaluate({ node: node.argument.property, scope, context, forceBindingRootScopeToFunctions }) : node.argument.property.name; + if (this.isDOMObject(obj)) { + throw new Error('Property assignments on DOM objects are prohibited in the CSP build'); + } + this.checkForDangerousKeywords(prop); const oldValue = obj[prop]; if (node.operator === '++') { @@ -843,7 +847,17 @@ class Evaluator { scope[node.left.name] = value; return value; } else if (node.left.type === 'MemberExpression') { - throw new Error('Property assignments are prohibited in the CSP build') + const obj = this.evaluate({ node: node.left.object, scope, context, forceBindingRootScopeToFunctions }); + const prop = node.left.computed + ? this.evaluate({ node: node.left.property, scope, context, forceBindingRootScopeToFunctions }) + : node.left.property.name; + if (this.isDOMObject(obj)) { + throw new Error('Property assignments on DOM objects are prohibited in the CSP build'); + } + this.checkForDangerousKeywords(prop); + + obj[prop] = value; + return value; } throw new Error('Invalid assignment target'); @@ -868,11 +882,21 @@ class Evaluator { } } + isDOMObject(obj) { + return obj instanceof Node + || (typeof CSSStyleDeclaration !== 'undefined' && obj instanceof CSSStyleDeclaration) + || (typeof DOMStringMap !== 'undefined' && obj instanceof DOMStringMap) + || (typeof DOMTokenList !== 'undefined' && obj instanceof DOMTokenList) + || (typeof NamedNodeMap !== 'undefined' && obj instanceof NamedNodeMap) + } + checkForDangerousKeywords(keyword) { let blacklist = [ 'constructor', 'prototype', '__proto__', '__defineGetter__', '__defineSetter__', 'insertAdjacentHTML', + 'setAttribute', 'setAttributeNS', + 'setAttributeNode', 'setAttributeNodeNS', ] if (blacklist.includes(keyword)) { diff --git a/tests/cypress/integration/plugins/csp-compatibility.spec.js b/tests/cypress/integration/plugins/csp-compatibility.spec.js index 3b1682305..109f678f1 100644 --- a/tests/cypress/integration/plugins/csp-compatibility.spec.js +++ b/tests/cypress/integration/plugins/csp-compatibility.spec.js @@ -15,6 +15,20 @@ test.csp('supports regular syntax', } ) +test.csp('supports x-model with dotted path', + [html` +
+ + +
+ `], + ({ get }) => { + get('span').should(haveText('initial')) + get('input').clear().type('updated') + get('span').should(haveText('updated')) + } +) + test.csp('throws when accessing a global', [html` @@ -59,12 +73,12 @@ test.csp('throws when accessing a global via function', }, ) -test.csp('throws when parsing a property assignment', +test.csp('throws when assigning to a DOM node property', [html` `], (cy) => { - cy.on('uncaught:exception', ({message}) => message.includes('Property assignments are prohibited') ? false : true) + cy.on('uncaught:exception', ({message}) => message.includes('Property assignments on DOM objects are prohibited') ? false : true) cy.get('button').click() cy.get('button').should(notContain('evil')) }, diff --git a/tests/vitest/csp-evaluator.spec.js b/tests/vitest/csp-evaluator.spec.js index 58e18c6f0..03b5bbae6 100644 --- a/tests/vitest/csp-evaluator.spec.js +++ b/tests/vitest/csp-evaluator.spec.js @@ -95,3 +95,237 @@ describe('cspRawEvaluator', () => { expect(cspRawEvaluator(element, '!false')).toBe(true) }); }); + +describe('MemberExpression assignments', () => { + it('simple dot-path assignment (x-model="form.name" setter)', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { form: { name: '' }, __placeholder: 'Alice' } + + cspRawEvaluator(element, 'form.name = __placeholder', { scope }) + + expect(scope.form.name).toBe('Alice') + }); + + it('nested dot-path assignment', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { a: { b: { c: 0 } }, __placeholder: 42 } + + cspRawEvaluator(element, 'a.b.c = __placeholder', { scope }) + + expect(scope.a.b.c).toBe(42) + }); + + it('computed property assignment', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: {}, key: 'x', __placeholder: 1 } + + cspRawEvaluator(element, 'obj[key] = __placeholder', { scope }) + + expect(scope.obj.x).toBe(1) + }); + + it('assignment returns the assigned value', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { form: { name: '' }, __placeholder: 'Alice' } + + let result = cspRawEvaluator(element, 'form.name = __placeholder', { scope }) + + expect(result).toBe('Alice') + }); + + it('computed property with string literal key', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: {}, __placeholder: 'hello' } + + cspRawEvaluator(element, "obj['key'] = __placeholder", { scope }) + + expect(scope.obj.key).toBe('hello') + }); + + it('array index assignment', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { arr: ['a', 'b', 'c'], __placeholder: 'z' } + + cspRawEvaluator(element, 'arr[0] = __placeholder', { scope }) + + expect(scope.arr[0]).toBe('z') + expect(scope.arr).toEqual(['z', 'b', 'c']) + }); + + it('right-hand side reads a member expression', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { form: { name: '' }, other: { value: 'Bob' } } + + cspRawEvaluator(element, 'form.name = other.value', { scope }) + + expect(scope.form.name).toBe('Bob') + }); + + it('identifier assignment still works (regression guard)', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { name: '', __placeholder: 'Carol' } + + cspRawEvaluator(element, 'name = __placeholder', { scope }) + + expect(scope.name).toBe('Carol') + }); + + it('dangerous keyword assignment is still blocked', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: {}, __placeholder: 'evil' } + + expect(() => { + cspRawEvaluator(element, 'obj.__proto__ = __placeholder', { scope }) + }).toThrow('prohibited') + + expect(() => { + cspRawEvaluator(element, 'obj.constructor = __placeholder', { scope }) + }).toThrow('prohibited') + + expect(() => { + cspRawEvaluator(element, 'obj.prototype = __placeholder', { scope }) + }).toThrow('prohibited') + }); + + it('DOM node check takes precedence over dangerous keyword check', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { $el: domNode, __placeholder: 'evil' } + + expect(() => { + cspRawEvaluator(element, '$el.__proto__ = __placeholder', { scope }) + }).toThrow('DOM objects are prohibited') + }); + + it('DOM node update check takes precedence over dangerous keyword check', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { $el: domNode } + + expect(() => { + cspRawEvaluator(element, '$el.__proto__++', { scope }) + }).toThrow('DOM objects are prohibited') + }); + + it('postfix increment on scope object', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: { count: 0 } } + + let result = cspRawEvaluator(element, 'obj.count++', { scope }) + + expect(scope.obj.count).toBe(1) + expect(result).toBe(0) + }); + + it('prefix increment on scope object', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: { count: 0 } } + + let result = cspRawEvaluator(element, '++obj.count', { scope }) + + expect(scope.obj.count).toBe(1) + expect(result).toBe(1) + }); + + it('decrement on scope object', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: { count: 5 } } + + cspRawEvaluator(element, 'obj.count--', { scope }) + + expect(scope.obj.count).toBe(4) + }); + + it('computed property update expression', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: { x: 10 }, key: 'x' } + + cspRawEvaluator(element, 'obj[key]++', { scope }) + + expect(scope.obj.x).toBe(11) + }); + + it('dangerous keyword update expression is blocked on scope object', () => { + let element = { parentNode: null, _x_dataStack: [] } + let scope = { obj: {} } + + expect(() => { + cspRawEvaluator(element, 'obj.__proto__++', { scope }) + }).toThrow('prohibited') + }); + + it('DOM node property assignment is blocked', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { $el: domNode, __placeholder: 'evil' } + + expect(() => { + cspRawEvaluator(element, '$el.innerHTML = __placeholder', { scope }) + }).toThrow('DOM objects are prohibited') + + expect(() => { + cspRawEvaluator(element, '$el.textContent = __placeholder', { scope }) + }).toThrow('DOM objects are prohibited') + }); + + it('DOM node update expression is blocked', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + domNode.count = 5 + let scope = { $el: domNode } + + expect(() => { + cspRawEvaluator(element, '$el.count++', { scope }) + }).toThrow('DOM objects are prohibited') + }); + + it('setAttribute is blocked via keyword blocklist', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { $el: domNode } + + expect(() => { + cspRawEvaluator(element, '$el.setAttribute("onclick", "alert(1)")', { scope }) + }).toThrow('prohibited') + }); + + it('setAttributeNS is blocked via keyword blocklist', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { $el: domNode } + + expect(() => { + cspRawEvaluator(element, '$el.setAttributeNS(null, "onclick", "alert(1)")', { scope }) + }).toThrow('prohibited') + }); + + it('setAttribute via computed property is blocked', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { $el: domNode, method: 'setAttribute' } + + expect(() => { + cspRawEvaluator(element, '$el[method]("onclick", "alert(1)")', { scope }) + }).toThrow('prohibited') + }); + + it('CSSStyleDeclaration assignment is blocked', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { style: domNode.style, __placeholder: 'red' } + + expect(() => { + cspRawEvaluator(element, 'style.background = __placeholder', { scope }) + }).toThrow('DOM objects are prohibited') + }); + + it('DOMStringMap assignment is blocked', () => { + let element = { parentNode: null, _x_dataStack: [] } + let domNode = document.createElement('div') + let scope = { dataset: domNode.dataset, __placeholder: 'evil' } + + expect(() => { + cspRawEvaluator(element, 'dataset.key = __placeholder', { scope }) + }).toThrow('DOM objects are prohibited') + }); +}); diff --git a/tests/vitest/csp-parser.spec.js b/tests/vitest/csp-parser.spec.js index 423600ae5..fb8e0f69a 100644 --- a/tests/vitest/csp-parser.spec.js +++ b/tests/vitest/csp-parser.spec.js @@ -597,12 +597,37 @@ describe('CSP Parser', () => { expect(() => generateRuntimeFunction('JSON.stringify({a: 1})')()).toThrow(); }); - it('should not handle property assignment', () => { - expect(() => generateRuntimeFunction('obj.prop = 10')()).toThrow(); + it('should handle scope property assignment', () => { + const scope = { obj: { prop: 0 } }; + generateRuntimeFunction('obj.prop = 10')({ scope }); + expect(scope.obj.prop).toBe(10); }); - it('should not handle computed property assignment', () => { - expect(() => generateRuntimeFunction('obj[key] = 20')()).toThrow(); + it('should handle computed scope property assignment', () => { + const scope = { obj: {}, key: 'x' }; + generateRuntimeFunction('obj[key] = 20')({ scope }); + expect(scope.obj.x).toBe(20); + }); + + it('should block DOM node property assignment', () => { + const scope = { $el: document.createElement('div') }; + expect(() => generateRuntimeFunction('$el.innerHTML = "evil"')({ scope })).toThrow('DOM objects are prohibited'); + }); + + it('should block DOM node computed property assignment', () => { + const scope = { $el: document.createElement('div'), key: 'innerHTML' }; + expect(() => generateRuntimeFunction('$el[key] = "evil"')({ scope })).toThrow('DOM objects are prohibited'); + }); + + it('should block setAttribute on DOM nodes', () => { + const scope = { $el: document.createElement('div') }; + expect(() => generateRuntimeFunction('$el.setAttribute("onclick", "alert(1)")')({ scope })).toThrow('prohibited'); + }); + + it('should block CSSStyleDeclaration assignment', () => { + const el = document.createElement('div'); + const scope = { style: el.style }; + expect(() => generateRuntimeFunction('style.background = "red"')({ scope })).toThrow('DOM objects are prohibited'); }); });