Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"componentDescription": "",
"propDescriptions": {
"attribute": { "description": "DOM attribute for applying a color scheme." },
"attribute": {
"description": "DOM attribute for applying a color scheme.<br>Requires a static value, do not derive from user input."
},
"colorSchemeNode": {
"description": "The node (provided as string) used to attach the color-scheme attribute."
"description": "The node (provided as string) used to attach the color-scheme attribute.<br>Requires a static value, do not derive from user input."
},
"colorSchemeStorageKey": {
"description": "localStorage key used to store <code>colorScheme</code>."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface InitColorSchemeScriptProps {
defaultDarkColorScheme?: string | undefined;
/**
* The node (provided as string) used to attach the color-scheme attribute.
*
* Requires a static value, do not derive from user input.
* @default 'document.documentElement'
*/
colorSchemeNode?: string | undefined;
Expand All @@ -42,6 +44,8 @@ export interface InitColorSchemeScriptProps {
colorSchemeStorageKey?: string | undefined;
/**
* DOM attribute for applying a color scheme.
*
* Requires a static value, do not derive from user input.
* @default 'data-mui-color-scheme'
* @example '.mode-%s' // for class based color scheme
* @example '[data-mode-%s]' // for data-attribute without '='
Expand Down Expand Up @@ -94,13 +98,17 @@ InitColorSchemeScript.propTypes /* remove-proptypes */ = {
// └─────────────────────────────────────────────────────────────────────┘
/**
* DOM attribute for applying a color scheme.
*
* Requires a static value, do not derive from user input.
* @default 'data-mui-color-scheme'
* @example '.mode-%s' // for class based color scheme
* @example '[data-mode-%s]' // for data-attribute without '='
*/
attribute: PropTypes.string,
/**
* The node (provided as string) used to attach the color-scheme attribute.
*
* Requires a static value, do not derive from user input.
* @default 'document.documentElement'
*/
colorSchemeNode: PropTypes.string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,46 @@ describe('InitColorSchemeScript', () => {
});
});

// `attribute` and `colorSchemeNode` are intentionally not serialized — they are documented as
// static, developer-defined values (see the prop docs), so only the storage keys and default
// scheme names are covered here.
it('should serialize storage keys and default scheme names before embedding them in the script', () => {
const payload = `';globalThis.muiScriptInjection = true;//</script><script>globalThis.muiScriptInjection = true</script>`;
const configurations = [
{ modeStorageKey: payload },
{ colorSchemeStorageKey: payload },
{ defaultMode: payload },
{ defaultLightColorScheme: payload },
{ defaultDarkColorScheme: payload },
];

configurations.forEach((configuration) => {
delete globalThis.muiScriptInjection;
const { container } = renderToString(<InitColorSchemeScript {...configuration} />);

expect(container.querySelectorAll('script')).to.have.length(1);
eval(container.firstChild.textContent);
expect(globalThis.muiScriptInjection).to.equal(undefined);
});
});

it('should read back serialized keys and default values with special characters', () => {
// Serialization must round-trip, not just neutralize: a storage key or default scheme name
// with `<`, a quote, and a line separator has to resolve to the exact same string at runtime.
// A mangled key would miss the mode lookup (applying the wrong scheme) and a mangled value
// would land altered - both fail this assertion.
const modeStorageKey = "a<b'c\u2028d";
const darkScheme = "x<y'z\u2029";
storage[modeStorageKey] = 'dark';

const { container } = renderToString(
<InitColorSchemeScript modeStorageKey={modeStorageKey} defaultDarkColorScheme={darkScheme} />,
);
eval(container.firstChild.textContent);

expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal(darkScheme);
});

// Client renders must stay script-free (#48595).
it('should not render the script on the client', () => {
const { container } = render(<InitColorSchemeScript />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface InitColorSchemeScriptProps {
defaultDarkColorScheme?: string | undefined;
/**
* The node (provided as string) used to attach the color-scheme attribute.
*
* Requires a static value, do not derive from user input.
* @default 'document.documentElement'
*/
colorSchemeNode?: string | undefined;
Expand All @@ -38,6 +40,8 @@ export interface InitColorSchemeScriptProps {
colorSchemeStorageKey?: string | undefined;
/**
* DOM attribute for applying color scheme.
*
* Requires a static value, do not derive from user input.
* @default 'data-color-scheme'
* @example '.mode-%s' // for class based color scheme
* @example '[data-mode-%s]' // for data-attribute without '='
Expand All @@ -56,6 +60,19 @@ const maybeReactUseSyncExternalStore: undefined | any = safeReact.useSyncExterna

const subscribe = () => () => {};

// Serialize a configuration string into a JS string literal for the inline script. The storage
// keys and default scheme names are the values an app is most likely to derive from
// user/tenant-controlled config, so treat them as data: `JSON.stringify` handles quotes and
// backslashes, and the extra escapes cover what it leaves intact but the HTML/JS parser does
// not — `<` (so `</script>` can't break out of the element) and the U+2028/U+2029 line
// separators (invalid raw inside a JS string).
function serializeScriptValue(value: string) {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}

/**
* `true` during the server render and the matching hydration render, `false`
* on every client render afterwards. React warns when a `<script>` is
Expand Down Expand Up @@ -122,9 +139,9 @@ export function buildInitColorSchemeScript(options?: InitColorSchemeScriptProps)
__html: `(function() {
try {
let colorScheme = '';
const mode = localStorage.getItem('${modeStorageKey}') || '${defaultMode}';
const dark = localStorage.getItem('${colorSchemeStorageKey}-dark') || '${defaultDarkColorScheme}';
const light = localStorage.getItem('${colorSchemeStorageKey}-light') || '${defaultLightColorScheme}';
const mode = localStorage.getItem(${serializeScriptValue(modeStorageKey)}) || ${serializeScriptValue(defaultMode)};
const dark = localStorage.getItem(${serializeScriptValue(`${colorSchemeStorageKey}-dark`)}) || ${serializeScriptValue(defaultDarkColorScheme)};
const light = localStorage.getItem(${serializeScriptValue(`${colorSchemeStorageKey}-light`)}) || ${serializeScriptValue(defaultLightColorScheme)};
if (mode === 'system') {
// handle system mode
const mql = window.matchMedia('(prefers-color-scheme: dark)');
Expand Down
Loading