Skip to content

Commit 8554b9e

Browse files
calebporzioclaude
andcommitted
Harden CSP evaluator DOM access checks
Tighten the CSP build's expression evaluator so DOM access can't be widened past its intended surface: - Reject Document objects (not just the global document) and SVG script elements in the value guard. - Treat host DOM/SVG/CSS interface objects as non-writable. - Block DOM node factory and insertion methods by name. Adds unit and browser coverage for the above. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7o77vU3GcUVt15BAFYR4V
1 parent fd2658a commit 8554b9e

3 files changed

Lines changed: 118 additions & 10 deletions

File tree

‎packages/csp/src/parser.js‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -910,20 +910,29 @@ class Evaluator {
910910
}
911911

912912
isDOMObject(obj) {
913-
return obj instanceof Node
914-
|| (typeof CSSStyleDeclaration !== 'undefined' && obj instanceof CSSStyleDeclaration)
915-
|| (typeof DOMStringMap !== 'undefined' && obj instanceof DOMStringMap)
916-
|| (typeof DOMTokenList !== 'undefined' && obj instanceof DOMTokenList)
917-
|| (typeof NamedNodeMap !== 'undefined' && obj instanceof NamedNodeMap)
913+
if (obj instanceof Node) return true
914+
if (typeof CSSStyleDeclaration !== 'undefined' && obj instanceof CSSStyleDeclaration) return true
915+
if (typeof DOMStringMap !== 'undefined' && obj instanceof DOMStringMap) return true
916+
if (typeof DOMTokenList !== 'undefined' && obj instanceof DOMTokenList) return true
917+
if (typeof NamedNodeMap !== 'undefined' && obj instanceof NamedNodeMap) return true
918+
919+
// Catch host DOM/SVG/CSS interface objects not enumerated above.
920+
let ctor = obj != null && obj.constructor
921+
if (ctor && typeof ctor.name === 'string' && /^(SVG|CSS|DOM)[A-Z]/.test(ctor.name)) return true
922+
923+
return false
918924
}
919925

920926
checkForDangerousKeywords(keyword) {
921927
let blacklist = [
922928
'constructor', 'prototype', '__proto__',
923929
'__defineGetter__', '__defineSetter__',
924-
'insertAdjacentHTML',
930+
'insertAdjacentHTML', 'insertAdjacentElement', 'insertAdjacentText',
925931
'setAttribute', 'setAttributeNS',
926932
'setAttributeNode', 'setAttributeNodeNS',
933+
'createElement', 'createElementNS',
934+
'createDocumentFragment', 'createContextualFragment',
935+
'appendChild', 'insertBefore', 'replaceChild',
927936
]
928937

929938
if (blacklist.includes(keyword)) {
@@ -944,10 +953,16 @@ class Evaluator {
944953
return
945954
}
946955

947-
if (prop instanceof HTMLIFrameElement || prop instanceof HTMLScriptElement) {
956+
if (prop instanceof HTMLIFrameElement
957+
|| prop instanceof HTMLScriptElement
958+
|| (typeof SVGScriptElement !== 'undefined' && prop instanceof SVGScriptElement)) {
948959
throw new Error('Accessing iframes and scripts is prohibited in the CSP build')
949960
}
950961

962+
if (typeof Document !== 'undefined' && prop instanceof Document) {
963+
throw new Error('Accessing document objects is prohibited in the CSP build')
964+
}
965+
951966
if (globals.has(prop)) {
952967
throw new Error('Accessing global variables is prohibited in the CSP build')
953968
}

‎tests/cypress/integration/plugins/csp-compatibility.spec.js‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ test.csp('throws when accessing a global via property',
5050
<button x-data x-on:click="$el.ownerDocument.write('evil')"></button>
5151
`],
5252
(cy) => {
53-
cy.on('uncaught:exception', ({message}) => message.includes('Accessing global variables is prohibited') ? false : true)
53+
cy.on('uncaught:exception', ({message}) => message.includes('Accessing document objects is prohibited') ? false : true)
5454
cy.get('button').click()
5555
cy.get('body').should(notContain('evil'))
5656
}
@@ -61,7 +61,7 @@ test.csp('throws when accessing a global via computed property',
6161
<button x-data x-on:click="$el['ownerDocument'].write('evil')"></button>
6262
`],
6363
(cy) => {
64-
cy.on('uncaught:exception', ({message}) => message.includes('Accessing global variables is prohibited') ? false : true)
64+
cy.on('uncaught:exception', ({message}) => message.includes('Accessing document objects is prohibited') ? false : true)
6565
cy.get('button').click()
6666
cy.get('body').should(notContain('evil'))
6767
},
@@ -72,7 +72,7 @@ test.csp('throws when accessing a global via function',
7272
<button x-data x-on:click="$el.getRootNode().write('evil')"></button>
7373
`],
7474
(cy) => {
75-
cy.on('uncaught:exception', ({message}) => message.includes('Accessing global variables is prohibited') ? false : true)
75+
cy.on('uncaught:exception', ({message}) => message.includes('Accessing document objects is prohibited') ? false : true)
7676
cy.get('button').click()
7777
cy.get('body').should(notContain('evil'))
7878
},
@@ -253,3 +253,27 @@ test.csp('throws when non-enumerable global is accessed',
253253
cy.get('span').should(notContain('evil'))
254254
},
255255
)
256+
257+
test.csp('throws when accessing an owner document',
258+
[html`
259+
<button x-data x-on:click="$el.parentElement.querySelector('template').content.ownerDocument.createElement('div')"></button>
260+
<template></template>
261+
`],
262+
(cy) => {
263+
cy.on('uncaught:exception', ({message}) => message.includes('Accessing document objects is prohibited') ? false : true)
264+
cy.get('button').click()
265+
},
266+
)
267+
268+
test.csp('throws when inserting a node into the DOM',
269+
[html`
270+
<div x-data>
271+
<button x-on:click="$root.appendChild($refs.item)"></button>
272+
<span x-ref="item">item</span>
273+
</div>
274+
`],
275+
(cy) => {
276+
cy.on('uncaught:exception', ({message}) => message.includes('Accessing "appendChild" is prohibited') ? false : true)
277+
cy.get('button').click()
278+
},
279+
)

‎tests/vitest/csp-evaluator.spec.js‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,72 @@ describe('MemberExpression assignments', () => {
377377
}).toThrow('DOM objects are prohibited')
378378
});
379379
});
380+
381+
describe('DOM access restrictions', () => {
382+
function domEl() {
383+
let node = document.createElement('div')
384+
node.innerHTML = '<template></template>'
385+
return node
386+
}
387+
388+
it('document objects are not accessible', () => {
389+
let element = { parentNode: null, _x_dataStack: [] }
390+
let scope = { doc: document }
391+
392+
expect(() => cspRawEvaluator(element, 'doc', { scope })).toThrow('prohibited')
393+
});
394+
395+
it('an owner document reached through the DOM is not accessible', () => {
396+
let element = { parentNode: null, _x_dataStack: [] }
397+
let scope = { $el: domEl() }
398+
399+
expect(() => {
400+
cspRawEvaluator(element, "$el.querySelector('template').content.ownerDocument", { scope })
401+
}).toThrow('prohibited')
402+
});
403+
404+
it('element factory methods are blocked', () => {
405+
let element = { parentNode: null, _x_dataStack: [] }
406+
let scope = { doc: document }
407+
408+
expect(() => cspRawEvaluator(element, "doc.createElement('div')", { scope })).toThrow('prohibited')
409+
expect(() => cspRawEvaluator(element, "doc.createElementNS('http://www.w3.org/2000/svg', 'a')", { scope })).toThrow('prohibited')
410+
});
411+
412+
it('svg script elements are treated like script elements', () => {
413+
let element = { parentNode: null, _x_dataStack: [] }
414+
let node = document.createElementNS('http://www.w3.org/2000/svg', 'script')
415+
let scope = { s: node }
416+
417+
// jsdom does not always implement the SVG script interface; assert only when it does.
418+
if (typeof SVGScriptElement !== 'undefined' && node instanceof SVGScriptElement) {
419+
expect(() => cspRawEvaluator(element, 's', { scope })).toThrow('prohibited')
420+
}
421+
});
422+
423+
it('node insertion methods are blocked', () => {
424+
let element = { parentNode: null, _x_dataStack: [] }
425+
let scope = { $el: domEl(), child: document.createElement('span') }
426+
427+
for (let method of ['appendChild', 'insertBefore', 'replaceChild', 'insertAdjacentElement']) {
428+
expect(() => cspRawEvaluator(element, `$el.${method}(child)`, { scope })).toThrow('prohibited')
429+
}
430+
});
431+
432+
it('writes to non-node DOM wrappers are blocked', () => {
433+
let element = { parentNode: null, _x_dataStack: [] }
434+
let anchor = document.createElementNS('http://www.w3.org/2000/svg', 'a')
435+
let scope = { ref: anchor.href, val: 'x' }
436+
437+
if (typeof SVGAnimatedString !== 'undefined' && scope.ref instanceof SVGAnimatedString) {
438+
expect(() => cspRawEvaluator(element, 'ref.baseVal = val', { scope })).toThrow('prohibited')
439+
}
440+
});
441+
442+
it('component methods named like DOM helpers still work', () => {
443+
let element = { parentNode: null, _x_dataStack: [] }
444+
let scope = { list: { items: [], append(x) { this.items.push(x); return this.items.length } } }
445+
446+
expect(cspRawEvaluator(element, "list.append('a')", { scope })).toBe(1)
447+
});
448+
});

0 commit comments

Comments
 (0)