Skip to content

Escape html comments in json #408

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 11, 2025
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
- Upgrade `react-components` to [23.2.0](https://github.com/cucumber/react-components/releases/tag/v23.2.0)

### Fixed
- Escape html comments in json ([#408](https://github.com/cucumber/html-formatter/pull/408))

## [21.13.0] - 2025-07-07
### Changed
- Upgrade `cucumber-messages` to [28.0.0](https://github.com/cucumber/messages/releases/tag/v28.0.0)
Expand Down
32 changes: 24 additions & 8 deletions dotnet/Cucumber.HtmlFormatter/JsonInHtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public override void Write(char[] source, int offset, int length)
throw new ArgumentException("Cannot read past the end of the input source char array.");

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

// Write with escapes
if (c == '/')
// Replace < with \x3C
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
if (c == '<')
{
destination[written++] = '\\';
destination[written++] = 'x';
destination[written++] = '3';
destination[written++] = 'C';
}
else
{
destination[written++] = c;
}
destination[written++] = c;
}
// Flush any remaining
if (written > 0)
Expand All @@ -66,7 +74,8 @@ public override async Task WriteAsync(char[] source, int offset, int length)
throw new ArgumentException("Cannot read past the end of the input source char array.");

var destination = PrepareBuffer();
var flushAt = BUFFER_SIZE - 2;
// Largest write without boundary check is 4 bytes
var flushAt = BUFFER_SIZE - 4;
var written = 0;
for (var i = offset; i < offset + length; i++)
{
Expand All @@ -79,12 +88,19 @@ public override async Task WriteAsync(char[] source, int offset, int length)
written = 0;
}

// Write with escapes
if (c == '/')
// Replace < with \x3C
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
if (c == '<')
{
destination[written++] = '\\';
destination[written++] = 'x';
destination[written++] = '3';
destination[written++] = 'C';
}
else
{
destination[written++] = c;
}
destination[written++] = c;
}
// Flush any remaining
if (written > 0)
Expand Down
32 changes: 16 additions & 16 deletions dotnet/Cucumber.HtmlFormatterTest/JsonInHtmlWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,29 +32,29 @@ public async Task WritesAsync()
[TestMethod]
public void EscapesSingle()
{
_writer.Write("/");
Assert.AreEqual("\\/", Output());
_writer.Write("<");
Assert.AreEqual("\\x3C", Output());
}

[TestMethod]
public async Task EscapesSingleAsync()
{
await _writer.WriteAsync("/");
Assert.AreEqual("\\/", await OutputAsync());
await _writer.WriteAsync("<");
Assert.AreEqual("\\x3C", await OutputAsync());
}

[TestMethod]
public void EscapesMultiple()
{
_writer.Write("</script><script></script>");
Assert.AreEqual("<\\/script><script><\\/script>", Output());
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", Output());
}

[TestMethod]
public async Task EscapesMultipleAsync()
{
await _writer.WriteAsync("</script><script></script>");
Assert.AreEqual("<\\/script><script><\\/script>", await OutputAsync());
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", await OutputAsync());
}

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

Assert.AreEqual("<\\/script><script><\\/script>", Output());
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", Output());
}

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

Assert.AreEqual("<\\/script><script><\\/script>", await OutputAsync());
Assert.AreEqual("\\x3C/script>\\x3Cscript>\\x3C/script>", await OutputAsync());
}
[TestMethod]
public void LargeWritesWithOddBoundaries()
Expand All @@ -99,15 +99,15 @@ public void LargeWritesWithOddBoundaries()
buffer[0] = 'a';
for (int i = 1; i < buffer.Length; i++)
{
buffer[i] = '/';
buffer[i] = '<';
}
_writer.Write(buffer);

StringBuilder expected = new StringBuilder();
expected.Append('a');
for (int i = 1; i < buffer.Length; i++)
{
expected.Append("\\/");
expected.Append("\\x3C");
}
Assert.AreEqual(expected.ToString(), Output());
}
Expand All @@ -119,15 +119,15 @@ public async Task LargeWritesWithOddBoundariesAsync()
buffer[0] = 'a';
for (int i = 1; i < buffer.Length; i++)
{
buffer[i] = '/';
buffer[i] = '<';
}
await _writer.WriteAsync(buffer);

StringBuilder expected = new StringBuilder();
expected.Append('a');
for (int i = 1; i < buffer.Length; i++)
{
expected.Append("\\/");
expected.Append("\\x3C");
}
Assert.AreEqual(expected.ToString(), await OutputAsync());
}
Expand All @@ -138,14 +138,14 @@ public void ReallyLargeWrites()
char[] buffer = new char[2048];
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = '/';
buffer[i] = '<';
}
_writer.Write(buffer);

StringBuilder expected = new StringBuilder();
for (int i = 0; i < buffer.Length; i++)
{
expected.Append("\\/");
expected.Append("\\x3C");
}
Assert.AreEqual(expected.ToString(), Output());
}
Expand All @@ -156,14 +156,14 @@ public async Task ReallyLargeWritesAsync()
char[] buffer = new char[2048];
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = '/';
buffer[i] = '<';
}
await _writer.WriteAsync(buffer);

StringBuilder expected = new StringBuilder();
for (int i = 0; i < buffer.Length; i++)
{
expected.Append("\\/");
expected.Append("\\x3C");
}
Assert.AreEqual(expected.ToString(), await OutputAsync());
}
Expand Down
12 changes: 6 additions & 6 deletions dotnet/Cucumber.HtmlFormatterTest/MessagesToHtmlWriterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,30 +173,30 @@ public async Task ItWritesTwoMessagesSeparatedByACommaAsync()
}

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

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

Expand Down
14 changes: 10 additions & 4 deletions java/src/main/java/io/cucumber/htmlformatter/JsonInHtmlWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class JsonInHtmlWriter extends Writer {
@Override
public void write(char[] source, int offset, int length) throws IOException {
char[] destination = prepareBuffer();
int flushAt = BUFFER_SIZE - 2;
// Largest write without boundary check is 4 bytes
int flushAt = BUFFER_SIZE - 4;
int written = 0;
for (int i = offset; i < offset + length; i++) {
char c = source[i];
Expand All @@ -30,11 +31,16 @@ public void write(char[] source, int offset, int length) throws IOException {
written = 0;
}

// Write with escapes
if (c == '/') {
// Replace < with \x3C
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
if (c == '<') {
destination[written++] = '\\';
destination[written++] = 'x';
destination[written++] = '3';
destination[written++] = 'C';
} else {
destination[written++] = c;
}
destination[written++] = c;
}
// Flush any remaining
if (written > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ void writes() throws IOException {

@Test
void escapes_single() throws IOException {
writer.write("/");
assertEquals("\\/", output());
writer.write("<");
assertEquals("\\x3C", output());
}

@Test
void escapes_multiple() throws IOException {
writer.write("</script><script></script>");
assertEquals("<\\/script><script><\\/script>", output());
assertEquals("\\x3C/script>\\x3Cscript>\\x3C/script>", output());
}

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

assertEquals("<\\/script><script><\\/script>", output());
assertEquals("\\x3C/script>\\x3Cscript>\\x3C/script>", output());
}

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

StringBuilder expected = new StringBuilder();
expected.append("a");
for (int i = 1; i < buffer.length; i++) {
expected.append("\\/");
expected.append("\\x3C");
}
assertEquals(expected.toString(), output());
}
Expand All @@ -71,12 +71,12 @@ void large_writes_with_odd_boundaries() throws IOException {
@Test
void really_large_writes() throws IOException {
char[] buffer = new char[2048];
Arrays.fill(buffer, '/');
Arrays.fill(buffer, '<');
writer.write(buffer);

StringBuilder expected = new StringBuilder();
for (int i = 0; i < buffer.length; i++) {
expected.append("\\/");
expected.append("\\x3C");
}
assertEquals(expected.toString(), output());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import io.cucumber.messages.Convertor;
import io.cucumber.messages.types.Comment;
import io.cucumber.messages.types.Envelope;
import io.cucumber.messages.types.Feature;
import io.cucumber.messages.types.GherkinDocument;
import io.cucumber.messages.types.Location;
import io.cucumber.messages.types.TestRunFinished;
Expand All @@ -14,8 +13,6 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;

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


@Test
void it_escapes_forward_slashes() throws IOException {
void it_escapes_opening_angle_bracket() throws IOException {
Envelope envelope = Envelope.of(new GherkinDocument(
null,
null,
Expand All @@ -102,7 +99,7 @@ void it_escapes_forward_slashes() throws IOException {
));
String html = renderAsHtml(envelope);
assertThat(html, containsString(
"window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"<\\/script><script>alert('Hello')<\\/script>\"}]}}];"));
"window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>\"}]}}];"));
}

private static String renderAsHtml(Envelope... messages) throws IOException {
Expand Down
2 changes: 1 addition & 1 deletion javascript/src/CucumberHtmlStream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('CucumberHtmlStream', () => {
const html = await renderAsHtml(e1)
assert(
html.indexOf(
`window.CUCUMBER_MESSAGES = [{"gherkinDocument":{"comments":[{"location":{"line":0,"column":0},"text":"<\\/script><script>alert('Hello')<\\/script>"}]}}];`
`window.CUCUMBER_MESSAGES = [{"gherkinDocument":{"comments":[{"location":{"line":0,"column":0},"text":"\\x3C/script>\\x3Cscript>alert('Hello')\\x3C/script>"}]}}];`
) >= 0
)
})
Expand Down
4 changes: 3 additions & 1 deletion javascript/src/CucumberHtmlStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export class CucumberHtmlStream extends Transform {
} else {
this.push(',')
}
this.push(JSON.stringify(envelope).replace(/\//g, '\\/'))
// Replace < with \x3C
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
this.push(JSON.stringify(envelope).replace(/</g, '\\x3C'))
}
}
4 changes: 3 additions & 1 deletion ruby/lib/cucumber/html_formatter/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def process_messages(messages)

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

@first_message = false
end
Expand Down
Loading
Loading