Skip to content

Improve arbitrary value validation when parsing candidates #17361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 28, 2025
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions packages/tailwindcss/src/candidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
})
22 changes: 22 additions & 0 deletions packages/tailwindcss/src/candidate.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -552,6 +565,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia

let selector = decodeArbitraryValue(variant.slice(1, -1))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kinda bothers me that this var is called selector and not arbitraryValue like in the 5 other places that look identical haha

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, in this case it is a selector because of the variant :P

[.foo]:…


// 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions packages/tailwindcss/src/utils/is-valid-arbitrary.ts
Original file line number Diff line number Diff line change
@@ -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
}