Repository: kestra-io/kestra (java)

Issue:
Disable Pebble rendering on webhook trigger variables (keep rendering secrets/env vars on key, but don't parse the trigger body)
### Feature description

## Problem 

Take the following flow as example:

```yaml
id: github_to_notion
namespace: product

concurrency:
  behavior: QUEUE
  limit: 1

inputs:
  - name: github_url
    type: STRING
    defaults: https://github.com/kestra-io/docs/issues/678

variables:
  github_url: "{{ trigger.body.pull_request.html_url ?? trigger.body.issue.html_url ?? inputs.github_url }}"

tasks:
  - id: github_to_notion
    type: io.kestra.plugin.scripts.python.Commands
    warningOnStdErr: false
    env:
      GITHUB_TOKEN: "{{ secret('GITHUB_TOKEN') }}"
      NOTION_TOKEN: "{{ secret('NOTION_TOKEN') }}"
      DATABASE_ID: "81a8f4c0b1af4f3481da3b9bcab5537b"
    beforeCommands:
      - pip install requests markdown2 beautifulsoup4 --no-warn-script-location
    runner: PROCESS
    namespaceFiles:
      enabled: true
    commands:
      - python scripts/github_to_notion.py "{{ vars.github_url }}"

triggers:
  - id: github
    type: io.kestra.core.models.triggers.types.Webhook
    key: "mykey"
```

It takes a GitHub issue URL and loads it to Notion.  

The execution triggered by a webhook fails with error:

```
Missing variable: 'secrets' on 'Hi, I've set-up a github action as per readme.
...
Root attribute [secrets] does not exist or can not be accessed and strict variables is set to true.
```

## Suggested solution

Disable Pebble rendering on webhook trigger variables. 

![image](https://github.com/kestra-io/kestra/assets/86264395/6c3fe9e3-6629-4c30-a317-ea9217eacc9a)
  

Pull request description:
fix/disable-recursive-rendering
closes #2740

Patch:
diff --git a/core/src/main/java/io/kestra/core/runners/VariableRenderer.java b/core/src/main/java/io/kestra/core/runners/VariableRenderer.java
index 95fcd42dc..e63f74eea 100644
--- a/core/src/main/java/io/kestra/core/runners/VariableRenderer.java
+++ b/core/src/main/java/io/kestra/core/runners/VariableRenderer.java
@@ -5,11 +5,6 @@ import com.github.jknack.handlebars.Handlebars;
 import com.github.jknack.handlebars.HandlebarsException;
 import com.github.jknack.handlebars.Template;
 import com.github.jknack.handlebars.helper.*;
-import io.pebbletemplates.pebble.PebbleEngine;
-import io.pebbletemplates.pebble.error.AttributeNotFoundException;
-import io.pebbletemplates.pebble.error.PebbleException;
-import io.pebbletemplates.pebble.extension.AbstractExtension;
-import io.pebbletemplates.pebble.template.PebbleTemplate;
 import io.kestra.core.exceptions.IllegalVariableEvaluationException;
 import io.kestra.core.runners.handlebars.VariableRendererPlugins;
 import io.kestra.core.runners.handlebars.helpers.*;
@@ -18,6 +13,15 @@ import io.kestra.core.runners.pebble.JsonWriter;
 import io.kestra.core.runners.pebble.PebbleLruCache;
 import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.annotation.ConfigurationProperties;
+import io.micronaut.core.annotation.Nullable;
+import io.pebbletemplates.pebble.PebbleEngine;
+import io.pebbletemplates.pebble.error.AttributeNotFoundException;
+import io.pebbletemplates.pebble.error.PebbleException;
+import io.pebbletemplates.pebble.extension.AbstractExtension;
+import io.pebbletemplates.pebble.template.PebbleTemplate;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import lombok.Getter;
 
 import java.io.IOException;
 import java.io.StringWriter;
@@ -26,17 +30,13 @@ import java.util.*;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import io.micronaut.core.annotation.Nullable;
-import jakarta.inject.Inject;
-import jakarta.inject.Singleton;
-import lombok.Getter;
-
 @Singleton
 public class VariableRenderer {
     private static final Pattern RAW_PATTERN = Pattern.compile("\\{%[-]*\\s*raw\\s*[-]*%\\}(.*?)\\{%[-]*\\s*endraw\\s*[-]*%\\}");
+    public static final int MAX_RENDERING_AMOUNT = 100;
 
     private Handlebars handlebars;
-    private final PebbleEngine pebbleEngine;
+    private PebbleEngine pebbleEngine;
     private final VariableConfiguration variableConfiguration;
 
     @SuppressWarnings("unchecked")
@@ -89,10 +89,27 @@ public class VariableRenderer {
             pebbleBuilder.templateCache(new PebbleLruCache(this.variableConfiguration.getCacheSize()));
         }
 
-        pebbleEngine = pebbleBuilder.build();
+        this.pebbleEngine = pebbleBuilder.build();
+    }
+
+    public static IllegalVariableEvaluationException properPebbleException(PebbleException e) {
+        if (e instanceof AttributeNotFoundException current) {
+            return new IllegalVariableEvaluationException(
+                "Missing variable: '" + current.getAttributeName() +
+                    "' on '" + current.getFileName() +
+                    "' at line " + current.getLineNumber(),
+                e
+            );
+        }
+
+        return new IllegalVariableEvaluationException(e);
+    }
+
+    public String render(String inline, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.render(inline, variables, this.variableConfiguration.getRecursiveRendering());
     }
 
-    public String recursiveRender(String inline, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+    public String render(String inline, Map<String, Object> variables, boolean recursive) throws IllegalVariableEvaluationException {
         if (inline == null) {
             return null;
         }
@@ -102,81 +119,83 @@ public class VariableRenderer {
             return inline;
         }
 
+        return recursive
+            ? renderRecursively(inline, variables)
+            : renderOnce(inline, variables);
+    }
+
+    public String renderOnce(String inline, Map<String, Object> variables) throws IllegalVariableEvaluationException {
         // pre-process raw tags
         Matcher rawMatcher = RAW_PATTERN.matcher(inline);
         Map<String, String> replacers = new HashMap<>((int) Math.ceil(rawMatcher.groupCount() / 0.75));
-        String currentTemplate = rawMatcher.replaceAll(result -> {
+        String result = rawMatcher.replaceAll(matchResult -> {
             var uuid = UUID.randomUUID().toString();
-            replacers.put(uuid, result.group(1));
+            replacers.put(uuid, matchResult.group(1));
             return uuid;
         });
 
-        boolean isSame = false;
-        String current = "";
-        PebbleTemplate compiledTemplate;
-        while (!isSame) {
-            try {
-                compiledTemplate = pebbleEngine.getLiteralTemplate(currentTemplate);
-
-                Writer writer = new JsonWriter(new StringWriter());
-                compiledTemplate.evaluate(writer, variables);
-                current = writer.toString();
-            } catch (IOException | PebbleException e) {
-                if (this.variableConfiguration.disableHandlebars) {
-                    if (e instanceof PebbleException) {
-                        throw properPebbleException((PebbleException) e);
-                    }
-
-                    throw new IllegalVariableEvaluationException(e);
-                }
+        try {
+            PebbleTemplate compiledTemplate = this.pebbleEngine.getLiteralTemplate(result);
 
-                try {
-                    Template  template = handlebars.compileInline(currentTemplate);
-                    current = template.apply(variables);
-                } catch (HandlebarsException | IOException hbE) {
-                    throw new IllegalVariableEvaluationException(
-                        "Pebble evaluation failed with '" + e.getMessage() +  "' " +
-                        "and Handlebars fallback failed also  with '" + hbE.getMessage() + "'" ,
-                        e
-                    );
+            Writer writer = new JsonWriter(new StringWriter());
+            compiledTemplate.evaluate(writer, variables);
+            result = writer.toString();
+        } catch (IOException | PebbleException e) {
+            if (this.handlebars == null) {
+                if (e instanceof PebbleException) {
+                    throw properPebbleException((PebbleException) e);
                 }
+
+                throw new IllegalVariableEvaluationException(e);
             }
 
-            isSame = currentTemplate.equals(current);
-            currentTemplate = current;
+            try {
+                Template template = this.handlebars.compileInline(inline);
+                result = template.apply(variables);
+            } catch (HandlebarsException | IOException hbE) {
+                throw new IllegalVariableEvaluationException(
+                    "Pebble evaluation failed with '" + e.getMessage() + "' " +
+                        "and Handlebars fallback failed also  with '" + hbE.getMessage() + "'",
+                    e
+                );
+            }
         }
 
         // post-process raw tags
-        for(var entry: replacers.entrySet()) {
-            current = current.replace(entry.getKey(), entry.getValue());
+        for (var entry : replacers.entrySet()) {
+            result = result.replace(entry.getKey(), entry.getValue());
         }
-        return current;
+
+        return result;
     }
 
-    public IllegalVariableEvaluationException properPebbleException(PebbleException e) {
-        if (e instanceof AttributeNotFoundException current) {
-            return new IllegalVariableEvaluationException(
-                "Missing variable: '" + current.getAttributeName() +
-                    "' on '" + current.getFileName() +
-                    "' at line " + current.getLineNumber(),
-                e
-            );
+    public String renderRecursively(String inline, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.renderRecursively(0, inline, variables);
+    }
+
+    private String renderRecursively(int renderingCount, String inline, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        if (renderingCount > MAX_RENDERING_AMOUNT) {
+            throw new IllegalVariableEvaluationException("Too many rendering attempts");
         }
 
-        return new IllegalVariableEvaluationException(e);
-    }
+        String result = this.renderOnce(inline, variables);
+        if (result.equals(inline)) {
+            return result;
+        }
 
-    public String render(String inline, Map<String, Object> variables) throws IllegalVariableEvaluationException {
-        return this.recursiveRender(inline, variables);
+        return renderRecursively(++renderingCount, result, variables);
     }
 
-
     public Map<String, Object> render(Map<String, Object> in, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.render(in, variables, this.variableConfiguration.getRecursiveRendering());
+    }
+
+    public Map<String, Object> render(Map<String, Object> in, Map<String, Object> variables, boolean recursive) throws IllegalVariableEvaluationException {
         Map<String, Object> map = new HashMap<>();
 
         for (Map.Entry<String, Object> r : in.entrySet()) {
             String key = this.render(r.getKey(), variables);
-            Object value = renderObject(r.getValue(), variables).orElse(r.getValue());
+            Object value = renderObject(r.getValue(), variables, recursive).orElse(r.getValue());
 
             map.putIfAbsent(
                 key,
@@ -187,24 +206,34 @@ public class VariableRenderer {
         return map;
     }
 
+    public Optional<Object> renderObject(Object object, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.renderObject(object, variables, this.variableConfiguration.getRecursiveRendering());
+    }
+
     @SuppressWarnings({"unchecked", "rawtypes"})
-    private Optional<Object> renderObject(Object object, Map<String, Object> variables) throws IllegalVariableEvaluationException {
-        if (object instanceof Map) {
-            return Optional.of(this.render((Map) object, variables));
-        } else if (object instanceof Collection) {
-            return Optional.of(this.renderList((List) object, variables));
-        } else if (object instanceof String) {
-            return Optional.of(this.render((String) object, variables));
+    public Optional<Object> renderObject(Object object, Map<String, Object> variables, boolean recursive) throws IllegalVariableEvaluationException {
+        if (object instanceof Map map) {
+            return Optional.of(this.render(map, variables, recursive));
+        } else if (object instanceof List list) {
+            return Optional.of(this.renderList(list, variables, recursive));
+        } else if (object instanceof Set set) {
+            return Optional.of(this.render(set, variables, recursive));
+        } else if (object instanceof String string) {
+            return Optional.of(this.render(string, variables, recursive));
         }
 
         return Optional.empty();
     }
 
     public List<Object> renderList(List<Object> list, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.renderList(list, variables, this.variableConfiguration.getRecursiveRendering());
+    }
+
+    public List<Object> renderList(List<Object> list, Map<String, Object> variables, boolean recursive) throws IllegalVariableEvaluationException {
         List<Object> result = new ArrayList<>();
 
         for (Object inline : list) {
-            this.renderObject(inline, variables)
+            this.renderObject(inline, variables, recursive)
                 .ifPresent(result::add);
         }
 
@@ -212,18 +241,26 @@ public class VariableRenderer {
     }
 
     public List<String> render(List<String> list, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.render(list, variables, this.variableConfiguration.getRecursiveRendering());
+    }
+
+    public List<String> render(List<String> list, Map<String, Object> variables, boolean recursive) throws IllegalVariableEvaluationException {
         List<String> result = new ArrayList<>();
         for (String inline : list) {
-            result.add(this.recursiveRender(inline, variables));
+            result.add(this.render(inline, variables, recursive));
         }
 
         return result;
     }
 
-    public Set<String> render(Set<String> list, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+    public Set<String> render(Set<String> set, Map<String, Object> variables) throws IllegalVariableEvaluationException {
+        return this.render(set, variables, this.variableConfiguration.getRecursiveRendering());
+    }
+
+    public Set<String> render(Set<String> list, Map<String, Object> variables, boolean recursive) throws IllegalVariableEvaluationException {
         Set<String> result = new HashSet<>();
         for (String inline : list) {
-            result.add(this.recursiveRender(inline, variables));
+            result.add(this.render(inline, variables, recursive));
         }
 
         return result;
@@ -236,10 +273,12 @@ public class VariableRenderer {
             this.disableHandlebars = true;
             this.cacheEnabled = true;
             this.cacheSize = 1000;
+            this.recursiveRendering = false;
         }
 
         Boolean disableHandlebars;
         Boolean cacheEnabled;
         Integer cacheSize;
+        Boolean recursiveRendering;
     }
 }
diff --git a/core/src/main/java/io/kestra/core/runners/handlebars/helpers/EvalHelper.java b/core/src/main/java/io/kestra/core/runners/handlebars/helpers/EvalHelper.java
index ee2f941f0..3233769bd 100644
--- a/core/src/main/java/io/kestra/core/runners/handlebars/helpers/EvalHelper.java
+++ b/core/src/main/java/io/kestra/core/runners/handlebars/helpers/EvalHelper.java
@@ -18,9 +18,9 @@ public class EvalHelper implements Helper<String> {
     @SneakyThrows
     @Override
     public CharSequence apply(final String value, final Options options) {
-        String finalTemplate = variableRenderer.recursiveRender(value, (Map<String, Object>) options.context.model());
+        String finalTemplate = variableRenderer.renderRecursively(value, (Map<String, Object>) options.context.model());
 
-        return variableRenderer.recursiveRender("{{" + finalTemplate + "}}", (Map<String, Object>) options.context.model());
+        return variableRenderer.renderRecursively("{{" + finalTemplate + "}}", (Map<String, Object>) options.context.model());
     }
 }
 
diff --git a/core/src/main/java/io/kestra/core/runners/handlebars/helpers/FirstDefinedEvalHelper.java b/core/src/main/java/io/kestra/core/runners/handlebars/helpers/FirstDefinedEvalHelper.java
index 82ae8dfb6..b0d5f28b9 100644
--- a/core/src/main/java/io/kestra/core/runners/handlebars/helpers/FirstDefinedEvalHelper.java
+++ b/core/src/main/java/io/kestra/core/runners/handlebars/helpers/FirstDefinedEvalHelper.java
@@ -38,9 +38,9 @@ public class FirstDefinedEvalHelper implements Helper<String> {
         while (result == null && i < params.size()) {
             try {
                 String param = params.get(i++);
-                String finalTemplate = variableRenderer.recursiveRender(param, (Map<String, Object>) options.context.model());
+                String finalTemplate = variableRenderer.render(param, (Map<String, Object>) options.context.model());
 
-                result = variableRenderer.recursiveRender("{{" + finalTemplate + "}}", (Map<String, Object>) options.context.model());
+                result = variableRenderer.render("{{" + finalTemplate + "}}", (Map<String, Object>) options.context.model());
             } catch (IllegalVariableEvaluationException | IllegalStateException | HandlebarsException ignored) {
             }
         }
diff --git a/core/src/main/java/io/kestra/core/runners/pebble/Extension.java b/core/src/main/java/io/kestra/core/runners/pebble/Extension.java
index 3529889b9..e72a79d55 100644
--- a/core/src/main/java/io/kestra/core/runners/pebble/Extension.java
+++ b/core/src/main/java/io/kestra/core/runners/pebble/Extension.java
@@ -1,6 +1,8 @@
 package io.kestra.core.runners.pebble;
 
+import io.kestra.core.runners.VariableRenderer;
 import io.kestra.core.runners.pebble.functions.*;
+import io.micronaut.core.annotation.Nullable;
 import io.pebbletemplates.pebble.extension.*;
 import io.pebbletemplates.pebble.operator.Associativity;
 import io.pebbletemplates.pebble.operator.BinaryOperator;
@@ -29,6 +31,10 @@ public class Extension extends AbstractExtension {
     @Inject
     private ReadFileFunction readFileFunction;
 
+    @Inject
+    @Nullable
+    private RenderFunction renderFunction;
+
     @Override
     public List<TokenParser> getTokenParsers() {
         return null;
@@ -84,15 +90,18 @@ public class Extension extends AbstractExtension {
 
     @Override
     public Map<String, Function> getFunctions() {
-        Map<String, Function> tests = new HashMap<>();
-
-        tests.put("now", new NowFunction());
-        tests.put("json", new JsonFunction());
-        tests.put("currentEachOutput", new CurrentEachOutputFunction());
-        tests.put("secret", secretFunction);
-        tests.put("read", readFileFunction);
-
-        return tests;
+        Map<String, Function> functions = new HashMap<>();
+
+        functions.put("now", new NowFunction());
+        functions.put("json", new JsonFunction());
+        functions.put("currentEachOutput", new CurrentEachOutputFunction());
+        functions.put("secret", secretFunction);
+        functions.put("read", readFileFunction);
+        if (this.renderFunction != null) {
+            functions.put("render", renderFunction);
+        }
+
+        return functions;
     }
 
     @Override
diff --git a/core/src/main/java/io/kestra/core/runners/pebble/functions/RenderFunction.java b/core/src/main/java/io/kestra/core/runners/pebble/functions/RenderFunction.java
new file mode 100644
index 000000000..fb60921e0
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/runners/pebble/functions/RenderFunction.java
@@ -0,0 +1,61 @@
+package io.kestra.core.runners.pebble.functions;
+
+import io.kestra.core.exceptions.IllegalVariableEvaluationException;
+import io.kestra.core.runners.VariableRenderer;
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.context.annotation.Requires;
+import io.micronaut.core.util.StringUtils;
+import io.pebbletemplates.pebble.error.PebbleException;
+import io.pebbletemplates.pebble.extension.Function;
+import io.pebbletemplates.pebble.template.EvaluationContext;
+import io.pebbletemplates.pebble.template.EvaluationContextImpl;
+import io.pebbletemplates.pebble.template.PebbleTemplate;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+@Requires(property = "kestra.variables.recursive-rendering", value = StringUtils.FALSE, defaultValue = StringUtils.FALSE)
+public class RenderFunction implements Function {
+    @Inject
+    private ApplicationContext applicationContext;
+
+    public List<String> getArgumentNames() {
+        return List.of("toRender", "recursive");
+    }
+
+    @Override
+    public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
+        if (!args.containsKey("toRender")) {
+            throw new PebbleException(null, "The 'render' function expects an argument 'toRender'.", lineNumber, self.getName());
+        }
+        Object toRender = args.get("toRender");
+
+        Object recursiveArg = args.get("recursive");
+        if (recursiveArg == null) {
+            recursiveArg = true;
+        }
+
+        if (!(recursiveArg instanceof Boolean)) {
+            throw new PebbleException(null, "The 'render' function expects an optional argument 'recursive' with type boolean.", lineNumber, self.getName());
+        }
+        Boolean recursive = (Boolean) recursiveArg;
+
+        EvaluationContextImpl evaluationContext = (EvaluationContextImpl) context;
+        Map<String, Object> variables = evaluationContext.getScopeChain().getGlobalScopes().stream()
+            .flatMap(scope -> scope.getKeys().stream())
+            .distinct()
+            .collect(HashMap::new, (m, v) -> m.put(v, context.getVariable(v)), HashMap::putAll);
+
+        VariableRenderer variableRenderer = applicationContext.getBean(VariableRenderer.class);
+
+        try {
+            return variableRenderer.renderObject(toRender, variables, recursive).orElse(null);
+        } catch (IllegalVariableEvaluationException e) {
+            throw new PebbleException(e, e.getMessage());
+        }
+    }
+}


Test patch:
diff --git a/core/src/test/java/io/kestra/core/runners/handlebars/helpers/HandlebarsVariableRendererTest.java b/core/src/test/java/io/kestra/core/runners/handlebars/helpers/HandlebarsVariableRendererTest.java
index 18f473429..b123c367b 100644
--- a/core/src/test/java/io/kestra/core/runners/handlebars/helpers/HandlebarsVariableRendererTest.java
+++ b/core/src/test/java/io/kestra/core/runners/handlebars/helpers/HandlebarsVariableRendererTest.java
@@ -48,12 +48,19 @@ class HandlebarsVariableRendererTest {
     void recursive() throws IllegalVariableEvaluationException {
         ImmutableMap<String, Object> vars = ImmutableMap.of(
             "first", "1",
-            "second", "{{third}}",
-            "third", "{{first}}"
+            "second", "{{first}}",
+            "third", "{{second}}",
+            "fourth", "{{render(third, recursive=false)}}"
         );
 
-        String render = variableRenderer.render("{{ second }}", vars);
+        String render = variableRenderer.render("{{ third }}", vars);
+        assertThat(render, is("{{second}}"));
 
+        render = variableRenderer.render("{{ render(third) }}", vars);
+        assertThat(render, is("1"));
+
+        // even if recursive = false in the underneath variable, we don't disable recursiveness since it's too hacky and an edge case
+        render = variableRenderer.render("{{ render(fourth) }}", vars);
         assertThat(render, is("1"));
     }
 
diff --git a/core/src/test/java/io/kestra/core/runners/handlebars/helpers/JqHelperTest.java b/core/src/test/java/io/kestra/core/runners/handlebars/helpers/JqHelperTest.java
index 3ffe3f546..3b97ef77e 100644
--- a/core/src/test/java/io/kestra/core/runners/handlebars/helpers/JqHelperTest.java
+++ b/core/src/test/java/io/kestra/core/runners/handlebars/helpers/JqHelperTest.java
@@ -26,7 +26,7 @@ class JqHelperTest {
             "third", "{{end}}"
         );
 
-        String render = variableRenderer.render("{{ jq first \".second.third\" }}", vars);
+        String render = variableRenderer.render("{{ jq (eval '{{first}}') \".second.third\" }}", vars);
 
         assertThat(render, is("[\"awesome\"]"));
     }
diff --git a/core/src/test/java/io/kestra/core/runners/pebble/PebbleVariableRendererTest.java b/core/src/test/java/io/kestra/core/runners/pebble/PebbleVariableRendererTest.java
index d74c3cfde..9cbe97e2e 100644
--- a/core/src/test/java/io/kestra/core/runners/pebble/PebbleVariableRendererTest.java
+++ b/core/src/test/java/io/kestra/core/runners/pebble/PebbleVariableRendererTest.java
@@ -1,6 +1,8 @@
 package io.kestra.core.runners.pebble;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import io.kestra.core.exceptions.IllegalVariableEvaluationException;
 import io.kestra.core.runners.VariableRenderer;
 import io.kestra.core.utils.Rethrow;
@@ -154,13 +156,65 @@ class PebbleVariableRendererTest {
     void recursive() throws IllegalVariableEvaluationException {
         ImmutableMap<String, Object> vars = ImmutableMap.of(
             "first", "1",
-            "second", "{{third}}",
-            "third", "{{first}}"
+            "second", "{{first}}",
+            "third", "{{second}}",
+            "fourth", "{{render(third, recursive=false)}}",
+            "map", ImmutableMap.of(
+                "third", "{{third}}"
+            ),
+            "list", ImmutableList.of(
+                "{{third}}"
+            ),
+            "set", ImmutableSet.of(
+                "{{third}}"
+            )
         );
 
-        String render = variableRenderer.render("{{ second }}", vars);
+        String render = variableRenderer.render("{{ third }}", vars);
+        assertThat(render, is("{{second}}"));
+        render = variableRenderer.render("{{ render(third, recursive=false) }}", vars);
+        assertThat(render, is("{{first}}"));
+        render = variableRenderer.render("{{ render(third) }}", vars);
+        assertThat(render, is("1"));
 
+        // even if recursive = false in the underneath variable, we don't disable recursiveness since it's too hacky and an edge case
+        render = variableRenderer.render("{{ render(fourth) }}", vars);
         assertThat(render, is("1"));
+
+        render = variableRenderer.render("{{ map }}", vars);
+        assertThat(render, is("{\"third\":\"{{third}}\"}"));
+        render = variableRenderer.render("{{ render(map, recursive=false) }}", vars);
+        assertThat(render, is("{\"third\":\"{{second}}\"}"));
+        render = variableRenderer.render("{{ render(map) }}", vars);
+        assertThat(render, is("{\"third\":\"1\"}"));
+
+        render = variableRenderer.render("{{ list }}", vars);
+        assertThat(render, is("[\"{{third}}\"]"));
+        render = variableRenderer.render("{{ render(list, recursive=false) }}", vars);
+        assertThat(render, is("[\"{{second}}\"]"));
+        render = variableRenderer.render("{{ render(list) }}", vars);
+        assertThat(render, is("[\"1\"]"));
+
+        render = variableRenderer.render("{{ set }}", vars);
+        assertThat(render, is("[\"{{third}}\"]"));
+        render = variableRenderer.render("{{ render(set, recursive=false) }}", vars);
+        assertThat(render, is("[\"{{second}}\"]"));
+        render = variableRenderer.render("{{ render(set) }}", vars);
+        assertThat(render, is("[\"1\"]"));
+    }
+
+    @Test
+    void recursiveRenderingAmountLimit() {
+        ImmutableMap<String, Object> vars = ImmutableMap.of(
+            "first", "{{second}}",
+            "second", "{{first}}"
+        );
+
+        IllegalVariableEvaluationException illegalVariableEvaluationException = assertThrows(
+            IllegalVariableEvaluationException.class,
+            () -> variableRenderer.render("{{ render(first) }}", vars)
+        );
+        assertThat(illegalVariableEvaluationException.getMessage(), containsString("Too many rendering attempts"));
     }
 
     @Test
diff --git a/core/src/test/java/io/kestra/core/runners/pebble/RecursivePebbleVariableRendererTest.java b/core/src/test/java/io/kestra/core/runners/pebble/RecursivePebbleVariableRendererTest.java
new file mode 100644
index 000000000..9452d7a7f
--- /dev/null
+++ b/core/src/test/java/io/kestra/core/runners/pebble/RecursivePebbleVariableRendererTest.java
@@ -0,0 +1,66 @@
+package io.kestra.core.runners.pebble;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import io.kestra.core.exceptions.IllegalVariableEvaluationException;
+import io.kestra.core.runners.VariableRenderer;
+import io.micronaut.context.annotation.Property;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@MicronautTest
+@Property(name = "kestra.variables.recursive-rendering", value = "true")
+class RecursivePebbleVariableRendererTest {
+    @Inject
+    VariableRenderer variableRenderer;
+
+    @Test
+    void recursive() throws IllegalVariableEvaluationException {
+        ImmutableMap<String, Object> vars = ImmutableMap.of(
+            "first", "1",
+            "second", "{{first}}",
+            "third", "{{second}}",
+            "map", ImmutableMap.of(
+                "third", "{{third}}"
+            ),
+            "list", ImmutableList.of(
+                "{{third}}"
+            ),
+            "set", ImmutableSet.of(
+                "{{third}}"
+            )
+        );
+
+        String render = variableRenderer.render("{{ third }}", vars);
+        assertThat(render, is("1"));
+
+        render = variableRenderer.render("{{ map }}", vars);
+        assertThat(render, is("{\"third\":\"1\"}"));
+
+        render = variableRenderer.render("{{ list }}", vars);
+        assertThat(render, is("[\"1\"]"));
+
+        render = variableRenderer.render("{{ set }}", vars);
+        assertThat(render, is("[\"1\"]"));
+    }
+
+    @Test
+    void renderFunctionNotInjectedIfRecursiveSettingsTrue() {
+        ImmutableMap<String, Object> vars = ImmutableMap.of(
+            "first", "1"
+        );
+
+        IllegalVariableEvaluationException illegalVariableEvaluationException = assertThrows(
+            IllegalVariableEvaluationException.class,
+            () -> variableRenderer.render("{{ render(first) }}", vars)
+        );
+        assertThat(illegalVariableEvaluationException.getMessage(), containsString("Function or Macro [render] does not exist"));
+    }
+}
\ No newline at end of file
diff --git a/core/src/test/java/io/kestra/core/runners/pebble/filters/JqFilterTest.java b/core/src/test/java/io/kestra/core/runners/pebble/filters/JqFilterTest.java
index 6d85c48e3..1299285f6 100644
--- a/core/src/test/java/io/kestra/core/runners/pebble/filters/JqFilterTest.java
+++ b/core/src/test/java/io/kestra/core/runners/pebble/filters/JqFilterTest.java
@@ -43,7 +43,7 @@ class JqFilterTest {
             "third", "{{end}}"
         );
 
-        String render = variableRenderer.render("{{  first | jq(\".second.third\") }}", vars);
+        String render = variableRenderer.render("{{  render(first) | jq(\".second.third\") }}", vars);
         assertThat(render, is("[\"awesome\"]"));
     }
 
diff --git a/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java b/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java
index 7514069e1..44c241eb6 100644
--- a/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java
+++ b/core/src/test/java/io/kestra/core/runners/pebble/functions/ReadFileFunctionTest.java
@@ -34,7 +34,7 @@ class ReadFileFunctionTest {
         storageInterface.createDirectory(null, URI.create(storageInterface.namespaceFilePrefix(namespace)));
         storageInterface.put(null, URI.create(storageInterface.namespaceFilePrefix(namespace) + "/" + filePath), new ByteArrayInputStream("Hello from {{ flow.namespace }}".getBytes()));
 
-        String render = variableRenderer.render("{{ read('" + filePath + "') }}", Map.of("flow", Map.of("namespace", namespace)));
+        String render = variableRenderer.render("{{ render(read('" + filePath + "')) }}", Map.of("flow", Map.of("namespace", namespace)));
         assertThat(render, is("Hello from " + namespace));
     }
 
diff --git a/core/src/test/resources/flows/valids/variables.yaml b/core/src/test/resources/flows/valids/variables.yaml
index 853df75b6..ed7b25fef 100644
--- a/core/src/test/resources/flows/valids/variables.yaml
+++ b/core/src/test/resources/flows/valids/variables.yaml
@@ -9,7 +9,7 @@ variables:
 tasks:
   - id: variable
     type: io.kestra.core.tasks.debugs.Return
-    format: "{{vars.third}}"
+    format: "{{render(vars.third)}}"
   - id: env
     type: io.kestra.core.tasks.debugs.Return
     format: "{{envs.test1}} {{envs.test2}}"