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