Skip to content
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

Qute - escape expressions in HTML by default #6194

Merged
merged 1 commit into from Dec 16, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/src/main/asciidoc/qute-reference.adoc
Expand Up @@ -182,6 +182,24 @@ This could be useful to access data for which the key is overriden:
====

===== Character Escapes

For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default.
If you need to render the unescaped value:

1. Use the `raw` or `safe` properties implemented as extension methods of the `java.lang.Object`,
2. Wrap the `String` value in a `io.quarkus.qute.RawString`.

[source,html]
----
<html>
<h1>{title}</h1> <1>
{paragraph.raw} <2>
</html>
----
<1> `title` that resolves to `Expressions & Escapes` will be rendered as `Expressions &amp;amp; Escapes`
<2> `paragraph` that resolves to `<p>My text!</p>` will be rendered as `<p>My text!</p>`

==== Sections

A section:
Expand Down
@@ -0,0 +1,66 @@
package io.quarkus.qute.deployment;

import static org.junit.jupiter.api.Assertions.assertEquals;

import javax.inject.Inject;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.qute.RawString;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateData;
import io.quarkus.test.QuarkusUnitTest;

public class EscapingTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClass(Item.class)
.addAsResource(new StringAsset("{text} {other} {text.raw} {text.safe} {item.foo}"),
"templates/foo.html")
.addAsResource(new StringAsset("{item} {item.raw}"),
"templates/item.html")
.addAsResource(new StringAsset("{text} {other} {text.raw} {text.safe} {item.foo}"),
"templates/bar.txt"));

@Inject
Template foo;

@Inject
Template bar;

@Inject
Template item;

@Test
public void testEscaper() {
assertEquals("&lt;div&gt; &amp;&quot;&#39; <div> <div> <span>",
foo.data("text", "<div>").data("other", "&\"'").data("item", new Item()).render());
// No escaping for txt templates
assertEquals("<div> &\"' <div> <div> <span>",
bar.data("text", "<div>").data("other", "&\"'").data("item", new Item()).render());
// Item.toString() is escaped too
assertEquals("&lt;h1&gt;Item&lt;/h1&gt; <h1>Item</h1>",
item.data("item", new Item()).render());
}

@TemplateData
public static class Item {

public RawString getFoo() {
return new RawString("<span>");
}

@Override
public String toString() {
return "<h1>Item</h1>";
}

}

}
Expand Up @@ -12,7 +12,7 @@
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.qute.TemplateInstance;
import io.quarkus.qute.api.Variant;
import io.quarkus.qute.Variant;
import io.quarkus.qute.api.VariantTemplate;
import io.quarkus.test.QuarkusUnitTest;

Expand Down
@@ -1,6 +1,7 @@
package io.quarkus.qute.api;

import io.quarkus.qute.Template;
import io.quarkus.qute.Variant;

/**
*
Expand Down
@@ -1,8 +1,9 @@
package io.quarkus.qute.runtime;

import java.io.InputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Optional;
Expand All @@ -19,12 +20,19 @@
import io.quarkus.arc.InstanceHandle;
import io.quarkus.qute.Engine;
import io.quarkus.qute.EngineBuilder;
import io.quarkus.qute.Escaper;
import io.quarkus.qute.Expression;
import io.quarkus.qute.NamespaceResolver;
import io.quarkus.qute.RawString;
import io.quarkus.qute.ReflectionValueResolver;
import io.quarkus.qute.ResultMapper;
import io.quarkus.qute.Results.Result;
import io.quarkus.qute.TemplateLocator.TemplateLocation;
import io.quarkus.qute.TemplateNode.Origin;
import io.quarkus.qute.UserTagSectionHelper;
import io.quarkus.qute.ValueResolver;
import io.quarkus.qute.ValueResolvers;
import io.quarkus.qute.Variant;

@Singleton
public class EngineProducer {
Expand Down Expand Up @@ -59,8 +67,31 @@ void init(QuteConfig config, List<String> resolverClasses, List<String> template

// We don't register the map resolver because of param declaration validation
// See DefaultTemplateExtensions
builder.addValueResolvers(ValueResolvers.thisResolver(), ValueResolvers.orResolver(), ValueResolvers.trueResolver(),
ValueResolvers.collectionResolver(), ValueResolvers.mapperResolver(), ValueResolvers.mapEntryResolver());
builder.addValueResolver(ValueResolvers.thisResolver());
builder.addValueResolver(ValueResolvers.orResolver());
builder.addValueResolver(ValueResolvers.trueResolver());
builder.addValueResolver(ValueResolvers.collectionResolver());
builder.addValueResolver(ValueResolvers.mapperResolver());
builder.addValueResolver(ValueResolvers.mapEntryResolver());
// foo.string.raw returns a RawString which is never escaped
builder.addValueResolver(ValueResolvers.rawResolver());

// Escape some characters for HTML templates
Escaper htmlEscaper = Escaper.builder().add('"', "&quot;").add('\'', "&#39;")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.add('&', "&amp;").add('<', "&lt;").add('>', "&gt;").build();
builder.addResultMapper(new ResultMapper() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure it's the last mapper? I don't think users should be able to register mappers after this one, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mappers with higher priority are tried first. So if a user wants to map some object then the priority should be > io.quarkus.qute.WithPriority.DEFAULT_PRIORITY.


@Override
public boolean appliesTo(Origin origin, Object result) {
return !(result instanceof RawString)
&& origin.getVariant().filter(EngineProducer::requiresDefaultEscaping).isPresent();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing in the future we'll need to turn this into an SPI for custom escaping of other variant content types. But it can wait.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for proper SPI... in the furure ;-).

}

@Override
public String map(Object result, Expression expression) {
return htmlEscaper.escape(result.toString());
}
});

// Fallback reflection resolver
builder.addValueResolver(new ReflectionValueResolver());
Expand Down Expand Up @@ -132,37 +163,79 @@ private ValueResolver createResolver(String resolverClassName) {
* @param path
* @return the optional reader
*/
private Optional<Reader> locate(String path) {
InputStream in = null;
private Optional<TemplateLocation> locate(String path) {
URL resource = null;
// First try to locate a tag template
if (tags.stream().anyMatch(tag -> tag.startsWith(path))) {
LOGGER.debugf("Locate tag for %s", path);
in = locatePath(tagPath + path);
resource = locatePath(tagPath + path);
// Try path with suffixes
for (String suffix : suffixes) {
in = locatePath(tagPath + path + "." + suffix);
if (in != null) {
resource = locatePath(tagPath + path + "." + suffix);
if (resource != null) {
break;
}
}
}
if (in == null) {
if (resource == null) {
String templatePath = basePath + path;
LOGGER.debugf("Locate template for %s", templatePath);
in = locatePath(templatePath);
resource = locatePath(templatePath);
}
if (in != null) {
return Optional.of(new InputStreamReader(in, Charset.forName("utf-8")));
if (resource != null) {
return Optional.of(new ResourceTemplateLocation(resource, guessVariant(path)));
}
return Optional.empty();
}

private InputStream locatePath(String path) {
private URL locatePath(String path) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = EngineProducer.class.getClassLoader();
}
return cl.getResourceAsStream(path);
return cl.getResource(path);
}

static Variant guessVariant(String path) {
// TODO we need a proper way to detect the variant
int suffixIdx = path.lastIndexOf('.');
if (suffixIdx != -1) {
String suffix = path.substring(suffixIdx);
return new Variant(null, VariantTemplateProducer.parseMediaType(suffix), null);
}
return null;
}

static boolean requiresDefaultEscaping(Variant variant) {
return variant.mediaType != null
? (Variant.TEXT_HTML.equals(variant.mediaType) || Variant.TEXT_XML.equals(variant.mediaType))
: false;
}

static class ResourceTemplateLocation implements TemplateLocation {

private final URL resource;
private final Optional<Variant> variant;

public ResourceTemplateLocation(URL resource, Variant variant) {
this.resource = resource;
this.variant = Optional.ofNullable(variant);
}

@Override
public Reader read() {
try {
return new InputStreamReader(resource.openStream(), Charset.forName("utf-8"));
} catch (IOException e) {
return null;
}
}

@Override
public Optional<Variant> getVariant() {
return variant;
}

}

}
Expand Up @@ -2,6 +2,7 @@

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;

Expand All @@ -16,6 +17,7 @@
import io.quarkus.qute.Expression;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.qute.Variant;
import io.quarkus.qute.api.ResourcePath;

@Singleton
Expand Down Expand Up @@ -104,6 +106,11 @@ public String getGeneratedId() {
return template.get().getGeneratedId();
}

@Override
public Optional<Variant> getVariant() {
return template.get().getVariant();
}

}

}
Expand Up @@ -7,6 +7,7 @@
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
Expand All @@ -27,8 +28,8 @@
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.qute.TemplateInstanceBase;
import io.quarkus.qute.Variant;
import io.quarkus.qute.api.ResourcePath;
import io.quarkus.qute.api.Variant;
import io.quarkus.qute.api.VariantTemplate;

@Singleton
Expand Down Expand Up @@ -114,6 +115,11 @@ public String getGeneratedId() {
throw new UnsupportedOperationException();
}

@Override
public Optional<Variant> getVariant() {
throw new UnsupportedOperationException();
}

}

class VariantTemplateInstanceImpl extends TemplateInstanceBase {
Expand Down Expand Up @@ -166,15 +172,15 @@ public TemplateVariants(Map<Variant, String> variants, String defaultTemplate) {
}

static String parseMediaType(String suffix) {
// TODO support more media types...
if (suffix.equalsIgnoreCase(".html")) {
return "text/html";
// TODO we need a proper way to parse the media type
if (suffix.equalsIgnoreCase(".html") || suffix.equalsIgnoreCase(".htm")) {
return Variant.TEXT_HTML;
} else if (suffix.equalsIgnoreCase(".xml")) {
return "text/xml";
return Variant.TEXT_XML;
} else if (suffix.equalsIgnoreCase(".txt")) {
return "text/plain";
return Variant.TEXT_PLAIN;
} else if (suffix.equalsIgnoreCase(".json")) {
return "application/json";
return Variant.APPLICATION_JSON;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We support JSON but I didn't see any escaper for JSON.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, actually we don't support JSON ATM ;-). TBD

}
LOGGER.warn("Unknown media type for suffix: " + suffix);
return "application/octet-stream";
Expand Down
Expand Up @@ -17,7 +17,7 @@
import org.jboss.resteasy.core.interception.jaxrs.SuspendableContainerResponseContext;

import io.quarkus.qute.TemplateInstance;
import io.quarkus.qute.api.Variant;
import io.quarkus.qute.Variant;
import io.quarkus.qute.api.VariantTemplate;

@Provider
Expand Down
Expand Up @@ -13,7 +13,11 @@ static EngineBuilder builder() {
return new EngineBuilder();
}

public Template parse(String content);
default Template parse(String content) {
return parse(content, null);
}

public Template parse(String content, Variant variant);

public SectionHelperFactory<?> getSectionHelperFactory(String name);

Expand Down
Expand Up @@ -13,7 +13,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;

Expand All @@ -25,7 +24,7 @@ public final class EngineBuilder {
private final Map<String, SectionHelperFactory<?>> sectionHelperFactories;
private final List<ValueResolver> valueResolvers;
private final List<NamespaceResolver> namespaceResolvers;
private final List<Function<String, Optional<Reader>>> locators;
private final List<TemplateLocator> locators;
private final List<ResultMapper> resultMappers;
private Function<String, SectionHelperFactory<?>> sectionHelperFunc;

Expand Down Expand Up @@ -105,7 +104,7 @@ public EngineBuilder addNamespaceResolver(NamespaceResolver resolver) {
* @return self
* @see Engine#getTemplate(String)
*/
public EngineBuilder addLocator(Function<String, Optional<Reader>> locator) {
public EngineBuilder addLocator(TemplateLocator locator) {
this.locators.add(locator);
return this;
}
Expand Down