Skip to content

Commit bbfde54

Browse files
committed
Validate arbitrary values in candidates
1 parent e8715d0 commit bbfde54

File tree

2 files changed

+115
-0
lines changed

2 files changed

+115
-0
lines changed

packages/tailwindcss/src/candidate.ts

Lines changed: 22 additions & 0 deletions
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
Lines changed: 93 additions & 0 deletions
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)