From 6b1510620fd21e99ad3d8cd03ba690955886723e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 24 Mar 2025 14:08:34 -0400 Subject: [PATCH 1/3] Validate arbitrary values in candidates --- packages/tailwindcss/src/candidate.ts | 22 +++++ .../src/utils/is-valid-arbitrary.ts | 93 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 packages/tailwindcss/src/utils/is-valid-arbitrary.ts diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 8238dfc2f0e9..406a16aad1b5 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -1,5 +1,6 @@ import type { DesignSystem } from './design-system' import { decodeArbitraryValue } from './utils/decode-arbitrary-value' +import { isValidArbitrary } from './utils/is-valid-arbitrary' import { segment } from './utils/segment' const COLON = 0x3a @@ -326,6 +327,9 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter let property = baseWithoutModifier.slice(0, idx) let value = decodeArbitraryValue(baseWithoutModifier.slice(idx + 1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(value)) return + yield { kind: 'arbitrary', property, @@ -443,6 +447,9 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter let arbitraryValue = decodeArbitraryValue(value.slice(startArbitraryIdx + 1, -1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(arbitraryValue)) continue + // Extract an explicit typehint if present, e.g. `bg-[color:var(--my-var)])` let typehint = '' for (let i = 0; i < arbitraryValue.length; i++) { @@ -500,6 +507,9 @@ function parseModifier(modifier: string): CandidateModifier | null { if (modifier[0] === '[' && modifier[modifier.length - 1] === ']') { let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(arbitraryValue)) return null + // Empty arbitrary values are invalid. E.g.: `data-[]:` // ^^ if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null @@ -513,6 +523,9 @@ function parseModifier(modifier: string): CandidateModifier | null { if (modifier[0] === '(' && modifier[modifier.length - 1] === ')') { let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(arbitraryValue)) return null + // Empty arbitrary values are invalid. E.g.: `data-():` // ^^ if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null @@ -552,6 +565,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia let selector = decodeArbitraryValue(variant.slice(1, -1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(selector)) return null + // Empty arbitrary values are invalid. E.g.: `[]:` // ^^ if (selector.length === 0 || selector.trim().length === 0) return null @@ -629,6 +645,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia let arbitraryValue = decodeArbitraryValue(value.slice(1, -1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(arbitraryValue)) return null + // Empty arbitrary values are invalid. E.g.: `data-[]:` // ^^ if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null @@ -650,6 +669,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia let arbitraryValue = decodeArbitraryValue(value.slice(1, -1)) + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(arbitraryValue)) return null + // Empty arbitrary values are invalid. E.g.: `data-():` // ^^ if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null diff --git a/packages/tailwindcss/src/utils/is-valid-arbitrary.ts b/packages/tailwindcss/src/utils/is-valid-arbitrary.ts new file mode 100644 index 000000000000..2ee433e48f0b --- /dev/null +++ b/packages/tailwindcss/src/utils/is-valid-arbitrary.ts @@ -0,0 +1,93 @@ +const BACKSLASH = 0x5c +const OPEN_CURLY = 0x7b +const CLOSE_CURLY = 0x7d +const OPEN_PAREN = 0x28 +const CLOSE_PAREN = 0x29 +const OPEN_BRACKET = 0x5b +const CLOSE_BRACKET = 0x5d +const DOUBLE_QUOTE = 0x22 +const SINGLE_QUOTE = 0x27 +const SEMICOLON = 0x3b + +// This is a shared buffer that is used to keep track of the current nesting level +// of parens, brackets, and braces. It is used to determine if a character is at +// the top-level of a string. This is a performance optimization to avoid memory +// allocations on every call to `segment`. +const closingBracketStack = new Uint8Array(256) + +/** + * Determine if a given string might be a valid arbitrary value. + * + * Unbalanced parens, brackets, and braces are not allowed. Additionally, a + * top-level `;` is not allowed. + * + * This function is very similar to `segment` but `segment` cannot be used + * because we'd need to split on a bracket stack character. + */ +export function isValidArbitrary(input: string) { + // SAFETY: We can use an index into a shared buffer because this function is + // synchronous, non-recursive, and runs in a single-threaded environment. + let stackPos = 0 + let len = input.length + + for (let idx = 0; idx < len; idx++) { + let char = input.charCodeAt(idx) + + switch (char) { + case BACKSLASH: + // The next character is escaped, so we skip it. + idx += 1 + break + // Strings should be handled as-is until the end of the string. No need to + // worry about balancing parens, brackets, or curlies inside a string. + case SINGLE_QUOTE: + case DOUBLE_QUOTE: + // Ensure we don't go out of bounds. + while (++idx < len) { + let nextChar = input.charCodeAt(idx) + + // The next character is escaped, so we skip it. + if (nextChar === BACKSLASH) { + idx += 1 + continue + } + + if (nextChar === char) { + break + } + } + break + case OPEN_PAREN: + closingBracketStack[stackPos] = CLOSE_PAREN + stackPos++ + break + case OPEN_BRACKET: + closingBracketStack[stackPos] = CLOSE_BRACKET + stackPos++ + break + case OPEN_CURLY: + // NOTE: We intentionally do not consider `{` to move the stack pointer + // because a candidate like `[&{color:red}]:flex` should not be valid. + break + case CLOSE_BRACKET: + case CLOSE_CURLY: + case CLOSE_PAREN: + if (stackPos === 0) return false + + if (stackPos > 0 && char === closingBracketStack[stackPos - 1]) { + // SAFETY: The buffer does not need to be mutated because the stack is + // only ever read from or written to its current position. Its current + // position is only ever incremented after writing to it. Meaning that + // the buffer can be dirty for the next use and still be correct since + // reading/writing always starts at position `0`. + stackPos-- + } + break + case SEMICOLON: + if (stackPos === 0) return false + break + } + } + + return true +} From 5e8488791a85a02fa854bd25e202187c1449c145 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 24 Mar 2025 14:28:47 -0400 Subject: [PATCH 2/3] Add tests --- packages/tailwindcss/src/candidate.test.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 2892f0053c9e..61be4378afda 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -1790,3 +1790,31 @@ it.each([ expect(run(rawCandidate, { utilities, variants })).toEqual([]) }) + +it.each([ + // Arbitrary properties with `;` or `}` + '[color:red;color:blue]', + '[color:red}html{color:blue]', + + // Arbitrary values that end the declaration + 'bg-[red;color:blue]', + + // Arbitrary values that end the block + 'bg-[red}html{color:blue]', + + // Arbitrary variants that end the block + '[&{color:red}]:flex', + + // Arbitrary variant values that end the block + 'data-[a]{color:red}foo[a]:flex', +])('should not parse invalid arbitrary values: %s', (rawCandidate) => { + let utilities = new Utilities() + utilities.static('flex', () => []) + utilities.functional('bg', () => []) + + let variants = new Variants() + variants.functional('data', () => {}) + variants.compound('group', Compounds.StyleRules, () => {}) + + expect(run(rawCandidate, { utilities, variants })).toEqual([]) +}) From 6276ab3e0ff749b9ebf1617b56274d9c9b3e7ac8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 27 Mar 2025 11:26:48 -0400 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ee1d6115e0..29d24a7237bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix symlink issues when resolving `@source` directives ([#17391](https://github.com/tailwindlabs/tailwindcss/pull/17391)) +- Disallow arbitrary values with top-level braces and semicolons as well as unbalanced parentheses and brackets ([#17361](https://github.com/tailwindlabs/tailwindcss/pull/17361)) ### Changed