From f5f60975bbc39c4987b1d09694917786c129125c Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 14 Sep 2026 12:54:28 +0100 Subject: [PATCH 1/2] fix(ui): validate cssLayerName before wrapping styles in @layer Reject values that are not a CSS layer name so an attacker-controlled appearance config cannot break out of the @layer rule (SEC-392). Co-Authored-By: Claude Fable 5.1 --- .changeset/validate-css-layer-name.md | 5 +++ .../__tests__/createEmotionCache.test.ts | 36 +++++++++++++++ .../ui/src/styledSystem/createEmotionCache.ts | 5 ++- .../src/utils/__tests__/cssLayerName.test.ts | 45 +++++++++++++++++++ packages/ui/src/utils/cssLayerName.ts | 21 +++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 .changeset/validate-css-layer-name.md create mode 100644 packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts create mode 100644 packages/ui/src/utils/__tests__/cssLayerName.test.ts create mode 100644 packages/ui/src/utils/cssLayerName.ts 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..a21c9c0696b --- /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('wraps insertions in the configured layer', () => { + expect(insertAndRead('app.clerk', 'color:red;')).toContain('@layer app.clerk'); + }); + + 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..c62faee8949 --- /dev/null +++ b/packages/ui/src/utils/__tests__/cssLayerName.test.ts @@ -0,0 +1,45 @@ +import { logger } from '@clerk/shared/logger'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isValidCssLayerName, sanitizeCssLayerName } from '../cssLayerName'; + +describe('cssLayerName', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(['components', 'clerk', 'app.components', 'theme_layer-1', '-vendor', '_x', 'a.b.c'])('accepts %s', value => { + expect(isValidCssLayerName(value)).toBe(true); + expect(sanitizeCssLayerName(value)).toBe(value); + }); + + it.each([ + 'x} body { color: red } /*', + 'x{}', + 'components;@import url(https://attacker.example/x)', + 'a b', + 'a.', + '.a', + '1abc', + 'a..b', + '', + ' clerk', + 'clerk\n', + ])('rejects %j', 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..eb095c09b62 --- /dev/null +++ b/packages/ui/src/utils/cssLayerName.ts @@ -0,0 +1,21 @@ +import { logger } from '@clerk/shared/logger'; + +// ASCII-only so the value can never carry `{`, `}`, `;`, `<` or whitespace into `@layer`. +const CSS_LAYER_NAME_RE = /^-?[A-Za-z_][\w-]*(?:\.-?[A-Za-z_][\w-]*)*$/; + +export function isValidCssLayerName(value: unknown): value is string { + return typeof value === 'string' && CSS_LAYER_NAME_RE.test(value); +} + +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; +} From b091ffbc2fb5e15d5198f243f0187f0e30421080 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 14 Sep 2026 13:21:32 +0100 Subject: [PATCH 2/2] fix(ui): match the CSS ident grammar when validating cssLayerName Accept the `--` prefix and the spec's non-ASCII ident code points so previously working layer names keep being wrapped, and reject CSS-wide keywords, which make the @layer rule invalid at parse time. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/createEmotionCache.test.ts | 4 +- .../src/utils/__tests__/cssLayerName.test.ts | 80 ++++++++++++++++++- packages/ui/src/utils/cssLayerName.ts | 17 +++- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts b/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts index a21c9c0696b..c78d064a58b 100644 --- a/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts +++ b/packages/ui/src/styledSystem/__tests__/createEmotionCache.test.ts @@ -15,8 +15,8 @@ describe('createEmotionCache', () => { vi.restoreAllMocks(); }); - it('wraps insertions in the configured layer', () => { - expect(insertAndRead('app.clerk', 'color:red;')).toContain('@layer app.clerk'); + 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', () => { diff --git a/packages/ui/src/utils/__tests__/cssLayerName.test.ts b/packages/ui/src/utils/__tests__/cssLayerName.test.ts index c62faee8949..cdf1b3418d8 100644 --- a/packages/ui/src/utils/__tests__/cssLayerName.test.ts +++ b/packages/ui/src/utils/__tests__/cssLayerName.test.ts @@ -3,14 +3,73 @@ 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', '_x', 'a.b.c'])('accepts %s', value => { + 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([ @@ -21,14 +80,33 @@ describe('cssLayerName', () => { '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(); diff --git a/packages/ui/src/utils/cssLayerName.ts b/packages/ui/src/utils/cssLayerName.ts index eb095c09b62..1b86d5fc9f2 100644 --- a/packages/ui/src/utils/cssLayerName.ts +++ b/packages/ui/src/utils/cssLayerName.ts @@ -1,10 +1,21 @@ import { logger } from '@clerk/shared/logger'; -// ASCII-only so the value can never carry `{`, `}`, `;`, `<` or whitespace into `@layer`. -const CSS_LAYER_NAME_RE = /^-?[A-Za-z_][\w-]*(?:\.-?[A-Za-z_][\w-]*)*$/; +// = [ '.' ]*, 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' && CSS_LAYER_NAME_RE.test(value); + 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 {