Browse Source

feat(csp): support CSP nonce on editor-injected style elements

The editor injects stylesheets at runtime, so a page served with a strict
`style-src`/`style-src-elem` policy blocks them. Unlike scripts, styles have
no `strict-dynamic` equivalent, so a nonce is the only way to allow them.

Add a `cspNonce` editor config option and apply it to every `<style>` the
editor creates:

- `CanvasView` canvas style element. Moved out of the view template and
  created imperatively, so the nonce is set before the element enters the
  document.
- `FrameView` frame base styles (`baseCss`/`frameStyle`/`canvasCss`/
  `protectedCss`).
- `CssRuleView`, which renders one `<style>` per CSS rule and is the main
  source of violations.
- `CssRulesView` `@keyframes` grouping element.
- `BrowserParserCss`, which round-trips CSS through a temporary `<style>` in
  `document.head` to reuse the browser parser. When blocked, `el.sheet` stays
  null and the parser silently returns no rules, so imported CSS was being
  dropped without any error under a strict policy.

Nonces have to be in place before insertion, hence the shared `createStyleEl`
and `setNonce` helpers in `utils/dom`.

Out of scope: inline `style` attributes (governed by `style-src-attr`) and
`<script>` elements appended to the canvas. The latter run today under
`script-src 'strict-dynamic'`, and nonce-ing user-authored component scripts
is a separate decision.
pull/6811/head
Gxrvish 2 months ago
parent
commit
480674ff00
  1. 7
      packages/core/src/canvas/view/CanvasView.ts
  2. 9
      packages/core/src/canvas/view/FrameView.ts
  3. 4
      packages/core/src/css_composer/view/CssRuleView.ts
  4. 4
      packages/core/src/css_composer/view/CssRulesView.ts
  5. 16
      packages/core/src/editor/config/config.ts
  6. 8
      packages/core/src/parser/model/BrowserParserCss.ts
  7. 2
      packages/core/src/parser/model/ParserCss.ts
  8. 22
      packages/core/src/utils/dom.ts
  9. 109
      packages/core/test/specs/editor/CspNonce.ts

7
packages/core/src/canvas/view/CanvasView.ts

@ -5,6 +5,7 @@ import Component from '../../dom_components/model/Component';
import ComponentView from '../../dom_components/view/ComponentView';
import {
createEl,
createStyleEl,
getDocumentScroll,
getElRect,
getKeyChar,
@ -58,7 +59,6 @@ export default class CanvasView extends ModuleView<Canvas> {
<div class="${pfx}canvas__spots" data-spots></div>
</div>
<div id="${pfx}tools" class="${pfx}canvas__tools" data-tools></div>
<style data-canvas-style></style>
`;
}
/*get className(){
@ -694,7 +694,10 @@ export default class CanvasView extends ModuleView<Canvas> {
this.fixedOffsetEl = el.querySelector(`.${ppfx}offset-fixed-v`)!;
this.toolsGlobEl = el.querySelector(`.${ppfx}tools-gl`)!;
this.spotsEl = el.querySelector('[data-spots]')!;
this.cvStyle = el.querySelector('[data-canvas-style]')!;
// Created here instead of in the template, so that the CSP nonce is in
// place before the element enters the document
this.cvStyle = createStyleEl('', em.getConfig().cspNonce, { 'data-canvas-style': '' });
el.appendChild(this.cvStyle);
el.className = getUiClass(em, this.className);
if (config.scrollableCanvas === true) {
el.style.overflow = 'auto';

9
packages/core/src/canvas/view/FrameView.ts

@ -7,7 +7,7 @@ import ComponentView from '../../dom_components/view/ComponentView';
import ComponentWrapperView from '../../dom_components/view/ComponentWrapperView';
import AutoScroller from '../../utils/AutoScroller';
import Droppable from '../../utils/Droppable';
import { append, appendVNodes, createCustomEvent, createEl, motionsEv, off, on } from '../../utils/dom';
import { append, appendVNodes, createCustomEvent, createEl, createStyleEl, motionsEv, off, on } from '../../utils/dom';
import { hasDnd, setViewEl } from '../../utils/mixins';
import Canvas from '../model/Canvas';
import Frame from '../model/Frame';
@ -374,7 +374,8 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
append(
body,
`<style>
createStyleEl(
`
${conf.baseCss || config.frameStyle || ''}
${hasAutoHeight ? 'body { overflow: hidden }' : ''}
@ -444,7 +445,9 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
${conf.canvasCss || ''}
${conf.protectedCss || ''}
</style>`,
`,
conf.cspNonce,
),
);
const { root } = model;
const { view } = this.getComponentView(root);

4
packages/core/src/css_composer/view/CssRuleView.ts

@ -1,6 +1,7 @@
import FrameView from '../../canvas/view/FrameView';
import { View } from '../../common';
import EditorModel from '../../editor/model/Editor';
import { setNonce } from '../../utils/dom';
import CssRule from '../model/CssRule';
import { CssEvents } from '../types';
@ -46,6 +47,9 @@ export default class CssRuleView extends View<CssRule> {
const css = model.toCSS({ important });
const mountProps = { rule: model, ruleView: this, css };
em?.trigger(CssEvents.mountBefore, mountProps);
// Has to be set before the style block is filled, otherwise a strict
// `style-src` policy blocks the rule
setNonce(el as HTMLElement, em?.getConfig().cspNonce);
el.innerHTML = mountProps.css;
em?.trigger(CssEvents.mount, mountProps);
return this;

4
packages/core/src/css_composer/view/CssRulesView.ts

@ -1,7 +1,7 @@
import { bindAll } from 'underscore';
import { View } from '../../common';
import { createEl } from '../../utils/dom';
import { createEl, createStyleEl } from '../../utils/dom';
import CssRuleView from './CssRuleView';
import CssGroupRuleView from './CssGroupRuleView';
import EditorModel from '../../editor/model/Editor';
@ -66,7 +66,7 @@ export default class CssRulesView extends View {
let atRuleEl = this.atRules[atRule];
if (!atRuleEl) {
const styleEl = document.createElement('style');
const styleEl = createStyleEl('', this.em?.getConfig().cspNonce);
atRuleEl = document.createTextNode('');
styleEl.appendChild(document.createTextNode(`${atRule}{`));
styleEl.appendChild(atRuleEl);

16
packages/core/src/editor/config/config.ts

@ -244,6 +244,21 @@ export interface EditorConfig {
*/
avoidInlineStyle?: boolean;
/**
* CSP nonce to set on every `<style>` element created by the editor.
* Required when the page is served with a strict `style-src`/`style-src-elem`
* policy, as the editor injects stylesheets at runtime (canvas styles, frame
* base styles and one `<style>` per CSS rule).
*
* The value has to match the `nonce-...` source of the policy sent with the
* document. Note that it does not cover inline `style` attributes, which are
* governed by `style-src-attr`.
* @default undefined
* @example
* cspNonce: 'r4nd0m'
*/
cspNonce?: string;
/**
* Avoid default properties from storable JSON data, like `components` and `styles`.
* With this option enabled your data will be smaller (usefull if need to
@ -467,6 +482,7 @@ const config: () => EditorConfig = () => ({
optsHtml: {},
optsCss: {},
avoidInlineStyle: true,
cspNonce: undefined,
avoidDefaults: true,
clearStyles: false,
listenToEl: [],

8
packages/core/src/parser/model/BrowserParserCss.ts

@ -1,6 +1,7 @@
import { keys } from 'underscore';
import { CssRuleJSON } from '../../css_composer/model/CssRule';
import { ObjectStrings } from '../../common';
import { createStyleEl } from '../../utils/dom';
/** @see https://developer.mozilla.org/en-US/docs/Web/API/CSSRule/type */
const CSS_RULE_TYPES = {
@ -271,11 +272,12 @@ export const parseNode = (el: CSSStyleSheet | CSSRule) => {
/**
* Parse CSS string and return the array of objects
* @param {String} str CSS string
* @param {String} [nonce] CSP nonce, required under a strict `style-src` policy,
* otherwise the style block is blocked and `el.sheet` stays empty
* @return {Array<Object>} Array of objects for the definition of CSSRules
*/
export default (str: string) => {
const el = document.createElement('style');
el.innerHTML = str;
export default (str: string, nonce?: string) => {
const el = createStyleEl(str, nonce);
// There is no .sheet before adding it to the <head>
document.head.appendChild(el);

2
packages/core/src/parser/model/ParserCss.ts

@ -29,7 +29,7 @@ export default class ParserCss {
const { input } = inputOptions;
try {
nodes = parserCss ? parserCss(input, editor!) : BrowserCssParser(input);
nodes = parserCss ? parserCss(input, editor!) : BrowserCssParser(input, this.em?.getConfig().cspNonce);
} catch (err) {
error = err;
if (opts.throwOnError) throw err;

22
packages/core/src/utils/dom.ts

@ -83,6 +83,28 @@ export const createEl = (tag: string, attrs: ObjectAny = {}, child?: ChildHTML)
export const createText = (str: string) => document.createTextNode(str);
/**
* Set the CSP nonce on an element.
* The nonce has to be in place before the element is inserted in the document,
* otherwise a strict `style-src`/`script-src` policy blocks it.
*/
export const setNonce = <T extends HTMLElement>(el: T, nonce?: string) => {
nonce && el.setAttribute('nonce', nonce);
return el;
};
/**
* Create a detached `<style>` element, optionally carrying a CSP nonce.
* Attributes (nonce included) are set while the element is still detached, so
* the style block is already trusted by the time the caller appends it.
*/
export const createStyleEl = (css = '', nonce?: string, attributes: ObjectAny = {}) => {
const el = createEl('style', attributes) as HTMLStyleElement;
setNonce(el, nonce);
if (css) el.innerHTML = css;
return el;
};
// Unfortunately just creating `KeyboardEvent(e.type, e)` is not enough,
// the keyCode/which will be always `0`. Even if it's an old/deprecated
// property keymaster (and many others) still use it... using `defineProperty`

109
packages/core/test/specs/editor/CspNonce.ts

@ -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);
});
});
});
Loading…
Cancel
Save