Skip to content

Commit aacc12b

Browse files
committed
automatically escape JSON values, if renderend in a template for a JSON content-type; removed 'fn.' prefix from Groovy function calls
1 parent c4cdbbe commit aacc12b

33 files changed

Lines changed: 166 additions & 114 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ api:
5656
htpasswdPath: .htpasswd
5757
- request:
5858
- template:
59+
contentType: application/json
5960
src: |
6061
{
61-
"sub": ${fn.toJSON(fn.user())}
62+
"sub": ${user()}
6263
}
6364
- jwtSign:
6465
jwk:
@@ -716,7 +717,7 @@ api:
716717
contentType: application/json
717718
src: |
718719
{
719-
"destination": ${fn.toJSON(json.city)}
720+
"destination": ${json.city}
720721
}
721722
- return:
722723
status: 200
@@ -750,7 +751,7 @@ api:
750751
- request:
751752
- template:
752753
src: |
753-
Buenas noches, ${fn.xpath('/person/@firstname')}
754+
Buenas noches, ${xpath('/person/@firstname')}
754755
- return:
755756
status: 200
756757
```

core/src/main/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptor.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import com.predic8.membrane.annot.*;
1818
import com.predic8.membrane.core.exceptions.*;
1919
import com.predic8.membrane.core.exchange.*;
20+
import com.predic8.membrane.core.http.MimeType;
2021
import com.predic8.membrane.core.interceptor.*;
22+
import com.predic8.membrane.core.lang.groovy.adapted.StreamingTemplateEngine;
2123
import com.predic8.membrane.core.util.*;
2224
import groovy.lang.*;
2325
import groovy.text.*;
@@ -40,7 +42,12 @@
4042
* header, query parameters, etc. If the extension of a referenced template file is <i>.xml</i> it will use
4143
* <a href="https://docs.groovy-lang.org/docs/next/html/documentation/template-engines.html#_xmltemplateengine">XMLTemplateEngine</a>
4244
* otherwise <a href="https://docs.groovy-lang.org/docs/next/html/documentation/template-engines.html#_streamingtemplateengine">StreamingTemplateEngine</a>.
43-
* Have a look at the samples in <a href="https://github.com/membrane/api-gateway/tree/master/distribution/examples">examples/template</a>.
45+
* Have a look at the samples in <a href="https://github.com/membrane/api-gateway/tree/master/distribution/examples/templating">examples/templating</a>.
46+
*
47+
* When the <code>contentType</code> is a JSON variant (e.g., <code>application/json</code>), the engine automatically escapes all inserted values. For example, in the
48+
* <a href="https://github.com/membrane/api-gateway/tree/master/distribution/examples/templating/json">JSON templating example</a>, executing
49+
* <code>curl "localhost:2000/?answer=20"</code> returns <code>{ "answer" : "20" }</code>. The quotes surrounding the value 20 are added by the auto-escaping mechanism
50+
* to ensure the output remains a valid string. This feature significantly mitigates security risks by preventing inadvertent JSON injection attacks.
4451
* @topic 2. Enterprise Integration Patterns
4552
*/
4653
@MCElement(name = "template", mixed = true)
@@ -121,13 +128,17 @@ protected byte[] getContent(Exchange exchange, Flow flow) {
121128
}
122129

123130
private @NotNull Map<String, Object> getVariableBinding(Exchange exc, Flow flow) {
124-
return createParameterBindings(router, exc, flow, scriptAccessesJson && isJsonMessage(exc, flow));
131+
return createParameterBindings(router, exc, flow, scriptAccessesJson && isJsonMessage(exc, flow), producesJSON());
125132
}
126133

127134
private static boolean isJsonMessage(Exchange exc, Flow flow) {
128135
return exc.getMessage(flow).isJSON();
129136
}
130137

138+
private boolean producesJSON() {
139+
return contentType != null && MimeType.isJson(contentType);
140+
}
141+
131142
private Template createTemplate() {
132143
if (src == null)
133144
throw new ConfigurationException("No template content provided via 'location' or inline text (%s).".formatted(getTemplateLocation()));

core/src/main/java/com/predic8/membrane/core/lang/AbstractScriptInterceptor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ protected void handleScriptExecutionException(Exchange exc, Exception e) {
207207
}
208208

209209
private Map<String, Object> getParameterBindings(Exchange exc, Flow flow, Message msg) {
210-
var binding = createParameterBindings(router, exc, flow, scriptAccessesJson && msg.isJSON());
210+
var binding = createParameterBindings(router, exc, flow, scriptAccessesJson && msg.isJSON(), false);
211211
addOutcomeObjects(binding);
212212
return binding;
213213
}

core/src/main/java/com/predic8/membrane/core/lang/ScriptingUtils.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ public class ScriptingUtils {
4242

4343
private static final ObjectMapper om = new ObjectMapper();
4444

45-
public static Map<String, Object> createParameterBindings(Router router, Exchange exc, Flow flow, boolean includeJsonObject) {
45+
public static final String BINDING = "fn";
46+
47+
public static Map<String, Object> createParameterBindings(Router router, Exchange exc, Flow flow, boolean includeJsonObject, boolean isJSON) {
4648

4749
var msg = exc.getMessage(flow);
4850

@@ -112,8 +114,16 @@ public static Map<String, Object> createParameterBindings(Router router, Exchang
112114

113115
params.put("pathParam", new PathParametersMap(exc));
114116

115-
// Allow invoking functions in Groovy with ${fn.functionname()}
116-
params.put("fn", new GroovyBuiltInFunctions(exc, flow, router));
117+
// "fn" is the special key used to set the Groovy Binding.
118+
// The Groovy Binding is allows function invocation in Groovy scripts with ${functionname()} .
119+
params.put(BINDING, new GroovyBuiltInFunctions(exc, flow, router, params) {
120+
@Override
121+
public Object escape(Object o) {
122+
if (isJSON)
123+
return toJSON(o);
124+
return o;
125+
}
126+
});
117127

118128
return params;
119129
}

core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,24 @@
1919
import com.predic8.membrane.core.interceptor.Interceptor.Flow;
2020
import com.predic8.membrane.core.lang.CommonBuiltInFunctions;
2121
import com.predic8.membrane.core.router.*;
22-
import groovy.lang.GroovyObjectSupport;
22+
import groovy.lang.Binding;
2323

2424
import java.util.List;
25+
import java.util.Map;
2526

2627
/**
2728
* Helper class for built-in functions that delegates to the implementation CommonBuiltInFunctions.
28-
* Difference to SpEL:
29-
* The functions are called with ${fn.functionname()} instead of ${functionname()} in the template interceptor
3029
*/
3130
@SuppressWarnings("unused")
32-
public class GroovyBuiltInFunctions extends GroovyObjectSupport {
31+
public class GroovyBuiltInFunctions extends Binding {
3332

3433
private final Exchange exchange;
3534
private final Flow flow;
3635
private final Router router;
3736
private XmlConfig xmlConfig;
3837

39-
public GroovyBuiltInFunctions(Exchange exchange, Flow flow, Router router) {
38+
public GroovyBuiltInFunctions(Exchange exchange, Flow flow, Router router, Map<String, Object> params) {
39+
super(params);
4040
this.exchange = exchange;
4141
this.flow = flow;
4242
this.router = router;
@@ -118,4 +118,13 @@ public long getDefaultSessionLifetime(String beanName) {
118118
public String env(String s) {
119119
return CommonBuiltInFunctions.env(s);
120120
}
121+
122+
/**
123+
* Post-Process values before they are written to the output.
124+
*
125+
* This gives subclasses the option to perform escaping (e.g. JSON/XML).
126+
*/
127+
public Object escape(Object o) {
128+
return o;
129+
}
121130
}

core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyExchangeExpression.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public GroovyExchangeExpression(String source, Router router) {
4747
public <T> T evaluate(Exchange exchange, Interceptor.Flow flow, Class<T> type) {
4848
Object o = null;
4949
try {
50-
o = script.apply(createParameterBindings(router, exchange, flow, false));
50+
o = script.apply(createParameterBindings(router, exchange, flow, false, false));
5151
} catch (MissingPropertyException mpe) {
5252
log.info("Expression {} tries to access non existing property {}",expression,mpe.getMessage());
5353
if (type.getName().equals(Object.class.getName())) {

core/src/main/java/com/predic8/membrane/core/lang/groovy/adapted/StreamingTemplateEngine.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
package com.predic8.membrane.core.lang.groovy.adapted;
2323

24+
import com.predic8.membrane.core.lang.ScriptingUtils;
2425
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
2526
import groovy.lang.*;
2627
import groovy.text.Template;
@@ -46,6 +47,8 @@
4647
import java.util.Map;
4748
import java.util.concurrent.atomic.AtomicInteger;
4849

50+
import static com.predic8.membrane.core.lang.ScriptingUtils.BINDING;
51+
4952
/**
5053
* Adapted from <a href="https://github.com/apache/groovy/blob/master/subprojects/groovy-templates/src/main/groovy/groovy/text/StreamingTemplateEngine.java">Apache Groovy</a> .
5154
*
@@ -55,7 +58,7 @@
5558
* methods.
5659
*
5760
* We therefore decided to copy the code of the inner class here (see first commit) and then made
58-
* some adjustments (the following commits).
61+
* some adjustments (the following commits, see "adjustment" comments).
5962
*/
6063
public class StreamingTemplateEngine extends groovy.text.StreamingTemplateEngine {
6164
@Override
@@ -86,7 +89,11 @@ private static class StreamingTemplate implements Template {
8689
+ "return { _p, _s, _b, out -> "
8790
+ "int _i = 0;"
8891
+ "try {"
89-
+ "delegate = new Binding(_b);";
92+
// adjustment: using the GroovyBuiltInFunctions binding implementation
93+
// provided from the outside (via the 'fn' map entry) allows the script
94+
// to directly call Membrane functions. This enables templating syntax like
95+
// ${xpath('/root/element')} .
96+
+ "delegate = _b.get('" + BINDING + "');";
9097

9198
/**
9299
* The 'footer' we use for the resulting groovy script source
@@ -514,7 +521,7 @@ private int parseDollarIdentifier(int c,
514521
final StringBuilder target,
515522
final Position sourcePosition,
516523
final Position targetPosition) throws IOException, FinishedReadingException {
517-
append(target, targetPosition, "out<<");
524+
append(target, targetPosition, "out<<escape("); // adjustment: escape the output
518525
append(target, targetPosition, (char) c);
519526

520527
while (true) {
@@ -525,7 +532,7 @@ private int parseDollarIdentifier(int c,
525532
append(target, targetPosition, (char) c);
526533
}
527534

528-
append(target, targetPosition, ";");
535+
append(target, targetPosition, ");");
529536

530537
return c;
531538
}
@@ -562,10 +569,13 @@ private void parseDollarCurlyIdentifier(final Reader reader,
562569
final StringBuilder target,
563570
final Position sourcePosition,
564571
final Position targetPosition) throws IOException, FinishedReadingException {
565-
append(target, targetPosition, "out<<\"\"\"${");
572+
append(target, targetPosition, "out<<\"\"\"${escape("); // adjustment: escape the output
566573

567574
while (true) {
568575
int c = read(reader, sourcePosition);
576+
// adjustment: escape the output
577+
if (c == '}')
578+
append(target, targetPosition, ')');
569579
append(target, targetPosition, (char) c);
570580
if (c == '}') break;
571581
}
@@ -612,11 +622,11 @@ private void parseExpression(final Reader reader,
612622
final StringBuilder target,
613623
final Position sourcePosition,
614624
final Position targetPosition) throws IOException, FinishedReadingException {
615-
append(target, targetPosition, "out<<\"\"\"${");
625+
append(target, targetPosition, "out<<\"\"\"${escape("); // adjustment: escape the output
616626

617627
readAndAppend(reader, target, sourcePosition, targetPosition);
618628

619-
append(target, targetPosition, "}\"\"\";");
629+
append(target, targetPosition, ")}\"\"\";");
620630
}
621631

622632
@Override
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.predic8.membrane.core.interceptor.templating;
2+
3+
import com.predic8.membrane.core.exchange.Exchange;
4+
import com.predic8.membrane.core.http.Request;
5+
import com.predic8.membrane.core.lang.groovy.adapted.StreamingTemplateEngine;
6+
import com.predic8.membrane.core.router.TestRouter;
7+
import groovy.text.Template;
8+
import org.junit.jupiter.api.BeforeEach;
9+
import org.junit.jupiter.api.Test;
10+
11+
import java.io.IOException;
12+
import java.io.StringReader;
13+
import java.net.URISyntaxException;
14+
15+
import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST;
16+
import static com.predic8.membrane.core.lang.ScriptingUtils.createParameterBindings;
17+
import static org.junit.jupiter.api.Assertions.assertEquals;
18+
19+
public class GroovyTemplateEscapingTest {
20+
private TestRouter r;
21+
private Exchange e;
22+
23+
@BeforeEach
24+
public void setUp() throws URISyntaxException {
25+
r = new TestRouter();
26+
e = Request.get("http://localhost/foo").buildExchange();
27+
}
28+
29+
@Test
30+
void noJson() throws IOException, ClassNotFoundException, URISyntaxException {
31+
Template t = new StreamingTemplateEngine().createTemplate(new StringReader(
32+
"${flow} $flow <%= flow %> ${hasScope('foo')}"));
33+
34+
assertEquals("REQUEST REQUEST REQUEST false",
35+
t.make(createParameterBindings(r, e, REQUEST, false, false))
36+
.toString());
37+
}
38+
39+
@Test
40+
void json() throws IOException, ClassNotFoundException, URISyntaxException {
41+
Template t = new StreamingTemplateEngine().createTemplate(new StringReader(
42+
"${flow} $flow <%= flow %> ${hasScope('foo')}"));
43+
44+
assertEquals("\"REQUEST\" \"REQUEST\" \"REQUEST\" false",
45+
t.make(createParameterBindings(r, e, REQUEST, false, true))
46+
.toString());
47+
}
48+
49+
}

core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ void accessBindings() throws Exception {
108108
<% for(h in header.allHeaderFields) { %>
109109
<%= h.headerName %> : <%= h.value %>
110110
<% } %>
111-
Exchange: <%= exc %>
111+
Exchange: <% out<<exc %>
112112
Flow: <%= flow %>
113113
Message.version: <%= message.version %>
114-
Body: <%= message.body %>
114+
Body: <% out<<message.body %>
115115
Properties: <%= property.baz %>
116116
<% for(p in property) { %>
117117
Key: <%= p.key %> : <%= p.value %>
@@ -128,11 +128,11 @@ void accessBindings() throws Exception {
128128

129129
String body = exchange.getRequest().getBodyAsStringDecoded();
130130
assertTrue(body.contains("/foo"));
131-
assertTrue(body.contains("Flow: REQUEST"));
131+
assertTrue(body.contains("Flow: \"REQUEST\""));
132132
assertTrue(body.contains("Body: vlinder"));
133133
assertTrue(body.contains("New: 7"));
134-
assertTrue(body.contains("A: 1"));
135-
assertTrue(body.contains("B: 2"));
134+
assertTrue(body.contains("A: \"1\""));
135+
assertTrue(body.contains("B: \"2\""));
136136
}
137137

138138
@Test
@@ -248,7 +248,7 @@ void builtInFunctions() {
248248
exc.setProperty(SECURITY_SCHEMES, List.of(new BasicHttpSecurityScheme().username("alice")));
249249
ti.setContentType(APPLICATION_JSON);
250250
ti.setSrc("""
251-
{ "foo": "${fn.user()}" }
251+
{ "foo": ${user()} }
252252
""");
253253
ti.init(router);
254254
ti.handleRequest(exc);

core/src/test/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctionsTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class GroovyBuiltInFunctionsTest {
3434

3535
@BeforeEach
3636
void setUp() throws URISyntaxException {
37-
functions = new GroovyBuiltInFunctions(post("/foo").xml("<person name='Fritz'/>").buildExchange(), REQUEST, new DummyTestRouter());
37+
functions = new GroovyBuiltInFunctions(post("/foo").xml("<person name='Fritz'/>").buildExchange(), REQUEST, new DummyTestRouter(), new HashMap<>());
3838
}
3939

4040
@Test

0 commit comments

Comments
 (0)