diff --git a/.changeset/validate-css-layer-name.md b/.changeset/validate-css-layer-name.md new file mode 100644 index 00000000000..4cadc1f6f6f --- /dev/null +++ b/.changeset/validate-css-layer-name.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Validate `appearance.cssLayerName` before wrapping component styles in `@layer`. Values that are not a valid CSS layer name (for example ones containing braces, semicolons, or markup) are now ignored with a one-time console warning instead of being interpolated into the generated stylesheet. diff --git a/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts b/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts new file mode 100644 index 00000000000..c78d064a58b --- /dev/null +++ b/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts @@ -0,0 +1,36 @@ +import { logger } from '@clerk/shared/logger'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createEmotionCache } from '../createEmotionCache'; + +function insertAndRead(cssLayerName: string | undefined, styles: string) { + const cache = createEmotionCache({ cssLayerName }); + const insert = vi.spyOn(cache.sheet, 'insert').mockImplementation(() => {}); + cache.insert('', { name: 'rule', styles, next: undefined } as any, cache.sheet, true); + return insert.mock.calls.map(([rule]) => rule).join(''); +} + +describe('createEmotionCache', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(['app.clerk', '--vendor', 'clérk'])('wraps insertions in the configured layer %s', name => { + expect(insertAndRead(name, 'color:red;')).toContain(`@layer ${name}`); + }); + + it('drops a cssLayerName that would break out of the @layer rule', () => { + vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + const payload = 'x} body { filter: blur(2px) } /*'; + const emitted = insertAndRead(payload, 'color:red;'); + expect(emitted).not.toContain('@layer'); + expect(emitted).not.toContain('blur'); + }); + + it('drops a cssLayerName carrying markup', () => { + vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + const emitted = insertAndRead('x{}'); + expect(emitted).not.toContain('@layer'); + }); +}); diff --git a/packages/ui/src/styledSystem/createEmotionCache.ts b/packages/ui/src/styledSystem/createEmotionCache.ts index 155c34942f9..fab7742f88d 100644 --- a/packages/ui/src/styledSystem/createEmotionCache.ts +++ b/packages/ui/src/styledSystem/createEmotionCache.ts @@ -1,6 +1,8 @@ // eslint-disable-next-line no-restricted-imports import createCache, { type EmotionCache } from '@emotion/cache'; +import { sanitizeCssLayerName } from '../utils/cssLayerName'; + type CreateEmotionCacheOptions = { /** The nonce value for CSP (Content Security Policy). */ nonce?: string; @@ -14,7 +16,8 @@ type CreateEmotionCacheOptions = { * `cssLayerName` is set, every insertion is wrapped in `@layer { ... }` * so consumers can control cascade precedence relative to their own styles. */ -export function createEmotionCache({ nonce, cssLayerName }: CreateEmotionCacheOptions): EmotionCache { +export function createEmotionCache({ nonce, cssLayerName: rawCssLayerName }: CreateEmotionCacheOptions): EmotionCache { + const cssLayerName = sanitizeCssLayerName(rawCssLayerName); const el = typeof document !== 'undefined' ? document.querySelector('style#cl-style-insertion-point') : null; const cache = createCache({ key: 'cl-internal', diff --git a/packages/ui/src/utils/__tests__/cssLayerName.test.ts b/packages/ui/src/utils/__tests__/cssLayerName.test.ts new file mode 100644 index 00000000000..cdf1b3418d8 --- /dev/null +++ b/packages/ui/src/utils/__tests__/cssLayerName.test.ts @@ -0,0 +1,123 @@ +import { logger } from '@clerk/shared/logger'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isValidCssLayerName, sanitizeCssLayerName } from '../cssLayerName'; + +// https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point +const NON_ASCII_IDENT_RANGES: Array<[number, number]> = [ + [0x00b7, 0x00b7], + [0x00c0, 0x00d6], + [0x00d8, 0x00f6], + [0x00f8, 0x037d], + [0x037f, 0x1fff], + [0x200c, 0x200d], + [0x203f, 0x2040], + [0x2070, 0x218f], + [0x2c00, 0x2fef], + [0x3001, 0xd7ff], + [0xf900, 0xfdcf], + [0xfdf0, 0xfffd], + [0x10000, 0x10ffff], +]; +const inNonAsciiIdentRanges = (cp: number) => NON_ASCII_IDENT_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi); +const hex = (cp: number) => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`; +const rangeEdges = [...new Set(NON_ASCII_IDENT_RANGES.flatMap(([lo, hi]) => [lo, hi]))].map(cp => ({ + cp, + label: hex(cp), +})); +const rangeNeighbours = [...new Set(NON_ASCII_IDENT_RANGES.flatMap(([lo, hi]) => [lo - 1, hi + 1]))] + .filter(cp => cp <= 0x10ffff && !inNonAsciiIdentRanges(cp)) + .map(cp => ({ cp, label: hex(cp) })); + +describe('cssLayerName', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + 'components', + 'clerk', + 'app.components', + 'theme_layer-1', + '-vendor', + '--vendor', + '--', + '---', + '--1', + '_x', + 'a.b.c', + 'a.--b', + 'clérk', + '-é', + 'レイヤー', + '\u{1F600}', + 'inherits', + 'revert-layers', + ])('accepts %s', value => { + const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + expect(isValidCssLayerName(value)).toBe(true); + expect(sanitizeCssLayerName(value)).toBe(value); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each(rangeEdges)('accepts non-ASCII ident code point $label as start and continuation', ({ cp }) => { + const char = String.fromCodePoint(cp); + expect(isValidCssLayerName(char)).toBe(true); + expect(isValidCssLayerName(`a${char}`)).toBe(true); + }); + + it.each(rangeNeighbours)('rejects excluded code point $label as start and continuation', ({ cp }) => { + const char = String.fromCodePoint(cp); + expect(isValidCssLayerName(char)).toBe(false); + expect(isValidCssLayerName(`a${char}`)).toBe(false); + }); + + it.each([ + 'x} body { color: red } /*', + 'x{}', + 'components;@import url(https://attacker.example/x)', + 'a b', + 'a.', + '.a', + '1abc', + '-1abc', + 'a..b', + '', + ' clerk', + 'clerk\n', + 'a\\}b', + 'a\u00A0b', + 'a\u2028b', + 'a\u00D7b', + ])('rejects %j', value => { + expect(isValidCssLayerName(value)).toBe(false); + }); + + it.each([ + 'initial', + 'inherit', + 'unset', + 'revert', + 'revert-layer', + 'revert-rule', + 'INITIAL', + 'app.revert', + 'App.Revert-Layer', + ])('rejects the CSS-wide keyword %s', value => { + expect(isValidCssLayerName(value)).toBe(false); + }); + + it('returns undefined and warns once for an invalid name', () => { + const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + expect(sanitizeCssLayerName('x} body { color: red } /*')).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('cssLayerName'); + }); + + it('returns undefined without warning for an empty value', () => { + const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + expect(sanitizeCssLayerName(undefined)).toBeUndefined(); + expect(sanitizeCssLayerName('')).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/utils/cssLayerName.ts b/packages/ui/src/utils/cssLayerName.ts new file mode 100644 index 00000000000..1b86d5fc9f2 --- /dev/null +++ b/packages/ui/src/utils/cssLayerName.ts @@ -0,0 +1,32 @@ +import { logger } from '@clerk/shared/logger'; + +// = [ '.' ]*, CSS-wide keywords reserved: +// https://drafts.csswg.org/css-cascade-5/#layer-names +// per https://drafts.csswg.org/css-syntax-3/#ident-token-diagram, minus escape sequences. +const NON_ASCII_IDENT = + '\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{10FFFF}'; +const IDENT_START = `[A-Za-z_${NON_ASCII_IDENT}]`; +const IDENT_CHAR = `[A-Za-z0-9_\\-${NON_ASCII_IDENT}]`; +const CSS_IDENT_RE = new RegExp(`^(?:--|-?${IDENT_START})${IDENT_CHAR}*$`, 'u'); +// https://drafts.csswg.org/css-cascade-5/#defaulting-keywords +const CSS_WIDE_KEYWORDS = new Set(['initial', 'inherit', 'unset', 'revert', 'revert-layer', 'revert-rule']); + +export function isValidCssLayerName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.split('.').every(segment => CSS_IDENT_RE.test(segment) && !CSS_WIDE_KEYWORDS.has(segment.toLowerCase())) + ); +} + +export function sanitizeCssLayerName(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if (isValidCssLayerName(value)) { + return value; + } + logger.warnOnce( + `Clerk: ignoring invalid \`cssLayerName\` ${JSON.stringify(value)}. It must be a CSS layer name such as "clerk" or "app.components".`, + ); + return undefined; +}