From 899be425c71224fb5cc3cf1aa75c045bca5beb9f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 13:14:45 +0200
Subject: [PATCH 01/38] init
---
.../core/interceptor/json/rpc/Allow.java | 6 ++++
.../core/interceptor/json/rpc/BatchRule.java | 30 ++++++++++++++++++
.../core/interceptor/json/rpc/Deny.java | 6 ++++
.../rpc/JsonRPCProtectionInterceptor.java | 31 +++++++++++++++++++
.../core/interceptor/json/rpc/Rule.java | 19 ++++++++++++
5 files changed, 92 insertions(+)
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
new file mode 100644
index 0000000000..f12f30a59b
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
@@ -0,0 +1,6 @@
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.predic8.membrane.annot.MCElement;
+
+@MCElement(name = "allow", collapsed = true, component = false, id = "rpc-allow")
+public class Allow extends Rule {}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
new file mode 100644
index 0000000000..facec614b2
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
@@ -0,0 +1,30 @@
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.predic8.membrane.annot.MCAttribute;
+import com.predic8.membrane.annot.MCElement;
+
+@MCElement(name = "batch", component = false)
+public class BatchRule {
+
+ private boolean enabled = true; // TODO
+
+ private Integer maxSize = 100; // TODO
+
+ @MCAttribute
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ @MCAttribute
+ public void setMaxSize(Integer maxSize) {
+ this.maxSize = maxSize;
+ }
+
+ public Integer getMaxSize() {
+ return maxSize;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
new file mode 100644
index 0000000000..6947fc3224
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
@@ -0,0 +1,6 @@
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.predic8.membrane.annot.MCElement;
+
+@MCElement(name = "deny", collapsed = true, component = false, id = "rpc-deny")
+public class Deny extends Rule {}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
new file mode 100644
index 0000000000..078f6794ef
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -0,0 +1,31 @@
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.predic8.membrane.annot.MCChildElement;
+import com.predic8.membrane.annot.MCElement;
+import com.predic8.membrane.annot.MCOtherAttributes;
+import com.predic8.membrane.core.interceptor.AbstractInterceptor;
+
+import java.util.List;
+import java.util.Map;
+
+@MCElement(name = "jsonRPCProtection")
+public class JsonRPCProtectionInterceptor extends AbstractInterceptor {
+
+ BatchRule batchRule;
+
+ @MCChildElement(order = 0)
+ public void setBatch(BatchRule batchRule) {
+ this.batchRule = batchRule;
+ }
+
+ @MCChildElement(order = 1)
+ public void setMethods(List methods) {
+ // TODO
+ }
+
+ @MCOtherAttributes
+ public void setParams(Map params) {
+ // TODO
+ }
+
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
new file mode 100644
index 0000000000..f8fc9765a0
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
@@ -0,0 +1,19 @@
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.predic8.membrane.annot.MCAttribute;
+import com.predic8.membrane.annot.Required;
+
+public abstract class Rule {
+
+ protected String method; // TODO to pattern
+
+ @Required
+ @MCAttribute
+ public void setMethod(String method) {
+ this.method = method;
+ }
+
+ public String getMethod() {
+ return method;
+ }
+}
From 9a15d188b83f67f5e28251ceac20ed8f98654f15 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 14:27:13 +0200
Subject: [PATCH 02/38] Implement JSON-RPC request validation with rules and
batch support
---
.../core/interceptor/json/rpc/Allow.java | 13 +-
.../core/interceptor/json/rpc/BatchRule.java | 8 +-
.../core/interceptor/json/rpc/Deny.java | 13 +-
.../rpc/JsonRPCProtectionInterceptor.java | 221 +++++++++++++++++-
.../core/interceptor/json/rpc/Rule.java | 24 +-
5 files changed, 269 insertions(+), 10 deletions(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
index f12f30a59b..2f70f8a643 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
@@ -3,4 +3,15 @@
import com.predic8.membrane.annot.MCElement;
@MCElement(name = "allow", collapsed = true, component = false, id = "rpc-allow")
-public class Allow extends Rule {}
+public class Allow extends Rule {
+
+ @Override
+ boolean permits() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "Allow{method=%s}".formatted(getMethod());
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
index facec614b2..cf14d6524d 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
@@ -2,13 +2,14 @@
import com.predic8.membrane.annot.MCAttribute;
import com.predic8.membrane.annot.MCElement;
+import com.predic8.membrane.core.util.ConfigurationException;
@MCElement(name = "batch", component = false)
public class BatchRule {
- private boolean enabled = true; // TODO
+ private boolean enabled = true;
- private Integer maxSize = 100; // TODO
+ private Integer maxSize = 100;
@MCAttribute
public void setEnabled(boolean enabled) {
@@ -17,6 +18,9 @@ public void setEnabled(boolean enabled) {
@MCAttribute
public void setMaxSize(Integer maxSize) {
+ if (maxSize == null || maxSize < 1) {
+ throw new ConfigurationException("batch maxSize must be greater than 0");
+ }
this.maxSize = maxSize;
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
index 6947fc3224..ac1c60414e 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
@@ -3,4 +3,15 @@
import com.predic8.membrane.annot.MCElement;
@MCElement(name = "deny", collapsed = true, component = false, id = "rpc-deny")
-public class Deny extends Rule {}
+public class Deny extends Rule {
+
+ @Override
+ boolean permits() {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "Deny{method=%s}".formatted(getMethod());
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 078f6794ef..48914b5462 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -1,17 +1,94 @@
package com.predic8.membrane.core.interceptor.json.rpc;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.predic8.membrane.annot.MCChildElement;
import com.predic8.membrane.annot.MCElement;
import com.predic8.membrane.annot.MCOtherAttributes;
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.interceptor.AbstractInterceptor;
+import com.predic8.membrane.core.interceptor.Outcome;
+import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
+import com.predic8.membrane.core.jsonrpc.JSONRPCResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.http.Response.statusCode;
+import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST;
+import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
+import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST;
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_METHOD_NOT_FOUND;
+import static java.util.EnumSet.of;
+
@MCElement(name = "jsonRPCProtection")
public class JsonRPCProtectionInterceptor extends AbstractInterceptor {
- BatchRule batchRule;
+ private static final Logger log = LoggerFactory.getLogger(JsonRPCProtectionInterceptor.class);
+ private static final ObjectMapper OM = new ObjectMapper();
+
+ private BatchRule batchRule = new BatchRule();
+ private List rules = List.of();
+ private final Map params = new LinkedHashMap<>();
+
+ public JsonRPCProtectionInterceptor() {
+ name = "json rpc protection";
+ setAppliedFlow(of(REQUEST));
+ }
+
+ @Override
+ public Outcome handleRequest(Exchange exc) {
+ if (!"POST".equals(exc.getRequest().getMethod())) {
+ return CONTINUE;
+ }
+
+ String body = null;
+ try {
+ body = exc.getRequest().getBodyAsStringDecoded();
+ if (body == null || body.isBlank()) {
+ return CONTINUE;
+ }
+
+ PayloadType payloadType = getPayloadType(body);
+ JsonNode root = OM.readTree(body);
+ ValidationError error = validate(root, payloadType);
+ if (error == null) {
+ return CONTINUE;
+ }
+
+ log.info("Rejected JSON-RPC request: {}", error.message());
+ exc.setResponse(createErrorResponse(error));
+ return RETURN;
+ } catch (JsonProcessingException e) {
+ exc.setResponse(createErrorResponse(new ValidationError(
+ getPayloadType(body),
+ null,
+ 400,
+ ERR_INVALID_REQUEST,
+ "Invalid JSON-RPC payload: " + e.getOriginalMessage()
+ )));
+ return RETURN;
+ } catch (RuntimeException e) {
+ log.debug("Rejected JSON-RPC request", e);
+ exc.setResponse(createErrorResponse(new ValidationError(
+ PayloadType.SINGLE,
+ null,
+ 400,
+ ERR_INVALID_REQUEST,
+ "Invalid JSON-RPC payload: " + e.getMessage()
+ )));
+ return RETURN;
+ }
+ }
@MCChildElement(order = 0)
public void setBatch(BatchRule batchRule) {
@@ -19,13 +96,149 @@ public void setBatch(BatchRule batchRule) {
}
@MCChildElement(order = 1)
- public void setMethods(List methods) {
- // TODO
+ public void setRules(List rules) {
+ this.rules = rules == null ? List.of() : new ArrayList<>(rules);
}
@MCOtherAttributes
public void setParams(Map params) {
- // TODO
+ this.params.putAll(params);
+ }
+
+ public BatchRule getBatch() {
+ return batchRule;
+ }
+
+ public List getRules() {
+ return rules;
+ }
+
+ public Map getParams() {
+ return params;
+ }
+
+ private ValidationError validate(JsonNode root, PayloadType payloadType) {
+ if (root == null) {
+ return null;
+ }
+ if (root.isObject()) {
+ return validateSingle(root);
+ }
+ if (root.isArray()) {
+ return validateBatch(root);
+ }
+ return new ValidationError(payloadType, null, 400, ERR_INVALID_REQUEST, "JSON-RPC payload must be an object or batch array.");
+ }
+
+ private ValidationError validateSingle(JsonNode node) {
+ try {
+ return validateMethod(parseRequest(node), PayloadType.SINGLE);
+ } catch (IOException e) {
+ return new ValidationError(PayloadType.SINGLE, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request: " + e.getMessage());
+ }
+ }
+
+ private ValidationError validateBatch(JsonNode batch) {
+ if (!batchRule.isEnabled()) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests are disabled.");
+ }
+ if (batch.isEmpty()) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests must not be empty.");
+ }
+ if (batch.size() > batchRule.getMaxSize()) {
+ return new ValidationError(
+ PayloadType.BATCH,
+ null,
+ 400,
+ ERR_INVALID_REQUEST,
+ "Batch request exceeds maxSize of " + batchRule.getMaxSize() + "."
+ );
+ }
+
+ for (JsonNode requestNode : batch) {
+ if (!requestNode.isObject()) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Each batch entry must be a JSON-RPC request object.");
+ }
+
+ try {
+ ValidationError error = validateMethod(parseRequest(requestNode), PayloadType.BATCH);
+ if (error != null) {
+ return error;
+ }
+ } catch (IOException e) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request in batch: " + e.getMessage());
+ }
+ }
+ return null;
+ }
+
+ private ValidationError validateMethod(JSONRPCRequest request, PayloadType payloadType) {
+ for (Rule rule : rules) {
+ if (!rule.matches(request.getMethod())) {
+ continue;
+ }
+ if (rule.permits()) {
+ return null;
+ }
+ return new ValidationError(
+ payloadType,
+ request,
+ 403,
+ ERR_METHOD_NOT_FOUND,
+ "JSON-RPC method '%s' is not allowed.".formatted(request.getMethod())
+ );
+ }
+ return null;
+ }
+
+ private JSONRPCRequest parseRequest(JsonNode node) throws IOException {
+ return JSONRPCRequest.parse(OM.writeValueAsString(node));
+ }
+
+ private Response createErrorResponse(ValidationError error) {
+ try {
+ if (error.payloadType() == PayloadType.BATCH) {
+ return statusCode(error.httpStatus())
+ .contentType(APPLICATION_JSON)
+ .body(OM.writeValueAsString(List.of(JSONRPCResponse.error(responseId(error.request()), error.code(), error.message()))))
+ .build();
+ }
+
+ return statusCode(error.httpStatus())
+ .contentType(APPLICATION_JSON)
+ .body(JSONRPCResponse.error(responseId(error.request()), error.code(), error.message()).toJson())
+ .build();
+ } catch (IOException e) {
+ throw new RuntimeException("Could not create JSON-RPC error response", e);
+ }
+ }
+
+ private Object responseId(JSONRPCRequest request) {
+ if (request == null || request.isNotification()) {
+ return null;
+ }
+ return request.getId();
+ }
+
+ private PayloadType getPayloadType(String body) {
+ if (body == null) {
+ return PayloadType.SINGLE;
+ }
+ return body.trim().startsWith("[") ? PayloadType.BATCH : PayloadType.SINGLE;
+ }
+
+ private enum PayloadType {
+ SINGLE,
+ BATCH
+ }
+
+ private record ValidationError(
+ PayloadType payloadType,
+ JSONRPCRequest request,
+ int httpStatus,
+ int code,
+ String message
+ ) {
}
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
index f8fc9765a0..e529fe9e6e 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
@@ -2,15 +2,35 @@
import com.predic8.membrane.annot.MCAttribute;
import com.predic8.membrane.annot.Required;
+import com.predic8.membrane.core.util.ConfigurationException;
+
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
public abstract class Rule {
- protected String method; // TODO to pattern
+ private String method;
+ private Pattern methodPattern;
+
+ public boolean matches(String method) {
+ return methodPattern != null && methodPattern.matcher(method).matches();
+ }
+
+ abstract boolean permits();
@Required
@MCAttribute
public void setMethod(String method) {
- this.method = method;
+ if (method == null || method.trim().isEmpty()) {
+ throw new ConfigurationException("method must not be empty");
+ }
+
+ this.method = method.trim();
+ try {
+ methodPattern = Pattern.compile(this.method);
+ } catch (PatternSyntaxException e) {
+ throw new ConfigurationException("Invalid method regex: " + this.method);
+ }
}
public String getMethod() {
From 69d37c716614d45c4227cf79b7aa3383c85112ca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 14:29:54 +0200
Subject: [PATCH 03/38] Add unit tests for JsonRPCProtectionInterceptor
---
.../rpc/JsonRPCProtectionInterceptorTest.java | 145 ++++++++++++++++++
1 file changed, 145 insertions(+)
create mode 100644 core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
new file mode 100644
index 0000000000..861bc25694
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -0,0 +1,145 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.predic8.membrane.core.http.Request;
+import com.predic8.membrane.core.http.Response;
+import com.predic8.membrane.core.router.DefaultRouter;
+import com.predic8.membrane.core.util.ConfigurationException;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
+import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class JsonRPCProtectionInterceptorTest {
+
+ private static final ObjectMapper OM = new ObjectMapper();
+
+ @Test
+ void allowRuleWinsBeforeDenyRule() throws Exception {
+ var interceptor = interceptor(List.of(
+ allow("^rpc\\.health$"),
+ deny("^rpc\\..*$")
+ ));
+
+ var exc = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.health"}
+ """);
+
+ assertEquals(CONTINUE, interceptor.handleRequest(exc));
+ }
+
+ @Test
+ void firstMatchingDenyRuleRejectsRequest() throws Exception {
+ var interceptor = interceptor(List.of(
+ deny("^rpc\\..*$"),
+ allow("^rpc\\.health$")
+ ));
+
+ var exc = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.health"}
+ """);
+
+ assertEquals(RETURN, interceptor.handleRequest(exc));
+ assertError(exc.getResponse(), 403, "JSON-RPC method 'rpc.health' is not allowed.");
+ }
+
+ @Test
+ void batchRequestsCanBeDisabled() throws Exception {
+ var interceptor = interceptor(List.of());
+ BatchRule batchRule = new BatchRule();
+ batchRule.setEnabled(false);
+ interceptor.setBatch(batchRule);
+
+ var exc = exchange("""
+ [{"jsonrpc":"2.0","id":1,"method":"rpc.health"}]
+ """);
+
+ assertEquals(RETURN, interceptor.handleRequest(exc));
+ assertBatchError(exc.getResponse(), 400, "Batch requests are disabled.");
+ }
+
+ @Test
+ void batchSizeIsLimited() throws Exception {
+ var interceptor = interceptor(List.of());
+ BatchRule batchRule = new BatchRule();
+ batchRule.setMaxSize(1);
+ interceptor.setBatch(batchRule);
+
+ var exc = exchange("""
+ [
+ {"jsonrpc":"2.0","id":1,"method":"rpc.one"},
+ {"jsonrpc":"2.0","id":2,"method":"rpc.two"}
+ ]
+ """);
+
+ assertEquals(RETURN, interceptor.handleRequest(exc));
+ assertBatchError(exc.getResponse(), 400, "Batch request exceeds maxSize of 1.");
+ }
+
+ @Test
+ void invalidRegexIsRejected() {
+ Allow allow = new Allow();
+ assertThrows(ConfigurationException.class, () -> allow.setMethod("[*"));
+ }
+
+ private JsonRPCProtectionInterceptor interceptor(List rules) {
+ var interceptor = new JsonRPCProtectionInterceptor();
+ interceptor.setRules(rules);
+ interceptor.init(new DefaultRouter());
+ return interceptor;
+ }
+
+ private com.predic8.membrane.core.exchange.Exchange exchange(String body) throws Exception {
+ return Request.post("/")
+ .contentType(APPLICATION_JSON)
+ .body(body)
+ .buildExchange();
+ }
+
+ private Allow allow(String method) {
+ Allow allow = new Allow();
+ allow.setMethod(method);
+ return allow;
+ }
+
+ private Deny deny(String method) {
+ Deny deny = new Deny();
+ deny.setMethod(method);
+ return deny;
+ }
+
+ private void assertError(Response response, int statusCode, String message) throws Exception {
+ assertEquals(statusCode, response.getStatusCode());
+ JsonNode node = OM.readTree(response.getBodyAsStringDecoded());
+ assertEquals(message, node.path("error").path("message").asText());
+ }
+
+ private void assertBatchError(Response response, int statusCode, String message) throws Exception {
+ assertEquals(statusCode, response.getStatusCode());
+ JsonNode node = OM.readTree(response.getBodyAsStringDecoded());
+ assertTrue(node.isArray());
+ assertEquals(1, node.size());
+ assertEquals(message, node.get(0).path("error").path("message").asText());
+ }
+}
From 9b7da29996c0dae1c7faec21c85e1a82eadd109a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 14:36:37 +0200
Subject: [PATCH 04/38] Refactor JSON-RPC validation logic into
JsonRPCValidator for improved modularity and reusability
---
.../rpc/JsonRPCProtectionInterceptor.java | 154 ++--------------
.../json/rpc/JsonRPCValidator.java | 166 ++++++++++++++++++
2 files changed, 181 insertions(+), 139 deletions(-)
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 48914b5462..ac4feb630f 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -1,7 +1,5 @@
package com.predic8.membrane.core.interceptor.json.rpc;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.predic8.membrane.annot.MCChildElement;
import com.predic8.membrane.annot.MCElement;
@@ -12,6 +10,7 @@
import com.predic8.membrane.core.interceptor.Outcome;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import com.predic8.membrane.core.jsonrpc.JSONRPCResponse;
+import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,8 +25,7 @@
import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST;
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
-import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST;
-import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_METHOD_NOT_FOUND;
+import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.*;
import static java.util.EnumSet.of;
@MCElement(name = "jsonRPCProtection")
@@ -51,43 +49,21 @@ public Outcome handleRequest(Exchange exc) {
return CONTINUE;
}
- String body = null;
- try {
- body = exc.getRequest().getBodyAsStringDecoded();
- if (body == null || body.isBlank()) {
- return CONTINUE;
- }
+ String body = exc.getRequest().getBodyAsStringDecoded();
+ if (body == null || body.isBlank()) {
+ return CONTINUE;
+ }
- PayloadType payloadType = getPayloadType(body);
- JsonNode root = OM.readTree(body);
- ValidationError error = validate(root, payloadType);
- if (error == null) {
- return CONTINUE;
- }
+ return reject(exc, new JsonRPCValidator(batchRule, rules).validate(body));
+ }
- log.info("Rejected JSON-RPC request: {}", error.message());
- exc.setResponse(createErrorResponse(error));
- return RETURN;
- } catch (JsonProcessingException e) {
- exc.setResponse(createErrorResponse(new ValidationError(
- getPayloadType(body),
- null,
- 400,
- ERR_INVALID_REQUEST,
- "Invalid JSON-RPC payload: " + e.getOriginalMessage()
- )));
- return RETURN;
- } catch (RuntimeException e) {
- log.debug("Rejected JSON-RPC request", e);
- exc.setResponse(createErrorResponse(new ValidationError(
- PayloadType.SINGLE,
- null,
- 400,
- ERR_INVALID_REQUEST,
- "Invalid JSON-RPC payload: " + e.getMessage()
- )));
- return RETURN;
+ private Outcome reject(Exchange exc, ValidationError error) {
+ if (error == null) {
+ return CONTINUE;
}
+ log.info("Rejected JSON-RPC request: {}", error.message());
+ exc.setResponse(createErrorResponse(error));
+ return RETURN;
}
@MCChildElement(order = 0)
@@ -117,87 +93,9 @@ public Map getParams() {
return params;
}
- private ValidationError validate(JsonNode root, PayloadType payloadType) {
- if (root == null) {
- return null;
- }
- if (root.isObject()) {
- return validateSingle(root);
- }
- if (root.isArray()) {
- return validateBatch(root);
- }
- return new ValidationError(payloadType, null, 400, ERR_INVALID_REQUEST, "JSON-RPC payload must be an object or batch array.");
- }
-
- private ValidationError validateSingle(JsonNode node) {
- try {
- return validateMethod(parseRequest(node), PayloadType.SINGLE);
- } catch (IOException e) {
- return new ValidationError(PayloadType.SINGLE, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request: " + e.getMessage());
- }
- }
-
- private ValidationError validateBatch(JsonNode batch) {
- if (!batchRule.isEnabled()) {
- return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests are disabled.");
- }
- if (batch.isEmpty()) {
- return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests must not be empty.");
- }
- if (batch.size() > batchRule.getMaxSize()) {
- return new ValidationError(
- PayloadType.BATCH,
- null,
- 400,
- ERR_INVALID_REQUEST,
- "Batch request exceeds maxSize of " + batchRule.getMaxSize() + "."
- );
- }
-
- for (JsonNode requestNode : batch) {
- if (!requestNode.isObject()) {
- return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Each batch entry must be a JSON-RPC request object.");
- }
-
- try {
- ValidationError error = validateMethod(parseRequest(requestNode), PayloadType.BATCH);
- if (error != null) {
- return error;
- }
- } catch (IOException e) {
- return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request in batch: " + e.getMessage());
- }
- }
- return null;
- }
-
- private ValidationError validateMethod(JSONRPCRequest request, PayloadType payloadType) {
- for (Rule rule : rules) {
- if (!rule.matches(request.getMethod())) {
- continue;
- }
- if (rule.permits()) {
- return null;
- }
- return new ValidationError(
- payloadType,
- request,
- 403,
- ERR_METHOD_NOT_FOUND,
- "JSON-RPC method '%s' is not allowed.".formatted(request.getMethod())
- );
- }
- return null;
- }
-
- private JSONRPCRequest parseRequest(JsonNode node) throws IOException {
- return JSONRPCRequest.parse(OM.writeValueAsString(node));
- }
-
private Response createErrorResponse(ValidationError error) {
try {
- if (error.payloadType() == PayloadType.BATCH) {
+ if (error.payloadType() == BATCH) {
return statusCode(error.httpStatus())
.contentType(APPLICATION_JSON)
.body(OM.writeValueAsString(List.of(JSONRPCResponse.error(responseId(error.request()), error.code(), error.message()))))
@@ -219,26 +117,4 @@ private Object responseId(JSONRPCRequest request) {
}
return request.getId();
}
-
- private PayloadType getPayloadType(String body) {
- if (body == null) {
- return PayloadType.SINGLE;
- }
- return body.trim().startsWith("[") ? PayloadType.BATCH : PayloadType.SINGLE;
- }
-
- private enum PayloadType {
- SINGLE,
- BATCH
- }
-
- private record ValidationError(
- PayloadType payloadType,
- JSONRPCRequest request,
- int httpStatus,
- int code,
- String message
- ) {
- }
-
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
new file mode 100644
index 0000000000..c34d606527
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
@@ -0,0 +1,166 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
+
+import java.io.IOException;
+import java.util.List;
+
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST;
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_METHOD_NOT_FOUND;
+
+public class JsonRPCValidator {
+
+ private static final ObjectMapper OM = new ObjectMapper();
+
+ private final BatchRule batchRule;
+ private final List rules;
+
+ public JsonRPCValidator(BatchRule batchRule, List rules) {
+ this.batchRule = batchRule == null ? new BatchRule() : batchRule;
+ this.rules = rules == null ? List.of() : List.copyOf(rules);
+ }
+
+ public ValidationError validate(String body) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+
+ try {
+ PayloadType payloadType = getPayloadType(body);
+ JsonNode root = OM.readTree(body);
+ return validate(root, payloadType);
+ } catch (JsonProcessingException e) {
+ return new ValidationError(
+ getPayloadType(body),
+ null,
+ 400,
+ ERR_INVALID_REQUEST,
+ "Invalid JSON-RPC payload: " + e.getOriginalMessage()
+ );
+ } catch (RuntimeException e) {
+ return new ValidationError(
+ PayloadType.SINGLE,
+ null,
+ 400,
+ ERR_INVALID_REQUEST,
+ "Invalid JSON-RPC payload: " + e.getMessage()
+ );
+ }
+ }
+
+ private ValidationError validate(JsonNode root, PayloadType payloadType) {
+ if (root == null) {
+ return null;
+ }
+ if (root.isObject()) {
+ return validateSingle(root);
+ }
+ if (root.isArray()) {
+ return validateBatch(root);
+ }
+ return new ValidationError(payloadType, null, 400, ERR_INVALID_REQUEST, "JSON-RPC payload must be an object or batch array.");
+ }
+
+ private ValidationError validateSingle(JsonNode node) {
+ try {
+ return validateMethod(parseRequest(node), PayloadType.SINGLE);
+ } catch (IOException e) {
+ return new ValidationError(PayloadType.SINGLE, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request: " + e.getMessage());
+ }
+ }
+
+ private ValidationError validateBatch(JsonNode batch) {
+ if (!batchRule.isEnabled()) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests are disabled.");
+ }
+ if (batch.isEmpty()) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests must not be empty.");
+ }
+ if (batch.size() > batchRule.getMaxSize()) {
+ return new ValidationError(
+ PayloadType.BATCH,
+ null,
+ 400,
+ ERR_INVALID_REQUEST,
+ "Batch request exceeds maxSize of " + batchRule.getMaxSize() + "."
+ );
+ }
+
+ for (JsonNode requestNode : batch) {
+ if (!requestNode.isObject()) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Each batch entry must be a JSON-RPC request object.");
+ }
+
+ try {
+ ValidationError error = validateMethod(parseRequest(requestNode), PayloadType.BATCH);
+ if (error != null) {
+ return error;
+ }
+ } catch (IOException e) {
+ return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request in batch: " + e.getMessage());
+ }
+ }
+ return null;
+ }
+
+ private ValidationError validateMethod(JSONRPCRequest request, PayloadType payloadType) {
+ for (Rule rule : rules) {
+ if (!rule.matches(request.getMethod())) {
+ continue;
+ }
+ if (rule.permits()) {
+ return null;
+ }
+ return new ValidationError(
+ payloadType,
+ request,
+ 403,
+ ERR_METHOD_NOT_FOUND,
+ "JSON-RPC method '%s' is not allowed.".formatted(request.getMethod())
+ );
+ }
+ return null;
+ }
+
+ private JSONRPCRequest parseRequest(JsonNode node) throws IOException {
+ return JSONRPCRequest.parse(OM.writeValueAsString(node));
+ }
+
+ private PayloadType getPayloadType(String body) {
+ if (body == null) {
+ return PayloadType.SINGLE;
+ }
+ return body.trim().startsWith("[") ? PayloadType.BATCH : PayloadType.SINGLE;
+ }
+
+ public enum PayloadType {
+ SINGLE,
+ BATCH
+ }
+
+ public record ValidationError(
+ PayloadType payloadType,
+ JSONRPCRequest request,
+ int httpStatus,
+ int code,
+ String message
+ ) {
+ }
+}
From 9222fc2ea2e2ef35ed9ccbe25eedccdd6bf35920 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 15:16:12 +0200
Subject: [PATCH 05/38] Add JSON-RPC parameter schema validation with unit
tests
---
.../interceptor/json/rpc/JsonRPCParams.java | 109 ++++++++++++++++++
.../rpc/JsonRPCProtectionInterceptor.java | 42 +++++--
.../json/rpc/JsonRPCValidator.java | 44 ++++++-
.../rpc/JsonRPCProtectionInterceptorTest.java | 33 ++++++
.../json/rpc/echo-params.schema.json | 11 ++
5 files changed, 225 insertions(+), 14 deletions(-)
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
create mode 100644 core/src/test/resources/json/rpc/echo-params.schema.json
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
new file mode 100644
index 0000000000..b878cdaead
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
@@ -0,0 +1,109 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.networknt.schema.InputFormat;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaLocation;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.resource.SchemaLoader;
+import com.predic8.membrane.annot.MCElement;
+import com.predic8.membrane.annot.MCOtherAttributes;
+import com.predic8.membrane.core.interceptor.schemavalidation.json.MembraneSchemaLoader;
+import com.predic8.membrane.core.resolver.Resolver;
+import com.predic8.membrane.core.util.ConfigurationException;
+import com.predic8.membrane.core.util.URIFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static com.networknt.schema.SchemaRegistry.withDefaultDialect;
+import static com.predic8.membrane.core.resolver.ResolverMap.combine;
+import static com.networknt.schema.InputFormat.JSON;
+import static com.networknt.schema.InputFormat.YAML;
+import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
+import static java.util.stream.Collectors.toUnmodifiableMap;
+
+@MCElement(name = "params", component = false)
+public class JsonRPCParams {
+
+ private Map params = new HashMap<>();
+ private Map schemas = Map.of();
+
+ @MCOtherAttributes
+ public void setParams(Map params) {
+ this.params = params == null ? new HashMap<>() : new HashMap<>(params);
+ }
+
+ public Map getParams() {
+ return params;
+ }
+
+ public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
+ if (params.isEmpty()) {
+ schemas = Map.of();
+ return;
+ }
+ if (resolver == null || uriFactory == null) {
+ throw new ConfigurationException("Cannot initialize JSON-RPC param schemas without resolver context.");
+ }
+
+ schemas = params.entrySet().stream().collect(toUnmodifiableMap(
+ Map.Entry::getKey,
+ entry -> loadSchema(entry.getKey(), entry.getValue(), resolver, uriFactory, beanBaseLocation)
+ ));
+ }
+
+ public Schema getSchema(String method) {
+ return schemas.get(method);
+ }
+
+ private static Schema loadSchema(String method, String schemaPath, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
+ if (schemaPath == null || schemaPath.trim().isEmpty()) {
+ throw new ConfigurationException("JSON-RPC param schema path for method '%s' must not be empty.".formatted(method));
+ }
+
+ String resolvedLocation = combine(uriFactory, beanBaseLocation, schemaPath.trim());
+ try (InputStream in = resolver.resolve(resolvedLocation)) {
+ Schema schema = createSchemaRegistry(resolver).getSchema(
+ SchemaLocation.of(resolvedLocation),
+ in,
+ getSchemaFormat(resolvedLocation)
+ );
+ schema.initializeValidators();
+ return schema;
+ } catch (IOException e) {
+ throw new ConfigurationException("Cannot read JSON-RPC param schema for method '%s' from '%s'.".formatted(method, schemaPath), e);
+ } catch (RuntimeException e) {
+ throw new ConfigurationException("Cannot create JSON-RPC param schema for method '%s' from '%s'.".formatted(method, schemaPath), e);
+ }
+ }
+
+ private static SchemaRegistry createSchemaRegistry(Resolver resolver) {
+ return withDefaultDialect(
+ DRAFT_2020_12,
+ builder -> builder.schemaLoader(SchemaLoader.builder()
+ .resourceLoaders(loaders -> loaders.values(list -> list.addFirst(new MembraneSchemaLoader(resolver))))
+ .build())
+ );
+ }
+
+ private static InputFormat getSchemaFormat(String schemaLocation) {
+ return schemaLocation.toLowerCase().endsWith(".yaml") || schemaLocation.toLowerCase().endsWith(".yml") ? YAML : JSON;
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index ac4feb630f..ed87d9d2d3 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -3,29 +3,26 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.predic8.membrane.annot.MCChildElement;
import com.predic8.membrane.annot.MCElement;
-import com.predic8.membrane.annot.MCOtherAttributes;
import com.predic8.membrane.core.exchange.Exchange;
import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.interceptor.AbstractInterceptor;
import com.predic8.membrane.core.interceptor.Outcome;
+import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import com.predic8.membrane.core.jsonrpc.JSONRPCResponse;
-import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
import static com.predic8.membrane.core.http.Response.statusCode;
import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST;
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
-import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.*;
+import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.BATCH;
import static java.util.EnumSet.of;
@MCElement(name = "jsonRPCProtection")
@@ -36,13 +33,21 @@ public class JsonRPCProtectionInterceptor extends AbstractInterceptor {
private BatchRule batchRule = new BatchRule();
private List rules = List.of();
- private final Map params = new LinkedHashMap<>();
+ private JsonRPCParams params = new JsonRPCParams();
+ private JsonRPCValidator validator;
public JsonRPCProtectionInterceptor() {
name = "json rpc protection";
setAppliedFlow(of(REQUEST));
}
+ @Override
+ public void init() {
+ super.init();
+ params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation());
+ validator = createValidator();
+ }
+
@Override
public Outcome handleRequest(Exchange exc) {
if (!"POST".equals(exc.getRequest().getMethod())) {
@@ -54,7 +59,7 @@ public Outcome handleRequest(Exchange exc) {
return CONTINUE;
}
- return reject(exc, new JsonRPCValidator(batchRule, rules).validate(body));
+ return reject(exc, getValidator().validate(body));
}
private Outcome reject(Exchange exc, ValidationError error) {
@@ -69,16 +74,19 @@ private Outcome reject(Exchange exc, ValidationError error) {
@MCChildElement(order = 0)
public void setBatch(BatchRule batchRule) {
this.batchRule = batchRule;
+ validator = null;
}
@MCChildElement(order = 1)
public void setRules(List rules) {
this.rules = rules == null ? List.of() : new ArrayList<>(rules);
+ validator = null;
}
- @MCOtherAttributes
- public void setParams(Map params) {
- this.params.putAll(params);
+ @MCChildElement(order = 2)
+ public void setParams(JsonRPCParams params) {
+ this.params = params == null ? new JsonRPCParams() : params;
+ validator = null;
}
public BatchRule getBatch() {
@@ -89,10 +97,22 @@ public List getRules() {
return rules;
}
- public Map getParams() {
+ public JsonRPCParams getParams() {
return params;
}
+ private JsonRPCValidator getValidator() {
+ if (validator == null) {
+ validator = createValidator();
+ }
+ return validator;
+ }
+
+ private JsonRPCValidator createValidator() {
+ params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation());
+ return new JsonRPCValidator(batchRule, rules, params);
+ }
+
private Response createErrorResponse(ValidationError error) {
try {
if (error.payloadType() == BATCH) {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
index c34d606527..1890d91416 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
@@ -17,12 +17,17 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.networknt.schema.Error;
+import com.networknt.schema.Schema;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import java.io.IOException;
import java.util.List;
+import java.util.stream.Collectors;
import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST;
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_PARAMS;
import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_METHOD_NOT_FOUND;
public class JsonRPCValidator {
@@ -31,10 +36,12 @@ public class JsonRPCValidator {
private final BatchRule batchRule;
private final List rules;
+ private final JsonRPCParams params;
- public JsonRPCValidator(BatchRule batchRule, List rules) {
+ public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCParams params) {
this.batchRule = batchRule == null ? new BatchRule() : batchRule;
this.rules = rules == null ? List.of() : List.copyOf(rules);
+ this.params = params == null ? new JsonRPCParams() : params;
}
public ValidationError validate(String body) {
@@ -126,7 +133,7 @@ private ValidationError validateMethod(JSONRPCRequest request, PayloadType paylo
continue;
}
if (rule.permits()) {
- return null;
+ break;
}
return new ValidationError(
payloadType,
@@ -136,13 +143,44 @@ private ValidationError validateMethod(JSONRPCRequest request, PayloadType paylo
"JSON-RPC method '%s' is not allowed.".formatted(request.getMethod())
);
}
- return null;
+ return validateParams(request, payloadType);
+ }
+
+ private ValidationError validateParams(JSONRPCRequest request, PayloadType payloadType) {
+ Schema schema = params.getSchema(request.getMethod());
+ if (schema == null) {
+ return null;
+ }
+
+ List errors = schema.validate(getParamsNode(request));
+ if (errors.isEmpty()) {
+ return null;
+ }
+
+ return new ValidationError(
+ payloadType,
+ request,
+ 400,
+ ERR_INVALID_PARAMS,
+ "Invalid params for method '%s': %s".formatted(
+ request.getMethod(),
+ errors.stream().map(Error::getMessage).collect(Collectors.joining("; "))
+ )
+ );
}
private JSONRPCRequest parseRequest(JsonNode node) throws IOException {
return JSONRPCRequest.parse(OM.writeValueAsString(node));
}
+ private JsonNode getParamsNode(JSONRPCRequest request) {
+ Object params = request.getParams();
+ if (params == null) {
+ return NullNode.instance;
+ }
+ return OM.valueToTree(params);
+ }
+
private PayloadType getPayloadType(String body) {
if (body == null) {
return PayloadType.SINGLE;
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 861bc25694..7376c9ab51 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -103,9 +103,36 @@ void invalidRegexIsRejected() {
assertThrows(ConfigurationException.class, () -> allow.setMethod("[*"));
}
+ @Test
+ void paramsValidation() throws Exception {
+ JsonRPCParams params = new JsonRPCParams();
+ params.setParams(java.util.Map.of(
+ "rpc.echo", "classpath:/json/rpc/echo-params.schema.json"
+ ));
+ var interceptor = interceptor(List.of(), params);
+
+ var exc = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"hello"}}
+ """);
+
+ assertEquals(CONTINUE, interceptor.handleRequest(exc));
+
+ var exc2 = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}}
+ """);
+
+ assertEquals(RETURN, interceptor.handleRequest(exc2));
+ assertErrorContains(exc2.getResponse(), 400, "Invalid params for method 'rpc.echo'");
+ }
+
private JsonRPCProtectionInterceptor interceptor(List rules) {
+ return interceptor(rules, new JsonRPCParams());
+ }
+
+ private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params) {
var interceptor = new JsonRPCProtectionInterceptor();
interceptor.setRules(rules);
+ interceptor.setParams(params);
interceptor.init(new DefaultRouter());
return interceptor;
}
@@ -135,6 +162,12 @@ private void assertError(Response response, int statusCode, String message) thro
assertEquals(message, node.path("error").path("message").asText());
}
+ private void assertErrorContains(Response response, int statusCode, String messagePart) throws Exception {
+ assertEquals(statusCode, response.getStatusCode());
+ JsonNode node = OM.readTree(response.getBodyAsStringDecoded());
+ assertTrue(node.path("error").path("message").asText().contains(messagePart));
+ }
+
private void assertBatchError(Response response, int statusCode, String message) throws Exception {
assertEquals(statusCode, response.getStatusCode());
JsonNode node = OM.readTree(response.getBodyAsStringDecoded());
diff --git a/core/src/test/resources/json/rpc/echo-params.schema.json b/core/src/test/resources/json/rpc/echo-params.schema.json
new file mode 100644
index 0000000000..f0b58898d6
--- /dev/null
+++ b/core/src/test/resources/json/rpc/echo-params.schema.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": ["message"],
+ "additionalProperties": false,
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ }
+}
From 89e65909f5bc6cf6ba8a8189a1bf0ac53b8b325a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 15:25:32 +0200
Subject: [PATCH 06/38] Support JSON-RPC parameter schema validation with
regex-based method patterns and add corresponding unit tests
---
.../interceptor/json/rpc/JsonRPCParams.java | 63 +++++++++++++------
.../rpc/JsonRPCProtectionInterceptorTest.java | 36 ++++++++++-
.../json/rpc/generic-rpc-params.schema.json | 11 ++++
3 files changed, 91 insertions(+), 19 deletions(-)
create mode 100644 core/src/test/resources/json/rpc/generic-rpc-params.schema.json
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
index b878cdaead..bd275bef8b 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
@@ -28,26 +28,27 @@
import java.io.IOException;
import java.io.InputStream;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
-import static com.networknt.schema.SchemaRegistry.withDefaultDialect;
-import static com.predic8.membrane.core.resolver.ResolverMap.combine;
import static com.networknt.schema.InputFormat.JSON;
import static com.networknt.schema.InputFormat.YAML;
+import static com.networknt.schema.SchemaRegistry.withDefaultDialect;
import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
-import static java.util.stream.Collectors.toUnmodifiableMap;
+import static com.predic8.membrane.core.resolver.ResolverMap.combine;
@MCElement(name = "params", component = false)
public class JsonRPCParams {
- private Map params = new HashMap<>();
- private Map schemas = Map.of();
+ private Map params = new LinkedHashMap<>();
+ private List schemas = List.of();
@MCOtherAttributes
public void setParams(Map params) {
- this.params = params == null ? new HashMap<>() : new HashMap<>(params);
+ this.params = params == null ? new LinkedHashMap<>() : new LinkedHashMap<>(params);
}
public Map getParams() {
@@ -56,26 +57,38 @@ public Map getParams() {
public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
if (params.isEmpty()) {
- schemas = Map.of();
+ schemas = List.of();
return;
}
if (resolver == null || uriFactory == null) {
throw new ConfigurationException("Cannot initialize JSON-RPC param schemas without resolver context.");
}
- schemas = params.entrySet().stream().collect(toUnmodifiableMap(
- Map.Entry::getKey,
- entry -> loadSchema(entry.getKey(), entry.getValue(), resolver, uriFactory, beanBaseLocation)
- ));
+ schemas = params.entrySet().stream()
+ .map(entry -> new CompiledSchema(
+ entry.getKey(),
+ compilePattern(entry.getKey()),
+ loadSchema(entry.getKey(), entry.getValue(), resolver, uriFactory, beanBaseLocation)
+ ))
+ .toList();
}
public Schema getSchema(String method) {
- return schemas.get(method);
+ if (method == null) {
+ return null;
+ }
+
+ for (CompiledSchema schema : schemas) {
+ if (schema.pattern().matcher(method).matches()) {
+ return schema.schema();
+ }
+ }
+ return null;
}
- private static Schema loadSchema(String method, String schemaPath, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
+ private static Schema loadSchema(String methodPattern, String schemaPath, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
if (schemaPath == null || schemaPath.trim().isEmpty()) {
- throw new ConfigurationException("JSON-RPC param schema path for method '%s' must not be empty.".formatted(method));
+ throw new ConfigurationException("JSON-RPC param schema path for method pattern '%s' must not be empty.".formatted(methodPattern));
}
String resolvedLocation = combine(uriFactory, beanBaseLocation, schemaPath.trim());
@@ -88,9 +101,9 @@ private static Schema loadSchema(String method, String schemaPath, Resolver reso
schema.initializeValidators();
return schema;
} catch (IOException e) {
- throw new ConfigurationException("Cannot read JSON-RPC param schema for method '%s' from '%s'.".formatted(method, schemaPath), e);
+ throw new ConfigurationException("Cannot read JSON-RPC param schema for method pattern '%s' from '%s'.".formatted(methodPattern, schemaPath), e);
} catch (RuntimeException e) {
- throw new ConfigurationException("Cannot create JSON-RPC param schema for method '%s' from '%s'.".formatted(method, schemaPath), e);
+ throw new ConfigurationException("Cannot create JSON-RPC param schema for method pattern '%s' from '%s'.".formatted(methodPattern, schemaPath), e);
}
}
@@ -106,4 +119,18 @@ private static SchemaRegistry createSchemaRegistry(Resolver resolver) {
private static InputFormat getSchemaFormat(String schemaLocation) {
return schemaLocation.toLowerCase().endsWith(".yaml") || schemaLocation.toLowerCase().endsWith(".yml") ? YAML : JSON;
}
+
+ private static Pattern compilePattern(String methodPattern) {
+ if (methodPattern == null || methodPattern.trim().isEmpty()) {
+ throw new ConfigurationException("JSON-RPC param method pattern must not be empty.");
+ }
+ try {
+ return Pattern.compile(methodPattern.trim());
+ } catch (PatternSyntaxException e) {
+ throw new ConfigurationException("Invalid JSON-RPC param method regex: " + methodPattern, e);
+ }
+ }
+
+ private record CompiledSchema(String methodPattern, Pattern pattern, Schema schema) {
+ }
}
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 7376c9ab51..ad314db7d1 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -22,7 +22,9 @@
import com.predic8.membrane.core.util.ConfigurationException;
import org.junit.jupiter.api.Test;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
@@ -106,7 +108,7 @@ void invalidRegexIsRejected() {
@Test
void paramsValidation() throws Exception {
JsonRPCParams params = new JsonRPCParams();
- params.setParams(java.util.Map.of(
+ params.setParams(Map.of(
"rpc.echo", "classpath:/json/rpc/echo-params.schema.json"
));
var interceptor = interceptor(List.of(), params);
@@ -125,6 +127,38 @@ void paramsValidation() throws Exception {
assertErrorContains(exc2.getResponse(), 400, "Invalid params for method 'rpc.echo'");
}
+ @Test
+ void paramsValidationSupportsRegexMethodPatterns() throws Exception {
+ JsonRPCParams params = new JsonRPCParams();
+ params.setParams(Map.of(
+ "^rpc\\..*$", "classpath:/json/rpc/generic-rpc-params.schema.json"
+ ));
+ var interceptor = interceptor(List.of(), params);
+
+ var exc = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.health","params":{"code":1}}
+ """);
+
+ assertEquals(CONTINUE, interceptor.handleRequest(exc));
+ }
+
+ @Test
+ void paramsValidationUsesFirstMatchingRegexPattern() throws Exception {
+ JsonRPCParams params = new JsonRPCParams();
+ LinkedHashMap map = new LinkedHashMap<>();
+ map.put("^rpc\\.echo$", "classpath:/json/rpc/echo-params.schema.json");
+ map.put("^rpc\\..*$", "classpath:/json/rpc/generic-rpc-params.schema.json");
+ params.setParams(map);
+ var interceptor = interceptor(List.of(), params);
+
+ var exc = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"code":1}}
+ """);
+
+ assertEquals(RETURN, interceptor.handleRequest(exc));
+ assertErrorContains(exc.getResponse(), 400, "Invalid params for method 'rpc.echo'");
+ }
+
private JsonRPCProtectionInterceptor interceptor(List rules) {
return interceptor(rules, new JsonRPCParams());
}
diff --git a/core/src/test/resources/json/rpc/generic-rpc-params.schema.json b/core/src/test/resources/json/rpc/generic-rpc-params.schema.json
new file mode 100644
index 0000000000..05ff6df485
--- /dev/null
+++ b/core/src/test/resources/json/rpc/generic-rpc-params.schema.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": ["code"],
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "integer"
+ }
+ }
+}
From 5a225af69e41c47a6b036c60672f16f8a7d158b1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 15:31:08 +0200
Subject: [PATCH 07/38] Make `fromNode` method public in JSONRPCRequest, add
logging to JsonRPCValidator, and enhance method null check in Rule
---
.../core/interceptor/json/rpc/JsonRPCValidator.java | 11 ++++++++---
.../membrane/core/interceptor/json/rpc/Rule.java | 3 +++
.../predic8/membrane/core/jsonrpc/JSONRPCRequest.java | 2 +-
3 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
index 1890d91416..4e733e98bd 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
@@ -21,6 +21,8 @@
import com.networknt.schema.Error;
import com.networknt.schema.Schema;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
@@ -32,6 +34,7 @@
public class JsonRPCValidator {
+ private static final Logger log = LoggerFactory.getLogger(JsonRPCValidator.class);
private static final ObjectMapper OM = new ObjectMapper();
private final BatchRule batchRule;
@@ -54,20 +57,22 @@ public ValidationError validate(String body) {
JsonNode root = OM.readTree(body);
return validate(root, payloadType);
} catch (JsonProcessingException e) {
+ log.debug("Invalid JSON-RPC payload.", e);
return new ValidationError(
getPayloadType(body),
null,
400,
ERR_INVALID_REQUEST,
- "Invalid JSON-RPC payload: " + e.getOriginalMessage()
+ "Invalid JSON-RPC payload"
);
} catch (RuntimeException e) {
+ log.debug("Invalid JSON-RPC payload.", e);
return new ValidationError(
PayloadType.SINGLE,
null,
400,
ERR_INVALID_REQUEST,
- "Invalid JSON-RPC payload: " + e.getMessage()
+ "Invalid JSON-RPC payload"
);
}
}
@@ -170,7 +175,7 @@ private ValidationError validateParams(JSONRPCRequest request, PayloadType paylo
}
private JSONRPCRequest parseRequest(JsonNode node) throws IOException {
- return JSONRPCRequest.parse(OM.writeValueAsString(node));
+ return JSONRPCRequest.fromNode(node);
}
private JsonNode getParamsNode(JSONRPCRequest request) {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
index e529fe9e6e..3a6c6ffc74 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
@@ -13,6 +13,9 @@ public abstract class Rule {
private Pattern methodPattern;
public boolean matches(String method) {
+ if (method == null) {
+ return false;
+ }
return methodPattern != null && methodPattern.matcher(method).matches();
}
diff --git a/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java b/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java
index f9b802c163..2e66a98e23 100644
--- a/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java
+++ b/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java
@@ -109,7 +109,7 @@ public static JSONRPCRequest parse(String json) throws IOException {
return fromNode(OM.readTree(json));
}
- private static JSONRPCRequest fromNode(JsonNode root) throws IOException {
+ public static JSONRPCRequest fromNode(JsonNode root) throws IOException {
if (root == null || !root.isObject()) throw new IOException("Invalid JSON-RPC request: expected JSON object");
JSONRPCRequest req = new JSONRPCRequest();
From 5bd32da27b619bb1cd1687da21e36d414c6f512d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 15:45:06 +0200
Subject: [PATCH 08/38] Add Javadoc comments for JSON-RPC protection classes
with detailed descriptions
---
.../core/interceptor/json/rpc/Allow.java | 3 ++
.../core/interceptor/json/rpc/BatchRule.java | 12 +++++
.../core/interceptor/json/rpc/Deny.java | 3 ++
.../interceptor/json/rpc/JsonRPCParams.java | 17 +++++++
.../rpc/JsonRPCProtectionInterceptor.java | 44 +++++++++++++++++++
.../core/interceptor/json/rpc/Rule.java | 4 ++
6 files changed, 83 insertions(+)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
index 2f70f8a643..c9382ef145 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
@@ -2,6 +2,9 @@
import com.predic8.membrane.annot.MCElement;
+/**
+ * @description Permits JSON-RPC requests whose method matches the configured regular expression.
+ */
@MCElement(name = "allow", collapsed = true, component = false, id = "rpc-allow")
public class Allow extends Rule {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
index cf14d6524d..14f274a3ec 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
@@ -4,6 +4,9 @@
import com.predic8.membrane.annot.MCElement;
import com.predic8.membrane.core.util.ConfigurationException;
+/**
+ * @description Controls whether JSON-RPC batch requests are allowed and how many request objects a batch may contain.
+ */
@MCElement(name = "batch", component = false)
public class BatchRule {
@@ -11,11 +14,20 @@ public class BatchRule {
private Integer maxSize = 100;
+ /**
+ * @description Enables or disables JSON-RPC batch requests.
+ * @default true
+ */
@MCAttribute
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
+ /**
+ * @description The maximum number of request objects allowed in one JSON-RPC batch.
+ * @default 100
+ * @example 50
+ */
@MCAttribute
public void setMaxSize(Integer maxSize) {
if (maxSize == null || maxSize < 1) {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
index ac1c60414e..c4b7352e4b 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
@@ -2,6 +2,9 @@
import com.predic8.membrane.annot.MCElement;
+/**
+ * @description Denies JSON-RPC requests whose method matches the configured regular expression.
+ */
@MCElement(name = "deny", collapsed = true, component = false, id = "rpc-deny")
public class Deny extends Rule {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
index bd275bef8b..ad76d61206 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
@@ -40,12 +40,29 @@
import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
import static com.predic8.membrane.core.resolver.ResolverMap.combine;
+/**
+ * @description
+ *
Maps JSON-RPC method name patterns to JSON Schema locations for validating the
+ * params member of a request.
+ *
+ *
Each key is a regular expression matched against the method name. The first matching
+ * key wins. Each value must point to an external JSON Schema document such as a classpath,
+ * file, or HTTP resource. Inline schemas are not supported.
+ */
@MCElement(name = "params", component = false)
public class JsonRPCParams {
private Map params = new LinkedHashMap<>();
private List schemas = List.of();
+ /**
+ * @description
+ *
Defines a map from method name regex to JSON Schema location.
+ *
+ *
The insertion order is preserved and determines precedence when multiple regexes match.
Protects JSON-RPC endpoints by validating request structure, controlling batch usage,
+ * applying ordered allow/deny rules to method names, and optionally validating
+ * method parameters against JSON Schema documents.
+ *
+ *
Method rules are evaluated in the configured order. The first matching rule decides
+ * whether a method is allowed or denied.
+ *
+ *
Parameter schemas are configured separately in the params child element. The
+ * keys are regular expressions matched against the JSON-RPC method name. The first matching
+ * schema entry is used to validate the params object or array. Schemas must be
+ * referenced by path or URL and cannot be configured inline.
+ */
@MCElement(name = "jsonRPCProtection")
public class JsonRPCProtectionInterceptor extends AbstractInterceptor {
@@ -71,18 +99,34 @@ private Outcome reject(Exchange exc, ValidationError error) {
return RETURN;
}
+ /**
+ * @description Configures whether JSON-RPC batch requests are allowed and how many request objects one batch may contain.
+ */
@MCChildElement(order = 0)
public void setBatch(BatchRule batchRule) {
this.batchRule = batchRule;
validator = null;
}
+ /**
+ * @description
+ *
Configures ordered allow/deny rules for JSON-RPC method names.
+ *
+ *
The first matching rule decides whether the method is allowed or denied. Methods that do not match any configured rule are allowed. To switch to default-deny behavior, add a final deny rule such as deny: .*.
Configures JSON Schema files for validating params per method name.
+ *
+ *
The keys are regular expressions matched against the JSON-RPC method name in order.
+ * The first matching entry is used. Values must be schema paths or URLs; inline schemas are not supported.
+ */
@MCChildElement(order = 2)
public void setParams(JsonRPCParams params) {
this.params = params == null ? new JsonRPCParams() : params;
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
index 3a6c6ffc74..f1291c9fba 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
@@ -21,6 +21,10 @@ public boolean matches(String method) {
abstract boolean permits();
+ /**
+ * @description The regular expression matched against the JSON-RPC method value.
+ * @example "^rpc\\.(health|echo)$"
+ */
@Required
@MCAttribute
public void setMethod(String method) {
From 4fd835a0ce020469833fa243c958f28c2ea4c876 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Thu, 28 May 2026 16:12:35 +0200
Subject: [PATCH 09/38] Support XML-style parameter mappings for JSON-RPC
schema validation and enhance configuration options
---
.../annot/yaml/McYamlIntrospector.java | 15 ++-
.../interceptor/json/rpc/JsonRPCParams.java | 94 +++++++++++++++++--
.../rpc/JsonRPCProtectionInterceptorTest.java | 15 +++
3 files changed, 111 insertions(+), 13 deletions(-)
diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java
index 114a583bc4..16abc0289b 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java
@@ -38,7 +38,7 @@ public static boolean isSetter(Method method) {
}
public static boolean isStructured(Method method) {
- return findAnnotation(method, MCChildElement.class) != null;
+ return isJsonVisibleChild(method);
}
public static boolean matchesJsonKey(Method method, String key) {
@@ -48,7 +48,7 @@ public static boolean matchesJsonKey(Method method, String key) {
}
private static boolean matchesJsonChildElementKey(Method method, String key) {
- return findAnnotation(method, MCChildElement.class) != null
+ return isJsonVisibleChild(method)
&& matchesPropertyName(method, key);
}
@@ -101,7 +101,7 @@ public static Method getSingleChildSetter(ParsingContext pc, Class clazz)
private static @NotNull List getChildSetters(Class clazz) {
List childSetters = stream(clazz.getMethods())
.filter(McYamlIntrospector::isSetter)
- .filter(method -> findAnnotation(method, MCChildElement.class) != null)
+ .filter(McYamlIntrospector::isJsonVisibleChild)
.toList();
if (childSetters.isEmpty()) {
throw new RuntimeException("No @MCChildElement setter found in " + clazz.getName());
@@ -155,7 +155,7 @@ public static boolean hasAttributes(Class> clazz) {
}
public static boolean hasChildren(Class> clazz) {
- return stream(clazz.getMethods()).anyMatch(m -> m.isAnnotationPresent(MCChildElement.class));
+ return stream(clazz.getMethods()).anyMatch(McYamlIntrospector::isJsonVisibleChild);
}
public static Method getAnySetter(Class clazz) {
@@ -169,7 +169,7 @@ public static Method getAnySetter(Class clazz) {
public static Method getChildSetter(Class clazz, Class> valueClass) {
return stream(clazz.getMethods())
.filter(McYamlIntrospector::isSetter)
- .filter(McYamlIntrospector::isStructured)
+ .filter(McYamlIntrospector::isJsonVisibleChild)
.filter(method -> method.getParameterTypes().length == 1)
.filter(method -> method.getParameterTypes()[0].isAssignableFrom(valueClass))
.reduce((a, b) -> {
@@ -178,6 +178,11 @@ public static Method getChildSetter(Class clazz, Class> valueClass) {
.orElseThrow(() -> new RuntimeException("Could not find child setter on %s for value of type %s".formatted(clazz.getName(), valueClass.getName())));
}
+ private static boolean isJsonVisibleChild(Method method) {
+ MCChildElement annotation = findAnnotation(method, MCChildElement.class);
+ return annotation != null && !annotation.excludeFromJson();
+ }
+
public static boolean isReferenceAttribute(Method setter) {
if (findAnnotation(setter, MCAttribute.class) == null)
return false;
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
index ad76d61206..4bf84b97a9 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
@@ -19,8 +19,11 @@
import com.networknt.schema.SchemaLocation;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.resource.SchemaLoader;
+import com.predic8.membrane.annot.MCAttribute;
+import com.predic8.membrane.annot.MCChildElement;
import com.predic8.membrane.annot.MCElement;
import com.predic8.membrane.annot.MCOtherAttributes;
+import com.predic8.membrane.annot.Required;
import com.predic8.membrane.core.interceptor.schemavalidation.json.MembraneSchemaLoader;
import com.predic8.membrane.core.resolver.Resolver;
import com.predic8.membrane.core.util.ConfigurationException;
@@ -45,14 +48,16 @@
*
Maps JSON-RPC method name patterns to JSON Schema locations for validating the
* params member of a request.
*
- *
Each key is a regular expression matched against the method name. The first matching
- * key wins. Each value must point to an external JSON Schema document such as a classpath,
- * file, or HTTP resource. Inline schemas are not supported.
+ *
In YAML, the configuration is expressed as a map from method regex to schema location.
+ * In XML, use repeated param child elements with method and
+ * schema attributes. Entries are checked in order and the first matching
+ * method pattern wins. Inline schemas are not supported.
*/
@MCElement(name = "params", component = false)
public class JsonRPCParams {
private Map params = new LinkedHashMap<>();
+ private List paramMappings = List.of();
private List schemas = List.of();
/**
@@ -72,8 +77,26 @@ public Map getParams() {
return params;
}
+ /**
+ * @description
+ *
Defines XML child elements for method-to-schema mappings.
+ *
+ *
This form is intended for XML configuration. YAML keeps using the map syntax shown above.
+ *
+ * @example <param method="^rpc\\.echo$" schema="classpath:/json/rpc/echo-params.schema.json"/>
+ */
+ @MCChildElement(excludeFromJson = true)
+ public void setParamMappings(List paramMappings) {
+ this.paramMappings = paramMappings == null ? List.of() : List.copyOf(paramMappings);
+ }
+
+ public List getParamMappings() {
+ return paramMappings;
+ }
+
public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
- if (params.isEmpty()) {
+ List effectiveMappings = getEffectiveMappings();
+ if (effectiveMappings.isEmpty()) {
schemas = List.of();
return;
}
@@ -81,11 +104,11 @@ public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocati
throw new ConfigurationException("Cannot initialize JSON-RPC param schemas without resolver context.");
}
- schemas = params.entrySet().stream()
+ schemas = effectiveMappings.stream()
.map(entry -> new CompiledSchema(
- entry.getKey(),
- compilePattern(entry.getKey()),
- loadSchema(entry.getKey(), entry.getValue(), resolver, uriFactory, beanBaseLocation)
+ entry.getMethod(),
+ compilePattern(entry.getMethod()),
+ loadSchema(entry.getMethod(), entry.getSchema(), resolver, uriFactory, beanBaseLocation)
))
.toList();
}
@@ -150,4 +173,59 @@ private static Pattern compilePattern(String methodPattern) {
private record CompiledSchema(String methodPattern, Pattern pattern, Schema schema) {
}
+
+ private List getEffectiveMappings() {
+ if (!params.isEmpty() && !paramMappings.isEmpty()) {
+ throw new ConfigurationException("Configure JSON-RPC params either as a YAML map or as XML child elements, not both.");
+ }
+ if (!paramMappings.isEmpty()) {
+ return paramMappings;
+ }
+ return params.entrySet().stream()
+ .map(entry -> new Param(entry.getKey(), entry.getValue()))
+ .toList();
+ }
+
+ @MCElement(name = "param", component = false)
+ public static class Param {
+
+ private String method;
+ private String schema;
+
+ public Param() {
+ }
+
+ public Param(String method, String schema) {
+ this.method = method;
+ this.schema = schema;
+ }
+
+ /**
+ * @description The regular expression matched against the JSON-RPC method value.
+ * @example ^rpc\\.echo$
+ */
+ @Required
+ @MCAttribute
+ public void setMethod(String method) {
+ this.method = method;
+ }
+
+ public String getMethod() {
+ return method;
+ }
+
+ /**
+ * @description The path or URL of the JSON Schema used to validate params for matching methods.
+ * @example classpath:/json/rpc/echo-params.schema.json
+ */
+ @Required
+ @MCAttribute
+ public void setSchema(String schema) {
+ this.schema = schema;
+ }
+
+ public String getSchema() {
+ return schema;
+ }
+ }
}
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index ad314db7d1..bf8b7a3e1c 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -159,6 +159,21 @@ void paramsValidationUsesFirstMatchingRegexPattern() throws Exception {
assertErrorContains(exc.getResponse(), 400, "Invalid params for method 'rpc.echo'");
}
+ @Test
+ void xmlStyleParamMappingsAreSupported() throws Exception {
+ JsonRPCParams params = new JsonRPCParams();
+ params.setParamMappings(List.of(
+ new JsonRPCParams.Param("^rpc\\.echo$", "classpath:/json/rpc/echo-params.schema.json")
+ ));
+ var interceptor = interceptor(List.of(), params);
+
+ var exc = exchange("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"hello"}}
+ """);
+
+ assertEquals(CONTINUE, interceptor.handleRequest(exc));
+ }
+
private JsonRPCProtectionInterceptor interceptor(List rules) {
return interceptor(rules, new JsonRPCParams());
}
From de22caa3518976746919eb9429e9818cca40b5d0 Mon Sep 17 00:00:00 2001
From: thomas
Date: Thu, 28 May 2026 17:06:20 +0200
Subject: [PATCH 10/38] Refactor JSON-RPC validation: rename `rules` to
`methods` and improve request handling
---
.../interceptor/json/rpc/JsonRPCParams.java | 22 ++++-----
.../rpc/JsonRPCProtectionInterceptor.java | 34 +++++++-------
.../json/rpc/JsonRPCValidator.java | 45 ++++++++-----------
.../core/interceptor/json/rpc/Rule.java | 4 ++
.../rpc/JsonRPCProtectionInterceptorTest.java | 6 +--
5 files changed, 52 insertions(+), 59 deletions(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
index ad76d61206..7f8582b1bb 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
@@ -25,9 +25,9 @@
import com.predic8.membrane.core.resolver.Resolver;
import com.predic8.membrane.core.util.ConfigurationException;
import com.predic8.membrane.core.util.URIFactory;
+import org.jetbrains.annotations.NotNull;
import java.io.IOException;
-import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -65,7 +65,7 @@ public class JsonRPCParams {
*/
@MCOtherAttributes
public void setParams(Map params) {
- this.params = params == null ? new LinkedHashMap<>() : new LinkedHashMap<>(params);
+ this.params = params;
}
public Map getParams() {
@@ -81,7 +81,11 @@ public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocati
throw new ConfigurationException("Cannot initialize JSON-RPC param schemas without resolver context.");
}
- schemas = params.entrySet().stream()
+ schemas = getCompiledSchemas(resolver, uriFactory, beanBaseLocation);
+ }
+
+ private @NotNull List getCompiledSchemas(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
+ return params.entrySet().stream()
.map(entry -> new CompiledSchema(
entry.getKey(),
compilePattern(entry.getKey()),
@@ -91,11 +95,7 @@ public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocati
}
public Schema getSchema(String method) {
- if (method == null) {
- return null;
- }
-
- for (CompiledSchema schema : schemas) {
+ for (var schema : schemas) {
if (schema.pattern().matcher(method).matches()) {
return schema.schema();
}
@@ -108,9 +108,9 @@ private static Schema loadSchema(String methodPattern, String schemaPath, Resolv
throw new ConfigurationException("JSON-RPC param schema path for method pattern '%s' must not be empty.".formatted(methodPattern));
}
- String resolvedLocation = combine(uriFactory, beanBaseLocation, schemaPath.trim());
- try (InputStream in = resolver.resolve(resolvedLocation)) {
- Schema schema = createSchemaRegistry(resolver).getSchema(
+ var resolvedLocation = combine(uriFactory, beanBaseLocation, schemaPath.trim());
+ try (var in = resolver.resolve(resolvedLocation)) {
+ var schema = createSchemaRegistry(resolver).getSchema(
SchemaLocation.of(resolvedLocation),
in,
getSchemaFormat(resolvedLocation)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 76f5135d66..4b03902d69 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -14,7 +14,6 @@
import org.slf4j.LoggerFactory;
import java.io.IOException;
-import java.util.ArrayList;
import java.util.List;
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
@@ -46,7 +45,7 @@
* batch:
* enabled: true
* maxSize: 50
- * rules:
+ * methods:
* - allow: "^rpc\\.(health|echo)$"
* - deny: "^rpc\\..*$"
* params:
@@ -60,12 +59,12 @@ public class JsonRPCProtectionInterceptor extends AbstractInterceptor {
private static final ObjectMapper OM = new ObjectMapper();
private BatchRule batchRule = new BatchRule();
- private List rules = List.of();
+ private List methods = List.of();
private JsonRPCParams params = new JsonRPCParams();
private JsonRPCValidator validator;
public JsonRPCProtectionInterceptor() {
- name = "json rpc protection";
+ name = "JSON-RPC protection";
setAppliedFlow(of(REQUEST));
}
@@ -78,16 +77,20 @@ public void init() {
@Override
public Outcome handleRequest(Exchange exc) {
- if (!"POST".equals(exc.getRequest().getMethod())) {
+ if (!exc.getRequest().isPOSTRequest()) {
return CONTINUE;
}
- String body = exc.getRequest().getBodyAsStringDecoded();
- if (body == null || body.isBlank()) {
+ if (exc.getRequest().isBodyEmpty()) {
return CONTINUE;
}
- return reject(exc, getValidator().validate(body));
+ if (!exc.getRequest().isJSON()) {
+ // @TODO Error msg
+ return RETURN;
+ }
+
+ return reject(exc, getValidator().validate(exc.getRequest().getBodyAsStringDecoded()));
}
private Outcome reject(Exchange exc, ValidationError error) {
@@ -105,7 +108,6 @@ private Outcome reject(Exchange exc, ValidationError error) {
@MCChildElement(order = 0)
public void setBatch(BatchRule batchRule) {
this.batchRule = batchRule;
- validator = null;
}
/**
@@ -115,9 +117,8 @@ public void setBatch(BatchRule batchRule) {
*
The first matching rule decides whether the method is allowed or denied. Methods that do not match any configured rule are allowed. To switch to default-deny behavior, add a final deny rule such as deny: .*.
*/
@MCChildElement(order = 1)
- public void setRules(List rules) {
- this.rules = rules == null ? List.of() : new ArrayList<>(rules);
- validator = null;
+ public void setMethods(List methods) {
+ this.methods = methods;
}
/**
@@ -129,16 +130,15 @@ public void setRules(List rules) {
*/
@MCChildElement(order = 2)
public void setParams(JsonRPCParams params) {
- this.params = params == null ? new JsonRPCParams() : params;
- validator = null;
+ this.params = params;
}
public BatchRule getBatch() {
return batchRule;
}
- public List getRules() {
- return rules;
+ public List getMethods() {
+ return methods;
}
public JsonRPCParams getParams() {
@@ -154,7 +154,7 @@ private JsonRPCValidator getValidator() {
private JsonRPCValidator createValidator() {
params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation());
- return new JsonRPCValidator(batchRule, rules, params);
+ return new JsonRPCValidator(batchRule, methods, params);
}
private Response createErrorResponse(ValidationError error) {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
index 4e733e98bd..197db30c67 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
@@ -19,7 +19,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.NullNode;
import com.networknt.schema.Error;
-import com.networknt.schema.Schema;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,23 +27,22 @@
import java.util.List;
import java.util.stream.Collectors;
-import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST;
-import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_PARAMS;
-import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_METHOD_NOT_FOUND;
+import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.SINGLE;
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.*;
public class JsonRPCValidator {
private static final Logger log = LoggerFactory.getLogger(JsonRPCValidator.class);
- private static final ObjectMapper OM = new ObjectMapper();
+ private static final ObjectMapper om = new ObjectMapper();
private final BatchRule batchRule;
private final List rules;
private final JsonRPCParams params;
public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCParams params) {
- this.batchRule = batchRule == null ? new BatchRule() : batchRule;
- this.rules = rules == null ? List.of() : List.copyOf(rules);
- this.params = params == null ? new JsonRPCParams() : params;
+ this.batchRule = batchRule;
+ this.rules = rules;
+ this.params = params;
}
public ValidationError validate(String body) {
@@ -53,8 +51,8 @@ public ValidationError validate(String body) {
}
try {
- PayloadType payloadType = getPayloadType(body);
- JsonNode root = OM.readTree(body);
+ var payloadType = getPayloadType(body);
+ var root = om.readTree(body);
return validate(root, payloadType);
} catch (JsonProcessingException e) {
log.debug("Invalid JSON-RPC payload.", e);
@@ -68,7 +66,7 @@ public ValidationError validate(String body) {
} catch (RuntimeException e) {
log.debug("Invalid JSON-RPC payload.", e);
return new ValidationError(
- PayloadType.SINGLE,
+ SINGLE,
null,
400,
ERR_INVALID_REQUEST,
@@ -78,9 +76,6 @@ public ValidationError validate(String body) {
}
private ValidationError validate(JsonNode root, PayloadType payloadType) {
- if (root == null) {
- return null;
- }
if (root.isObject()) {
return validateSingle(root);
}
@@ -92,9 +87,9 @@ private ValidationError validate(JsonNode root, PayloadType payloadType) {
private ValidationError validateSingle(JsonNode node) {
try {
- return validateMethod(parseRequest(node), PayloadType.SINGLE);
+ return validateMethod(JSONRPCRequest.fromNode(node), SINGLE);
} catch (IOException e) {
- return new ValidationError(PayloadType.SINGLE, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request: " + e.getMessage());
+ return new ValidationError(SINGLE, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request: " + e.getMessage());
}
}
@@ -121,7 +116,7 @@ private ValidationError validateBatch(JsonNode batch) {
}
try {
- ValidationError error = validateMethod(parseRequest(requestNode), PayloadType.BATCH);
+ ValidationError error = validateMethod(JSONRPCRequest.fromNode(requestNode), PayloadType.BATCH);
if (error != null) {
return error;
}
@@ -133,7 +128,7 @@ private ValidationError validateBatch(JsonNode batch) {
}
private ValidationError validateMethod(JSONRPCRequest request, PayloadType payloadType) {
- for (Rule rule : rules) {
+ for (var rule : rules) {
if (!rule.matches(request.getMethod())) {
continue;
}
@@ -152,12 +147,12 @@ private ValidationError validateMethod(JSONRPCRequest request, PayloadType paylo
}
private ValidationError validateParams(JSONRPCRequest request, PayloadType payloadType) {
- Schema schema = params.getSchema(request.getMethod());
+ var schema = params.getSchema(request.getMethod());
if (schema == null) {
return null;
}
- List errors = schema.validate(getParamsNode(request));
+ var errors = schema.validate(getParamsNode(request));
if (errors.isEmpty()) {
return null;
}
@@ -174,23 +169,19 @@ private ValidationError validateParams(JSONRPCRequest request, PayloadType paylo
);
}
- private JSONRPCRequest parseRequest(JsonNode node) throws IOException {
- return JSONRPCRequest.fromNode(node);
- }
-
private JsonNode getParamsNode(JSONRPCRequest request) {
Object params = request.getParams();
if (params == null) {
return NullNode.instance;
}
- return OM.valueToTree(params);
+ return om.valueToTree(params);
}
private PayloadType getPayloadType(String body) {
if (body == null) {
- return PayloadType.SINGLE;
+ return SINGLE;
}
- return body.trim().startsWith("[") ? PayloadType.BATCH : PayloadType.SINGLE;
+ return body.trim().startsWith("[") ? PayloadType.BATCH : SINGLE;
}
public enum PayloadType {
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
index f1291c9fba..c3de73f305 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
@@ -7,6 +7,10 @@
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
+/**
+ * @TODO => util.allowdeny
+ * method => probe?
+ */
public abstract class Rule {
private String method;
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index ad314db7d1..6bd5e81282 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -29,9 +29,7 @@
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
public class JsonRPCProtectionInterceptorTest {
@@ -165,7 +163,7 @@ private JsonRPCProtectionInterceptor interceptor(List rules) {
private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params) {
var interceptor = new JsonRPCProtectionInterceptor();
- interceptor.setRules(rules);
+ interceptor.setMethods(rules);
interceptor.setParams(params);
interceptor.init(new DefaultRouter());
return interceptor;
From 6825c328436be00f76785b054b5e2b0a00366d9f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 08:33:34 +0200
Subject: [PATCH 11/38] Fix test failures: set batch rules before interceptor
init
---
.../json/rpc/JsonRPCProtectionInterceptorTest.java | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 9900f7b2c8..0888d22d3c 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -66,10 +66,9 @@ void firstMatchingDenyRuleRejectsRequest() throws Exception {
@Test
void batchRequestsCanBeDisabled() throws Exception {
- var interceptor = interceptor(List.of());
BatchRule batchRule = new BatchRule();
batchRule.setEnabled(false);
- interceptor.setBatch(batchRule);
+ var interceptor = interceptor(List.of(), new JsonRPCParams(), batchRule);
var exc = exchange("""
[{"jsonrpc":"2.0","id":1,"method":"rpc.health"}]
@@ -81,10 +80,9 @@ void batchRequestsCanBeDisabled() throws Exception {
@Test
void batchSizeIsLimited() throws Exception {
- var interceptor = interceptor(List.of());
BatchRule batchRule = new BatchRule();
batchRule.setMaxSize(1);
- interceptor.setBatch(batchRule);
+ var interceptor = interceptor(List.of(), new JsonRPCParams(), batchRule);
var exc = exchange("""
[
@@ -177,7 +175,12 @@ private JsonRPCProtectionInterceptor interceptor(List rules) {
}
private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params) {
+ return interceptor(rules, params, new BatchRule());
+ }
+
+ private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, BatchRule batchRule) {
var interceptor = new JsonRPCProtectionInterceptor();
+ interceptor.setBatch(batchRule);
interceptor.setMethods(rules);
interceptor.setParams(params);
interceptor.init(new DefaultRouter());
From 7d431c3b5e44dbf9030d0217d80e56e8e1a3c95e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 08:55:18 +0200
Subject: [PATCH 12/38] Filter child elements excluded from JSON schema in
`JsonSchemaGenerator` and add helper method
`getJsonVisibleChildElementSpecs`.
---
.../annot/generator/JsonSchemaGenerator.java | 25 ++++++++++++++-----
.../annot/model/ChildElementInfo.java | 6 ++++-
2 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java
index 4a5701e17b..5f45f943fa 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java
@@ -100,7 +100,7 @@ private void addTopLevelProperties(Model m, MainInfo main) {
private void addParserDefinitions(Model m, MainInfo main) {
for (ElementInfo elementInfo : main.getElements().values()) {
- if (elementInfo.getAnnotation().mixed() && !elementInfo.getChildElementSpecs().isEmpty()) {
+ if (elementInfo.getAnnotation().mixed() && !getJsonVisibleChildElementSpecs(elementInfo).isEmpty()) {
throw new ProcessingException(
"@MCElement(..., mixed=true) and @MCTextContent is not compatible with @MCChildElement.",
elementInfo.getElement()
@@ -140,7 +140,14 @@ private static AbstractSchema> tagElementId(AbstractSchema> schema, ElementI
private AbstractSchema> createNoEnvelopeParser(Model model, MainInfo main, ElementInfo elementInfo, String parserName) {
// With noEnvelope=true, there should be exactly one child element
- ChildElementInfo childSpec = elementInfo.getChildElementSpecs().getFirst();
+ List visibleChildElementSpecs = getJsonVisibleChildElementSpecs(elementInfo);
+ if (visibleChildElementSpecs.isEmpty()) {
+ throw new ProcessingException(
+ "@MCElement(noEnvelope=true) must declare at least one JSON-visible @MCChildElement.",
+ elementInfo.getElement()
+ );
+ }
+ ChildElementInfo childSpec = visibleChildElementSpecs.getFirst();
String childName = childSpec.getPropertyName();
boolean flowParserType = shouldGenerateFlowParserType(childSpec);
@@ -198,7 +205,7 @@ private AbstractSchema> createCollapsedInlineParser(ElementInfo ei, String par
var attrs = ei.getAis().stream().toList();
boolean hasText = ei.getTci() != null;
- boolean hasChildren = !ei.getChildElementSpecs().isEmpty();
+ boolean hasChildren = !getJsonVisibleChildElementSpecs(ei).isEmpty();
if (hasChildren) {
throw new ProcessingException("@MCElement(collapsed=true) must not declare child elements.", ei.getElement());
@@ -318,7 +325,7 @@ private void collectTextContent(ElementInfo elementInfo, SchemaObject parserSche
}
private void processMCChilds(Model model, MainInfo main, ElementInfo parentElementInfo, AbstractSchema> parentSchema) {
- for (ChildElementInfo childSpec : parentElementInfo.getChildElementSpecs()) {
+ for (ChildElementInfo childSpec : getJsonVisibleChildElementSpecs(parentElementInfo)) {
if (!childSpec.isList()) {
if (parentSchema instanceof SchemaObject parentObjectSchema) {
@@ -537,7 +544,7 @@ private SchemaObject createComponentsMapParser(Model m, MainInfo main, ElementIn
}
private boolean hasComponentChild(ElementInfo parentElementInfo, MainInfo main) {
- for (ChildElementInfo childSpec : parentElementInfo.getChildElementSpecs()) {
+ for (ChildElementInfo childSpec : getJsonVisibleChildElementSpecs(parentElementInfo)) {
var childDeclaration = getChildElementDeclarationInfo(main, childSpec);
if (childDeclaration == null) continue;
@@ -586,11 +593,17 @@ private boolean hasAnyConfigurableProperty(ElementInfo elementInfo, MainInfo mai
.filter(attributeInfo -> !attributeInfo.excludedFromJsonSchema())
.anyMatch(attributeInfo -> !"id".equals(attributeInfo.getXMLName()))
|| elementInfo.getTci() != null
- || !elementInfo.getChildElementSpecs().isEmpty()
+ || !getJsonVisibleChildElementSpecs(elementInfo).isEmpty()
|| elementInfo.getOai() != null
|| hasComponentChild(elementInfo, main);
}
+ private static List getJsonVisibleChildElementSpecs(ElementInfo elementInfo) {
+ return elementInfo.getChildElementSpecs().stream()
+ .filter(childElementInfo -> !childElementInfo.excludedFromJsonSchema())
+ .toList();
+ }
+
private void setItemsIfArray(AbstractSchema> parentSchema, AbstractSchema> itemsSchema) {
if (parentSchema instanceof SchemaArray schemaArray) {
schemaArray.items(itemsSchema);
diff --git a/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java b/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java
index b780b63289..1a8b8b6bc8 100644
--- a/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java
+++ b/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java
@@ -93,6 +93,10 @@ public void setList(boolean list) {
this.list = list;
}
+ public boolean excludedFromJsonSchema() {
+ return annotation != null && annotation.excludeFromJson();
+ }
+
@Override
public String toString() {
return "ChildElementInfo{" +
@@ -105,4 +109,4 @@ public String toString() {
", required=" + required +
'}';
}
-}
\ No newline at end of file
+}
From c25259cc8826dc4398ddc7c064066fc420785b8e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 09:19:13 +0200
Subject: [PATCH 13/38] Refactor Allow/Deny rule hierarchy: move to
util.allowdeny, rename methods, and update references
---
.../core/interceptor/json/rpc/Allow.java | 20 -------
.../core/interceptor/json/rpc/Deny.java | 20 -------
.../rpc/JsonRPCProtectionInterceptor.java | 1 +
.../json/rpc/JsonRPCValidator.java | 1 +
.../core/interceptor/json/rpc/Rule.java | 50 ----------------
.../membrane/core/util/allowdeny/Allow.java | 20 +++++++
.../membrane/core/util/allowdeny/Deny.java | 20 +++++++
.../membrane/core/util/allowdeny/Rule.java | 59 +++++++++++++++++++
.../rpc/JsonRPCProtectionInterceptorTest.java | 13 ++--
9 files changed, 109 insertions(+), 95 deletions(-)
delete mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
delete mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
delete mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
deleted file mode 100644
index c9382ef145..0000000000
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Allow.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.predic8.membrane.core.interceptor.json.rpc;
-
-import com.predic8.membrane.annot.MCElement;
-
-/**
- * @description Permits JSON-RPC requests whose method matches the configured regular expression.
- */
-@MCElement(name = "allow", collapsed = true, component = false, id = "rpc-allow")
-public class Allow extends Rule {
-
- @Override
- boolean permits() {
- return true;
- }
-
- @Override
- public String toString() {
- return "Allow{method=%s}".formatted(getMethod());
- }
-}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
deleted file mode 100644
index c4b7352e4b..0000000000
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Deny.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.predic8.membrane.core.interceptor.json.rpc;
-
-import com.predic8.membrane.annot.MCElement;
-
-/**
- * @description Denies JSON-RPC requests whose method matches the configured regular expression.
- */
-@MCElement(name = "deny", collapsed = true, component = false, id = "rpc-deny")
-public class Deny extends Rule {
-
- @Override
- boolean permits() {
- return false;
- }
-
- @Override
- public String toString() {
- return "Deny{method=%s}".formatted(getMethod());
- }
-}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 4b03902d69..595214acea 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -10,6 +10,7 @@
import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import com.predic8.membrane.core.jsonrpc.JSONRPCResponse;
+import com.predic8.membrane.core.util.allowdeny.Rule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
index 197db30c67..2d85ad7fb4 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
@@ -20,6 +20,7 @@
import com.fasterxml.jackson.databind.node.NullNode;
import com.networknt.schema.Error;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
+import com.predic8.membrane.core.util.allowdeny.Rule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
deleted file mode 100644
index c3de73f305..0000000000
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/Rule.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.predic8.membrane.core.interceptor.json.rpc;
-
-import com.predic8.membrane.annot.MCAttribute;
-import com.predic8.membrane.annot.Required;
-import com.predic8.membrane.core.util.ConfigurationException;
-
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
-
-/**
- * @TODO => util.allowdeny
- * method => probe?
- */
-public abstract class Rule {
-
- private String method;
- private Pattern methodPattern;
-
- public boolean matches(String method) {
- if (method == null) {
- return false;
- }
- return methodPattern != null && methodPattern.matcher(method).matches();
- }
-
- abstract boolean permits();
-
- /**
- * @description The regular expression matched against the JSON-RPC method value.
- * @example "^rpc\\.(health|echo)$"
- */
- @Required
- @MCAttribute
- public void setMethod(String method) {
- if (method == null || method.trim().isEmpty()) {
- throw new ConfigurationException("method must not be empty");
- }
-
- this.method = method.trim();
- try {
- methodPattern = Pattern.compile(this.method);
- } catch (PatternSyntaxException e) {
- throw new ConfigurationException("Invalid method regex: " + this.method);
- }
- }
-
- public String getMethod() {
- return method;
- }
-}
diff --git a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java b/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java
new file mode 100644
index 0000000000..8d9040f1ac
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java
@@ -0,0 +1,20 @@
+package com.predic8.membrane.core.util.allowdeny;
+
+import com.predic8.membrane.annot.MCElement;
+
+/**
+ * @description Permits values matching the configured regular expression.
+ */
+@MCElement(name = "allow", collapsed = true, component = false, id = "allow-rule")
+public class Allow extends Rule {
+
+ @Override
+ public boolean permits() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "Allow{pattern=%s}".formatted(getPattern());
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java b/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java
new file mode 100644
index 0000000000..a7fa76d060
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java
@@ -0,0 +1,20 @@
+package com.predic8.membrane.core.util.allowdeny;
+
+import com.predic8.membrane.annot.MCElement;
+
+/**
+ * @description Denies values matching the configured regular expression.
+ */
+@MCElement(name = "deny", collapsed = true, component = false, id = "deny-rule")
+public class Deny extends Rule {
+
+ @Override
+ public boolean permits() {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "Deny{pattern=%s}".formatted(getPattern());
+ }
+}
diff --git a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java b/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java
new file mode 100644
index 0000000000..9209037841
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java
@@ -0,0 +1,59 @@
+package com.predic8.membrane.core.util.allowdeny;
+
+import com.predic8.membrane.annot.MCAttribute;
+import com.predic8.membrane.annot.Required;
+import com.predic8.membrane.core.util.ConfigurationException;
+
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/**
+ * Ordered allow/deny rule based on a regular expression.
+ */
+public abstract class Rule {
+
+ private String pattern;
+ private Pattern compiledPattern;
+
+ public boolean matches(String probe) {
+ if (probe == null) {
+ return false;
+ }
+ return compiledPattern != null && compiledPattern.matcher(probe).matches();
+ }
+
+ public abstract boolean permits();
+
+ /**
+ * @description The regular expression matched against the input value.
+ * @example "^rpc\\.(health|echo)$"
+ */
+ @Required
+ @MCAttribute
+ public void setPattern(String pattern) {
+ if (pattern == null || pattern.trim().isEmpty()) {
+ throw new ConfigurationException("pattern must not be empty");
+ }
+
+ this.pattern = pattern.trim();
+ try {
+ compiledPattern = Pattern.compile(this.pattern);
+ } catch (PatternSyntaxException e) {
+ throw new ConfigurationException("Invalid regex pattern: " + this.pattern);
+ }
+ }
+
+ public String getPattern() {
+ return pattern;
+ }
+
+ @Deprecated
+ public void setMethod(String method) {
+ setPattern(method);
+ }
+
+ @Deprecated
+ public String getMethod() {
+ return getPattern();
+ }
+}
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 0888d22d3c..6e970f2fac 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -20,6 +20,9 @@
import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.router.DefaultRouter;
import com.predic8.membrane.core.util.ConfigurationException;
+import com.predic8.membrane.core.util.allowdeny.Allow;
+import com.predic8.membrane.core.util.allowdeny.Deny;
+import com.predic8.membrane.core.util.allowdeny.Rule;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
@@ -98,7 +101,7 @@ void batchSizeIsLimited() throws Exception {
@Test
void invalidRegexIsRejected() {
Allow allow = new Allow();
- assertThrows(ConfigurationException.class, () -> allow.setMethod("[*"));
+ assertThrows(ConfigurationException.class, () -> allow.setPattern("[*"));
}
@Test
@@ -194,15 +197,15 @@ private com.predic8.membrane.core.exchange.Exchange exchange(String body) throws
.buildExchange();
}
- private Allow allow(String method) {
+ private Allow allow(String pattern) {
Allow allow = new Allow();
- allow.setMethod(method);
+ allow.setPattern(pattern);
return allow;
}
- private Deny deny(String method) {
+ private Deny deny(String pattern) {
Deny deny = new Deny();
- deny.setMethod(method);
+ deny.setPattern(pattern);
return deny;
}
From fd8d1f79b97d9cd8bfaf991174bd961fed282faf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 09:24:24 +0200
Subject: [PATCH 14/38] Reject non-JSON content types in
`JsonRPCProtectionInterceptor` with proper error handling and add
corresponding test.
---
.../json/rpc/JsonRPCProtectionInterceptor.java | 18 ++++++++++++++++--
.../rpc/JsonRPCProtectionInterceptorTest.java | 14 ++++++++++++++
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 595214acea..466ded87f6 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -23,7 +23,9 @@
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.BATCH;
+import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.SINGLE;
import static java.util.EnumSet.of;
+import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST;
/**
* @topic 3. Security and Validation
@@ -87,8 +89,13 @@ public Outcome handleRequest(Exchange exc) {
}
if (!exc.getRequest().isJSON()) {
- // @TODO Error msg
- return RETURN;
+ return reject(exc, new ValidationError(
+ payloadType(exc.getRequest().getBodyAsStringDecoded()),
+ null,
+ 415,
+ ERR_INVALID_REQUEST,
+ "Content-Type %s is not supported. Expected application/json.".formatted(exc.getRequest().getHeader().getContentType())
+ ));
}
return reject(exc, getValidator().validate(exc.getRequest().getBodyAsStringDecoded()));
@@ -182,4 +189,11 @@ private Object responseId(JSONRPCRequest request) {
}
return request.getId();
}
+
+ private JsonRPCValidator.PayloadType payloadType(String body) {
+ if (body == null) {
+ return SINGLE;
+ }
+ return body.trim().startsWith("[") ? BATCH : SINGLE;
+ }
}
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 6e970f2fac..57cb9c65a1 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -30,6 +30,7 @@
import java.util.Map;
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+import static com.predic8.membrane.core.http.MimeType.TEXT_PLAIN;
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
import static org.junit.jupiter.api.Assertions.*;
@@ -104,6 +105,19 @@ void invalidRegexIsRejected() {
assertThrows(ConfigurationException.class, () -> allow.setPattern("[*"));
}
+ @Test
+ void nonJsonContentTypeIsRejected() throws Exception {
+ var interceptor = interceptor(List.of());
+
+ var exc = Request.post("/")
+ .contentType(TEXT_PLAIN)
+ .body("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"rpc.health\"}")
+ .buildExchange();
+
+ assertEquals(RETURN, interceptor.handleRequest(exc));
+ assertError(exc.getResponse(), 415, "Content-Type text/plain is not supported. Expected application/json.");
+ }
+
@Test
void paramsValidation() throws Exception {
JsonRPCParams params = new JsonRPCParams();
From c1bab2be8a8aaf6d307e5437e03ab1cb780b36b5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 12:26:11 +0200
Subject: [PATCH 15/38] Move Allow/Deny rules to `util.config.allowdeny`
package and update all references
---
.../interceptor/json/rpc/JsonRPCProtectionInterceptor.java | 3 ++-
.../core/interceptor/json/rpc/JsonRPCValidator.java | 2 +-
.../membrane/core/util/{ => config}/allowdeny/Allow.java | 2 +-
.../membrane/core/util/{ => config}/allowdeny/Deny.java | 2 +-
.../membrane/core/util/{ => config}/allowdeny/Rule.java | 2 +-
.../json/rpc/JsonRPCProtectionInterceptorTest.java | 6 +++---
6 files changed, 9 insertions(+), 8 deletions(-)
rename core/src/main/java/com/predic8/membrane/core/util/{ => config}/allowdeny/Allow.java (88%)
rename core/src/main/java/com/predic8/membrane/core/util/{ => config}/allowdeny/Deny.java (88%)
rename core/src/main/java/com/predic8/membrane/core/util/{ => config}/allowdeny/Rule.java (96%)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 466ded87f6..4452926a2f 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -10,7 +10,7 @@
import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import com.predic8.membrane.core.jsonrpc.JSONRPCResponse;
-import com.predic8.membrane.core.util.allowdeny.Rule;
+import com.predic8.membrane.core.util.config.allowdeny.Rule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -51,6 +51,7 @@
* methods:
* - allow: "^rpc\\.(health|echo)$"
* - deny: "^rpc\\..*$"
+ * - deny: * # Switch to default-deny behavior
* params:
* "^rpc\\.echo$": "classpath:/json/rpc/echo-params.schema.json"
*
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
index 2d85ad7fb4..0a3db6b96a 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java
@@ -20,7 +20,7 @@
import com.fasterxml.jackson.databind.node.NullNode;
import com.networknt.schema.Error;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
-import com.predic8.membrane.core.util.allowdeny.Rule;
+import com.predic8.membrane.core.util.config.allowdeny.Rule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
diff --git a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java
similarity index 88%
rename from core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java
rename to core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java
index 8d9040f1ac..b5895d6c96 100644
--- a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Allow.java
+++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java
@@ -1,4 +1,4 @@
-package com.predic8.membrane.core.util.allowdeny;
+package com.predic8.membrane.core.util.config.allowdeny;
import com.predic8.membrane.annot.MCElement;
diff --git a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java
similarity index 88%
rename from core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java
rename to core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java
index a7fa76d060..a09263d6f7 100644
--- a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Deny.java
+++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java
@@ -1,4 +1,4 @@
-package com.predic8.membrane.core.util.allowdeny;
+package com.predic8.membrane.core.util.config.allowdeny;
import com.predic8.membrane.annot.MCElement;
diff --git a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java
similarity index 96%
rename from core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java
rename to core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java
index 9209037841..97ab297003 100644
--- a/core/src/main/java/com/predic8/membrane/core/util/allowdeny/Rule.java
+++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java
@@ -1,4 +1,4 @@
-package com.predic8.membrane.core.util.allowdeny;
+package com.predic8.membrane.core.util.config.allowdeny;
import com.predic8.membrane.annot.MCAttribute;
import com.predic8.membrane.annot.Required;
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 57cb9c65a1..1f11ed5e04 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -20,9 +20,9 @@
import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.router.DefaultRouter;
import com.predic8.membrane.core.util.ConfigurationException;
-import com.predic8.membrane.core.util.allowdeny.Allow;
-import com.predic8.membrane.core.util.allowdeny.Deny;
-import com.predic8.membrane.core.util.allowdeny.Rule;
+import com.predic8.membrane.core.util.config.allowdeny.Allow;
+import com.predic8.membrane.core.util.config.allowdeny.Deny;
+import com.predic8.membrane.core.util.config.allowdeny.Rule;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
From f52a9a69e0d72f31c03e3774160fdc6372434dc2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 13:54:25 +0200
Subject: [PATCH 16/38] Add JSON-RPC protection tutorial with method filtering,
parameter validation, and batch size limiting
---
distribution/tutorials/README.md | 2 +-
.../json/30-JSON-RPC-Protection.yaml | 70 +++++++++++++++++++
distribution/tutorials/json/README.md | 3 +-
.../tutorials/json/echo-params.schema.json | 15 ++++
4 files changed, 88 insertions(+), 2 deletions(-)
create mode 100644 distribution/tutorials/json/30-JSON-RPC-Protection.yaml
create mode 100644 distribution/tutorials/json/echo-params.schema.json
diff --git a/distribution/tutorials/README.md b/distribution/tutorials/README.md
index 6e0d82988d..2f76fdc3bf 100644
--- a/distribution/tutorials/README.md
+++ b/distribution/tutorials/README.md
@@ -12,7 +12,7 @@ Complete this tutorial before moving on to the JSON or XML tutorials.
## [JSON](json)
-This tutorial builds on 'Getting Started'. It explains how to read, create and transform JSON data, and how to use JsonPath to extract information or compute values.
+This tutorial builds on 'Getting Started'. It explains how to read, create and transform JSON data, how to use JsonPath to extract information or compute values, and how to protect JSON-RPC endpoints.
## [XML](xml)
diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml
new file mode 100644
index 0000000000..90e51f524b
--- /dev/null
+++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml
@@ -0,0 +1,70 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.2.json
+#
+# Tutorial: JSON-RPC Protection
+#
+# Protect a JSON-RPC endpoint by:
+# - allowing only selected methods
+# - rejecting all other methods
+# - validating params with JSON Schema
+# - limiting batch requests
+#
+# Notes:
+# - Method rules are evaluated top-down.
+# - The first matching rule wins.
+#
+# 1.) Send an allowed request
+# curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.health"}' -H "Content-Type: application/json" http://localhost:2000
+#
+# 2.) Try a blocked method
+# curl -d '{"jsonrpc":"2.0","id":2,"method":"rpc.admin.shutdown"}' -H "Content-Type: application/json" http://localhost:2000
+#
+# Should return a JSON-RPC error explaining that the method is not allowed.
+#
+# 3.) Try invalid params
+# curl -d '{"jsonrpc":"2.0","id":3,"method":"rpc.echo","params":{}}' -H "Content-Type: application/json" http://localhost:2000
+#
+# The schema requires params.message.
+#
+# 4.) Send a valid request
+# curl -d '{"jsonrpc":"2.0","id":4,"method":"rpc.echo","params":{"message":"Hello"}}' -H "Content-Type: application/json" http://localhost:2000
+#
+# The request should be forwarded to the mock upstream on port 2001.
+#
+# 5.) Try a batch that is too large
+# curl -d '[{"jsonrpc":"2.0","id":5,"method":"rpc.health"},{"jsonrpc":"2.0","id":6,"method":"rpc.echo","params":{"message":"Hi"}},{"jsonrpc":"2.0","id":7,"method":"rpc.health"}]' -H "Content-Type: application/json" http://localhost:2000
+#
+# Should return a JSON-RPC batch error because maxSize is 2.
+
+api:
+ port: 2000
+ flow:
+ - request:
+ - jsonRPCProtection:
+ batch:
+ enabled: true
+ maxSize: 2 # At most two calls per batch
+ methods:
+ - allow: '^rpc\.(health|echo)$'
+ - deny: '.*' # Default deny for everything else
+ params:
+ '^rpc\.echo$': echo-params.schema.json # Validate params for rpc.echo
+ target:
+ url: http://localhost:2001
+
+---
+api:
+ port: 2001
+ flow:
+ - request:
+ - log:
+ message: "Accepted JSON-RPC request reached the upstream."
+ - response:
+ - template:
+ contentType: application/json
+ pretty: true
+ src: |
+ {
+ "accepted": true
+ }
+ - return:
+ status: 200
diff --git a/distribution/tutorials/json/README.md b/distribution/tutorials/json/README.md
index d2a149b503..2186d50737 100644
--- a/distribution/tutorials/json/README.md
+++ b/distribution/tutorials/json/README.md
@@ -4,7 +4,8 @@ This tutorial covers working with JSON in Membrane API Gateway, including:
- JsonPath
- JSON message transformations
+- JSON-RPC protection
To begin, open [10-JSONPath.yaml](10-JSONPath.yaml) and follow the instructions in the file.
-More examples are available in the examples folder.
\ No newline at end of file
+More examples are available in the examples folder.
diff --git a/distribution/tutorials/json/echo-params.schema.json b/distribution/tutorials/json/echo-params.schema.json
new file mode 100644
index 0000000000..0b9ad4703d
--- /dev/null
+++ b/distribution/tutorials/json/echo-params.schema.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "properties": {
+ "message": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ }
+ },
+ "additionalProperties": false
+}
From cd0bd99f96e66e4bd30eefb9ef40b231ed5d1651 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 14:23:52 +0200
Subject: [PATCH 17/38] Add tests for JSON-RPC protection tutorial covering
method filtering, parameter validation, and batch size limits
---
.../core/interceptor/json/rpc/BatchRule.java | 14 +++
.../rpc/JsonRPCProtectionInterceptor.java | 14 +++
.../core/util/config/allowdeny/Allow.java | 14 +++
.../core/util/config/allowdeny/Deny.java | 14 +++
.../core/util/config/allowdeny/Rule.java | 14 +++
.../json/JsonRpcProtectionTutorialTest.java | 113 ++++++++++++++++++
6 files changed, 183 insertions(+)
create mode 100644 distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
index 14f274a3ec..90924204f9 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
package com.predic8.membrane.core.interceptor.json.rpc;
import com.predic8.membrane.annot.MCAttribute;
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 4452926a2f..1897c47225 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
package com.predic8.membrane.core.interceptor.json.rpc;
import com.fasterxml.jackson.databind.ObjectMapper;
diff --git a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java
index b5895d6c96..cd2c12f13f 100644
--- a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java
+++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
package com.predic8.membrane.core.util.config.allowdeny;
import com.predic8.membrane.annot.MCElement;
diff --git a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java
index a09263d6f7..cdd8052be9 100644
--- a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java
+++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
package com.predic8.membrane.core.util.config.allowdeny;
import com.predic8.membrane.annot.MCElement;
diff --git a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java
index 97ab297003..a582d6bd87 100644
--- a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java
+++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
package com.predic8.membrane.core.util.config.allowdeny;
import com.predic8.membrane.annot.MCAttribute;
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java
new file mode 100644
index 0000000000..e9809749eb
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java
@@ -0,0 +1,113 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
+package com.predic8.membrane.tutorials.json;
+
+import org.junit.jupiter.api.Test;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.http.ContentType.JSON;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.nullValue;
+
+public class JsonRpcProtectionTutorialTest extends AbstractJsonTutorialTest {
+
+ @Override
+ protected String getTutorialYaml() {
+ return "30-JSON-RPC-Protection.yaml";
+ }
+
+ @Test
+ void allowsConfiguredMethods() {
+ // @formatter:off
+ given()
+ .contentType(JSON)
+ .body("""
+ {"jsonrpc":"2.0","id":1,"method":"rpc.health"}
+ """)
+ .when()
+ .post("http://localhost:2000")
+ .then()
+ .statusCode(200)
+ .contentType(JSON)
+ .body("accepted", equalTo(true));
+ // @formatter:on
+ }
+
+ @Test
+ void rejectsMethodsOutsideAllowlist() {
+ // @formatter:off
+ given()
+ .contentType(JSON)
+ .body("""
+ {"jsonrpc":"2.0","id":2,"method":"rpc.admin.shutdown"}
+ """)
+ .when()
+ .post("http://localhost:2000")
+ .then()
+ .statusCode(403)
+ .contentType(JSON)
+ .body("jsonrpc", equalTo("2.0"))
+ .body("id", equalTo(2))
+ .body("error.code", equalTo(-32601))
+ .body("error.message", containsString("rpc.admin.shutdown"));
+ // @formatter:on
+ }
+
+ @Test
+ void validatesParamsAgainstSchema() {
+ // @formatter:off
+ given()
+ .contentType(JSON)
+ .body("""
+ {"jsonrpc":"2.0","id":3,"method":"rpc.echo","params":{}}
+ """)
+ .when()
+ .post("http://localhost:2000")
+ .then()
+ .statusCode(400)
+ .contentType(JSON)
+ .body("jsonrpc", equalTo("2.0"))
+ .body("id", equalTo(3))
+ .body("error.code", equalTo(-32602))
+ .body("error.message", containsString("Invalid params for method 'rpc.echo'"));
+ // @formatter:on
+ }
+
+ @Test
+ void rejectsBatchesThatExceedMaxSize() {
+ // @formatter:off
+ given()
+ .contentType(JSON)
+ .body("""
+ [
+ {"jsonrpc":"2.0","id":5,"method":"rpc.health"},
+ {"jsonrpc":"2.0","id":6,"method":"rpc.echo","params":{"message":"Hi"}},
+ {"jsonrpc":"2.0","id":7,"method":"rpc.health"}
+ ]
+ """)
+ .when()
+ .post("http://localhost:2000")
+ .then()
+ .statusCode(400)
+ .contentType(JSON)
+ .body("size()", equalTo(1))
+ .body("[0].jsonrpc", equalTo("2.0"))
+ .body("[0].id", nullValue())
+ .body("[0].error.code", equalTo(-32600))
+ .body("[0].error.message", equalTo("Batch request exceeds maxSize of 2."));
+ // @formatter:on
+ }
+}
From fe0833b054de6537106a4ab2f7db23b468cd8d7b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 14:59:28 +0200
Subject: [PATCH 18/38] Switch JSON-RPC `params` method matching from regex to
exact names and update associated logic, tests, and documentation
---
.../interceptor/json/rpc/JsonRPCParams.java | 76 +++++++++----------
.../rpc/JsonRPCProtectionInterceptor.java | 12 +--
.../rpc/JsonRPCProtectionInterceptorTest.java | 31 +++++---
.../json/30-JSON-RPC-Protection.yaml | 2 +-
4 files changed, 61 insertions(+), 60 deletions(-)
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
index abe45ee559..4541b10e16 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java
@@ -28,45 +28,44 @@
import com.predic8.membrane.core.resolver.Resolver;
import com.predic8.membrane.core.util.ConfigurationException;
import com.predic8.membrane.core.util.URIFactory;
-import org.jetbrains.annotations.NotNull;
import java.io.IOException;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
import static com.networknt.schema.InputFormat.JSON;
import static com.networknt.schema.InputFormat.YAML;
import static com.networknt.schema.SchemaRegistry.withDefaultDialect;
import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
import static com.predic8.membrane.core.resolver.ResolverMap.combine;
+import static java.util.Collections.unmodifiableMap;
/**
* @description
- *
Maps JSON-RPC method name patterns to JSON Schema locations for validating the
+ *
Maps JSON-RPC method names to JSON Schema locations for validating the
* params member of a request.
*
- *
In YAML, the configuration is expressed as a map from method regex to schema location.
+ *
In YAML, the configuration is expressed as a map from exact method name to schema location.
* In XML, use repeated param child elements with method and
- * schema attributes. Entries are checked in order and the first matching
- * method pattern wins. Inline schemas are not supported.
+ * schema attributes. Each method name can be configured once. Inline schemas are
+ * not supported.
*/
@MCElement(name = "params", component = false)
public class JsonRPCParams {
private Map params = new LinkedHashMap<>();
private List paramMappings = List.of();
- private List schemas = List.of();
+ private Map schemas = Map.of();
/**
* @description
- *
Defines a map from method name regex to JSON Schema location.
+ *
Defines a map from exact method name to JSON Schema location.
*
- *
The insertion order is preserved and determines precedence when multiple regexes match.
+ *
The keys are matched literally against the JSON-RPC method value.
This form is intended for XML configuration. YAML keeps using the map syntax shown above.
*
- * @example <param method="^rpc\\.echo$" schema="classpath:/json/rpc/echo-params.schema.json"/>
+ * @example <param method="rpc.echo" schema="classpath:/json/rpc/echo-params.schema.json"/>
*/
@MCChildElement(excludeFromJson = true)
public void setParamMappings(List paramMappings) {
@@ -97,34 +96,35 @@ public List getParamMappings() {
public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
List effectiveMappings = getEffectiveMappings();
if (effectiveMappings.isEmpty()) {
- schemas = List.of();
+ schemas = Map.of();
return;
}
if (resolver == null || uriFactory == null) {
throw new ConfigurationException("Cannot initialize JSON-RPC param schemas without resolver context.");
}
- schemas = effectiveMappings.stream()
- .map(entry -> new CompiledSchema(
- entry.getMethod(),
- compilePattern(entry.getMethod()),
- loadSchema(entry.getMethod(), entry.getSchema(), resolver, uriFactory, beanBaseLocation)
- ))
- .toList();
+ Map resolvedSchemas = new LinkedHashMap<>();
+ for (Param entry : effectiveMappings) {
+ String methodName = validateMethodName(entry.getMethod());
+ if (resolvedSchemas.containsKey(methodName)) {
+ throw new ConfigurationException("Duplicate JSON-RPC param schema mapping for method '%s'.".formatted(methodName));
+ }
+ resolvedSchemas.put(methodName,
+ loadSchema(methodName, entry.getSchema(), resolver, uriFactory, beanBaseLocation));
+ }
+ schemas = unmodifiableMap(resolvedSchemas);
}
public Schema getSchema(String method) {
- for (var schema : schemas) {
- if (schema.pattern().matcher(method).matches()) {
- return schema.schema();
- }
+ if (method == null) {
+ return null;
}
- return null;
+ return schemas.get(method);
}
- private static Schema loadSchema(String methodPattern, String schemaPath, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
+ private static Schema loadSchema(String methodName, String schemaPath, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
if (schemaPath == null || schemaPath.trim().isEmpty()) {
- throw new ConfigurationException("JSON-RPC param schema path for method pattern '%s' must not be empty.".formatted(methodPattern));
+ throw new ConfigurationException("JSON-RPC param schema path for method '%s' must not be empty.".formatted(methodName));
}
var resolvedLocation = combine(uriFactory, beanBaseLocation, schemaPath.trim());
@@ -137,9 +137,9 @@ private static Schema loadSchema(String methodPattern, String schemaPath, Resolv
schema.initializeValidators();
return schema;
} catch (IOException e) {
- throw new ConfigurationException("Cannot read JSON-RPC param schema for method pattern '%s' from '%s'.".formatted(methodPattern, schemaPath), e);
+ throw new ConfigurationException("Cannot read JSON-RPC param schema for method '%s' from '%s'.".formatted(methodName, schemaPath), e);
} catch (RuntimeException e) {
- throw new ConfigurationException("Cannot create JSON-RPC param schema for method pattern '%s' from '%s'.".formatted(methodPattern, schemaPath), e);
+ throw new ConfigurationException("Cannot create JSON-RPC param schema for method '%s' from '%s'.".formatted(methodName, schemaPath), e);
}
}
@@ -156,18 +156,12 @@ private static InputFormat getSchemaFormat(String schemaLocation) {
return schemaLocation.toLowerCase().endsWith(".yaml") || schemaLocation.toLowerCase().endsWith(".yml") ? YAML : JSON;
}
- private static Pattern compilePattern(String methodPattern) {
- if (methodPattern == null || methodPattern.trim().isEmpty()) {
- throw new ConfigurationException("JSON-RPC param method pattern must not be empty.");
- }
- try {
- return Pattern.compile(methodPattern.trim());
- } catch (PatternSyntaxException e) {
- throw new ConfigurationException("Invalid JSON-RPC param method regex: " + methodPattern, e);
+ private static String validateMethodName(String methodName) {
+ if (methodName == null || methodName.trim().isEmpty()) {
+ throw new ConfigurationException("JSON-RPC param method name must not be empty.");
}
- }
- private record CompiledSchema(String methodPattern, Pattern pattern, Schema schema) {
+ return methodName.trim();
}
private List getEffectiveMappings() {
@@ -197,8 +191,8 @@ public Param(String method, String schema) {
}
/**
- * @description The regular expression matched against the JSON-RPC method value.
- * @example ^rpc\\.echo$
+ * @description The exact JSON-RPC method value whose params should be validated.
+ * @example rpc.echo
*/
@Required
@MCAttribute
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index 1897c47225..eabb0fb146 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -52,9 +52,9 @@
* whether a method is allowed or denied.
*
*
Parameter schemas are configured separately in the params child element. The
- * keys are regular expressions matched against the JSON-RPC method name. The first matching
- * schema entry is used to validate the params object or array. Schemas must be
- * referenced by path or URL and cannot be configured inline.
+ * keys are exact JSON-RPC method names. The matching schema entry is used to validate the
+ * params object or array. Schemas must be referenced by path or URL and cannot
+ * be configured inline.
*
* @yaml
*
Configures JSON Schema files for validating params per method name.
*
- *
The keys are regular expressions matched against the JSON-RPC method name in order.
- * The first matching entry is used. Values must be schema paths or URLs; inline schemas are not supported.
+ *
The keys are exact JSON-RPC method names. Values must be schema paths or URLs; inline
+ * schemas are not supported.
*/
@MCChildElement(order = 2)
public void setParams(JsonRPCParams params) {
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
index 1f11ed5e04..b5d6983990 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java
@@ -25,7 +25,6 @@
import com.predic8.membrane.core.util.config.allowdeny.Rule;
import org.junit.jupiter.api.Test;
-import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -141,10 +140,10 @@ void paramsValidation() throws Exception {
}
@Test
- void paramsValidationSupportsRegexMethodPatterns() throws Exception {
+ void paramsValidationUsesExactMethodName() throws Exception {
JsonRPCParams params = new JsonRPCParams();
params.setParams(Map.of(
- "^rpc\\..*$", "classpath:/json/rpc/generic-rpc-params.schema.json"
+ "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json"
));
var interceptor = interceptor(List.of(), params);
@@ -156,27 +155,35 @@ void paramsValidationSupportsRegexMethodPatterns() throws Exception {
}
@Test
- void paramsValidationUsesFirstMatchingRegexPattern() throws Exception {
+ void paramsValidationDoesNotMatchDifferentMethodNames() throws Exception {
JsonRPCParams params = new JsonRPCParams();
- LinkedHashMap map = new LinkedHashMap<>();
- map.put("^rpc\\.echo$", "classpath:/json/rpc/echo-params.schema.json");
- map.put("^rpc\\..*$", "classpath:/json/rpc/generic-rpc-params.schema.json");
- params.setParams(map);
+ params.setParams(Map.of(
+ "rpc.echo", "classpath:/json/rpc/echo-params.schema.json"
+ ));
var interceptor = interceptor(List.of(), params);
var exc = exchange("""
- {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"code":1}}
+ {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2","params":{"code":1}}
""");
- assertEquals(RETURN, interceptor.handleRequest(exc));
- assertErrorContains(exc.getResponse(), 400, "Invalid params for method 'rpc.echo'");
+ assertEquals(CONTINUE, interceptor.handleRequest(exc));
+ }
+
+ @Test
+ void regexLikeMethodNamesAreRejectedInYamlConfig() {
+ JsonRPCParams params = new JsonRPCParams();
+ params.setParams(Map.of(
+ "^rpc\\.echo$", "classpath:/json/rpc/echo-params.schema.json"
+ ));
+
+ assertThrows(ConfigurationException.class, () -> interceptor(List.of(), params));
}
@Test
void xmlStyleParamMappingsAreSupported() throws Exception {
JsonRPCParams params = new JsonRPCParams();
params.setParamMappings(List.of(
- new JsonRPCParams.Param("^rpc\\.echo$", "classpath:/json/rpc/echo-params.schema.json")
+ new JsonRPCParams.Param("rpc.echo", "classpath:/json/rpc/echo-params.schema.json")
));
var interceptor = interceptor(List.of(), params);
diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml
index 90e51f524b..b29442599a 100644
--- a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml
+++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml
@@ -47,7 +47,7 @@ api:
- allow: '^rpc\.(health|echo)$'
- deny: '.*' # Default deny for everything else
params:
- '^rpc\.echo$': echo-params.schema.json # Validate params for rpc.echo
+ 'rpc.echo': echo-params.schema.json # Validate params for rpc.echo
target:
url: http://localhost:2001
From f7d963f4faca9a759a1b42c657f353dd0f7a6d2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20G=C3=B6rdes?=
Date: Mon, 1 Jun 2026 15:33:52 +0200
Subject: [PATCH 19/38] Add result schema validation to JSON-RPC protection and
refactor schema handling logic
---
.../rpc/AbstractJsonRPCMethodSchemas.java | 212 ++++++++++++++++
.../interceptor/json/rpc/JsonRPCParams.java | 190 +-------------
.../rpc/JsonRPCProtectionInterceptor.java | 79 ++++--
.../interceptor/json/rpc/JsonRPCResult.java | 29 +++
.../json/rpc/JsonRPCValidator.java | 234 ++++++++++++++----
.../rpc/JsonRPCProtectionInterceptorTest.java | 144 +++++++++--
6 files changed, 625 insertions(+), 263 deletions(-)
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/AbstractJsonRPCMethodSchemas.java
create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/AbstractJsonRPCMethodSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/AbstractJsonRPCMethodSchemas.java
new file mode 100644
index 0000000000..85063deac4
--- /dev/null
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/AbstractJsonRPCMethodSchemas.java
@@ -0,0 +1,212 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. */
+
+package com.predic8.membrane.core.interceptor.json.rpc;
+
+import com.networknt.schema.InputFormat;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaLocation;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.resource.SchemaLoader;
+import com.predic8.membrane.annot.MCAttribute;
+import com.predic8.membrane.annot.MCChildElement;
+import com.predic8.membrane.annot.MCElement;
+import com.predic8.membrane.annot.MCOtherAttributes;
+import com.predic8.membrane.annot.Required;
+import com.predic8.membrane.core.interceptor.schemavalidation.json.MembraneSchemaLoader;
+import com.predic8.membrane.core.resolver.Resolver;
+import com.predic8.membrane.core.util.ConfigurationException;
+import com.predic8.membrane.core.util.URIFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.networknt.schema.InputFormat.JSON;
+import static com.networknt.schema.InputFormat.YAML;
+import static com.networknt.schema.SchemaRegistry.withDefaultDialect;
+import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12;
+import static com.predic8.membrane.core.resolver.ResolverMap.combine;
+
+public abstract class AbstractJsonRPCMethodSchemas {
+
+ private Map mappings = new LinkedHashMap<>();
+ private List paramMappings = List.of();
+ private Map schemas = Map.of();
+
+ /**
+ * @description
+ *
Defines a map from method name to JSON Schema location.
+ *
+ *
The keys are matched against the JSON-RPC method value.
Defines XML child elements for method-to-schema mappings.
- *
- *
This form is intended for XML configuration. YAML keeps using the map syntax shown above.
- *
- * @example <param method="rpc.echo" schema="classpath:/json/rpc/echo-params.schema.json"/>
- */
- @MCChildElement(excludeFromJson = true)
- public void setParamMappings(List paramMappings) {
- this.paramMappings = paramMappings == null ? List.of() : List.copyOf(paramMappings);
- }
-
- public List getParamMappings() {
- return paramMappings;
- }
-
- public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
- List effectiveMappings = getEffectiveMappings();
- if (effectiveMappings.isEmpty()) {
- schemas = Map.of();
- return;
- }
- if (resolver == null || uriFactory == null) {
- throw new ConfigurationException("Cannot initialize JSON-RPC param schemas without resolver context.");
- }
-
- Map resolvedSchemas = new LinkedHashMap<>();
- for (Param entry : effectiveMappings) {
- String methodName = validateMethodName(entry.getMethod());
- if (resolvedSchemas.containsKey(methodName)) {
- throw new ConfigurationException("Duplicate JSON-RPC param schema mapping for method '%s'.".formatted(methodName));
- }
- resolvedSchemas.put(methodName,
- loadSchema(methodName, entry.getSchema(), resolver, uriFactory, beanBaseLocation));
- }
- schemas = unmodifiableMap(resolvedSchemas);
- }
-
- public Schema getSchema(String method) {
- if (method == null) {
- return null;
- }
- return schemas.get(method);
- }
-
- private static Schema loadSchema(String methodName, String schemaPath, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) {
- if (schemaPath == null || schemaPath.trim().isEmpty()) {
- throw new ConfigurationException("JSON-RPC param schema path for method '%s' must not be empty.".formatted(methodName));
- }
-
- var resolvedLocation = combine(uriFactory, beanBaseLocation, schemaPath.trim());
- try (var in = resolver.resolve(resolvedLocation)) {
- var schema = createSchemaRegistry(resolver).getSchema(
- SchemaLocation.of(resolvedLocation),
- in,
- getSchemaFormat(resolvedLocation)
- );
- schema.initializeValidators();
- return schema;
- } catch (IOException e) {
- throw new ConfigurationException("Cannot read JSON-RPC param schema for method '%s' from '%s'.".formatted(methodName, schemaPath), e);
- } catch (RuntimeException e) {
- throw new ConfigurationException("Cannot create JSON-RPC param schema for method '%s' from '%s'.".formatted(methodName, schemaPath), e);
- }
- }
-
- private static SchemaRegistry createSchemaRegistry(Resolver resolver) {
- return withDefaultDialect(
- DRAFT_2020_12,
- builder -> builder.schemaLoader(SchemaLoader.builder()
- .resourceLoaders(loaders -> loaders.values(list -> list.addFirst(new MembraneSchemaLoader(resolver))))
- .build())
- );
- }
-
- private static InputFormat getSchemaFormat(String schemaLocation) {
- return schemaLocation.toLowerCase().endsWith(".yaml") || schemaLocation.toLowerCase().endsWith(".yml") ? YAML : JSON;
- }
-
- private static String validateMethodName(String methodName) {
- if (methodName == null || methodName.trim().isEmpty()) {
- throw new ConfigurationException("JSON-RPC param method name must not be empty.");
- }
-
- return methodName.trim();
- }
-
- private List getEffectiveMappings() {
- if (!params.isEmpty() && !paramMappings.isEmpty()) {
- throw new ConfigurationException("Configure JSON-RPC params either as a YAML map or as XML child elements, not both.");
- }
- if (!paramMappings.isEmpty()) {
- return paramMappings;
- }
- return params.entrySet().stream()
- .map(entry -> new Param(entry.getKey(), entry.getValue()))
- .toList();
- }
-
- @MCElement(name = "param", component = false)
- public static class Param {
-
- private String method;
- private String schema;
-
- public Param() {
- }
-
- public Param(String method, String schema) {
- this.method = method;
- this.schema = schema;
- }
-
- /**
- * @description The exact JSON-RPC method value whose params should be validated.
- * @example rpc.echo
- */
- @Required
- @MCAttribute
- public void setMethod(String method) {
- this.method = method;
- }
-
- public String getMethod() {
- return method;
- }
-
- /**
- * @description The path or URL of the JSON Schema used to validate params for matching methods.
- * @example classpath:/json/rpc/echo-params.schema.json
- */
- @Required
- @MCAttribute
- public void setSchema(String schema) {
- this.schema = schema;
- }
-
- public String getSchema() {
- return schema;
- }
- }
+public class JsonRPCParams extends AbstractJsonRPCMethodSchemas {
}
diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
index eabb0fb146..b53dbca497 100644
--- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
+++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java
@@ -21,8 +21,9 @@
import com.predic8.membrane.core.http.Response;
import com.predic8.membrane.core.interceptor.AbstractInterceptor;
import com.predic8.membrane.core.interceptor.Outcome;
+import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.RequestValidationResult;
+import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ResponseValidationContext;
import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError;
-import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
import com.predic8.membrane.core.jsonrpc.JSONRPCResponse;
import com.predic8.membrane.core.util.config.allowdeny.Rule;
import org.slf4j.Logger;
@@ -34,6 +35,7 @@
import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
import static com.predic8.membrane.core.http.Response.statusCode;
import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST;
+import static com.predic8.membrane.core.interceptor.Interceptor.Flow.RESPONSE;
import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.BATCH;
@@ -56,6 +58,10 @@
* params object or array. Schemas must be referenced by path or URL and cannot
* be configured inline.
*
+ *
Result schemas can be configured in the result child element using the same
+ * method-to-schema mapping format. Successful JSON-RPC responses are then validated against the
+ * configured schema for the originating request method.