Skip to content

Commit 536c79e

Browse files
authored
Escape </style> in CSS output (#2074)
1 parent afa96b2 commit 536c79e

2 files changed

Lines changed: 60 additions & 8 deletions

File tree

‎lib/stringifier.js‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
'use strict'
22

3+
// Escapes sequences that could break out of an HTML <style> context.
4+
// Uses CSS unicode escaping (\3c = '<') which is valid CSS and parsed
5+
// correctly by all compliant CSS consumers.
6+
const STYLE_TAG = /(<)(\/?style\b)/gi
7+
const COMMENT_OPEN = /(<)(!--)/g
8+
9+
function escapeHTMLInCSS(str) {
10+
if (typeof str !== 'string') return str
11+
if (!str.includes('<')) return str
12+
return str.replace(STYLE_TAG, '\\3c $2').replace(COMMENT_OPEN, '\\3c $2')
13+
}
14+
315
const DEFAULT_RAW = {
416
after: '\n',
517
beforeClose: '\n',
@@ -38,7 +50,7 @@ class Stringifier {
3850
this.block(node, name + params)
3951
} else {
4052
let end = (node.raws.between || '') + (semicolon ? ';' : '')
41-
this.builder(name + params + end, node)
53+
this.builder(escapeHTMLInCSS(name + params + end), node)
4254
}
4355
}
4456

@@ -73,7 +85,7 @@ class Stringifier {
7385

7486
block(node, start) {
7587
let between = this.raw(node, 'between', 'beforeOpen')
76-
this.builder(start + between + '{', node, 'start')
88+
this.builder(escapeHTMLInCSS(start + between) + '{', node, 'start')
7789

7890
let after
7991
if (node.nodes && node.nodes.length) {
@@ -83,7 +95,7 @@ class Stringifier {
8395
after = this.raw(node, 'after', 'emptyBody')
8496
}
8597

86-
if (after) this.builder(after)
98+
if (after) this.builder(escapeHTMLInCSS(after))
8799
this.builder('}', node, 'end')
88100
}
89101

@@ -95,18 +107,19 @@ class Stringifier {
95107
}
96108

97109
let semicolon = this.raw(node, 'semicolon')
110+
let isDocument = node.type === 'document'
98111
for (let i = 0; i < node.nodes.length; i++) {
99112
let child = node.nodes[i]
100113
let before = this.raw(child, 'before')
101-
if (before) this.builder(before)
114+
if (before) this.builder(isDocument ? before : escapeHTMLInCSS(before))
102115
this.stringify(child, last !== i || semicolon)
103116
}
104117
}
105118

106119
comment(node) {
107120
let left = this.raw(node, 'left', 'commentLeft')
108121
let right = this.raw(node, 'right', 'commentRight')
109-
this.builder('/*' + left + node.text + right + '*/', node)
122+
this.builder(escapeHTMLInCSS('/*' + left + node.text + right + '*/'), node)
110123
}
111124

112125
decl(node, semicolon) {
@@ -118,7 +131,7 @@ class Stringifier {
118131
}
119132

120133
if (semicolon) string += ';'
121-
this.builder(string, node)
134+
this.builder(escapeHTMLInCSS(string), node)
122135
}
123136

124137
document(node) {
@@ -324,13 +337,17 @@ class Stringifier {
324337

325338
root(node) {
326339
this.body(node)
327-
if (node.raws.after) this.builder(node.raws.after)
340+
if (node.raws.after) {
341+
let after = node.raws.after
342+
let isDocument = node.parent && node.parent.type === 'document'
343+
this.builder(isDocument ? after : escapeHTMLInCSS(after))
344+
}
328345
}
329346

330347
rule(node) {
331348
this.block(node, this.rawValue(node, 'selector'))
332349
if (node.raws.ownSemicolon) {
333-
this.builder(node.raws.ownSemicolon, node, 'end')
350+
this.builder(escapeHTMLInCSS(node.raws.ownSemicolon), node, 'end')
334351
}
335352
}
336353

‎test/stringifier.test.js‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,39 @@ test('handles document with three roots, with before and after raws', () => {
285285
is(s, 'a.one {}AFTER_ONEa.two {}AFTER_TWOa.three {}AFTER_THREE')
286286
})
287287

288+
test('escapes </style & <!-- with \\3c CSS escape', () => {
289+
let root = new Root()
290+
root.append(new Rule({ selector: '</style>' }))
291+
root.append(new AtRule({ name: 'media', params: '<style>' }))
292+
root.append({ text: '</style><!--<style>' })
293+
let rule = new Rule({ selector: 'a' })
294+
rule.raws.before = '\n</style>'
295+
rule.raws.after = '</style>'
296+
rule.append(new Declaration({ prop: 'color', value: '</style>' }))
297+
root.append(rule)
298+
299+
is(
300+
root.toString(),
301+
'\\3c /style> {}\n' +
302+
'@media \\3c style>;\n' +
303+
'/* \\3c /style>\\3c !--\\3c style> */\n' +
304+
'a {\n' +
305+
' color: \\3c /style>' +
306+
'\\3c /style>}'
307+
)
308+
})
309+
310+
test('does not escape Document raws', () => {
311+
let document = new Document()
312+
let root1 = new Root()
313+
root1.append(new Rule({ selector: 'a' }))
314+
let root2 = new Root({ raws: { after: '</style>' } })
315+
root2.raws.before = '</style>'
316+
root2.append(new Rule({ selector: 'b' }))
317+
document.append(root1)
318+
document.append(root2)
319+
320+
is(document.toString(), 'a {}</style>b {}</style>')
321+
})
322+
288323
test.run()

0 commit comments

Comments
 (0)