Skip to content

Commit ab868c6

Browse files
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`
1 parent 1b6230f commit ab868c6

File tree

4 files changed

+144
-0
lines changed

4 files changed

+144
-0
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- `@tailwindcss/cli` considers ignore rules in `--watch` mode ([#17255](https://github.com/tailwindlabs/tailwindcss/pull/17255))
2929
- Fix negated `content` rules in legacy JavaScript configuration ([#17255](https://github.com/tailwindlabs/tailwindcss/pull/17255))
3030
- Extract special `@("@")md:…` syntax in Razor files ([#17427](https://github.com/tailwindlabs/tailwindcss/pull/17427))
31+
- Disallow arbitrary values with top-level braces and semicolons as well as unbalanced parentheses and brackets ([#17361](https://github.com/tailwindlabs/tailwindcss/pull/17361))
3132

3233
### Changed
3334

packages/tailwindcss/src/candidate.test.ts

+28
Original file line numberDiff line numberDiff line change
@@ -1790,3 +1790,31 @@ it.each([
17901790

17911791
expect(run(rawCandidate, { utilities, variants })).toEqual([])
17921792
})
1793+
1794+
it.each([
1795+
// Arbitrary properties with `;` or `}`
1796+
'[color:red;color:blue]',
1797+
'[color:red}html{color:blue]',
1798+
1799+
// Arbitrary values that end the declaration
1800+
'bg-[red;color:blue]',
1801+
1802+
// Arbitrary values that end the block
1803+
'bg-[red}html{color:blue]',
1804+
1805+
// Arbitrary variants that end the block
1806+
'[&{color:red}]:flex',
1807+
1808+
// Arbitrary variant values that end the block
1809+
'data-[a]{color:red}foo[a]:flex',
1810+
])('should not parse invalid arbitrary values: %s', (rawCandidate) => {
1811+
let utilities = new Utilities()
1812+
utilities.static('flex', () => [])
1813+
utilities.functional('bg', () => [])
1814+
1815+
let variants = new Variants()
1816+
variants.functional('data', () => {})
1817+
variants.compound('group', Compounds.StyleRules, () => {})
1818+
1819+
expect(run(rawCandidate, { utilities, variants })).toEqual([])
1820+
})

packages/tailwindcss/src/candidate.ts

+22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DesignSystem } from './design-system'
22
import { decodeArbitraryValue } from './utils/decode-arbitrary-value'
3+
import { isValidArbitrary } from './utils/is-valid-arbitrary'
34
import { segment } from './utils/segment'
45

56
const COLON = 0x3a
@@ -326,6 +327,9 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
326327
let property = baseWithoutModifier.slice(0, idx)
327328
let value = decodeArbitraryValue(baseWithoutModifier.slice(idx + 1))
328329

330+
// Values can't contain `;` or `}` characters at the top-level.
331+
if (!isValidArbitrary(value)) return
332+
329333
yield {
330334
kind: 'arbitrary',
331335
property,
@@ -443,6 +447,9 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
443447

444448
let arbitraryValue = decodeArbitraryValue(value.slice(startArbitraryIdx + 1, -1))
445449

450+
// Values can't contain `;` or `}` characters at the top-level.
451+
if (!isValidArbitrary(arbitraryValue)) continue
452+
446453
// Extract an explicit typehint if present, e.g. `bg-[color:var(--my-var)])`
447454
let typehint = ''
448455
for (let i = 0; i < arbitraryValue.length; i++) {
@@ -500,6 +507,9 @@ function parseModifier(modifier: string): CandidateModifier | null {
500507
if (modifier[0] === '[' && modifier[modifier.length - 1] === ']') {
501508
let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1))
502509

510+
// Values can't contain `;` or `}` characters at the top-level.
511+
if (!isValidArbitrary(arbitraryValue)) return null
512+
503513
// Empty arbitrary values are invalid. E.g.: `data-[]:`
504514
// ^^
505515
if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null
@@ -513,6 +523,9 @@ function parseModifier(modifier: string): CandidateModifier | null {
513523
if (modifier[0] === '(' && modifier[modifier.length - 1] === ')') {
514524
let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1))
515525

526+
// Values can't contain `;` or `}` characters at the top-level.
527+
if (!isValidArbitrary(arbitraryValue)) return null
528+
516529
// Empty arbitrary values are invalid. E.g.: `data-():`
517530
// ^^
518531
if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null
@@ -552,6 +565,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
552565

553566
let selector = decodeArbitraryValue(variant.slice(1, -1))
554567

568+
// Values can't contain `;` or `}` characters at the top-level.
569+
if (!isValidArbitrary(selector)) return null
570+
555571
// Empty arbitrary values are invalid. E.g.: `[]:`
556572
// ^^
557573
if (selector.length === 0 || selector.trim().length === 0) return null
@@ -629,6 +645,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
629645

630646
let arbitraryValue = decodeArbitraryValue(value.slice(1, -1))
631647

648+
// Values can't contain `;` or `}` characters at the top-level.
649+
if (!isValidArbitrary(arbitraryValue)) return null
650+
632651
// Empty arbitrary values are invalid. E.g.: `data-[]:`
633652
// ^^
634653
if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null
@@ -650,6 +669,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
650669

651670
let arbitraryValue = decodeArbitraryValue(value.slice(1, -1))
652671

672+
// Values can't contain `;` or `}` characters at the top-level.
673+
if (!isValidArbitrary(arbitraryValue)) return null
674+
653675
// Empty arbitrary values are invalid. E.g.: `data-():`
654676
// ^^
655677
if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const BACKSLASH = 0x5c
2+
const OPEN_CURLY = 0x7b
3+
const CLOSE_CURLY = 0x7d
4+
const OPEN_PAREN = 0x28
5+
const CLOSE_PAREN = 0x29
6+
const OPEN_BRACKET = 0x5b
7+
const CLOSE_BRACKET = 0x5d
8+
const DOUBLE_QUOTE = 0x22
9+
const SINGLE_QUOTE = 0x27
10+
const SEMICOLON = 0x3b
11+
12+
// This is a shared buffer that is used to keep track of the current nesting level
13+
// of parens, brackets, and braces. It is used to determine if a character is at
14+
// the top-level of a string. This is a performance optimization to avoid memory
15+
// allocations on every call to `segment`.
16+
const closingBracketStack = new Uint8Array(256)
17+
18+
/**
19+
* Determine if a given string might be a valid arbitrary value.
20+
*
21+
* Unbalanced parens, brackets, and braces are not allowed. Additionally, a
22+
* top-level `;` is not allowed.
23+
*
24+
* This function is very similar to `segment` but `segment` cannot be used
25+
* because we'd need to split on a bracket stack character.
26+
*/
27+
export function isValidArbitrary(input: string) {
28+
// SAFETY: We can use an index into a shared buffer because this function is
29+
// synchronous, non-recursive, and runs in a single-threaded environment.
30+
let stackPos = 0
31+
let len = input.length
32+
33+
for (let idx = 0; idx < len; idx++) {
34+
let char = input.charCodeAt(idx)
35+
36+
switch (char) {
37+
case BACKSLASH:
38+
// The next character is escaped, so we skip it.
39+
idx += 1
40+
break
41+
// Strings should be handled as-is until the end of the string. No need to
42+
// worry about balancing parens, brackets, or curlies inside a string.
43+
case SINGLE_QUOTE:
44+
case DOUBLE_QUOTE:
45+
// Ensure we don't go out of bounds.
46+
while (++idx < len) {
47+
let nextChar = input.charCodeAt(idx)
48+
49+
// The next character is escaped, so we skip it.
50+
if (nextChar === BACKSLASH) {
51+
idx += 1
52+
continue
53+
}
54+
55+
if (nextChar === char) {
56+
break
57+
}
58+
}
59+
break
60+
case OPEN_PAREN:
61+
closingBracketStack[stackPos] = CLOSE_PAREN
62+
stackPos++
63+
break
64+
case OPEN_BRACKET:
65+
closingBracketStack[stackPos] = CLOSE_BRACKET
66+
stackPos++
67+
break
68+
case OPEN_CURLY:
69+
// NOTE: We intentionally do not consider `{` to move the stack pointer
70+
// because a candidate like `[&{color:red}]:flex` should not be valid.
71+
break
72+
case CLOSE_BRACKET:
73+
case CLOSE_CURLY:
74+
case CLOSE_PAREN:
75+
if (stackPos === 0) return false
76+
77+
if (stackPos > 0 && char === closingBracketStack[stackPos - 1]) {
78+
// SAFETY: The buffer does not need to be mutated because the stack is
79+
// only ever read from or written to its current position. Its current
80+
// position is only ever incremented after writing to it. Meaning that
81+
// the buffer can be dirty for the next use and still be correct since
82+
// reading/writing always starts at position `0`.
83+
stackPos--
84+
}
85+
break
86+
case SEMICOLON:
87+
if (stackPos === 0) return false
88+
break
89+
}
90+
}
91+
92+
return true
93+
}

0 commit comments

Comments
 (0)