Skip to content

Commit

Permalink
Escape json when writing in html (#312)
Browse files Browse the repository at this point in the history
We're writing the json messages inside a `<script>` element. This means
that the `</script>` element must be escaped. Or more generally, any
`/`.
  • Loading branch information
mpkorstanje authored Jul 18, 2024
1 parent 5d6c999 commit 8bfb97c
Show file tree
Hide file tree
Showing 10 changed files with 506 additions and 265 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

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

## [21.4.0] - 2024-06-21
### Changed
- Upgrade `react-components` to [22.2.0](https://github.com/cucumber/react-components/releases/tag/v22.2.0)
Expand Down
62 changes: 62 additions & 0 deletions java/src/main/java/io/cucumber/htmlformatter/JsonInHtmlWriter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.cucumber.htmlformatter;

import java.io.IOException;
import java.io.Writer;

/**
* Writes json with the forward slash ({@code /}) escaped. Assumes
* JSON has not been escaped yet.
*/
class JsonInHtmlWriter extends Writer {
private static final int BUFFER_SIZE = 1024;
private final Writer delegate;
private char[] escapeBuffer;

JsonInHtmlWriter(Writer delegate) {
this.delegate = delegate;
}

@Override
public void write(char[] source, int offset, int length) throws IOException {
char[] destination = prepareBuffer();
int flushAt = BUFFER_SIZE - 2;
int written = 0;
for (int i = offset; i < offset + length; i++) {
char c = source[i];

// Flush buffer if (nearly) full
if (written >= flushAt) {
delegate.write(destination, 0, written);
written = 0;
}

// Write with escapes
if (c == '/') {
destination[written++] = '\\';
}
destination[written++] = c;
}
// Flush any remaining
if (written > 0) {
delegate.write(destination, 0, written);
}
}

private char[] prepareBuffer() {
// Reuse the same buffer, avoids repeated array allocation
if (escapeBuffer == null) {
escapeBuffer = new char[BUFFER_SIZE];
}
return escapeBuffer;
}

@Override
public void flush() throws IOException {
delegate.flush();
}

@Override
public void close() throws IOException {
delegate.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
public final class MessagesToHtmlWriter implements AutoCloseable {
private final String template;
private final Writer writer;
private final JsonInHtmlWriter jsonInHtmlWriter;
private final Serializer serializer;
private boolean preMessageWritten = false;
private boolean postMessageWritten = false;
Expand All @@ -37,8 +38,10 @@ public MessagesToHtmlWriter(OutputStream outputStream, Serializer serializer) th
);
}


private MessagesToHtmlWriter(Writer writer, Serializer serializer) throws IOException {
this.writer = writer;
this.jsonInHtmlWriter = new JsonInHtmlWriter(writer);
this.serializer = serializer;
this.template = readResource("index.mustache.html");
}
Expand Down Expand Up @@ -77,7 +80,7 @@ public void write(Envelope envelope) throws IOException {
writer.write(",");
}

serializer.writeValue(writer, envelope);
serializer.writeValue(jsonInHtmlWriter, envelope);
}

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

/**
* Serializes a message to JSON.
*/
@FunctionalInterface
public interface Serializer {

/**
* Serialize a message to JSON and write it to the given {@code writer}.
*
* <ul>
* <li>Values must be included unless their value is {@code null}
* or an "absent" reference values such as empty optionals.
* <li>Enums must be written as strings.
* <li>The solidus {@code /} may not be escaped. Writing json
* into the html context is handled in this implementation.
* <li>Implementations may not close the {@code writer} after
* writing a {@code value}.
* </ul>
*
* @param writer to write to
* @param value to serialize
* @throws IOException if anything goes wrong
*/
void writeValue(Writer writer, Envelope value) throws IOException;

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.cucumber.htmlformatter;

import org.junit.jupiter.api.Test;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.Arrays;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;

class JsonInHtmlWriterTest {

private final ByteArrayOutputStream out = new ByteArrayOutputStream();
private final OutputStreamWriter outputStreamWriter = new OutputStreamWriter(out, UTF_8);
private final JsonInHtmlWriter writer = new JsonInHtmlWriter(outputStreamWriter);

@Test
void writes() throws IOException {
writer.write("{\"hello\": \"world\"}");
assertEquals("{\"hello\": \"world\"}", output());
}

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

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

@Test
void partial_writes() throws IOException {
char[] buffer = new char[100];
String text = "</script><script></script>";

text.getChars(0, 9, buffer, 0);
writer.write(buffer, 0, 9);

text.getChars(9, 17, buffer, 2);
writer.write(buffer, 2, 8);

text.getChars(17, 26, buffer, 4);
writer.write(buffer, 4, 9);

assertEquals("<\\/script><script><\\/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, '/');
writer.write(buffer);

StringBuilder expected = new StringBuilder();
expected.append("a");
for (int i = 1; i < buffer.length; i++) {
expected.append("\\/");
}
assertEquals(expected.toString(), output());
}


@Test
void really_large_writes() throws IOException {
char[] buffer = new char[2048];
Arrays.fill(buffer, '/');
writer.write(buffer);

StringBuilder expected = new StringBuilder();
for (int i = 0; i < buffer.length; i++) {
expected.append("\\/");
}
assertEquals(expected.toString(), output());
}

@Test
void empty_write() throws IOException {
char[] buffer = new char[0];
writer.write(buffer);
assertEquals("", output());
}

private String output() throws IOException {
writer.flush();
return new String(out.toByteArray(), UTF_8);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@

import io.cucumber.htmlformatter.MessagesToHtmlWriter.Serializer;
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;
import io.cucumber.messages.types.TestRunStarted;
import org.junit.jupiter.api.Test;

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;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
Expand Down Expand Up @@ -82,6 +89,22 @@ void it_writes_two_messages_separated_by_a_comma() throws IOException {
"window.CUCUMBER_MESSAGES = [{\"testRunStarted\":{\"timestamp\":{\"seconds\":10,\"nanos\":0}}},{\"testRunFinished\":{\"success\":true,\"timestamp\":{\"seconds\":15,\"nanos\":0}}}];"));
}


@Test
void it_escapes_forward_slashes() throws IOException {
Envelope envelope = Envelope.of(new GherkinDocument(
null,
null,
singletonList(new Comment(
new Location(0L, 0L),
"</script><script>alert('Hello')</script>"
))
));
String html = renderAsHtml(envelope);
assertThat(html, containsString(
"window.CUCUMBER_MESSAGES = [{\"gherkinDocument\":{\"comments\":[{\"location\":{\"line\":0,\"column\":0},\"text\":\"<\\/script><script>alert('Hello')<\\/script>\"}]}}];"));
}

private static String renderAsHtml(Envelope... messages) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (MessagesToHtmlWriter messagesToHtmlWriter = new MessagesToHtmlWriter(bytes, serializer)) {
Expand Down
Loading

0 comments on commit 8bfb97c

Please sign in to comment.