From 9c573d731b2c36a2f6fb753156467ab90c02b843 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Tue, 8 Sep 2026 16:09:56 +0200 Subject: [PATCH 1/4] fix(react-provider): validate serialized theme tokens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Concepts/Theming.mdx | 4 + .../createCSSRuleFromThemeDescription.md | 4 + ...-8f64011f-66bb-4fae-bf5e-e52d91e39408.json | 7 + .../contributing/patterns/extending-tokens.md | 3 + .../FluentProvider-node.test.tsx | 25 ++ .../FluentProvider/FluentProvider.types.ts | 7 +- .../createCSSRuleFromTheme.test.ts | 107 ++++++ .../FluentProvider/createCSSRuleFromTheme.ts | 330 +++++++++++++++++- .../useFluentProviderThemeStyleTag.test.tsx | 21 ++ 9 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 change/@fluentui-react-provider-8f64011f-66bb-4fae-bf5e-e52d91e39408.json diff --git a/apps/public-docsite-v9/src/Concepts/Theming.mdx b/apps/public-docsite-v9/src/Concepts/Theming.mdx index 0b116fa1418cc..446b7c2644a76 100644 --- a/apps/public-docsite-v9/src/Concepts/Theming.mdx +++ b/apps/public-docsite-v9/src/Concepts/Theming.mdx @@ -112,6 +112,10 @@ export const customDarkTheme = createDarkTheme(customBrandRamp); A theme is a flat object containing `{ [token name]: CSS value }` pairs. You can copy the object and overwrite any tokens you wish. +Theme names and values are developer-authored CSS. If theme customization is based on dynamic data, validate it against +an application-specific schema before constructing the theme. Fluent UI contains generated declarations structurally, +but does not determine whether a valid CSS value or URL is appropriate for your application. + ```tsx import { webLightTheme, Theme } from '@fluentui/react-components'; diff --git a/apps/public-docsite-v9/src/Utilities/Theme/createCSSRuleFromTheme/createCSSRuleFromThemeDescription.md b/apps/public-docsite-v9/src/Utilities/Theme/createCSSRuleFromTheme/createCSSRuleFromThemeDescription.md index 2d6e57860012b..dc977f1871e2f 100644 --- a/apps/public-docsite-v9/src/Utilities/Theme/createCSSRuleFromTheme/createCSSRuleFromThemeDescription.md +++ b/apps/public-docsite-v9/src/Utilities/Theme/createCSSRuleFromTheme/createCSSRuleFromThemeDescription.md @@ -1 +1,5 @@ This API allows you to create CSS from a theme and apply this CSS, for example, to ``. + +The selector and theme are developer-authored CSS. Validate dynamic data against an application-specific schema before +using it to construct a theme. The generated declarations are structurally contained, but the API does not determine +whether a valid CSS value or URL is appropriate for your application. diff --git a/change/@fluentui-react-provider-8f64011f-66bb-4fae-bf5e-e52d91e39408.json b/change/@fluentui-react-provider-8f64011f-66bb-4fae-bf5e-e52d91e39408.json new file mode 100644 index 0000000000000..ed3af4a6b461b --- /dev/null +++ b/change/@fluentui-react-provider-8f64011f-66bb-4fae-bf5e-e52d91e39408.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: structurally validate serialized theme tokens", + "packageName": "@fluentui/react-provider", + "email": "paulmardling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/docs/react-v9/contributing/patterns/extending-tokens.md b/docs/react-v9/contributing/patterns/extending-tokens.md index f26008de479f0..1fc1e94679843 100644 --- a/docs/react-v9/contributing/patterns/extending-tokens.md +++ b/docs/react-v9/contributing/patterns/extending-tokens.md @@ -4,6 +4,9 @@ It's often useful for an app to extend the base set of tokens from Fluent UI. ⚠ Warning that adding more tokens adds more CSS variables which can effect run time performance as each DOM Node carries all the tokens. +Theme names and values are developer-authored CSS. Validate dynamic data against an application-specific schema before +using it to construct or extend a theme. + ```tsx import { makeStyles, themeToTokensObject, webLightTheme, FluentProvider, Theme } from '@fluentui/react-components'; diff --git a/packages/react-components/react-provider/library/src/components/FluentProvider/FluentProvider-node.test.tsx b/packages/react-components/react-provider/library/src/components/FluentProvider/FluentProvider-node.test.tsx index 3bca12bf969f9..f989b31b353eb 100644 --- a/packages/react-components/react-provider/library/src/components/FluentProvider/FluentProvider-node.test.tsx +++ b/packages/react-components/react-provider/library/src/components/FluentProvider/FluentProvider-node.test.tsx @@ -76,4 +76,29 @@ describe('FluentProvider (node)', () => { " `); }); + + it('omits invalid theme entries from the server style element', () => { + const logWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const theme = { + invalidToken: 'url(resource/*);token/**/)', + validToken: 'green', + } as unknown as PartialTheme; + + const html = renderToStaticMarkup(); + + expect(parseHTMLString(html)).toMatchInlineSnapshot(` + "
+ +
" + `); + expect(html.match(/ " `); expect(html.match(/ diff --git a/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.test.ts b/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.test.ts index 6c41c364e89d2..85dba30d485bc 100644 --- a/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.test.ts +++ b/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.test.ts @@ -81,6 +81,23 @@ describe('createCSSRuleFromTheme', () => { }, ); + it.each([5_000, 10_000, 20_000, 100_000])('serializes deep block patterns of length %i', depth => { + const openingBlocks = '('.repeat(depth); + const matchingBlocks = `${openingBlocks}${')'.repeat(depth)}`; + const nonmatchingBlocks = `${openingBlocks}${']'.repeat(depth)}`; + const mixedBlocks = `${'([{'.repeat(depth)}${')'.repeat(depth)}`; + + expect(createCSSRuleFromTheme('.selector', { customToken: matchingBlocks } as unknown as PartialTheme)).toContain( + `--customToken: ${matchingBlocks};`, + ); + expect( + createCSSRuleFromTheme('.selector', { customToken: nonmatchingBlocks } as unknown as PartialTheme), + ).toContain(`--customToken: ${nonmatchingBlocks}${')'.repeat(depth)};`); + expect(createCSSRuleFromTheme('.selector', { customToken: mixedBlocks } as unknown as PartialTheme)).toContain( + `--customToken: ${'([{'.repeat(depth)}${'}])'.repeat(depth)};`, + ); + }); + it.each([ { description: 'font family fallbacks', value: '"Segoe UI", system-ui, sans-serif' }, { description: 'system and functional colors', value: 'color-mix(in srgb, CanvasText 40%, transparent)' }, @@ -108,6 +125,9 @@ describe('createCSSRuleFromTheme', () => { value: String.raw`u\52${'\r\n'}l(resource/*)`, }, { description: 'zero-padded hexadecimal escaped URL name', value: String.raw`\000055rl(resource/*)` }, + { description: 'hash token followed by a parenthesized block', value: '#url(/* ) */; x)' }, + { description: 'at-keyword followed by a parenthesized block', value: '@url(/* ) */; x)' }, + { description: 'escaped hash token followed by a parenthesized block', value: String.raw`#\75rl(/* ) */; x)` }, { description: 'escaped generic function names', value: String.raw`f\6f o((x); y)` }, { description: 'quoted URL functions with nested blocks', value: String.raw`url("image" (x); fallback)` }, { description: 'backslash and line feed', value: 'first\\\nsecond' }, @@ -169,6 +189,11 @@ describe('createCSSRuleFromTheme', () => { value: '\\000075\r\n\\000072\r\n\\00006c\r\n(resource/*);token/**/)', containedValue: '\\000075\r\n\\000072\r\n\\00006c\r\n(resource/*)\\3B token/**/)', }, + { + description: 'malformed unquoted URL content', + value: 'url(\\x")', + containedValue: 'url(\\x\\22 )', + }, ])('contains a value with $description without affecting later tokens', ({ value, containedValue }) => { const theme = { customToken: value, @@ -190,6 +215,11 @@ describe('createCSSRuleFromTheme', () => { { description: 'unterminated escape', value: 'red\\' }, { description: 'escaped whitespace in a generic function name', value: String.raw`ur\ l(resource{)` }, { description: 'non-URL escaped function name', value: String.raw`\54rl(resource/*)` }, + { description: 'malformed unquoted URL content', value: 'url(\\x")' }, + { description: 'malformed unquoted URL content after whitespace', value: "url( \\x')" }, + { description: 'malformed unquoted URL content after an escaped delimiter', value: 'url(\\)")' }, + { description: 'unterminated unquoted URL', value: 'url(resource' }, + { description: 'unterminated escape in an unquoted URL', value: 'url(resource\\' }, ])('repairs a value with $description without affecting later tokens', ({ value }) => { const ruleText = createCSSRuleFromTheme('.selector', { customToken: value, @@ -211,7 +241,14 @@ describe('createCSSRuleFromTheme', () => { String.raw`u\52l(resource.png)`, String.raw`ur\4c(resource.png)`, String.raw`\55 rl(resource.png)`, - ])('preserves escaped unquoted URL syntax through CSSOM for %j', value => { + '#url(/* ) */; x)', + '@url(/* ) */; x)', + String.raw`#\75rl(/* ) */; x)`, + ])('preserves token semantics through CSSOM for %j', value => { + const baselineStyleElement = document.createElement('style'); + baselineStyleElement.textContent = `.selector { --customToken: ${value}; }`; + document.head.appendChild(baselineStyleElement); + const styleElement = document.createElement('style'); styleElement.textContent = createCSSRuleFromTheme('.selector', { customToken: value, @@ -219,8 +256,10 @@ describe('createCSSRuleFromTheme', () => { document.head.appendChild(styleElement); const rule = styleElement.sheet?.cssRules[0] as CSSStyleRule; - expect(rule.style.getPropertyValue('--customToken')).toBe(value); + const baselineRule = baselineStyleElement.sheet?.cssRules[0] as CSSStyleRule; + expect(rule.style.getPropertyValue('--customToken')).toBe(baselineRule.style.getPropertyValue('--customToken')); + baselineStyleElement.remove(); styleElement.remove(); }); diff --git a/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.ts b/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.ts index 1efbb973558f3..a3dc876d98c1d 100644 --- a/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.ts +++ b/packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.ts @@ -33,6 +33,7 @@ function escapeForStyleTag(value: string): string { function containThemeTokenValue(value: string): string { const result = value.split(''); const blocks: string[] = []; + const blockIndexes: Record = { ')': [], ']': [], '}': [] }; let identifier = ''; let quote = ''; let comment = false; @@ -79,18 +80,23 @@ function containThemeTokenValue(value: string): string { continue; } if (character === ')') { - blocks.pop(); + const closingBlock = blocks.pop()!; + blockIndexes[closingBlock].pop(); urlState = 0; - } else if (character === '\\') { + continue; + } + if (character === '"' || character === "'") { + result[i] = character === '"' ? '\\22 ' : '\\27 '; + } + if (character === '\\') { const escape = value.slice(i).match(ESCAPE_AT_START_PATTERN)?.[0]; if (escape) { i += escape.length - 1; } else if (nextCharacter === undefined) { result[i] = '\\\n'; } - } else { - urlState = 2; } + urlState = 2; continue; } @@ -114,7 +120,7 @@ function containThemeTokenValue(value: string): string { } const functionNameIsUrl = URL_FUNCTION_PATTERN.test(identifier); - identifier = ''; + identifier = character === '#' || character === '@' ? character : ''; if (character === '/' && nextCharacter === '*') { comment = true; @@ -122,19 +128,20 @@ function containThemeTokenValue(value: string): string { } else if (character === '"' || character === "'") { quote = character; } else if (character === '(' || character === '[' || character === '{') { - blocks.push(character === '(' ? ')' : character === '[' ? ']' : '}'); + const closingBlock = character === '(' ? ')' : character === '[' ? ']' : '}'; + blockIndexes[closingBlock].push(blocks.push(closingBlock) - 1); if (character === '(' && functionNameIsUrl) { urlState = 1; } } else if (character === ')' || character === ']' || character === '}') { - const blockIndex = blocks.lastIndexOf(character); - if (blockIndex >= 0) { - result[i] = - blocks - .splice(blockIndex + 1) - .reverse() - .join('') + character; + const blockIndex = blockIndexes[character].pop(); + if (blockIndex !== undefined) { + const repairedBlocks = blocks.splice(blockIndex + 1).reverse(); + for (const closingBlock of repairedBlocks) { + blockIndexes[closingBlock].pop(); + } blocks.pop(); + result[i] = repairedBlocks.join('') + character; } else if (character === '}') { result[i] = CSS_ESCAPE_MAP[character]; } diff --git a/packages/react-components/react-provider/library/src/components/FluentProvider/useFluentProviderThemeStyleTag.test.tsx b/packages/react-components/react-provider/library/src/components/FluentProvider/useFluentProviderThemeStyleTag.test.tsx index 7e0925465d141..2ee523b83810e 100644 --- a/packages/react-components/react-provider/library/src/components/FluentProvider/useFluentProviderThemeStyleTag.test.tsx +++ b/packages/react-components/react-provider/library/src/components/FluentProvider/useFluentProviderThemeStyleTag.test.tsx @@ -92,6 +92,22 @@ describe('useFluentProviderThemeStyleTag', () => { ); }); + it('should isolate malformed URL content from later CSS variables', () => { + const theme = { + customToken: 'url(\\x")', + validToken: 'green', + } as unknown as Theme; + + const { result } = renderHook(() => + useFluentProviderThemeStyleTag({ theme, targetDocument: document, rendererAttributes: {} }), + ); + + const tag = document.getElementById(result.current.styleTagId) as HTMLStyleElement; + const rule = (tag.sheet as CSSStyleSheet).cssRules[0] as CSSStyleRule; + + expect(rule.style.getPropertyValue('--validToken')).toBe('green'); + }); + it('should update style tag on theme change', () => { // Arrange let theme = defaultTheme;