diff --git a/.devops/templates/build-test-lint.yml b/.devops/templates/build-test-lint.yml index 1ad5296fdba160..29b4fb761f1683 100644 --- a/.devops/templates/build-test-lint.yml +++ b/.devops/templates/build-test-lint.yml @@ -29,9 +29,9 @@ steps: - script: | if [[ -n "$(targetBranch)" ]]; then - yarn format --since $(targetBranch) --check + yarn nx format:check --base $(targetBranch) else - yarn format --all --check + yarn nx format:check --all fi displayName: check formatting diff --git a/change/@fluentui-merge-styles-006a4ebc-3844-4381-b6de-5cd56fbe3742.json b/change/@fluentui-merge-styles-006a4ebc-3844-4381-b6de-5cd56fbe3742.json new file mode 100644 index 00000000000000..6d8ec622aa5a0c --- /dev/null +++ b/change/@fluentui-merge-styles-006a4ebc-3844-4381-b6de-5cd56fbe3742.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(security): escape < and > in serialized CSS values and stylesheet state so they cannot terminate a ` would otherwise terminate it and inject markup. + +To prevent that, `merge-styles` emits `<` and `>` inside declaration values as the CSS code point escapes `\3C ` and `\3E `. This is semantics-preserving - the escapes decode back to the same characters, including inside quoted strings and `url()`. Likewise, `Stylesheet.serialize` emits `<` as `\u003C` so its output can be embedded in an inline `" };'); + + const serializedStylesheet = _stylesheet.serialize(); + + expect(serializedStylesheet).not.toContain('<'); + expect(JSON.parse(serializedStylesheet).rules).toEqual(['a { content: "" };']); + }); + it('can be deserialized', () => { const rehydrationData = JSON.parse( // eslint-disable-next-line @fluentui/max-len diff --git a/packages/merge-styles/src/Stylesheet.ts b/packages/merge-styles/src/Stylesheet.ts index 0130f878d479a0..6e31128cf0ee88 100644 --- a/packages/merge-styles/src/Stylesheet.ts +++ b/packages/merge-styles/src/Stylesheet.ts @@ -168,6 +168,7 @@ export class Stylesheet { private _keyToClassName: { [key: string]: string } = {}; private _onInsertRuleCallbacks: (Function | InsertRuleCallback)[] = []; private _onResetCallbacks: Function[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any private _classNameToArgs: { [key: string]: { args: any; rules: string[] } } = {}; /** @@ -210,6 +211,10 @@ export class Stylesheet { /** * Serializes the Stylesheet instance into a format which allows rehydration on creation. + * + * `<` is emitted as the `\u003C` JSON escape so the result can be safely embedded in an inline + * `', + src: 'url("foo")', + }); + + const rules = _stylesheet.getRules(true); + + expect(rules).not.toContain(' { expect(_stylesheet.getRules()).toEqual('@keyframes css-0{from{opacity:0;}to{opacity:1;}}'); }); + + it('escapes values that would terminate the style element', () => { + keyframes({ + from: { background: 'red;}' }, + to: { opacity: 1 }, + }); + + const rules = _stylesheet.getRules(true); + + expect(rules).not.toContain(' { expect(html).toEqual(`
Hello!
`); expect(css).toEqual(`.test-root-0{background:red;}`); }); + + it('does not emit a style element terminator from an untrusted style value', () => { + const { css } = renderStatic(() => { + const classNames: { root: string } = mergeCssSets([ + { + root: { + background: 'red;}