Skip to content

Commit 8bfb97c

Browse files
authored
Escape json when writing in html (#312)
We're writing the json messages inside a `<script>` element. This means that the `</script>` element must be escaped. Or more generally, any `/`.
1 parent 5d6c999 commit 8bfb97c

File tree

10 files changed

+506
-265
lines changed

10 files changed

+506
-265
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## [Unreleased]
9-
9+
### Fixed
10+
- Escape json when writing in html ([#312](https://github.com/cucumber/html-formatter/pull/312))
11+
1012
## [21.4.0] - 2024-06-21
1113
### Changed
1214
- Upgrade `react-components` to [22.2.0](https://github.com/cucumber/react-components/releases/tag/v22.2.0)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package io.cucumber.htmlformatter;
2+
3+
import java.io.IOException;
4+
import java.io.Writer;
5+
6+
/**
7+
* Writes json with the forward slash ({@code /}) escaped. Assumes
8+
* JSON has not been escaped yet.
9+
*/
10+
class JsonInHtmlWriter extends Writer {
11+
private static final int BUFFER_SIZE = 1024;
12+
private final Writer delegate;
13+
private char[] escapeBuffer;
14+
15+
JsonInHtmlWriter(Writer delegate) {
16+
this.delegate = delegate;
17+
}
18+
19+
@Override
20+
public void write(char[] source, int offset, int length) throws IOException {
21+
char[] destination = prepareBuffer();
22+
int flushAt = BUFFER_SIZE - 2;
23+
int written = 0;
24+
for (int i = offset; i < offset + length; i++) {
25+
char c = source[i];
26+
27+
// Flush buffer if (nearly) full
28+
if (written >= flushAt) {
29+
delegate.write(destination, 0, written);
30+
written = 0;
31+
}
32+
33+
// Write with escapes
34+
if (c == '/') {
35+
destination[written++] = '\\';
36+
}
37+
destination[written++] = c;
38+
}
39+
// Flush any remaining
40+
if (written > 0) {
41+
delegate.write(destination, 0, written);
42+
}
43+
}
44+
45+
private char[] prepareBuffer() {
46+
// Reuse the same buffer, avoids repeated array allocation
47+
if (escapeBuffer == null) {
48+
escapeBuffer = new char[BUFFER_SIZE];
49+
}
50+
return escapeBuffer;
51+
}
52+
53+
@Override
54+
public void flush() throws IOException {
55+
delegate.flush();
56+
}
57+
58+
@Override
59+
public void close() throws IOException {
60+
delegate.close();
61+
}
62+
}

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
public final class MessagesToHtmlWriter implements AutoCloseable {
2323
private final String template;
2424
private final Writer writer;
25+
private final JsonInHtmlWriter jsonInHtmlWriter;
2526
private final Serializer serializer;
2627
private boolean preMessageWritten = false;
2728
private boolean postMessageWritten = false;
@@ -37,8 +38,10 @@ public MessagesToHtmlWriter(OutputStream outputStream, Serializer serializer) th
3738
);
3839
}
3940

41+
4042
private MessagesToHtmlWriter(Writer writer, Serializer serializer) throws IOException {
4143
this.writer = writer;
44+
this.jsonInHtmlWriter = new JsonInHtmlWriter(writer);
4245
this.serializer = serializer;
4346
this.template = readResource("index.mustache.html");
4447
}
@@ -77,7 +80,7 @@ public void write(Envelope envelope) throws IOException {
7780
writer.write(",");
7881
}
7982

80-
serializer.writeValue(writer, envelope);
83+
serializer.writeValue(jsonInHtmlWriter, envelope);
8184
}
8285

8386
/**
@@ -135,9 +138,29 @@ private static String readResource(String name) throws IOException {
135138
return new String(baos.toByteArray(), UTF_8);
136139
}
137140

141+
/**
142+
* Serializes a message to JSON.
143+
*/
138144
@FunctionalInterface
139145
public interface Serializer {
140146

147+
/**
148+
* Serialize a message to JSON and write it to the given {@code writer}.
149+
*
150+
* <ul>
151+
* <li>Values must be included unless their value is {@code null}
152+
* or an "absent" reference values such as empty optionals.
153+
* <li>Enums must be written as strings.
154+
* <li>The solidus {@code /} may not be escaped. Writing json
155+
* into the html context is handled in this implementation.
156+
* <li>Implementations may not close the {@code writer} after
157+
* writing a {@code value}.
158+
* </ul>
159+
*
160+
* @param writer to write to
161+
* @param value to serialize
162+
* @throws IOException if anything goes wrong
163+
*/
141164
void writeValue(Writer writer, Envelope value) throws IOException;
142165

143166
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package io.cucumber.htmlformatter;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.ByteArrayOutputStream;
6+
import java.io.IOException;
7+
import java.io.OutputStreamWriter;
8+
import java.util.Arrays;
9+
10+
import static java.nio.charset.StandardCharsets.UTF_8;
11+
import static org.junit.jupiter.api.Assertions.assertEquals;
12+
13+
class JsonInHtmlWriterTest {
14+
15+
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
16+
private final OutputStreamWriter outputStreamWriter = new OutputStreamWriter(out, UTF_8);
17+
private final JsonInHtmlWriter writer = new JsonInHtmlWriter(outputStreamWriter);
18+
19+
@Test
20+
void writes() throws IOException {
21+
writer.write("{\"hello\": \"world\"}");
22+
assertEquals("{\"hello\": \"world\"}", output());
23+
}
24+
25+
@Test
26+
void escapes_single() throws IOException {
27+
writer.write("/");
28+
assertEquals("\\/", output());
29+
}
30+
31+
@Test
32+
void escapes_multiple() throws IOException {
33+
writer.write("</script><script></script>");
34+
assertEquals("<\\/script><script><\\/script>", output());
35+
}
36+
37+
@Test
38+
void partial_writes() throws IOException {
39+
char[] buffer = new char[100];
40+
String text = "</script><script></script>";
41+
42+
text.getChars(0, 9, buffer, 0);
43+
writer.write(buffer, 0, 9);
44+
45+
text.getChars(9, 17, buffer, 2);
46+
writer.write(buffer, 2, 8);
47+
48+
text.getChars(17, 26, buffer, 4);
49+
writer.write(buffer, 4, 9);
50+
51+
assertEquals("<\\/script><script><\\/script>", output());
52+
}
53+
54+
@Test
55+
void large_writes_with_odd_boundaries() throws IOException {
56+
char[] buffer = new char[1024];
57+
// This forces the buffer to flush after every 1023 written characters.
58+
buffer[0] = 'a';
59+
Arrays.fill(buffer, 1, buffer.length, '/');
60+
writer.write(buffer);
61+
62+
StringBuilder expected = new StringBuilder();
63+
expected.append("a");
64+
for (int i = 1; i < buffer.length; i++) {
65+
expected.append("\\/");
66+
}
67+
assertEquals(expected.toString(), output());
68+
}
69+
70+
71+
@Test
72+
void really_large_writes() throws IOException {
73+
char[] buffer = new char[2048];
74+
Arrays.fill(buffer, '/');
75+
writer.write(buffer);
76+
77+
StringBuilder expected = new StringBuilder();
78+
for (int i = 0; i < buffer.length; i++) {
79+
expected.append("\\/");
80+
}
81+
assertEquals(expected.toString(), output());
82+
}
83+
84+
@Test
85+
void empty_write() throws IOException {
86+
char[] buffer = new char[0];
87+
writer.write(buffer);
88+
assertEquals("", output());
89+
}
90+
91+
private String output() throws IOException {
92+
writer.flush();
93+
return new String(out.toByteArray(), UTF_8);
94+
}
95+
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@
22

33
import io.cucumber.htmlformatter.MessagesToHtmlWriter.Serializer;
44
import io.cucumber.messages.Convertor;
5+
import io.cucumber.messages.types.Comment;
56
import io.cucumber.messages.types.Envelope;
7+
import io.cucumber.messages.types.Feature;
8+
import io.cucumber.messages.types.GherkinDocument;
9+
import io.cucumber.messages.types.Location;
610
import io.cucumber.messages.types.TestRunFinished;
711
import io.cucumber.messages.types.TestRunStarted;
812
import org.junit.jupiter.api.Test;
913

1014
import java.io.ByteArrayOutputStream;
1115
import java.io.IOException;
1216
import java.time.Instant;
17+
import java.util.Arrays;
18+
import java.util.Collections;
1319

1420
import static java.nio.charset.StandardCharsets.UTF_8;
21+
import static java.util.Collections.singletonList;
1522
import static org.hamcrest.CoreMatchers.containsString;
1623
import static org.hamcrest.MatcherAssert.assertThat;
1724
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@@ -82,6 +89,22 @@ void it_writes_two_messages_separated_by_a_comma() throws IOException {
8289
"window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}},{\"testRunFinished\":{\"success\":true,\"timestamp\":{\"seconds\":15,\"nanos\":0}}}];"));
8390
}
8491

92+
93+
@Test
94+
void it_escapes_forward_slashes() throws IOException {
95+
Envelope envelope = Envelope.of(new GherkinDocument(
96+
null,
97+
null,
98+
singletonList(new Comment(
99+
new Location(0L, 0L),
100+
"</script><script>alert('Hello')</script>"
101+
))
102+
));
103+
String html = renderAsHtml(envelope);
104+
assertThat(html, containsString(
105+
"window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"<\\/script><script>alert('Hello')<\\/script>\"}]}}];"));
106+
}
107+
85108
private static String renderAsHtml(Envelope... messages) throws IOException {
86109
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
87110
try (MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, serializer)) {

0 commit comments

Comments
 (0)