Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Create the release
if: steps.changelog.outputs.changelog_content != ''
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
name: ${{ github.ref_name }}
body: '${{ steps.changelog.outputs.changelog_content }}'
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This project adheres to [Semantic Versioning](https://semver.org/).

## 8.5.10

- Fixed XSS via unescaped `</style>` in non-bundler cases (by @TharVid).

## 8.5.9

- Speed up source map encoding paring in case of the error.
Expand Down
4 changes: 4 additions & 0 deletions lib/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ class Input {
}
}

getLineToIndex() {
return getLineToIndex(this)
}

mapResolve(file) {
if (/^\w+:\/\//.test(file)) {
return file
Expand Down
63 changes: 60 additions & 3 deletions lib/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class Parser {
this.current = this.root
this.spaces = ''
this.semicolon = false
this.lineToIndex = null
this.lastLine = 0

this.createTokenizer()
this.root.source = { input, start: { column: 1, line: 1, offset: 0 } }
Expand Down Expand Up @@ -356,10 +358,65 @@ class Parser {
// Helpers

getPosition(offset) {
let pos = this.input.fromOffset(offset)
let lineToIndex = this.lineToIndex
if (lineToIndex === null) {
lineToIndex = this.input.getLineToIndex()
this.lineToIndex = lineToIndex
}

let len = lineToIndex.length
let min = this.lastLine

// Fast path: check if offset is still on the same line or a nearby line
if (offset >= lineToIndex[min]) {
if (min + 1 >= len || offset < lineToIndex[min + 1]) {
// Same line as last time
return {
column: offset - lineToIndex[min] + 1,
line: min + 1,
offset
}
}
// Check next few lines (common for sequential token processing)
let check = min + 1
let limit = min + 5
if (limit >= len) limit = len - 1
while (check < limit && offset >= lineToIndex[check + 1]) {
check++
}
if (check < len && (check + 1 >= len || offset < lineToIndex[check + 1])) {
this.lastLine = check
return {
column: offset - lineToIndex[check] + 1,
line: check + 1,
offset
}
}
}

// Fallback: binary search with hint
let lo = offset >= lineToIndex[min] ? min : 0
let hi = len - 1
if (offset < lineToIndex[hi]) {
hi = hi - 1
let mid
while (lo < hi) {
mid = lo + ((hi - lo) >> 1)
if (offset < lineToIndex[mid]) {
hi = mid - 1
} else if (offset >= lineToIndex[mid + 1]) {
lo = mid + 1
} else {
lo = mid
break
}
}
}
min = lo
this.lastLine = min
return {
column: pos.col,
line: pos.line,
column: offset - lineToIndex[min] + 1,
line: min + 1,
offset
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ let Root = require('./root')

class Processor {
constructor(plugins = []) {
this.version = '8.5.9'
this.version = '8.5.10'
this.plugins = this.normalize(plugins)
}

Expand Down
33 changes: 25 additions & 8 deletions lib/stringifier.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
'use strict'

// Escapes sequences that could break out of an HTML <style> context.
// Uses CSS unicode escaping (\3c = '<') which is valid CSS and parsed
// correctly by all compliant CSS consumers.
const STYLE_TAG = /(<)(\/?style\b)/gi
const COMMENT_OPEN = /(<)(!--)/g

function escapeHTMLInCSS(str) {
if (typeof str !== 'string') return str
if (!str.includes('<')) return str
return str.replace(STYLE_TAG, '\\3c $2').replace(COMMENT_OPEN, '\\3c $2')
}

const DEFAULT_RAW = {
after: '\n',
beforeClose: '\n',
Expand Down Expand Up @@ -38,7 +50,7 @@ class Stringifier {
this.block(node, name + params)
} else {
let end = (node.raws.between || '') + (semicolon ? ';' : '')
this.builder(name + params + end, node)
this.builder(escapeHTMLInCSS(name + params + end), node)
}
}

Expand Down Expand Up @@ -73,7 +85,7 @@ class Stringifier {

block(node, start) {
let between = this.raw(node, 'between', 'beforeOpen')
this.builder(start + between + '{', node, 'start')
this.builder(escapeHTMLInCSS(start + between) + '{', node, 'start')

let after
if (node.nodes && node.nodes.length) {
Expand All @@ -83,7 +95,7 @@ class Stringifier {
after = this.raw(node, 'after', 'emptyBody')
}

if (after) this.builder(after)
if (after) this.builder(escapeHTMLInCSS(after))
this.builder('}', node, 'end')
}

Expand All @@ -95,18 +107,19 @@ class Stringifier {
}

let semicolon = this.raw(node, 'semicolon')
let isDocument = node.type === 'document'
for (let i = 0; i < node.nodes.length; i++) {
let child = node.nodes[i]
let before = this.raw(child, 'before')
if (before) this.builder(before)
if (before) this.builder(isDocument ? before : escapeHTMLInCSS(before))
this.stringify(child, last !== i || semicolon)
}
}

comment(node) {
let left = this.raw(node, 'left', 'commentLeft')
let right = this.raw(node, 'right', 'commentRight')
this.builder('/*' + left + node.text + right + '*/', node)
this.builder(escapeHTMLInCSS('/*' + left + node.text + right + '*/'), node)
}

decl(node, semicolon) {
Expand All @@ -118,7 +131,7 @@ class Stringifier {
}

if (semicolon) string += ';'
this.builder(string, node)
this.builder(escapeHTMLInCSS(string), node)
}

document(node) {
Expand Down Expand Up @@ -324,13 +337,17 @@ class Stringifier {

root(node) {
this.body(node)
if (node.raws.after) this.builder(node.raws.after)
if (node.raws.after) {
let after = node.raws.after
let isDocument = node.parent && node.parent.type === 'document'
this.builder(isDocument ? after : escapeHTMLInCSS(after))
}
}

rule(node) {
this.block(node, this.rawValue(node, 'selector'))
if (node.raws.ownSemicolon) {
this.builder(node.raws.ownSemicolon, node, 'end')
this.builder(escapeHTMLInCSS(node.raws.ownSemicolon), node, 'end')
}
}

Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "postcss",
"version": "8.5.9",
"version": "8.5.10",
"description": "Tool for transforming styles with JS plugins",
"keywords": [
"css",
Expand Down Expand Up @@ -95,10 +95,10 @@
},
"devDependencies": {
"@logux/eslint-config": "^57.1.0",
"@logux/oxc-configs": "^0.2.2",
"@size-limit/preset-small-lib": "^12.0.1",
"@types/node": "^25.5.2",
"actions-up": "^1.12.1",
"@logux/oxc-configs": "^0.3.3",
"@size-limit/preset-small-lib": "^12.1.0",
"@types/node": "^25.6.0",
"actions-up": "^1.13.0",
"c8": "^11.0.0",
"check-dts": "^0.9.0",
"clean-publish": "^6.0.5",
Expand All @@ -107,10 +107,10 @@
"multiocular": "^0.8.2",
"nanodelay": "^1.0.8",
"nanospy": "^1.0.0",
"oxfmt": "^0.43.0",
"oxfmt": "^0.45.0",
"postcss-parser-tests": "^8.9.0",
"simple-git-hooks": "^2.13.1",
"size-limit": "^12.0.1",
"size-limit": "^12.1.0",
"strip-ansi": "^6.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
Expand Down
Loading