Repository: PebbleTemplates/pebble (java)

Issue:
Protecting from macro bombs
Suppose I want to run Pebble with macros I don't control. Is there a way to control against macro bombs (by analogy with [zip bombs](https://en.wikipedia.org/wiki/Zip_bomb)) such as this?
```twig
{{ m9() }}
{% macro m0() %}1234567890{% endmacro %}
{% macro m1() %}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{% endmacro %}
{% macro m2() %}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{% endmacro %}
{% macro m3() %}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{% endmacro %}
{% macro m4() %}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{% endmacro %}
{% macro m5() %}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{% endmacro %}
{% macro m6() %}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{% endmacro %}
{% macro m7() %}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{% endmacro %}
{% macro m8() %}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{% endmacro %}
{% macro m9() %}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{% endmacro %}
```
(This OOMs around `m7` on my machine.)

I'm thinking of several possible ways:
- let the caller disallow macros altogether - not ideal
- let the caller disallow macro uses in macros - not sure how this would be implemented
- let the caller set a limit for the size of the output and throw an exception once it's reached - maybe decorate the `Writer` passed by the caller with a wrapper that keeps count of how many bytes were written to it.

What do you think @ebussieres, is any of the above worth adding to the project? I'm happy to create PRs for any of them.



Pull request:
Add setting to limit the size of output when evaluating a template
Should fix #526.

The solution is to have a shared counter of all the chars written by all the writers involved in evaluating a template and check each time if we are over the limit. This is opt-in. I tried to be detailed in comments and high-level in the public docs.

Patch:
diff --git a/docs/src/orchid/resources/wiki/guide/basic-usage.md b/docs/src/orchid/resources/wiki/guide/basic-usage.md
index f8681444..42f1b248 100644
--- a/docs/src/orchid/resources/wiki/guide/basic-usage.md
+++ b/docs/src/orchid/resources/wiki/guide/basic-usage.md
@@ -396,7 +396,7 @@ Tests can be negated by using the is not operator:
 ```
 
 ### Conditional (Ternary) Operator
-The conditional operator is similar to it's Java counterpart:
+The conditional operator is similar to its Java counterpart:
 ```twig
 {% verbatim %}{{ foo ? "yes" : "no" }}{% endverbatim %}
 ```
@@ -412,5 +412,20 @@ In order from highest to lowest precedence:
 - `and`
 - `or`
 
+### Limiting the size of the rendered output
+
+In case you’re running Pebble with templates provided by someone else, there’s an attack similar to
+[zip bombs](https://en.wikipedia.org/wiki/Zip_bomb) or [XML bombs](https://en.wikipedia.org/wiki/Billion_laughs_attack)
+that might cause your process to run out of memory. To protect against it, you can limit the size of the output when
+evaluating a template:
+```java
+PebbleEngine pebble = new PebbleEngine.Builder()
+                // Output should not exceed 10 MB.
+                .maxRenderedSize(10 * 1024 * 1024)
+                .build();
+```
+
+This will throw a `PebbleException` when a template evaluation tries to write more characters than the limit you set.
+
 ### IDE's plugin
-If you want to add IDE's syntax highlighting, you can install this [plugin](https://plugins.jetbrains.com/idea/plugin/9407-pebble) for IntelliJ. Thank you to Bastien Jansen for his contribution.
\ No newline at end of file
+If you want to add IDE's syntax highlighting, you can install this [plugin](https://plugins.jetbrains.com/idea/plugin/9407-pebble) for IntelliJ. Thank you to Bastien Jansen for his contribution.
diff --git a/docs/src/orchid/resources/wiki/guide/installation.md b/docs/src/orchid/resources/wiki/guide/installation.md
index f46f6c1e..cbc71198 100644
--- a/docs/src/orchid/resources/wiki/guide/installation.md
+++ b/docs/src/orchid/resources/wiki/guide/installation.md
@@ -88,3 +88,4 @@ All the settings are set during the construction of the `PebbleEngine` object.
 | `literalDecimalTreatedAsInteger` | option for toggling to enable/disable literal decimal treated as integer | `false` |
 | `literalNumbersAsBigDecimals` | option for toggling to enable/disable literal numbers treated as BigDecimals | `false` |
 | `greedyMatchMethod` | option for toggling to enable/disable greedy matching mode for finding java method. Reduce the limit of the parameter type, try to find other method which has compatible parameter types. | `false` |
+| `maxRenderedSize` | option for limiting the size of the rendered output | `-1 (disabled)` |
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java b/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java
index c7ac19a3..498d5b35 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/PebbleEngine.java
@@ -69,6 +69,8 @@ public class PebbleEngine {
 
   private final Locale defaultLocale;
 
+  private final int maxRenderedSize;
+
   private final PebbleCache<CacheKey, Object> tagCache;
 
   private final ExecutorService executorService;
@@ -92,6 +94,7 @@ public class PebbleEngine {
       Syntax syntax,
       boolean strictVariables,
       Locale defaultLocale,
+      int maxRenderedSize,
       PebbleCache<CacheKey, Object> tagCache,
       PebbleCache<Object, PebbleTemplate> templateCache,
       ExecutorService executorService,
@@ -103,6 +106,7 @@ public class PebbleEngine {
     this.syntax = syntax;
     this.strictVariables = strictVariables;
     this.defaultLocale = defaultLocale;
+    this.maxRenderedSize = maxRenderedSize;
     this.tagCache = tagCache;
     this.executorService = executorService;
     this.templateCache = templateCache;
@@ -221,6 +225,15 @@ public class PebbleEngine {
     return this.defaultLocale;
   }
 
+  /**
+   * Returns the max rendered size.
+   *
+   *  @return The max rendered size.
+   */
+  public int getMaxRenderedSize() {
+    return this.maxRenderedSize;
+  }
+
   /**
    * Returns the executor service
    *
@@ -274,6 +287,8 @@ public class PebbleEngine {
 
     private Locale defaultLocale;
 
+    private int maxRenderedSize = -1;
+
     private ExecutorService executorService;
 
     private PebbleCache<Object, PebbleTemplate> templateCache;
@@ -386,6 +401,19 @@ public class PebbleEngine {
       return this;
     }
 
+    /**
+     * Sets the maximum size of the rendered template to protect against macro bombs.
+     * See for example https://github.com/PebbleTemplates/pebble/issues/526.
+     * If the rendered template exceeds this limit, then a PebbleException is thrown.
+     * The default value is -1 and it means unlimited.
+     * @param maxRenderedSize The maximum allowed size of the rendered template.
+     * @return This builder object.
+     */
+    public Builder maxRenderedSize(int maxRenderedSize) {
+      this.maxRenderedSize = maxRenderedSize;
+      return this;
+    }
+
     /**
      * Sets the executor service which is required if using one of Pebble's multithreading features
      * such as the "parallel" tag.
@@ -585,7 +613,7 @@ public class PebbleEngine {
 
       EvaluationOptions evaluationOptions = new EvaluationOptions(this.greedyMatchMethod,
           this.methodAccessValidator);
-      return new PebbleEngine(this.loader, this.syntax, this.strictVariables, this.defaultLocale,
+      return new PebbleEngine(this.loader, this.syntax, this.strictVariables, this.defaultLocale, this.maxRenderedSize,
           this.tagCache, this.templateCache,
           this.executorService, extensionRegistry, parserOptions, evaluationOptions);
     }
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/node/CacheNode.java b/pebble/src/main/java/com/mitchellbosecke/pebble/node/CacheNode.java
index 800fa555..7fbb17f5 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/node/CacheNode.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/node/CacheNode.java
@@ -15,6 +15,8 @@ import com.mitchellbosecke.pebble.extension.NodeVisitor;
 import com.mitchellbosecke.pebble.node.expression.Expression;
 import com.mitchellbosecke.pebble.template.EvaluationContextImpl;
 import com.mitchellbosecke.pebble.template.PebbleTemplateImpl;
+import com.mitchellbosecke.pebble.utils.LimitedSizeWriter;
+
 import java.io.IOException;
 import java.io.StringWriter;
 import java.io.Writer;
@@ -65,7 +67,7 @@ public class CacheNode extends AbstractRenderableNode {
 
   private String render(final PebbleTemplateImpl self, final EvaluationContextImpl context)
       throws IOException {
-    StringWriter tempWriter = new StringWriter();
+    Writer tempWriter = LimitedSizeWriter.from(new StringWriter(), context);
     CacheNode.this.body.render(self, tempWriter, context);
 
     return tempWriter.toString();
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/node/MacroNode.java b/pebble/src/main/java/com/mitchellbosecke/pebble/node/MacroNode.java
index 7a20c429..9a45527f 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/node/MacroNode.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/node/MacroNode.java
@@ -14,6 +14,8 @@ import com.mitchellbosecke.pebble.template.EvaluationContextImpl;
 import com.mitchellbosecke.pebble.template.Macro;
 import com.mitchellbosecke.pebble.template.PebbleTemplateImpl;
 import com.mitchellbosecke.pebble.template.ScopeChain;
+import com.mitchellbosecke.pebble.utils.LimitedSizeWriter;
+
 import java.io.IOException;
 import java.io.StringWriter;
 import java.io.Writer;
@@ -65,7 +67,7 @@ public class MacroNode extends AbstractRenderableNode {
       @Override
       public String call(PebbleTemplateImpl self, EvaluationContextImpl context,
           Map<String, Object> macroArgs) {
-        Writer writer = new StringWriter();
+        Writer writer = LimitedSizeWriter.from(new StringWriter(), context);
         ScopeChain scopeChain = context.getScopeChain();
 
         // scope for default arguments
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/RenderableNodeExpression.java b/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/RenderableNodeExpression.java
index 56cca00b..f56da9e0 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/RenderableNodeExpression.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/node/expression/RenderableNodeExpression.java
@@ -12,6 +12,8 @@ import com.mitchellbosecke.pebble.error.PebbleException;
 import com.mitchellbosecke.pebble.node.RenderableNode;
 import com.mitchellbosecke.pebble.template.EvaluationContextImpl;
 import com.mitchellbosecke.pebble.template.PebbleTemplateImpl;
+import com.mitchellbosecke.pebble.utils.LimitedSizeWriter;
+
 import java.io.IOException;
 import java.io.StringWriter;
 import java.io.Writer;
@@ -35,7 +37,7 @@ public class RenderableNodeExpression extends UnaryExpression {
 
   @Override
   public Object evaluate(PebbleTemplateImpl self, EvaluationContextImpl context) {
-    Writer writer = new StringWriter();
+    Writer writer = LimitedSizeWriter.from(new StringWriter(), context);
     try {
       this.node.render(self, writer, context);
     } catch (IOException e) {
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/template/EvaluationContextImpl.java b/pebble/src/main/java/com/mitchellbosecke/pebble/template/EvaluationContextImpl.java
index 88ffabbf..38b11a40 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/template/EvaluationContextImpl.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/template/EvaluationContextImpl.java
@@ -21,6 +21,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * An evaluation context will store all stateful data that is necessary for the evaluation of a
@@ -28,7 +29,7 @@ import java.util.concurrent.ExecutorService;
  *
  * @author Mitchell
  */
-public class EvaluationContextImpl implements EvaluationContext {
+public class EvaluationContextImpl implements EvaluationContext, RenderedSizeContext {
 
   private final boolean strictVariables;
 
@@ -54,6 +55,11 @@ public class EvaluationContextImpl implements EvaluationContext {
    */
   private final Locale locale;
 
+  /**
+   * The maximum size of the rendered template, in chars.
+   */
+  private final int maxRenderedSize;
+
   /**
    * All the available filters/tests/functions for this template.
    */
@@ -84,6 +90,11 @@ public class EvaluationContextImpl implements EvaluationContext {
    */
   private final EvaluationOptions evaluationOptions;
 
+  /**
+   * Total number of chars written by all writers sharing this context.
+   */
+  private final AtomicInteger charsRendered = new AtomicInteger();
+
   /**
    * Constructor used to provide all final variables.
    *
@@ -96,7 +107,7 @@ public class EvaluationContextImpl implements EvaluationContext {
    * @param hierarchy The inheritance chain
    * @param tagCache The cache used by the "cache" tag
    */
-  public EvaluationContextImpl(PebbleTemplateImpl self, boolean strictVariables, Locale locale,
+  public EvaluationContextImpl(PebbleTemplateImpl self, boolean strictVariables, Locale locale, int maxRenderedSize,
       ExtensionRegistry extensionRegistry, PebbleCache<CacheKey, Object> tagCache,
       ExecutorService executorService, List<PebbleTemplateImpl> importedTemplates,
       Map<String, PebbleTemplateImpl> namedImportedTemplates, ScopeChain scopeChain,
@@ -108,6 +119,7 @@ public class EvaluationContextImpl implements EvaluationContext {
 
     this.strictVariables = strictVariables;
     this.locale = locale;
+    this.maxRenderedSize = maxRenderedSize;
     this.extensionRegistry = extensionRegistry;
     this.tagCache = tagCache;
     this.executorService = executorService;
@@ -127,7 +139,7 @@ public class EvaluationContextImpl implements EvaluationContext {
    */
   public EvaluationContextImpl shallowCopyWithoutInheritanceChain(PebbleTemplateImpl self) {
     EvaluationContextImpl result = new EvaluationContextImpl(self, this.strictVariables,
-        this.locale, this.extensionRegistry, this.tagCache,
+        this.locale, this.maxRenderedSize, this.extensionRegistry, this.tagCache,
         this.executorService, this.importedTemplates, this.namedImportedTemplates, this.scopeChain,
         null, this.evaluationOptions);
     return result;
@@ -142,7 +154,7 @@ public class EvaluationContextImpl implements EvaluationContext {
    */
   public EvaluationContextImpl threadSafeCopy(PebbleTemplateImpl self) {
     EvaluationContextImpl result = new EvaluationContextImpl(self, this.strictVariables,
-        this.locale, this.extensionRegistry, this.tagCache,
+        this.locale, this.maxRenderedSize, this.extensionRegistry, this.tagCache,
         this.executorService, new ArrayList<>(this.importedTemplates),
         new HashMap<>(this.namedImportedTemplates), this.scopeChain.deepCopy(), this.hierarchy,
         this.evaluationOptions);
@@ -169,7 +181,7 @@ public class EvaluationContextImpl implements EvaluationContext {
   /**
    * Returns whether or not this template is being evaluated in "strict templates" mode
    *
-   * @return Whether or not this template is being evaluated in "strict tempaltes" mode.
+   * @return Whether or not this template is being evaluated in "strict templates" mode.
    */
   @Override
   public boolean isStrictVariables() {
@@ -186,6 +198,15 @@ public class EvaluationContextImpl implements EvaluationContext {
     return this.locale;
   }
 
+  /**
+   * Returns the max rendered size.
+   * @return The max rendered size.
+   */
+  @Override
+  public int getMaxRenderedSize() {
+    return this.maxRenderedSize;
+  }
+
   /**
    * Returns the extension registry used to access all of the tests/filters/functions
    *
@@ -290,4 +311,9 @@ public class EvaluationContextImpl implements EvaluationContext {
             scopedFunction
     );
   }
+
+  @Override
+  public int addAndGet(int delta) {
+    return charsRendered.addAndGet(delta);
+  }
 }
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/template/PebbleTemplateImpl.java b/pebble/src/main/java/com/mitchellbosecke/pebble/template/PebbleTemplateImpl.java
index ae0cc5dd..d0255ab6 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/template/PebbleTemplateImpl.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/template/PebbleTemplateImpl.java
@@ -17,6 +17,7 @@ import com.mitchellbosecke.pebble.node.BodyNode;
 import com.mitchellbosecke.pebble.node.RenderableNode;
 import com.mitchellbosecke.pebble.node.RootNode;
 import com.mitchellbosecke.pebble.utils.FutureWriter;
+import com.mitchellbosecke.pebble.utils.LimitedSizeWriter;
 import com.mitchellbosecke.pebble.utils.Pair;
 
 import java.io.IOException;
@@ -152,6 +153,7 @@ public class PebbleTemplateImpl implements PebbleTemplate {
     if (context.getExecutorService() != null) {
       writer = new FutureWriter(writer);
     }
+    writer = LimitedSizeWriter.from(writer, context);
     this.rootNode.render(this, writer, context);
 
     /*
@@ -189,7 +191,7 @@ public class PebbleTemplateImpl implements PebbleTemplate {
     // global vars provided from extensions
     scopeChain.pushScope(this.engine.getExtensionRegistry().getGlobalVariables());
 
-    return new EvaluationContextImpl(this, this.engine.isStrictVariables(), locale,
+    return new EvaluationContextImpl(this, this.engine.isStrictVariables(), locale, this.engine.getMaxRenderedSize(),
         this.engine.getExtensionRegistry(), this.engine.getTagCache(),
         this.engine.getExecutorService(),
         new ArrayList<>(), new HashMap<>(), scopeChain, null, this.engine.getEvaluationOptions());
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/template/RenderedSizeContext.java b/pebble/src/main/java/com/mitchellbosecke/pebble/template/RenderedSizeContext.java
new file mode 100644
index 00000000..ed6f026e
--- /dev/null
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/template/RenderedSizeContext.java
@@ -0,0 +1,7 @@
+package com.mitchellbosecke.pebble.template;
+
+public interface RenderedSizeContext {
+    int getMaxRenderedSize();
+
+    int addAndGet(int delta);
+}
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/utils/FutureWriter.java b/pebble/src/main/java/com/mitchellbosecke/pebble/utils/FutureWriter.java
index bd8d678b..ea05d1a9 100644
--- a/pebble/src/main/java/com/mitchellbosecke/pebble/utils/FutureWriter.java
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/utils/FutureWriter.java
@@ -20,7 +20,7 @@ import java.util.concurrent.TimeUnit;
  * ExecutorService to the main PebbleEngine. A FutureWriter is capable of handling Futures that will
  * return a string.
  *
- * It is not thread safe but that is okay. Each thread will have it's own writer, provided by the
+ * It is not thread safe but that is okay. Each thread will have its own writer, provided by the
  * "parallel" node; i.e. they will never share writers.
  *
  * @author Mitchell
diff --git a/pebble/src/main/java/com/mitchellbosecke/pebble/utils/LimitedSizeWriter.java b/pebble/src/main/java/com/mitchellbosecke/pebble/utils/LimitedSizeWriter.java
new file mode 100644
index 00000000..eda9a8f3
--- /dev/null
+++ b/pebble/src/main/java/com/mitchellbosecke/pebble/utils/LimitedSizeWriter.java
@@ -0,0 +1,81 @@
+package com.mitchellbosecke.pebble.utils;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.template.RenderedSizeContext;
+
+import java.io.IOException;
+import java.io.Writer;
+
+/**
+ * A Writer that will wrap around the internal writer if the user also provided a limit
+ * on the size of the rendered template. The context is shared between all the writers
+ * used to evaluate a template: the one supplied by the user when calling template.evaluate
+ * as well as any internally created writers e.g. when evaluating a macro.
+ *
+ * There will be false positives. For example if a function writes something and its output
+ * is passed to a filter than we count both the output of the function and the output of the
+ * filter, when we should only count the output of the filter. This is fine because the user
+ * can increase the maximum allowable size accordingly. The purpose here is not to be precise
+ * but to protect against abuse.
+ *
+ * If the limit is reached a PebbleException will be thrown.
+ * If the limit is negative then no checks will be performed and the original writer used as is.
+ *
+ * This is thread-safe if RenderedSizeContext is thread-safe.
+ */
+public class LimitedSizeWriter extends Writer {
+
+    private final Writer internalWriter;
+
+    private final RenderedSizeContext context;
+
+    public static Writer from(Writer internalWriter, RenderedSizeContext context) {
+        if (context.getMaxRenderedSize() < 0) {
+            return internalWriter;
+        }
+
+        return new LimitedSizeWriter(internalWriter, context);
+    }
+
+    private LimitedSizeWriter(Writer internalWriter, RenderedSizeContext context) {
+        if (context.getMaxRenderedSize() < 0) {
+            throw new IllegalArgumentException("maxRenderedSize should not be negative");
+        }
+        this.internalWriter = internalWriter;
+        this.context = context;
+    }
+
+    @Override
+    public void write(char[] cbuf, int off, int len) throws IOException {
+        if (this.willExceedMaxChars(len)) {
+            throw new PebbleException(null, String.format("Tried to write more than %d chars.", this.context.getMaxRenderedSize()));
+        }
+        this.internalWriter.write(cbuf, off, len);
+    }
+
+    @Override
+    public void flush() throws IOException {
+        this.internalWriter.flush();
+    }
+
+    @Override
+    public void close() throws IOException {
+        this.internalWriter.close();
+    }
+
+    @Override
+    public String toString() {
+        return internalWriter.toString();
+    }
+
+    // This has the side effect of incrementing the number of chars written.
+    // This is necessary to maintain thread-safety, otherwise two threads might check
+    // the size before writing, both checks might be fine but the resulting output
+    // will be greater than the limit.
+    // If internalWriter.write throws than the content written and the count of chars
+    // written will get out of sync, but that's fine because at that point we don't
+    // care about accuracy anymore.
+    private boolean willExceedMaxChars(int charsToWrite) {
+        return this.context.addAndGet(charsToWrite) > this.context.getMaxRenderedSize();
+    }
+}


Test patch:
diff --git a/pebble/src/test/java/com/mitchellbosecke/pebble/MaxRenderedSizeTest.java b/pebble/src/test/java/com/mitchellbosecke/pebble/MaxRenderedSizeTest.java
new file mode 100644
index 00000000..0def4db3
--- /dev/null
+++ b/pebble/src/test/java/com/mitchellbosecke/pebble/MaxRenderedSizeTest.java
@@ -0,0 +1,44 @@
+package com.mitchellbosecke.pebble;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.template.PebbleTemplate;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.io.StringWriter;
+import java.io.Writer;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MaxRenderedSizeTest {
+    @Test
+    void renderingExplodingMacroWithLimitWillThrowPebbleException() {
+        PebbleEngine pebble = new PebbleEngine.Builder()
+                .maxRenderedSize(1000)
+                .build();
+
+        PebbleTemplate template = pebble.getTemplate("templates/template.macro.exploding.peb");
+
+        Writer writer = new StringWriter();
+
+        PebbleException thrown = assertThrows(PebbleException.class, () -> template.evaluate(writer));
+        String result = writer.toString();
+        // We didn't write more than allowed.
+        assertTrue(result.length() <= 1000);
+        assertTrue(thrown.getMessage().contains("1000"));
+    }
+
+    @Test
+    @Disabled("This test passes but takes about a minute to do so. Creating a faster macro bomb would be nice.")
+    void renderingExplodingMacroWithoutLimitWillThrowOOMException() {
+        PebbleEngine pebble = new PebbleEngine.Builder()
+                .build();
+
+        PebbleTemplate template = pebble.getTemplate("templates/template.macro.exploding.peb");
+
+        StringWriter writer = new StringWriter();
+
+        assertThrows(OutOfMemoryError.class, () -> template.evaluate(writer));
+    }
+}
diff --git a/pebble/src/test/java/com/mitchellbosecke/pebble/utils/LimitedSizeWriterTest.java b/pebble/src/test/java/com/mitchellbosecke/pebble/utils/LimitedSizeWriterTest.java
new file mode 100644
index 00000000..2bba26e8
--- /dev/null
+++ b/pebble/src/test/java/com/mitchellbosecke/pebble/utils/LimitedSizeWriterTest.java
@@ -0,0 +1,91 @@
+package com.mitchellbosecke.pebble.utils;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.template.RenderedSizeContext;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests {@link LimitedSizeWriter}.
+ */
+class LimitedSizeWriterTest {
+    @Test
+    void negativeMaxSizeReturnsTheOriginalWriter() {
+        Writer internalWriter = new StringWriter();
+
+        Writer limitedSizeWriter = LimitedSizeWriter.from(internalWriter, new TestRenderedSizeContext(-1));
+
+        assertSame(internalWriter, limitedSizeWriter);
+    }
+
+    @Test
+    void canWriteLessThanLimitChars() throws IOException {
+        Writer internalWriter = new StringWriter();
+
+        Writer limitedSizeWriter = LimitedSizeWriter.from(internalWriter, new TestRenderedSizeContext(20));
+
+        limitedSizeWriter.write("0123456789");
+        limitedSizeWriter.write("0123456789");
+
+        assertEquals("01234567890123456789", internalWriter.toString());
+    }
+
+    @Test
+    void cannotWriteMoreThanLimitChars() throws IOException {
+        Writer internalWriter = new StringWriter();
+
+        Writer limitedSizeWriter = LimitedSizeWriter.from(internalWriter, new TestRenderedSizeContext(19));
+
+        limitedSizeWriter.write("0123456789");
+
+        PebbleException thrown = assertThrows(
+                PebbleException.class,
+                () -> limitedSizeWriter.write("0123456789"));
+
+        assertTrue(thrown.getMessage().contains("19"));
+    }
+
+    @Test
+    void contextIsSharedBetweenWriters() throws IOException {
+        Writer internalWriter1 = new StringWriter();
+        Writer internalWriter2 = new StringWriter();
+
+        TestRenderedSizeContext context = new TestRenderedSizeContext(19);
+        Writer limitedSizeWriter1 = LimitedSizeWriter.from(internalWriter1, context);
+        Writer limitedSizeWriter2 = LimitedSizeWriter.from(internalWriter2, context);
+
+        limitedSizeWriter1.write("0123456789");
+
+        PebbleException thrown = assertThrows(
+                PebbleException.class,
+                () -> limitedSizeWriter2.write("0123456789"));
+
+        assertTrue(thrown.getMessage().contains("19"));
+    }
+
+    // This is the exact same implementation as in EvaluationContextImpl.
+    static private class TestRenderedSizeContext implements RenderedSizeContext {
+        private final int maxRenderedSize;
+        private final AtomicInteger charsRendered = new AtomicInteger();
+
+        public TestRenderedSizeContext(int maxRenderedSize) {
+            this.maxRenderedSize = maxRenderedSize;
+        }
+
+        @Override
+        public int getMaxRenderedSize() {
+            return this.maxRenderedSize;
+        }
+
+        @Override
+        public int addAndGet(int delta) {
+            return this.charsRendered.addAndGet(delta);
+        }
+    }
+}
diff --git a/pebble/src/test/resources/templates/template.macro.exploding.peb b/pebble/src/test/resources/templates/template.macro.exploding.peb
new file mode 100644
index 00000000..aa9b7902
--- /dev/null
+++ b/pebble/src/test/resources/templates/template.macro.exploding.peb
@@ -0,0 +1,11 @@
+{{ m9() }}
+{% macro m0() %}1234567890{% endmacro %}
+{% macro m1() %}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{{m0()}}{% endmacro %}
+{% macro m2() %}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{{m1()}}{% endmacro %}
+{% macro m3() %}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{{m2()}}{% endmacro %}
+{% macro m4() %}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{{m3()}}{% endmacro %}
+{% macro m5() %}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{{m4()}}{% endmacro %}
+{% macro m6() %}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{{m5()}}{% endmacro %}
+{% macro m7() %}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{{m6()}}{% endmacro %}
+{% macro m8() %}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{{m7()}}{% endmacro %}
+{% macro m9() %}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{{m8()}}{% endmacro %}