From 9ded4a23de06fb7e8cdc34a3bdf9318e7e8d2bbc Mon Sep 17 00:00:00 2001
From: Adam Wathan
Date: Wed, 25 Feb 2026 10:16:09 -0500
Subject: [PATCH 1/2] Guard object lookups against inherited prototype
properties (#19725)
When user-controlled candidate values like "constructor" are used as
keys to look up values in plain objects (staticValues, plugin values,
modifiers, config), they can match inherited Object.prototype properties
instead of returning undefined. This caused crashes like "V.map is not
a function" when scanning source files containing strings like
"row-constructor".
Use Object.hasOwn() checks before all user-keyed object lookups in:
- utilities.ts (staticValues lookup)
- plugin-api.ts (values, modifiers, and variant values lookups)
- plugin-functions.ts (get() config traversal function)
Fixes #19721
https://claude.ai/code/session_011CYSGw3DLh2Z8xnuyoaCgC
---------
Co-authored-by: Claude
Co-authored-by: Robin Malfait
---
CHANGELOG.md | 4 ++
.../tailwindcss/src/compat/plugin-api.test.ts | 44 +++++++++++++++++++
packages/tailwindcss/src/compat/plugin-api.ts | 22 +++++++---
.../src/compat/plugin-functions.ts | 11 ++---
packages/tailwindcss/src/utilities.test.ts | 7 +++
packages/tailwindcss/src/utilities.ts | 2 +
6 files changed, 78 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2359f1812b0..96297b4d449d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
+### Fixed
+
+- Guard object lookups against inherited prototype properties ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725))
+
## [4.2.1] - 2026-02-23
### Fixed
diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts
index 4e35afa5a84e..cc410995b442 100644
--- a/packages/tailwindcss/src/compat/plugin-api.test.ts
+++ b/packages/tailwindcss/src/compat/plugin-api.test.ts
@@ -4592,4 +4592,48 @@ describe('config()', () => {
expect(fn).toHaveBeenCalledWith('defaultvalue')
})
+
+ // https://github.com/tailwindlabs/tailwindcss/issues/19721
+ test('matchUtilities does not match Object.prototype properties as values', async ({
+ expect,
+ }) => {
+ let input = css`
+ @tailwind utilities;
+ @plugin "my-plugin";
+ `
+
+ let compiler = await compile(input, {
+ loadModule: async (id, base) => {
+ return {
+ path: '',
+ base,
+ module: plugin(function ({ matchUtilities }) {
+ matchUtilities(
+ {
+ test: (value) => ({ '--test': value }),
+ },
+ {
+ values: {
+ foo: 'bar',
+ },
+ },
+ )
+ }),
+ }
+ },
+ })
+
+ // These should not crash or produce output
+ expect(
+ optimizeCss(
+ compiler.build([
+ 'test-constructor',
+ 'test-hasOwnProperty',
+ 'test-toString',
+ 'test-valueOf',
+ 'test-__proto__',
+ ]),
+ ).trim(),
+ ).toEqual('')
+ })
})
diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts
index 5954040017d2..af6e98d770ff 100644
--- a/packages/tailwindcss/src/compat/plugin-api.ts
+++ b/packages/tailwindcss/src/compat/plugin-api.ts
@@ -202,6 +202,9 @@ export function buildPluginApi({
ruleNodes.nodes,
)
} else if (variant.value.kind === 'named' && options?.values) {
+ if (!Object.hasOwn(options.values, variant.value.value)) {
+ return null
+ }
let defaultValue = options.values[variant.value.value]
if (typeof defaultValue !== 'string') {
return null
@@ -223,8 +226,14 @@ export function buildPluginApi({
let aValueKey = a.value ? a.value.value : 'DEFAULT'
let zValueKey = z.value ? z.value.value : 'DEFAULT'
- let aValue = options?.values?.[aValueKey] ?? aValueKey
- let zValue = options?.values?.[zValueKey] ?? zValueKey
+ let aValue =
+ (options?.values && Object.hasOwn(options.values, aValueKey)
+ ? options.values[aValueKey]
+ : undefined) ?? aValueKey
+ let zValue =
+ (options?.values && Object.hasOwn(options.values, zValueKey)
+ ? options.values[zValueKey]
+ : undefined) ?? zValueKey
if (options && typeof options.sort === 'function') {
return options.sort(
@@ -406,10 +415,13 @@ export function buildPluginApi({
value = values.DEFAULT ?? null
} else if (candidate.value.kind === 'arbitrary') {
value = candidate.value.value
- } else if (candidate.value.fraction && values[candidate.value.fraction]) {
+ } else if (
+ candidate.value.fraction &&
+ Object.hasOwn(values, candidate.value.fraction)
+ ) {
value = values[candidate.value.fraction]
ignoreModifier = true
- } else if (values[candidate.value.value]) {
+ } else if (Object.hasOwn(values, candidate.value.value)) {
value = values[candidate.value.value]
} else if (values.__BARE_VALUE__) {
value = values.__BARE_VALUE__(candidate.value) ?? null
@@ -430,7 +442,7 @@ export function buildPluginApi({
modifier = null
} else if (modifiers === 'any' || candidate.modifier.kind === 'arbitrary') {
modifier = candidate.modifier.value
- } else if (modifiers?.[candidate.modifier.value]) {
+ } else if (modifiers && Object.hasOwn(modifiers, candidate.modifier.value)) {
modifier = modifiers[candidate.modifier.value]
} else if (isColor && !Number.isNaN(Number(candidate.modifier.value))) {
modifier = `${candidate.modifier.value}%`
diff --git a/packages/tailwindcss/src/compat/plugin-functions.ts b/packages/tailwindcss/src/compat/plugin-functions.ts
index f311ad1e8c43..40b8c93e89f1 100644
--- a/packages/tailwindcss/src/compat/plugin-functions.ts
+++ b/packages/tailwindcss/src/compat/plugin-functions.ts
@@ -223,8 +223,10 @@ function get(obj: any, path: string[]) {
for (let i = 0; i < path.length; ++i) {
let key = path[i]
- // The key does not exist so concatenate it with the next key
- if (obj?.[key] === undefined) {
+ // The key does not exist so concatenate it with the next key.
+ // We use Object.hasOwn to avoid matching inherited prototype properties
+ // (e.g. "constructor", "toString") when traversing config objects.
+ if (obj === null || obj === undefined || typeof obj !== 'object' || !Object.hasOwn(obj, key)) {
if (path[i + 1] === undefined) {
return undefined
}
@@ -233,11 +235,6 @@ function get(obj: any, path: string[]) {
continue
}
- // We never want to index into strings
- if (typeof obj === 'string') {
- return undefined
- }
-
obj = obj[key]
}
diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts
index 360acd9e29a6..e562cd3be15a 100644
--- a/packages/tailwindcss/src/utilities.test.ts
+++ b/packages/tailwindcss/src/utilities.test.ts
@@ -1646,6 +1646,13 @@ test('row', async () => {
'row-span-full/foo',
'row-[span_123/span_123]/foo',
'row-span-[var(--my-variable)]/foo',
+
+ // Candidates matching Object.prototype properties should not crash or
+ // produce output (see: https://github.com/tailwindlabs/tailwindcss/issues/19721)
+ 'row-constructor',
+ 'row-hasOwnProperty',
+ 'row-toString',
+ 'row-valueOf',
]),
).toEqual('')
diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts
index aa2187887405..62e4510b7309 100644
--- a/packages/tailwindcss/src/utilities.ts
+++ b/packages/tailwindcss/src/utilities.ts
@@ -391,6 +391,8 @@ export function createUtilities(theme: Theme) {
* user's theme.
*/
function functionalUtility(classRoot: string, desc: UtilityDescription) {
+ if (desc.staticValues) desc.staticValues = Object.assign(Object.create(null), desc.staticValues)
+
function handleFunctionalUtility({ negative }: { negative: boolean }) {
return (candidate: Extract) => {
let value: string | null = null
From bf2e2fe08aaf001e7a5dc0ba9872e95c2dbb2d64 Mon Sep 17 00:00:00 2001
From: Robin Malfait
Date: Thu, 26 Feb 2026 12:23:50 +0100
Subject: [PATCH 2/2] Extract classes from interpolated expressions in Ruby
(#19730)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR ensures that interpolated expressions in Ruby syntax are
correctly extracted.
The issue was that we ignore comments in Ruby syntax (which start with
`#`). We already made an exception for locals (`<%# locals: … %>`), but
we also need to handle interpolated expressions (`#{ … }`) in the same
way because they are not comments.
Fixes: #19728
## Test plan
1. Existing tests pass
2. Added a regression test for this scenario
3. Tested using the extractor on the given code snippet:
Notice that the `w-100` gets extracted now.
---
.../oxide/src/extractor/pre_processors/ruby.rs | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/crates/oxide/src/extractor/pre_processors/ruby.rs b/crates/oxide/src/extractor/pre_processors/ruby.rs
index 1f2221414ff4..5a3a0fabbfc7 100644
--- a/crates/oxide/src/extractor/pre_processors/ruby.rs
+++ b/crates/oxide/src/extractor/pre_processors/ruby.rs
@@ -124,7 +124,9 @@ impl PreProcessor for Ruby {
// Except for strict locals, these are defined in a `<%# locals: … %>`. Checking if
// the comment is preceded by a `%` should be enough without having to perform more
// parsing logic. Worst case we _do_ scan a few comments.
- b'#' if !matches!(cursor.prev(), b'%') => {
+ //
+ // We also want to skip interpolation syntax, which look like `#{…}`.
+ b'#' if !matches!(cursor.prev(), b'%') && !matches!(cursor.next(), b'{') => {
result[cursor.pos] = b' ';
cursor.advance();
@@ -388,6 +390,20 @@ mod tests {
Ruby::test_extract_contains(input, vec!["z-1", "z-2", "z-3"]);
}
+ // https://github.com/tailwindlabs/tailwindcss/issues/19728
+ #[test]
+ fn test_interpolated_expressions() {
+ let input = r#"
+ def width_class(width = nil)
+ <<~STYLE_CLASS
+ #{width || 'w-100'}
+ STYLE_CLASS
+ end
+ "#;
+
+ Ruby::test_extract_contains(input, vec!["w-100"]);
+ }
+
// https://github.com/tailwindlabs/tailwindcss/issues/19239
#[test]
fn test_skip_comments() {