Skip to content

Commit fdbb90c

Browse files
committed
Escape html comments in json
In brief, explained in more detail by Jon Surrel[1], both `</script>` and `<!--` are interpreted by the html render. We caught the first one, but not the second. The W3C recommendation is to escape the `<` instead with `\x3C`[2]. 1. https://sirre.al/2025/08/06/safe-json-in-script-tags-how-not-to-break-a-site/ 2. https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
1 parent 05d0779 commit fdbb90c

File tree

11 files changed

+78
-52
lines changed

11 files changed

+78
-52
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
99
### Changed
1010
- Upgrade `react-components` to [23.2.0](https://github.com/cucumber/react-components/releases/tag/v23.2.0)
1111

12+
### Fixed
13+
- Escape html comments in json ([#408](https://github.com/cucumber/html-formatter/pull/408))
14+
1215
## [21.13.0] - 2025-07-07
1316
### Changed
1417
- Upgrade `cucumber-messages` to [28.0.0](https://github.com/cucumber/messages/releases/tag/v28.0.0)

dotnet/Cucumber.HtmlFormatter/JsonInHtmlWriter.cs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public override void Write(char[] source, int offset, int length)
3333
throw new ArgumentException("Cannot read past the end of the input source char array.");
3434

3535
var destination = PrepareBuffer();
36-
var flushAt = BUFFER_SIZE - 2;
36+
// Largest write without boundary check is 4 bytes
37+
var flushAt = BUFFER_SIZE - 4;
3738
var written = 0;
3839
for (var i = offset; i < offset + length; i++)
3940
{
@@ -46,12 +47,19 @@ public override void Write(char[] source, int offset, int length)
4647
written = 0;
4748
}
4849

49-
// Write with escapes
50-
if (c == '/')
50+
// Replace < with \x3C
51+
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
52+
if (c == '<')
5153
{
5254
destination[written++] = '\\';
55+
destination[written++] = 'x';
56+
destination[written++] = '3';
57+
destination[written++] = 'C';
58+
}
59+
else
60+
{
61+
destination[written++] = c;
5362
}
54-
destination[written++] = c;
5563
}
5664
// Flush any remaining
5765
if (written > 0)
@@ -66,7 +74,8 @@ public override async Task WriteAsync(char[] source, int offset, int length)
6674
throw new ArgumentException("Cannot read past the end of the input source char array.");
6775

6876
var destination = PrepareBuffer();
69-
var flushAt = BUFFER_SIZE - 2;
77+
// Largest write without boundary check is 4 bytes
78+
var flushAt = BUFFER_SIZE - 4;
7079
var written = 0;
7180
for (var i = offset; i < offset + length; i++)
7281
{
@@ -79,12 +88,19 @@ public override async Task WriteAsync(char[] source, int offset, int length)
7988
written = 0;
8089
}
8190

82-
// Write with escapes
83-
if (c == '/')
91+
// Replace < with \x3C
92+
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
93+
if (c == '<')
8494
{
8595
destination[written++] = '\\';
96+
destination[written++] = 'x';
97+
destination[written++] = '3';
98+
destination[written++] = 'C';
99+
}
100+
else
101+
{
102+
destination[written++] = c;
86103
}
87-
destination[written++] = c;
88104
}
89105
// Flush any remaining
90106
if (written > 0)

dotnet/Cucumber.HtmlFormatterTest/JsonInHtmlWriterTests.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,29 @@ public async Task WritesAsync()
3232
[TestMethod]
3333
public void EscapesSingle()
3434
{
35-
_writer.Write("/");
36-
Assert.AreEqual("\\/", Output());
35+
_writer.Write("<");
36+
Assert.AreEqual("\\x3C", Output());
3737
}
3838

3939
[TestMethod]
4040
public async Task EscapesSingleAsync()
4141
{
42-
await _writer.WriteAsync("/");
43-
Assert.AreEqual("\\/", await OutputAsync());
42+
await _writer.WriteAsync("<");
43+
Assert.AreEqual("\\x3C", await OutputAsync());
4444
}
4545

4646
[TestMethod]
4747
public void EscapesMultiple()
4848
{
4949
_writer.Write("</script><script></script>");
50-
Assert.AreEqual("<\\/script><script><\\/script>", Output());
50+
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", Output());
5151
}
5252

5353
[TestMethod]
5454
public async Task EscapesMultipleAsync()
5555
{
5656
await _writer.WriteAsync("</script><script></script>");
57-
Assert.AreEqual("<\\/script><script><\\/script>", await OutputAsync());
57+
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", await OutputAsync());
5858
}
5959

6060
[TestMethod]
@@ -72,7 +72,7 @@ public void PartialWrites()
7272
text.CopyTo(17, buffer, 4, 9);
7373
_writer.Write(buffer, 4, 9);
7474

75-
Assert.AreEqual("<\\/script><script><\\/script>", Output());
75+
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", Output());
7676
}
7777

7878
[TestMethod]
@@ -90,7 +90,7 @@ public async Task PartialWritesAsync()
9090
text.CopyTo(17, buffer, 4, 9);
9191
await _writer.WriteAsync(buffer, 4, 9);
9292

93-
Assert.AreEqual("<\\/script><script><\\/script>", await OutputAsync());
93+
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", await OutputAsync());
9494
}
9595
[TestMethod]
9696
public void LargeWritesWithOddBoundaries()
@@ -99,15 +99,15 @@ public void LargeWritesWithOddBoundaries()
9999
buffer[0] = 'a';
100100
for (int i = 1; i < buffer.Length; i++)
101101
{
102-
buffer[i] = '/';
102+
buffer[i] = '<';
103103
}
104104
_writer.Write(buffer);
105105

106106
StringBuilder expected = new StringBuilder();
107107
expected.Append('a');
108108
for (int i = 1; i < buffer.Length; i++)
109109
{
110-
expected.Append("\\/");
110+
expected.Append("\\x3C");
111111
}
112112
Assert.AreEqual(expected.ToString(), Output());
113113
}
@@ -119,15 +119,15 @@ public async Task LargeWritesWithOddBoundariesAsync()
119119
buffer[0] = 'a';
120120
for (int i = 1; i < buffer.Length; i++)
121121
{
122-
buffer[i] = '/';
122+
buffer[i] = '<';
123123
}
124124
await _writer.WriteAsync(buffer);
125125

126126
StringBuilder expected = new StringBuilder();
127127
expected.Append('a');
128128
for (int i = 1; i < buffer.Length; i++)
129129
{
130-
expected.Append("\\/");
130+
expected.Append("\\x3C");
131131
}
132132
Assert.AreEqual(expected.ToString(), await OutputAsync());
133133
}
@@ -138,14 +138,14 @@ public void ReallyLargeWrites()
138138
char[] buffer = new char[2048];
139139
for (int i = 0; i < buffer.Length; i++)
140140
{
141-
buffer[i] = '/';
141+
buffer[i] = '<';
142142
}
143143
_writer.Write(buffer);
144144

145145
StringBuilder expected = new StringBuilder();
146146
for (int i = 0; i < buffer.Length; i++)
147147
{
148-
expected.Append("\\/");
148+
expected.Append("\\x3C");
149149
}
150150
Assert.AreEqual(expected.ToString(), Output());
151151
}
@@ -156,14 +156,14 @@ public async Task ReallyLargeWritesAsync()
156156
char[] buffer = new char[2048];
157157
for (int i = 0; i < buffer.Length; i++)
158158
{
159-
buffer[i] = '/';
159+
buffer[i] = '<';
160160
}
161161
await _writer.WriteAsync(buffer);
162162

163163
StringBuilder expected = new StringBuilder();
164164
for (int i = 0; i < buffer.Length; i++)
165165
{
166-
expected.Append("\\/");
166+
expected.Append("\\x3C");
167167
}
168168
Assert.AreEqual(expected.ToString(), await OutputAsync());
169169
}

dotnet/Cucumber.HtmlFormatterTest/MessagesToHtmlWriterTest.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,30 +173,30 @@ public async Task ItWritesTwoMessagesSeparatedByACommaAsync()
173173
}
174174

175175
[TestMethod]
176-
public void ItEscapesForwardSlashes()
176+
public void ItEscapesOpeningAngleBracket()
177177
{
178178
Envelope envelope = Envelope.Create(new GherkinDocument(
179179
null,
180180
null,
181181
[new(new Location(0L, 0L), "</script><script>alert('Hello')</script>")]
182182
));
183183
string html = RenderAsHtml(envelope);
184-
Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"<\\/script><script>alert('Hello')<\\/script>\"}]}}];"),
185-
$"Expected \"window.CUCUMBER_MESSAGES = [{{\\\"gherkinDocument\\\":{{\\\"comments\\\":[{{\\\"location\\\":{{\\\"line\\\":0,\\\"column\\\":0}},\\\"text\\\":\\\"<\\\\/script><script>alert('Hello')<\\\\/script>\\\"}}]}}];" +
184+
Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];"),
185+
$"Expected \"window.CUCUMBER_MESSAGES = [{{\\\"gherkinDocument\\\":{{\\\"comments\\\":[{{\\\"location\\\":{{\\\"line\\\":0,\\\"column\\\":0}},\\\"text\\\":\\\"\\\\x3C/script>\\\\x3Cscript>alert('Hello')\\\\x3C/script>\\\"}}]}}];" +
186186
$"\nbut instead had: \n{html.Substring(html.IndexOf("window.CUCUMBER"))}");
187187
}
188188

189189
[TestMethod]
190-
public async Task ItEscapesForwardSlashesAsync()
190+
public async Task ItEscapesOpeningAngleBracketAsync()
191191
{
192192
Envelope envelope = Envelope.Create(new GherkinDocument(
193193
null,
194194
null,
195195
[new(new Location(0L, 0L), "</script><script>alert('Hello')</script>")]
196196
));
197197
string html = await RenderAsHtmlAsync(envelope);
198-
Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"<\\/script><script>alert('Hello')<\\/script>\"}]}}];"),
199-
$"Expected \"window.CUCUMBER_MESSAGES = [{{\\\"gherkinDocument\\\":{{\\\"comments\\\":[{{\\\"location\\\":{{\\\"line\\\":0,\\\"column\\\":0}},\\\"text\\\":\\\"<\\\\/script><script>alert('Hello')<\\\\/script>\\\"}}]}}];" +
198+
Assert.IsTrue(html.Contains("window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];"),
199+
$"Expected \"window.CUCUMBER_MESSAGES = [{{\\\"gherkinDocument\\\":{{\\\"comments\\\":[{{\\\"location\\\":{{\\\"line\\\":0,\\\"column\\\":0}},\\\"text\\\":\\\"\\\\x3C/script>\\\\x3Cscript>alert('Hello')\\\\x3C/script>\\\"}}]}}];" +
200200
$"\nbut instead had: \n{html.Substring(html.IndexOf("window.CUCUMBER"))}");
201201
}
202202

java/src/main/java/io/cucumber/htmlformatter/JsonInHtmlWriter.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ class JsonInHtmlWriter extends Writer {
1919
@Override
2020
public void write(char[] source, int offset, int length) throws IOException {
2121
char[] destination = prepareBuffer();
22-
int flushAt = BUFFER_SIZE - 2;
22+
// Largest write without boundary check is 4 bytes
23+
int flushAt = BUFFER_SIZE - 4;
2324
int written = 0;
2425
for (int i = offset; i < offset + length; i++) {
2526
char c = source[i];
@@ -30,11 +31,16 @@ public void write(char[] source, int offset, int length) throws IOException {
3031
written = 0;
3132
}
3233

33-
// Write with escapes
34-
if (c == '/') {
34+
// Replace < with \x3C
35+
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
36+
if (c == '<') {
3537
destination[written++] = '\\';
38+
destination[written++] = 'x';
39+
destination[written++] = '3';
40+
destination[written++] = 'C';
41+
} else {
42+
destination[written++] = c;
3643
}
37-
destination[written++] = c;
3844
}
3945
// Flush any remaining
4046
if (written > 0) {

java/src/test/java/io/cucumber/htmlformatter/JsonInHtmlWriterTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ void writes() throws IOException {
2424

2525
@Test
2626
void escapes_single() throws IOException {
27-
writer.write("/");
28-
assertEquals("\\/", output());
27+
writer.write("<");
28+
assertEquals("\\x3C", output());
2929
}
3030

3131
@Test
3232
void escapes_multiple() throws IOException {
3333
writer.write("</script><script></script>");
34-
assertEquals("<\\/script><script><\\/script>", output());
34+
assertEquals("\\x3C/script>\\x3Cscript>\\x3C/script>", output());
3535
}
3636

3737
@Test
@@ -48,21 +48,21 @@ void partial_writes() throws IOException {
4848
text.getChars(17, 26, buffer, 4);
4949
writer.write(buffer, 4, 9);
5050

51-
assertEquals("<\\/script><script><\\/script>", output());
51+
assertEquals("\\x3C/script>\\x3Cscript>\\x3C/script>", output());
5252
}
5353

5454
@Test
5555
void large_writes_with_odd_boundaries() throws IOException {
5656
char[] buffer = new char[1024];
5757
// This forces the buffer to flush after every 1023 written characters.
5858
buffer[0] = 'a';
59-
Arrays.fill(buffer, 1, buffer.length, '/');
59+
Arrays.fill(buffer, 1, buffer.length, '<');
6060
writer.write(buffer);
6161

6262
StringBuilder expected = new StringBuilder();
6363
expected.append("a");
6464
for (int i = 1; i < buffer.length; i++) {
65-
expected.append("\\/");
65+
expected.append("\\x3C");
6666
}
6767
assertEquals(expected.toString(), output());
6868
}
@@ -71,12 +71,12 @@ void large_writes_with_odd_boundaries() throws IOException {
7171
@Test
7272
void really_large_writes() throws IOException {
7373
char[] buffer = new char[2048];
74-
Arrays.fill(buffer, '/');
74+
Arrays.fill(buffer, '<');
7575
writer.write(buffer);
7676

7777
StringBuilder expected = new StringBuilder();
7878
for (int i = 0; i < buffer.length; i++) {
79-
expected.append("\\/");
79+
expected.append("\\x3C");
8080
}
8181
assertEquals(expected.toString(), output());
8282
}

java/src/test/java/io/cucumber/htmlformatter/MessagesToHtmlWriterTest.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import io.cucumber.messages.Convertor;
55
import io.cucumber.messages.types.Comment;
66
import io.cucumber.messages.types.Envelope;
7-
import io.cucumber.messages.types.Feature;
87
import io.cucumber.messages.types.GherkinDocument;
98
import io.cucumber.messages.types.Location;
109
import io.cucumber.messages.types.TestRunFinished;
@@ -14,8 +13,6 @@
1413
import java.io.ByteArrayOutputStream;
1514
import java.io.IOException;
1615
import java.time.Instant;
17-
import java.util.Arrays;
18-
import java.util.Collections;
1916

2017
import static java.nio.charset.StandardCharsets.UTF_8;
2118
import static java.util.Collections.singletonList;
@@ -91,7 +88,7 @@ void it_writes_two_messages_separated_by_a_comma() throws IOException {
9188

9289

9390
@Test
94-
void it_escapes_forward_slashes() throws IOException {
91+
void it_escapes_opening_angle_bracket() throws IOException {
9592
Envelope envelope = Envelope.of(new GherkinDocument(
9693
null,
9794
null,
@@ -102,7 +99,7 @@ void it_escapes_forward_slashes() throws IOException {
10299
));
103100
String html = renderAsHtml(envelope);
104101
assertThat(html, containsString(
105-
"window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"<\\/script><script>alert('Hello')<\\/script>\"}]}}];"));
102+
"window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];"));
106103
}
107104

108105
private static String renderAsHtml(Envelope... messages) throws IOException {

javascript/src/CucumberHtmlStream.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('CucumberHtmlStream', () => {
9191
const html = await renderAsHtml(e1)
9292
assert(
9393
html.indexOf(
94-
`window.CUCUMBER_MESSAGES = [{"gherkinDocument":{"comments":[{"location":{"line":0,"column":0},"text":"<\\/script><script>alert('Hello')<\\/script>"}]}}];`
94+
`window.CUCUMBER_MESSAGES = [{"gherkinDocument":{"comments":[{"location":{"line":0,"column":0},"text":"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>"}]}}];`
9595
) >= 0
9696
)
9797
})

javascript/src/CucumberHtmlStream.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export class CucumberHtmlStream extends Transform {
116116
} else {
117117
this.push(',')
118118
}
119-
this.push(JSON.stringify(envelope).replace(/\//g, '\\/'))
119+
// Replace < with \x3C
120+
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
121+
this.push(JSON.stringify(envelope).replace(/</g, '\\x3C'))
120122
}
121123
}

ruby/lib/cucumber/html_formatter/formatter.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def process_messages(messages)
2323

2424
def write_message(message)
2525
out.puts(',') unless @first_message
26-
out.print(message.to_json.gsub('/', '\/'))
26+
# Replace < with \x3C
27+
# https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
28+
out.print(message.to_json.gsub('<', "\\x3C"))
2729

2830
@first_message = false
2931
end

0 commit comments

Comments
 (0)