diff --git a/logbook-core/src/main/java/org/zalando/logbook/BodyFilters.java b/logbook-core/src/main/java/org/zalando/logbook/BodyFilters.java index 3e34dbc9f..c04f82d34 100644 --- a/logbook-core/src/main/java/org/zalando/logbook/BodyFilters.java +++ b/logbook-core/src/main/java/org/zalando/logbook/BodyFilters.java @@ -1,5 +1,6 @@ package org.zalando.logbook; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apiguardian.api.API; import java.util.HashSet; @@ -67,4 +68,14 @@ public static BodyFilter truncate(final int maxSize) { return (contentType, body) -> body.length() <= maxSize ? body : body.substring(0, maxSize) + "..."; } + @API(status = EXPERIMENTAL) + public static BodyFilter compactJson(final ObjectMapper objectMapper) { + return new JsonCompactingBodyFilter(objectMapper); + } + + @API(status = EXPERIMENTAL) + public static BodyFilter compactXml() { + return new XmlCompactingBodyFilter(); + } + } diff --git a/logbook-core/src/main/java/org/zalando/logbook/JsonCompactingBodyFilter.java b/logbook-core/src/main/java/org/zalando/logbook/JsonCompactingBodyFilter.java new file mode 100644 index 000000000..cb0f6489d --- /dev/null +++ b/logbook-core/src/main/java/org/zalando/logbook/JsonCompactingBodyFilter.java @@ -0,0 +1,39 @@ +package org.zalando.logbook; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.function.Predicate; + + +@Slf4j +class JsonCompactingBodyFilter implements BodyFilter { + + private final JsonCompactor jsonCompactor; + private final JsonHeuristic heuristic = new JsonHeuristic(); + private final Predicate contentTypes = MediaTypeQuery.compile("application/json", "application/*+json"); + + JsonCompactingBodyFilter(final ObjectMapper objectMapper) { + jsonCompactor = new JsonCompactor(objectMapper); + } + + @Override + public String filter(@Nullable final String contentType, final String body) { + return contentTypes.test(contentType) && shouldCompact(body) ? compact(body) : body; + } + + private boolean shouldCompact(final String body) { + return heuristic.isProbablyJson(body) && !jsonCompactor.isCompacted(body); + } + + private String compact(final String body) { + try { + return jsonCompactor.compact(body); + } catch (final IOException e) { + log.trace("Unable to compact body, is it a JSON?. Keep it as-is: `{}`", e.getMessage()); + return body; + } + } +} diff --git a/logbook-core/src/main/java/org/zalando/logbook/XmlCompactingBodyFilter.java b/logbook-core/src/main/java/org/zalando/logbook/XmlCompactingBodyFilter.java new file mode 100644 index 000000000..86de060a0 --- /dev/null +++ b/logbook-core/src/main/java/org/zalando/logbook/XmlCompactingBodyFilter.java @@ -0,0 +1,99 @@ +package org.zalando.logbook; + +import lombok.extern.slf4j.Slf4j; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.annotation.Nullable; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathFactory; +import java.io.ByteArrayInputStream; +import java.io.StringWriter; +import java.util.function.Predicate; + +import static javax.xml.transform.OutputKeys.INDENT; +import static javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION; +import static javax.xml.xpath.XPathConstants.NODESET; +import static org.zalando.fauxpas.FauxPas.throwingSupplier; + +@Slf4j +class XmlCompactingBodyFilter implements BodyFilter { + + private final Predicate contentTypes = MediaTypeQuery.compile("*/xml", "*/*+xml"); + private final DocumentBuilderFactory documentBuilderFactory = documentBuilderFactory(); + private final Transformer transformer = transformer(); + + @Override + public String filter(@Nullable final String contentType, final String body) { + return contentTypes.test(contentType) && shouldCompact(body) ? compact(body) : body; + } + + private boolean shouldCompact(final String body) { + return body.indexOf('\n') != -1; + } + + private String compact(final String body) { + try { + final StringWriter output = new StringWriter(); + final Document document = parseDocument(body); + transformer.transform(new DOMSource(document), new StreamResult(output)); + return output.toString(); + } catch (Exception e) { + log.trace("Unable to compact body, is it a XML?. Keep it as-is: `{}`", e.getMessage()); + return body; + } + } + + private Document parseDocument(final String body) throws Exception { + final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + final Document document = documentBuilder.parse(new ByteArrayInputStream(body.getBytes())); + removeEmptyTextNodes(document); + return document; + } + + private void removeEmptyTextNodes(final Document document) throws Exception { + final XPathFactory xPathFactory = XPathFactory.newInstance(); + final XPath xpath = xPathFactory.newXPath(); + final NodeList empty = (NodeList) xpath.evaluate("//text()[normalize-space(.) = '']", document, NODESET); + for (int i = 0; i < empty.getLength(); i++) { + final Node node = empty.item(i); + node.getParentNode().removeChild(node); + } + } + + private Transformer transformer() { + final TransformerFactory factory = TransformerFactory.newInstance(); + final Transformer transformer = throwingSupplier(factory::newTransformer).get(); + transformer.setOutputProperty(INDENT, "no"); + transformer.setOutputProperty(OMIT_XML_DECLARATION, "yes"); + return transformer; + } + + /** + * @return {@link DocumentBuilderFactory}, configured against + * + * XML External Entity (XXE) + * + */ + private DocumentBuilderFactory documentBuilderFactory() { + return throwingSupplier(() -> { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory; + }).get(); + } +} diff --git a/logbook-core/src/test/java/org/zalando/logbook/BodyFiltersTest.java b/logbook-core/src/test/java/org/zalando/logbook/BodyFiltersTest.java index ae9873c94..7be033c14 100644 --- a/logbook-core/src/test/java/org/zalando/logbook/BodyFiltersTest.java +++ b/logbook-core/src/test/java/org/zalando/logbook/BodyFiltersTest.java @@ -1,10 +1,12 @@ package org.zalando.logbook; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.util.Collections; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; public final class BodyFiltersTest { @@ -72,4 +74,17 @@ void shouldNotTruncateBodyIfTooShort() { assertThat(actual, is("{\"foo\":\"secret\"}")); } + @Test + void shouldReturnJsonCompactingBodyFilter() { + final BodyFilter bodyFilter = BodyFilters.compactJson(new ObjectMapper()); + + assertThat(bodyFilter, instanceOf(JsonCompactingBodyFilter.class)); + } + + @Test + void shouldReturnXmlCompactingBodyFilter() { + final BodyFilter bodyFilter = BodyFilters.compactXml(); + + assertThat(bodyFilter, instanceOf(XmlCompactingBodyFilter.class)); + } } diff --git a/logbook-core/src/test/java/org/zalando/logbook/JsonCompactingBodyFilterTest.java b/logbook-core/src/test/java/org/zalando/logbook/JsonCompactingBodyFilterTest.java new file mode 100644 index 000000000..645e0c657 --- /dev/null +++ b/logbook-core/src/test/java/org/zalando/logbook/JsonCompactingBodyFilterTest.java @@ -0,0 +1,66 @@ +package org.zalando.logbook; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class JsonCompactingBodyFilterTest { + + private JsonCompactingBodyFilter bodyFilter; + + /*language=JSON*/ + private final String prettifiedJson = "{\n" + + " \"root\": {\n" + + " \"child\": \"text\"\n" + + " }\n" + + "}"; + + /*language=JSON*/ + private final String minimisedJson = "{\"root\":{\"child\":\"text\"}}"; + + @BeforeEach + void setUp() { + bodyFilter = new JsonCompactingBodyFilter(new ObjectMapper()); + } + + @Test + void shouldIgnoreEmptyBody() { + final String filtered = bodyFilter.filter("application/json", ""); + assertThat(filtered, is("")); + } + + @Test + void shouldIgnoreInvalidContent() { + final String invalidBody = "{\ninvalid}"; + final String filtered = bodyFilter.filter("application/json", invalidBody); + assertThat(filtered, is(invalidBody)); + } + + @Test + void shouldIgnoreInvalidContentType() { + final String filtered = bodyFilter.filter("text/plain", prettifiedJson); + assertThat(filtered, is(prettifiedJson)); + } + + @Test + void shouldTransformValidJsonRequestWithSimpleContentType() { + final String filtered = bodyFilter.filter("application/json", prettifiedJson); + assertThat(filtered, is(minimisedJson)); + } + + @Test + void shouldTransformValidJsonRequestWithCompatibleContentType() { + final String filtered = bodyFilter.filter("application/custom+json", prettifiedJson); + assertThat(filtered, is(minimisedJson)); + } + + @Test + void shouldSkipInvalidJsonLookingLikeAValidOne() { + final String invalidJson = "{invalid}"; + final String filtered = bodyFilter.filter("application/custom+json", invalidJson); + assertThat(filtered, is(invalidJson)); + } +} \ No newline at end of file diff --git a/logbook-core/src/test/java/org/zalando/logbook/MediaTypeQueryTest.java b/logbook-core/src/test/java/org/zalando/logbook/MediaTypeQueryTest.java index 76c43de1d..468ef4b72 100644 --- a/logbook-core/src/test/java/org/zalando/logbook/MediaTypeQueryTest.java +++ b/logbook-core/src/test/java/org/zalando/logbook/MediaTypeQueryTest.java @@ -18,6 +18,7 @@ void shouldMatchAllMatch() { deny("text/plain", "application/json"); allow("*/*", "text/plain"); allow("text/*", "text/plain"); + allow("*/plain", "text/plain"); allow("text/plain", "text/plain"); allow("text/plain", "text/plain;charset=UTF-8"); allow("text/plain;charset=UTF-8", "text/plain"); // TODO should deny diff --git a/logbook-core/src/test/java/org/zalando/logbook/XmlCompactingBodyFilterTest.java b/logbook-core/src/test/java/org/zalando/logbook/XmlCompactingBodyFilterTest.java new file mode 100644 index 000000000..bce4309b6 --- /dev/null +++ b/logbook-core/src/test/java/org/zalando/logbook/XmlCompactingBodyFilterTest.java @@ -0,0 +1,60 @@ +package org.zalando.logbook; + + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class XmlCompactingBodyFilterTest { + + private XmlCompactingBodyFilter bodyFilter; + + /*language=XML*/ + private final String prettifiedXml = "" + + "" + + "\n\n" + + " text\n" + + ""; + + /*language=XML*/ + private final String minimisedXml = "text"; + + @BeforeEach + void setUp() { + bodyFilter = new XmlCompactingBodyFilter(); + } + + @Test + void shouldIgnoreEmptyBody() { + final String filtered = bodyFilter.filter("application/xml", ""); + assertThat(filtered, is("")); + } + + @Test + void shouldIgnoreInvalidContent() { + final String invalidBody = "\n"; + final String filtered = bodyFilter.filter("application/xml", invalidBody); + assertThat(filtered, is(invalidBody)); + } + + @Test + void shouldIgnoreInvalidContentType() { + final String filtered = bodyFilter.filter("text/plain", prettifiedXml); + assertThat(filtered, is(prettifiedXml)); + } + + @Test + void shouldTransformValidXmlRequestWithSimpleContentType() { + final String filtered = bodyFilter.filter("application/xml", prettifiedXml); + assertThat(filtered, is(minimisedXml)); + } + + @Test + void shouldTransformValidXmlRequestWithCompatibleContentType() { + final String filtered = bodyFilter.filter("application/custom+xml", prettifiedXml); + assertThat(filtered, is(minimisedXml)); + } + +} \ No newline at end of file