From db8318197739136cc8a2b033f13faf4ae5160a4f Mon Sep 17 00:00:00 2001 From: ogomezdi Date: Wed, 16 Oct 2024 09:50:33 +0200 Subject: [PATCH] Qute: add JsonEscaper - fixes #43369 --- docs/src/main/asciidoc/qute-reference.adoc | 15 +++- .../quarkus/qute/deployment/EscapingTest.java | 10 ++- .../quarkus/qute/runtime/EngineProducer.java | 4 ++ .../qute/CharReplacementResultMapper.java | 45 ++++++++++++ .../java/io/quarkus/qute/HtmlEscaper.java | 38 +--------- .../java/io/quarkus/qute/JsonEscaper.java | 50 +++++++++++++ .../java/io/quarkus/qute/ResultMapper.java | 2 +- .../java/io/quarkus/qute/JsonEscaperTest.java | 70 +++++++++++++++++++ 8 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java create mode 100644 independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java create mode 100644 independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index 4b62a037bd432..f0f204fb9f4fa 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -457,7 +457,8 @@ First two elements: {#each myArray.take(2)}{it}{/each} <5> ==== Character Escapes -For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default if a template variant is set. +For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default if a corresponding template variant is set. +For JSON templates the `"`, `\` and the control characters (`U+0000` through `U+001F`) are escaped by default if a corresponding template variant is set. NOTE: In Quarkus, a variant is set automatically for templates located in the `src/main/resources/templates`. By default, the `java.net.URLConnection#getFileNameMap()` is used to determine the content-type of a template file. The additional map of suffixes to content types can be set via `quarkus.qute.content-types`. @@ -466,6 +467,7 @@ If you need to render the unescaped value: 1. Either use the `raw` or `safe` properties implemented as extension methods of the `java.lang.Object`, 2. Or wrap the `String` value in a `io.quarkus.qute.RawString`. +.HTML Example [source,html] ---- @@ -478,6 +480,17 @@ If you need to render the unescaped value: TIP: By default, a template with one of the following content types is escaped: `text/html`, `text/xml`, `application/xml` and `application/xhtml+xml`. However, it's possible to extend this list via the `quarkus.qute.escape-content-types` configuration property. +.JSON Example +[source,json] +---- +{ + "id": "{valueId.raw}", <1> + "name": "{valueName}" <2> +} +---- +<1> `valueId` that resolves to `\nA12345` will be rendered as `\nA12345` that will result in an invalid JSON Object because of the new line inserted inside the string value for the attribute `id`. +<2> `valueName` that resolves to `\tExpressions \n Escapes` will be rendered as `\\tExpressions \\n Escapes`. + [[virtual_methods]] ==== Virtual Methods diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java index a9d4564479892..4e6032fb159f9 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/EscapingTest.java @@ -30,7 +30,9 @@ public class EscapingTest { .addAsResource(new StringAsset("{text} {other} {text.raw} {text.safe} {item.foo}"), "templates/bar.txt") .addAsResource(new StringAsset("{@java.lang.String text}{text} {text.raw} {text.safe}"), - "templates/validation.html")) + "templates/validation.html") + .addAsResource(new StringAsset("{ \"strVal\":\"{strVal}\", \"intVal\":{intVal} }"), + "templates/val.json")) .overrideConfigKey("quarkus.qute.content-types.xhtml", "application/xhtml+xml") .overrideConfigKey("quarkus.qute.suffixes", "qute.html,qute.txt,html,txt,xhtml"); @@ -58,6 +60,12 @@ public void testEscaper() { item.data("item", new Item()).render()); } + @Test + public void testJsonEscaper() { + assertEquals("{ \"strVal\":\"\\t Foo \\u000b\", \"intVal\":42 }", + engine.getTemplate("val.json").data("strVal", "\t Foo \u000B").data("intVal", 42).render()); + } + @Test public void testValidation() { assertEquals("<div>
", diff --git a/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java b/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java index cce813dff6155..aeba46a8cd3d8 100644 --- a/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java +++ b/extensions/qute/runtime/src/main/java/io/quarkus/qute/runtime/EngineProducer.java @@ -40,6 +40,7 @@ import io.quarkus.qute.EvalContext; import io.quarkus.qute.Expression; import io.quarkus.qute.HtmlEscaper; +import io.quarkus.qute.JsonEscaper; import io.quarkus.qute.NamespaceResolver; import io.quarkus.qute.ParserHook; import io.quarkus.qute.Qute; @@ -157,6 +158,9 @@ public EngineProducer(QuteContext context, QuteConfig config, QuteRuntimeConfig // Escape some characters for HTML/XML templates builder.addResultMapper(new HtmlEscaper(List.copyOf(config.escapeContentTypes))); + // Escape some characters for JSON templates + builder.addResultMapper(new JsonEscaper()); + // Fallback reflection resolver builder.addValueResolver(new ReflectionValueResolver()); diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java new file mode 100644 index 0000000000000..04499e72a8da5 --- /dev/null +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/CharReplacementResultMapper.java @@ -0,0 +1,45 @@ +package io.quarkus.qute; + +/** + * Makes it possible to replace chars from Basic Multilingual Plane (BMP). + * + * @see Character#isBmpCodePoint(int) + */ +abstract class CharReplacementResultMapper implements ResultMapper { + + @Override + public String map(Object result, Expression expression) { + return escape(result.toString()); + } + + String escape(CharSequence value) { + if (value.length() == 0) { + return value.toString(); + } + for (int i = 0; i < value.length(); i++) { + String replacement = replacementFor(value.charAt(i)); + if (replacement != null) { + // In most cases we will not need to escape the value at all + return doEscape(value, i, new StringBuilder(value.subSequence(0, i)).append(replacement)); + } + } + return value.toString(); + } + + private String doEscape(CharSequence value, int index, StringBuilder builder) { + int length = value.length(); + while (++index < length) { + char c = value.charAt(index); + String replacement = replacementFor(c); + if (replacement != null) { + builder.append(replacement); + } else { + builder.append(c); + } + } + return builder.toString(); + } + + protected abstract String replacementFor(char c); + +} diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java index 194720231f85e..ff21a0742cf7a 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/HtmlEscaper.java @@ -1,12 +1,11 @@ package io.quarkus.qute; import java.util.List; -import java.util.Objects; import java.util.Optional; import io.quarkus.qute.TemplateNode.Origin; -public class HtmlEscaper implements ResultMapper { +public class HtmlEscaper extends CharReplacementResultMapper { private final List escapedContentTypes; @@ -26,25 +25,6 @@ public boolean appliesTo(Origin origin, Object result) { return false; } - @Override - public String map(Object result, Expression expression) { - return escape(result.toString()); - } - - String escape(CharSequence value) { - if (Objects.requireNonNull(value).length() == 0) { - return value.toString(); - } - for (int i = 0; i < value.length(); i++) { - String replacement = replacementFor(value.charAt(i)); - if (replacement != null) { - // In most cases we will not need to escape the value at all - return doEscape(value, i, new StringBuilder(value.subSequence(0, i)).append(replacement)); - } - } - return value.toString(); - } - private boolean requiresDefaultEscaping(Variant variant) { String contentType = variant.getContentType(); if (contentType == null) { @@ -58,21 +38,7 @@ private boolean requiresDefaultEscaping(Variant variant) { return false; } - private String doEscape(CharSequence value, int index, StringBuilder builder) { - int length = value.length(); - while (++index < length) { - char c = value.charAt(index); - String replacement = replacementFor(c); - if (replacement != null) { - builder.append(replacement); - } else { - builder.append(c); - } - } - return builder.toString(); - } - - private String replacementFor(char c) { + protected String replacementFor(char c) { switch (c) { case '"': return """; diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java new file mode 100644 index 0000000000000..662adc30c8797 --- /dev/null +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/JsonEscaper.java @@ -0,0 +1,50 @@ +package io.quarkus.qute; + +import java.util.Optional; + +import io.quarkus.qute.TemplateNode.Origin; + +public class JsonEscaper extends CharReplacementResultMapper { + + @Override + public boolean appliesTo(Origin origin, Object result) { + if (result instanceof RawString) { + return false; + } + Optional variant = origin.getVariant(); + if (variant.isPresent()) { + String contentType = variant.get().getContentType(); + if (contentType != null) { + return contentType.startsWith(Variant.APPLICATION_JSON); + } + } + return false; + } + + protected String replacementFor(char c) { + // All Unicode characters may be placed within the quotation marks, + // except for the characters that MUST be escaped: quotation mark, + // reverse solidus, and the control characters (U+0000 through U+001F). + // See also https://datatracker.ietf.org/doc/html/rfc8259#autoid-10 + switch (c) { + case '"': + return "\\\""; + case '\\': + return "\\\\"; + case '\r': + return "\\r"; + case '\b': + return "\\b"; + case '\n': + return "\\n"; + case '\t': + return "\\t"; + case '\f': + return "\\f"; + case '/': + return "\\/"; + default: + return c < 32 ? String.format("\\u%04x", (int) c) : null; + } + } +} \ No newline at end of file diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java index fcefb57b6da42..55e803cb0f479 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ResultMapper.java @@ -25,7 +25,7 @@ default boolean appliesTo(Origin origin, Object result) { /** * - * @param result + * @param result The result, never {@code null} * @param expression The original expression * @return the string value */ diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java new file mode 100644 index 0000000000000..e9fe26418efe6 --- /dev/null +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/JsonEscaperTest.java @@ -0,0 +1,70 @@ +package io.quarkus.qute; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.quarkus.qute.TemplateNode.Origin; + +public class JsonEscaperTest { + + @Test + public void testAppliesTo() { + JsonEscaper json = new JsonEscaper(); + Origin jsonOrigin = new Origin() { + + @Override + public Optional getVariant() { + return Optional.of(Variant.forContentType(Variant.APPLICATION_JSON)); + } + + @Override + public String getTemplateId() { + return null; + } + + @Override + public String getTemplateGeneratedId() { + return null; + } + + @Override + public int getLineCharacterStart() { + return 0; + } + + @Override + public int getLineCharacterEnd() { + return 0; + } + + @Override + public int getLine() { + return 0; + } + }; + assertFalse(json.appliesTo(jsonOrigin, new RawString("foo"))); + assertTrue(json.appliesTo(jsonOrigin, "foo")); + } + + @Test + public void testEscaping() throws IOException { + JsonEscaper json = new JsonEscaper(); + assertEquals("Čolek 1", json.escape("Čolek 1")); + assertEquals("\\rČolek\\n", json.escape("\rČolek\n")); + assertEquals("\\tČolek", json.escape("\tČolek")); + assertEquals("\\\"tČolek", json.escape("\"tČolek")); + assertEquals("\\\\tČolek", json.escape("\\tČolek")); + assertEquals("\\\\u005C", json.escape("\\u005C")); + assertEquals("\\u000bČolek", json.escape("\u000BČolek")); + assertEquals("\\\\u000BČolek", json.escape("\\u000BČolek")); + // Control char - start of Header + assertEquals("\\u0001", json.escape("\u0001")); + } + +}