From 98338ce51531cf81e54bb7860c0c8b5b19dcf667 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 11 Nov 2026 13:17:49 -0800 Subject: [PATCH 1/2] fix: reject incomplete attribute operators and missing values --- CHANGELOG.md | 4 + src/__tests__/attributeValues.mjs | 153 ++++++++++++++++++++++++++++++ src/__tests__/attributes.mjs | 16 ++-- src/parser.js | 16 ++++ 4 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/attributeValues.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc0a58..4a72c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog of `postcss-selector-parser` +## Unreleased + +- fix: reject attribute operators without a value and incomplete trailing matchers (`*`, `$`, `^`, `~` or `|`) instead of silently dropping them; quoted empty values and attribute namespaces remain supported ([#352](https://github.com/postcss/postcss-selector-parser/pull/352)) + ## 7.2.0 - 2026-11-08 - feat: add the opt-in `maxInputLength` option, which rejects a selector longer than the limit before parsing it diff --git a/src/__tests__/attributeValues.mjs b/src/__tests__/attributeValues.mjs new file mode 100644 index 0000000..8ba03d9 --- /dev/null +++ b/src/__tests__/attributeValues.mjs @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "./util/runner.mjs"; +import parser from "../../dist/index.js"; +import postcss from "postcss"; + +const message = "Expected an attribute value."; +const operators = ["=", "~=", "|=", "^=", "$=", "*="]; + +for (const operator of operators) { + test(`missing attribute value after ${operator}`, (t) => { + const input = `[href${operator}]`; + t.throws(() => parser().processSync(input), { message }); + t.throws(() => parser().astSync(input), { message }); + }); + + test(`quoted empty attribute value after ${operator}`, (t) => { + for (const quote of ['"', "'"]) { + const input = `[href${operator}${quote}${quote} i]`; + const attribute = parser().astSync(input).first.first; + t.is(attribute.value, ""); + t.is(attribute.operator, operator); + t.is(attribute.insensitive, true); + t.is(parser().processSync(input), input); + } + }); + + test(`unquoted attribute value after ${operator}`, (t) => { + const input = `[href${operator}example]`; + t.is(parser().astSync(input).first.first.value, "example"); + t.is(parser().processSync(input), input); + }); +} + +for (const input of [ + "[href= ]", + "[href=\n\t ]", + "[href=/**/]", + "[href= /* value */ ]", + "[ns|href=]", + "[*|href=]", + "[|href=]", + "[ ns | href = ]", +]) { + test(`missing attribute value in ${JSON.stringify(input)}`, (t) => { + t.throws(() => parser().processSync(input), { message }); + t.throws(() => parser().processSync(input, { lossless: false }), { message }); + }); +} + +test("missing attribute value rejects asynchronous processing", async () => { + await assert.rejects(parser().process("[href=]"), { message }); + await assert.rejects(parser().ast("[href=]"), { message }); +}); + +test("missing attribute value reports the closing bracket location", (t) => { + const rule = postcss.parse("a,\n [href=] {}").first; + const error = t.throws(() => parser().processSync(rule)); + t.is(error.name, "CssSyntaxError"); + t.is(error.reason, message); + t.is(error.line, 2); + t.is(error.column, 9); +}); + +test("presence and namespaced quoted-empty attributes remain valid", (t) => { + for (const input of [ + "[href]", + "[ns|href]", + '[ns|href=""]', + "[*|href='']", + '[|href="" i]', + '[ ns | href = "" ]', + '[href=/**/""/* value */]', + ]) { + t.is(parser().processSync(input), input); + } + t.is(parser().processSync('[ ns | href = "" ]', { lossless: false }), '[ns|href=""]'); +}); + +const incompleteOperators = ["$", "*", "~", "|", "^"]; + +for (const operator of incompleteOperators) { + for (const input of [ + `[href${operator}]`, + `[href${operator} ]`, + `[href${operator}/**/]`, + `[href /* name */ ${operator} /* operator */ ]`, + `[ns|href${operator}]`, + `[*|href${operator}]`, + `[|href${operator}]`, + ]) { + test(`incomplete attribute operator in ${JSON.stringify(input)}`, (t) => { + for (const lossless of [true, false]) { + const error = t.throws(() => parser().processSync(input, { lossless })); + t.is(error.name, "Error"); + t.regex(error.message, /^(Expected|Unexpected) /); + t.throws(() => parser().astSync(input, { lossless }), { + message: error.message, + }); + } + }); + } +} + +test("incomplete attribute operators reject asynchronous processing", async () => { + for (const operator of incompleteOperators) { + const input = `[href${operator}]`; + await assert.rejects(parser().process(input), { name: "Error" }); + await assert.rejects(parser().ast(input), { name: "Error" }); + } +}); + +test("incomplete attribute operator reports the operator location", (t) => { + const rule = postcss.parse("a,\n [href$] {}").first; + const error = t.throws(() => parser().processSync(rule)); + t.is(error.name, "CssSyntaxError"); + t.regex(error.reason, /^(Expected|Unexpected) /); + t.is(error.line, 2); + t.is(error.column, 8); +}); + +test("escaped matcher characters remain part of attribute names", (t) => { + for (const character of incompleteOperators) { + const input = `[href\\${character}]`; + const attribute = parser().astSync(input).first.first; + t.is(attribute.attribute, `href${character}`); + t.is(attribute.operator, undefined); + t.is(parser().processSync(input), input); + t.is(parser().processSync(input, { lossless: false }), input); + } +}); + +test("interpolated attribute names and operator-like values remain valid", (t) => { + for (const input of [ + "[#{$attr}]", + "[@{attr}]", + "[#{$attr}=#{$value}]", + "[lang=#{$locale}]", + "[name=foo$]", + '[name="$*~|^"]', + ]) { + t.is(parser().processSync(input), input); + t.is(parser().processSync(input, { lossless: false }), input); + } +}); + +test("missing attribute names retain their syntax errors", (t) => { + for (const input of ["[]", "[ ]", "[ * ]", "[ns|*]", "[ns|* ]", "[ns|$]", "[ns|^]"]) { + const error = t.throws(() => parser().processSync(input), { + message: "Expected an attribute.", + }); + t.is(error.name, "Error"); + } +}); diff --git a/src/__tests__/attributes.mjs b/src/__tests__/attributes.mjs index 95081c4..ba9f89a 100644 --- a/src/__tests__/attributes.mjs +++ b/src/__tests__/attributes.mjs @@ -677,17 +677,13 @@ testDeprecation("set Attribute#quoteMark", "[data-foo=bar]", (t, tree) => { // A trailing `* $ ^ ~ |` immediately before the closing bracket used to look // ahead past the end of the token stream and throw a raw TypeError. None of -// them is a valid operator without a following `=`, so the token is dropped, -// which matches how `[href=]` already drops an operator that has no value. -// Asserted with `ava` rather than `test` because the output is intentionally -// not a round-trip, and pinning it with `test` would assert the lossy form is -// correct. +// them is a valid operator without a following `=`, so report a syntax error +// rather than dropping the token and silently changing the selector. for (const trailing of ["*", "$", "^", "~", "|"]) { ava(`attribute name followed by a trailing ${trailing}`, (t) => { - const tree = parser().astSync(`[href${trailing}]`); - const attr = tree.nodes[0].nodes[0]; - t.deepEqual(attr.attribute, "href"); - t.deepEqual(attr.operator, undefined); - t.deepEqual(tree.toString(), "[href]"); + const error = t.throws(() => parser().astSync(`[href${trailing}]`), { + message: "Expected an attribute operator.", + }); + t.is(error.name, "Error"); }); } diff --git a/src/parser.js b/src/parser.js index 24d35be..aab61ef 100644 --- a/src/parser.js +++ b/src/parser.js @@ -285,6 +285,9 @@ export default class Parser { } break; case tokens.asterisk: + if (node.attribute && !node.operator && !(next && next[TOKEN.TYPE] === tokens.equals)) { + return this.expected("attribute operator", token[TOKEN.START_POS]); + } if (next && next[TOKEN.TYPE] === tokens.equals) { node.operator = content; lastAdded = "operator"; @@ -342,10 +345,20 @@ export default class Parser { if (next && next[TOKEN.TYPE] === tokens.equals) { node.operator = content; lastAdded = "operator"; + } else if (node.attribute && !node.operator) { + return this.expected("attribute operator", token[TOKEN.START_POS]); } spaceAfterMeaningfulToken = false; break; case tokens.combinator: + if ( + node.attribute && + !node.operator && + (content === "~" || content === "|") && + !(next && next[TOKEN.TYPE] === tokens.equals) + ) { + return this.expected("attribute operator", token[TOKEN.START_POS]); + } if (content === "~" && next && next[TOKEN.TYPE] === tokens.equals) { node.operator = content; lastAdded = "operator"; @@ -516,6 +529,9 @@ export default class Parser { // needs to look. return this.expected("attribute", startingToken[TOKEN.START_POS]); } + if (node.operator && node.value === undefined) { + return this.expected("attribute value", this.currToken[TOKEN.START_POS]); + } unescapeProp(node, "attribute"); unescapeProp(node, "namespace"); this.newNode(new Attribute(node)); From 32c063ac4e1ecbcf6105c6e3fade762d218425b9 Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 12 Nov 2026 17:39:45 +0100 Subject: [PATCH 2/2] chore: drop the changelog entry, written at release time --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a72c76..0fc0a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog of `postcss-selector-parser` -## Unreleased - -- fix: reject attribute operators without a value and incomplete trailing matchers (`*`, `$`, `^`, `~` or `|`) instead of silently dropping them; quoted empty values and attribute namespaces remain supported ([#352](https://github.com/postcss/postcss-selector-parser/pull/352)) - ## 7.2.0 - 2026-11-08 - feat: add the opt-in `maxInputLength` option, which rejects a selector longer than the limit before parsing it