Improve arbitrary value validation when parsing candidates (#17361)

Fixes #17357

This affects the CDN and Play

Now candidates like these no longer parse and emit CSS:
- `[--foo:1rem;--bar:2rem]`
- `[&{color:red}]:flex`
- `data-[a]{color:red}foo[a]:flex`
This commit is contained in:
Jordan Pittman 2025-03-28 12:38:57 -04:00 committed by GitHub
parent 1b6230f54f
commit ab868c6098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 0 deletions

View File

@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `@tailwindcss/cli` considers ignore rules in `--watch` mode ([#17255](https://github.com/tailwindlabs/tailwindcss/pull/17255))
- Fix negated `content` rules in legacy JavaScript configuration ([#17255](https://github.com/tailwindlabs/tailwindcss/pull/17255))
- Extract special `@("@")md:…` syntax in Razor files ([#17427](https://github.com/tailwindlabs/tailwindcss/pull/17427))
- 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

View File

@ -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([])
})

View File

@ -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

View File

@ -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
}