mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
1b6230f54f
commit
ab868c6098
@ -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
|
||||
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
93
packages/tailwindcss/src/utils/is-valid-arbitrary.ts
Normal file
93
packages/tailwindcss/src/utils/is-valid-arbitrary.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user