Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .devops/templates/build-test-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix(security): escape < and > in serialized CSS values and stylesheet state so they cannot terminate a <style> or <script> element during SSR",
"packageName": "@fluentui/merge-styles",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: warn in development when DocumentCard onClickHref is given a script URL",
"packageName": "@fluentui/react",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-calendar-compat",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "docs: note that IChartDataPoint.color is rendered into generated CSS and should be validated when it comes from untrusted input",
"packageName": "@fluentui/react-charting",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-datepicker-compat",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "docs: warn that Markdown's `enableRenderHtmlBlock` renders unsanitized HTML and must only be used with developer-authored content",
"packageName": "@fluentui/react-docsite-components",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "docs: clarify how `supportsSize` option of `useFieldControlProps_unstable` applies the Field size",
"packageName": "@fluentui/react-field",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "refactor: collapse redundant headless type, render, and hook wrappers to direct re-exports",
"packageName": "@fluentui/react-headless-components-preview",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-icons-compat",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-infolabel",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: add TODO for the no-op `supportsSize` field control option",
"packageName": "@fluentui/react-input",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-list",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-message-bar",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-migration-v8-v9",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-motion",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-rating",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-swatch-picker",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: remove no-op `supportsSize` field control option from `TagPickerInput`",
"packageName": "@fluentui/react-tag-picker",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-teaching-popover",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: add TODO for the no-op `supportsSize` field control option",
"packageName": "@fluentui/react-textarea",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: align react/react-dom/@types peerDependencies with the react/jsx-runtime 16.14.0 floor",
"packageName": "@fluentui/react-timepicker-compat",
"email": "martinhochel@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Server-side rendering

> **Security note**
>
> Both recipes below write Fluent UI output into raw text elements using `dangerouslySetInnerHTML` - `Stylesheet.getRules()` into a `<style>` and `Stylesheet.serialize()` into a `<script>`. `merge-styles` escapes `<`/`>` in declaration values and `<` in the serialized state so neither string can terminate its element, but you are still responsible for validating any untrusted data (user- or tenant-supplied colors, accents, background image URLs) that you route into a style value, and for serving a Content Security Policy. Do not pass untrusted data to `Stylesheet.insertRule`, which is not escaped because it accepts full rules including selectors.

## Next.js setup

For basic instructions on getting Next.js set up, see https://nextjs.org/
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"check:installed-dependencies-versions": "satisfied --no-peers --skip-invalid",
"create-component": "nx g @fluentui/workspace-plugin:react-component",
"create-package": "nx g @fluentui/workspace-plugin:react-library",
"format": "node scripts/executors/src/format.js",
"format": "nx format",
"generate-version-files": "node -r ./scripts/ts-node/src/register ./scripts/generators/src/generate-version-files",
"postinstall": "husky && node ./scripts/package-manager/src/postinstall.js",
"preinstall": "node ./scripts/package-manager/src/preinstall.js",
Expand Down
2 changes: 2 additions & 0 deletions packages/charts/react-charting/src/types/IDataPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export interface IChartDataPoint {

/**
* Color for the legend in the chart. If not provided, it will fallback on the default color palette.
*
* @remarks This value is rendered into generated CSS. Validate it before assigning untrusted input to it.
*/
color?: string;

Expand Down
14 changes: 14 additions & 0 deletions packages/merge-styles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,20 @@ renderStatic(() => ReactDOM.renderToString(<App/>);

- Rehydration on the client may result in mismatched rules. You can apply a namespace on the server side to ensure there aren't name collisions.

### Untrusted data in style values

The `css` returned by `renderStatic` (and by `Stylesheet.getRules`) is raw CSS text that is normally written into a `<style>` element. A `<style>` element is HTML raw text, so a declaration value containing `</style>` 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 `<script>` for rehydration.

This escaping is defense in depth, not a substitute for validating input. Applications remain responsible for:

- Validating any untrusted data (user- or tenant-supplied brand colors, accents, background image URLs) before using it as a style value.
- `Stylesheet.insertRule`, which is intentionally **not** escaped because it accepts complete rules including selectors, and selectors legitimately contain `<` and `>` adjacent characters such as the `>` child combinator. Never pass untrusted data to it.
- Serving a Content Security Policy, which limits the impact of any injection.

Note also that rules registered with `preserve` - which is what `fontFace` and `keyframes` do - are intentionally retained by `Stylesheet.reset`, so they are re-emitted by every later `getRules(true)` call in the same process. Do not derive font faces or keyframes from per-request untrusted data in a shared server process.

## Working with content security policy (CSP)

Some content security policies prevent style injection without a nonce. To set the nonce used by `merge-styles`:
Expand Down
10 changes: 10 additions & 0 deletions packages/merge-styles/src/Stylesheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe('Stylesheet', () => {

it('returns a new instance on mismatched global', () => {
// We need to modify internals in order to mock this behaviour.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_stylesheet as any)._lastStyleElement = { ownerDocument: {} };

const newInstance = Stylesheet.getInstance();
Expand Down Expand Up @@ -409,6 +410,15 @@ describe('Stylesheet', () => {
);
});

it('escapes `<` so the result is safe to embed in an inline script element', () => {
_stylesheet.insertRule('a { content: "</script>" };');

const serializedStylesheet = _stylesheet.serialize();

expect(serializedStylesheet).not.toContain('<');
expect(JSON.parse(serializedStylesheet).rules).toEqual(['a { content: "</script>" };']);
});

it('can be deserialized', () => {
const rehydrationData = JSON.parse(
// eslint-disable-next-line @fluentui/max-len
Expand Down
23 changes: 21 additions & 2 deletions packages/merge-styles/src/Stylesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } } = {};

/**
Expand Down Expand Up @@ -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
* `<script>` element without terminating it. `JSON.parse` round-trips to an identical object.
*
* @returns string representation of `ISerializedStylesheet` interface.
*/
public serialize(): string {
Expand All @@ -219,7 +224,7 @@ export class Stylesheet {
keyToClassName: this._keyToClassName,
preservedRules: this._preservedRules,
rules: this._rules,
});
}).replace(/</g, '\\u003C');
}

/**
Expand Down Expand Up @@ -321,7 +326,14 @@ export class Stylesheet {

/**
* Inserts a css rule into the stylesheet.
* @param preserve - Preserves the rule beyond a reset boundary.
*
* The rule is inserted verbatim - no escaping is performed, because a rule includes its selector
* and selectors legitimately contain characters such as `>`. Callers passing untrusted data are
* responsible for validating it before it reaches this method.
*
* @param preserve - Preserves the rule beyond a reset boundary. Note that {@link Stylesheet.reset}
* intentionally does not clear preserved rules, so a preserved rule is re-emitted by every
* subsequent {@link Stylesheet.getRules} call in the same process.
*/
public insertRule(rule: string, preserve?: boolean, stylesheetKey: string = GLOBAL_STYLESHEET_KEY): void {
const { injectionMode } = this._config;
Expand Down Expand Up @@ -361,6 +373,10 @@ export class Stylesheet {
/**
* Gets all rules registered with the stylesheet; only valid when
* using InsertionMode.none.
*
* The return value is raw CSS text intended for a `<style>` element. Values that went through
* `mergeStyles`, `fontFace` or `keyframes` have `<` and `>` escaped as CSS code points so they
* cannot terminate that element, but rules added via {@link Stylesheet.insertRule} are unescaped.
*/
public getRules(includePreservedRules?: boolean): string {
return (includePreservedRules ? this._preservedRules.join('') : '') + this._rules.join('');
Expand All @@ -369,6 +385,9 @@ export class Stylesheet {
/**
* Resets the internal state of the stylesheet. Only used in server
* rendered scenarios where we're using InsertionMode.none.
*
* Rules registered with `preserve` (via `fontFace` and `keyframes`) are intentionally retained,
* so they survive into every later render served by the same process.
*/
public reset(): void {
this._rules = [];
Expand Down
13 changes: 13 additions & 0 deletions packages/merge-styles/src/fontFace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,17 @@ describe('fontFace', () => {

expect(_stylesheet.getRules()).toEqual('@font-face{font-family:Segoe UI;src:url("foo");}');
});

it('escapes values that would terminate the style element', () => {
_stylesheet.reset();
fontFace({
fontFamily: 'x</style><script>alert(1)</script>',
src: 'url("foo")',
});

const rules = _stylesheet.getRules(true);

expect(rules).not.toContain('</style');
expect(rules).toContain('font-family:x\\3C /style\\3E ');
});
});
12 changes: 12 additions & 0 deletions packages/merge-styles/src/keyframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,16 @@ describe('keyframes', () => {

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;}</style><script>alert(1)</script>' },
to: { opacity: 1 },
});

const rules = _stylesheet.getRules(true);

expect(rules).not.toContain('</style');
expect(rules).not.toContain('<script');
});
});
17 changes: 17 additions & 0 deletions packages/merge-styles/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,21 @@ describe('staticRender', () => {
expect(html).toEqual(`<div class="test-root-0">Hello!</div>`);
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;}</style><script>alert(document.cookie)</script><style>.x{color:red',
},
},
]);

return `<div class="${classNames.root}">Hello!</div>`;
});

expect(css).not.toContain('</style');
expect(css).not.toContain('<script');
});
});
5 changes: 5 additions & 0 deletions packages/merge-styles/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { InjectionMode, Stylesheet } from './Stylesheet';

/**
* Renders a given string and returns both html and css needed for the html.
*
* The returned `css` is raw CSS text meant to be placed in a `<style>` element. Declaration values
* have `<` and `>` escaped as CSS code points so they cannot terminate that element, but callers
* remain responsible for validating untrusted data used in style values.
*
* @param onRender - Function that returns a string.
* @param namespace - Optional namespace to prepend to css classnames to avoid collisions.
*/
Expand Down
Loading
Loading