mirror of https://github.com/artf/grapesjs.git
committed by
GitHub
32 changed files with 463 additions and 68 deletions
@ -0,0 +1,109 @@ |
|||
import Editor from '../../../src/editor'; |
|||
import EditorModel from '../../../src/editor/model/Editor'; |
|||
import BrowserParserCss from '../../../src/parser/model/BrowserParserCss'; |
|||
import { setupTestEditor } from '../../common'; |
|||
|
|||
const NONCE = 'test-nonce-123'; |
|||
|
|||
const getStyleEls = (em: EditorModel) => { |
|||
const docs = [document, em.Canvas.getDocument()].filter(Boolean) as Document[]; |
|||
return docs.reduce<HTMLStyleElement[]>((res, doc) => res.concat(Array.from(doc.querySelectorAll('style'))), []); |
|||
}; |
|||
|
|||
describe('CSP nonce', () => { |
|||
let editor: Editor; |
|||
let em: EditorModel; |
|||
|
|||
const setup = (cspNonce?: string) => { |
|||
({ editor, em } = setupTestEditor({ withCanvas: true, config: { cspNonce } })); |
|||
editor.setComponents('<div class="cmp">Hello</div>'); |
|||
editor.setStyle('.cmp { color: red; } @media (max-width: 480px) { .cmp { color: blue } }'); |
|||
em.Css.addRules('@keyframes anim { from { opacity: 0 } to { opacity: 1 } }'); |
|||
}; |
|||
|
|||
afterEach(() => { |
|||
em?.destroy(); |
|||
}); |
|||
|
|||
describe('with cspNonce set', () => { |
|||
beforeEach(() => setup(NONCE)); |
|||
|
|||
test('every style element created by the editor carries the nonce', () => { |
|||
const els = getStyleEls(em); |
|||
expect(els.length).toBeGreaterThan(0); |
|||
els.forEach((el) => expect(el.getAttribute('nonce')).toBe(NONCE)); |
|||
}); |
|||
|
|||
test('canvas style element carries the nonce', () => { |
|||
const el = document.querySelector('[data-canvas-style]'); |
|||
expect(el).toBeTruthy(); |
|||
expect(el!.getAttribute('nonce')).toBe(NONCE); |
|||
}); |
|||
|
|||
test('frame base styles carry the nonce', () => { |
|||
const doc = em.Canvas.getDocument()!; |
|||
const el = doc.body.querySelector('style'); |
|||
expect(el).toBeTruthy(); |
|||
expect(el!.getAttribute('nonce')).toBe(NONCE); |
|||
}); |
|||
|
|||
test('each CSS rule style element carries the nonce', () => { |
|||
const doc = em.Canvas.getDocument()!; |
|||
const els = Array.from(doc.querySelectorAll('style')).filter((el) => el.innerHTML.includes('.cmp')); |
|||
expect(els.length).toBeGreaterThan(0); |
|||
els.forEach((el) => expect(el.getAttribute('nonce')).toBe(NONCE)); |
|||
}); |
|||
|
|||
test('the CSS parser sets the nonce on its temporary style element', () => { |
|||
const nonces: (string | null)[] = []; |
|||
const appendChild = jest.spyOn(document.head, 'appendChild').mockImplementation(<T extends Node>(node: T) => { |
|||
nonces.push((node as unknown as HTMLElement).getAttribute?.('nonce') ?? null); |
|||
return node; |
|||
}); |
|||
const removeChild = jest.spyOn(document.head, 'removeChild').mockImplementation(<T extends Node>(n: T) => n); |
|||
|
|||
em.Parser.parseCss('.parsed { color: green }'); |
|||
|
|||
appendChild.mockRestore(); |
|||
removeChild.mockRestore(); |
|||
expect(nonces).toEqual([NONCE]); |
|||
}); |
|||
}); |
|||
|
|||
describe('without cspNonce', () => { |
|||
beforeEach(() => setup()); |
|||
|
|||
test('no style element gets a nonce attribute', () => { |
|||
const els = getStyleEls(em); |
|||
expect(els.length).toBeGreaterThan(0); |
|||
els.forEach((el) => expect(el.hasAttribute('nonce')).toBe(false)); |
|||
}); |
|||
|
|||
test('canvas style element is still created', () => { |
|||
expect(document.querySelector('[data-canvas-style]')).toBeTruthy(); |
|||
}); |
|||
}); |
|||
|
|||
describe('BrowserParserCss', () => { |
|||
test('parses CSS and applies the nonce to the temporary style element', () => { |
|||
const create = jest.spyOn(document, 'createElement'); |
|||
const res = BrowserParserCss('.a { color: red }', NONCE); |
|||
const el = create.mock.results.find((r) => (r.value as HTMLElement).tagName === 'STYLE')! |
|||
.value as HTMLStyleElement; |
|||
create.mockRestore(); |
|||
|
|||
expect(el.getAttribute('nonce')).toBe(NONCE); |
|||
expect(res).toEqual([expect.objectContaining({ selectors: ['a'] })]); |
|||
}); |
|||
|
|||
test('omits the nonce attribute when none is given', () => { |
|||
const create = jest.spyOn(document, 'createElement'); |
|||
BrowserParserCss('.a { color: red }'); |
|||
const el = create.mock.results.find((r) => (r.value as HTMLElement).tagName === 'STYLE')! |
|||
.value as HTMLStyleElement; |
|||
create.mockRestore(); |
|||
|
|||
expect(el.hasAttribute('nonce')).toBe(false); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,62 @@ |
|||
import fs from 'fs'; |
|||
import path from 'path'; |
|||
|
|||
/** |
|||
* The editor renders most of its chrome by assigning HTML strings, so a |
|||
* `style="..."` literal in a template ends up parsed as an inline style |
|||
* attribute, which a strict `style-src-attr` CSP blocks. The same goes for |
|||
* `setAttribute('style', ...)`. |
|||
* |
|||
* CSSOM writes (`el.style.prop = value`, `setStyleText`) are not covered by CSP |
|||
* and are the supported way to apply runtime values. |
|||
*/ |
|||
const SRC_DIR = path.join(__dirname, '../../../src'); |
|||
|
|||
// Files allowed to keep an inline style, with the reason why
|
|||
const ALLOWED: Record<string, string> = { |
|||
'dom_components/model/ComponentImage.ts': |
|||
'SVG placeholder serialized to a base64 data URL and used as `img` src, so it is a separate document governed by `img-src`', |
|||
'dom_components/view/ComponentView.ts': |
|||
'writes the style attribute of a user component, which is the content the editor exists to author (and is off by default via `avoidInlineStyle`)', |
|||
}; |
|||
|
|||
const PATTERNS = [ |
|||
{ name: 'style attribute in markup', re: /(^|[^-\w])style\s*=\s*["'`]/ }, |
|||
{ name: "setAttribute('style')", re: /setAttribute\(\s*['"`]style['"`]/ }, |
|||
]; |
|||
|
|||
const stripComments = (code: string) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); |
|||
|
|||
const walk = (dir: string): string[] => |
|||
fs.readdirSync(dir, { withFileTypes: true }).reduce<string[]>((res, entry) => { |
|||
const full = path.join(dir, entry.name); |
|||
if (entry.isDirectory()) return res.concat(walk(full)); |
|||
return entry.name.endsWith('.ts') ? res.concat(full) : res; |
|||
}, []); |
|||
|
|||
describe('No inline style attributes in editor markup', () => { |
|||
test('src is free of `style=` and `setAttribute("style")`, except the documented cases', () => { |
|||
const found: string[] = []; |
|||
|
|||
walk(SRC_DIR).forEach((file) => { |
|||
const relative = path.relative(SRC_DIR, file).split(path.sep).join('/'); |
|||
if (ALLOWED[relative]) return; |
|||
|
|||
stripComments(fs.readFileSync(file, 'utf8')) |
|||
.split('\n') |
|||
.forEach((line, i) => { |
|||
PATTERNS.forEach(({ name, re }) => { |
|||
re.test(line) && found.push(`${relative}:${i + 1} (${name}) ${line.trim()}`); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
expect(found).toEqual([]); |
|||
}); |
|||
|
|||
test('the allowlist only names files that exist', () => { |
|||
Object.keys(ALLOWED).forEach((relative) => { |
|||
expect(fs.existsSync(path.join(SRC_DIR, relative))).toBe(true); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,63 @@ |
|||
import { setStyleText } from '../../../src/utils/dom'; |
|||
|
|||
describe('setStyleText', () => { |
|||
let el: HTMLElement; |
|||
|
|||
beforeEach(() => { |
|||
el = document.createElement('div'); |
|||
}); |
|||
|
|||
test('applies a single declaration', () => { |
|||
setStyleText(el, 'color: red'); |
|||
expect(el.style.color).toBe('red'); |
|||
}); |
|||
|
|||
test('applies multiple declarations', () => { |
|||
setStyleText(el, 'color: red; padding-left: 10px'); |
|||
expect(el.style.color).toBe('red'); |
|||
expect(el.style.paddingLeft).toBe('10px'); |
|||
}); |
|||
|
|||
test('replaces any style previously set', () => { |
|||
setStyleText(el, 'color: red; width: 10px'); |
|||
setStyleText(el, 'color: blue'); |
|||
expect(el.style.color).toBe('blue'); |
|||
expect(el.style.width).toBe(''); |
|||
}); |
|||
|
|||
test('keeps `;` nested in functions', () => { |
|||
const url = 'data:image/gif;base64,R0lGODlh'; |
|||
setStyleText(el, `background-image: url(${url}); color: red`); |
|||
// jsdom re-serializes the url with quotes, what matters is that the
|
|||
// `;` inside it did not split the declaration
|
|||
expect(el.style.backgroundImage).toContain(url); |
|||
expect(el.style.color).toBe('red'); |
|||
}); |
|||
|
|||
test('keeps `;` nested in strings', () => { |
|||
setStyleText(el, `content: "a;b"; color: red`); |
|||
expect(el.style.color).toBe('red'); |
|||
}); |
|||
|
|||
test('supports !important', () => { |
|||
setStyleText(el, 'color: red !important'); |
|||
expect(el.style.getPropertyPriority('color')).toBe('important'); |
|||
expect(el.style.color).toBe('red'); |
|||
}); |
|||
|
|||
test('supports custom properties', () => { |
|||
setStyleText(el, '--my-var: 10px'); |
|||
expect(el.style.getPropertyValue('--my-var')).toBe('10px'); |
|||
}); |
|||
|
|||
test('tolerates empty, partial and trailing declarations', () => { |
|||
setStyleText(el, ';; color: red ;; padding ;'); |
|||
expect(el.style.color).toBe('red'); |
|||
}); |
|||
|
|||
test('clears the style with an empty input', () => { |
|||
setStyleText(el, 'color: red'); |
|||
setStyleText(el); |
|||
expect(el.getAttribute('style')).toBe(null); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue