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.

+ * + * @example "^rpc\\.echo$": "classpath:/json/rpc/echo-params.schema.json" + */ @MCOtherAttributes public void setParams(Map params) { this.params = params == null ? new LinkedHashMap<>() : new LinkedHashMap<>(params); 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 ed87d9d2d3..76f5135d66 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 @@ -25,6 +25,34 @@ import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.BATCH; import static java.util.EnumSet.of; +/** + * @topic 3. Security and Validation + * @description + *

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.

+ * + * @yaml + *

+ * - jsonRPCProtection:
+ *     batch:
+ *       enabled: true
+ *       maxSize: 50
+ *     rules:
+ *       - allow: "^rpc\\.(health|echo)$"
+ *       - deny: "^rpc\\..*$"
+ *     params:
+ *       "^rpc\\.echo$": "classpath:/json/rpc/echo-params.schema.json"
+ * 
+ */ @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: .*.

+ */ @MCChildElement(order = 1) public void setRules(List rules) { this.rules = rules == null ? List.of() : new ArrayList<>(rules); validator = null; } + /** + * @description + *

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.

* - * @example "^rpc\\.echo$": "classpath:/json/rpc/echo-params.schema.json" + * @example "rpc.echo": "classpath:/json/rpc/echo-params.schema.json" */ @MCOtherAttributes public void setParams(Map params) { @@ -83,7 +82,7 @@ public Map getParams() { * *

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 *

@@ -67,7 +67,7 @@
  *       - deny: "^rpc\\..*$"
  *       - deny: * # Switch to default-deny behavior
  *     params:
- *       "^rpc\\.echo$": "classpath:/json/rpc/echo-params.schema.json"
+ *       "rpc.echo": "classpath:/json/rpc/echo-params.schema.json"
  * 
*/ @MCElement(name = "jsonRPCProtection") @@ -148,8 +148,8 @@ public void setMethods(List methods) { * @description *

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.

+ * + * @example "rpc.echo": "classpath:/json/rpc/echo-params.schema.json" + */ + @MCOtherAttributes + public void setMappings(Map mappings) { + this.mappings = mappings == null ? new LinkedHashMap<>() : new LinkedHashMap<>(mappings); + } + + public Map getMappings() { + return mappings; + } + + /** + * @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 boolean isEmpty() { + return mappings.isEmpty() && paramMappings.isEmpty(); + } + + 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 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 schema mapping for method '%s'.".formatted(methodName)); + } + resolvedSchemas.put(methodName, + loadSchema(methodName, entry.getSchema(), resolver, uriFactory, beanBaseLocation)); + } + schemas = Collections.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 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 schema for method '%s' from '%s'.".formatted(methodName, schemaPath), e); + } catch (RuntimeException e) { + throw new ConfigurationException("Cannot create JSON-RPC 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 method name must not be empty."); + } + return methodName.trim(); + } + + private List getEffectiveMappings() { + if (!mappings.isEmpty() && !paramMappings.isEmpty()) { + throw new ConfigurationException("Configure JSON-RPC schemas either as a YAML map or as XML child elements, not both."); + } + if (!paramMappings.isEmpty()) { + return paramMappings; + } + return mappings.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 payload 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 for the configured method. + * @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/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 4541b10e16..eca96c6c85 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 @@ -14,34 +14,10 @@ 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; -import static java.util.Collections.unmodifiableMap; - /** * @description *

Maps JSON-RPC method names to JSON Schema locations for validating the @@ -53,169 +29,5 @@ * not supported.

*/ @MCElement(name = "params", component = false) -public class JsonRPCParams { - - private Map params = new LinkedHashMap<>(); - private List paramMappings = List.of(); - private Map schemas = Map.of(); - - /** - * @description - *

Defines a map from exact method name to JSON Schema location.

- * - *

The keys are matched literally against the JSON-RPC method value.

- * - * @example "rpc.echo": "classpath:/json/rpc/echo-params.schema.json" - */ - @MCOtherAttributes - public void setParams(Map params) { - this.params = params; - } - - 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) { - 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.

+ * * @yaml *

  * - jsonRPCProtection:
@@ -68,6 +74,8 @@
  *       - deny: * # Switch to default-deny behavior
  *     params:
  *       "rpc.echo": "classpath:/json/rpc/echo-params.schema.json"
+ *     result:
+ *       "rpc.echo": "classpath:/json/rpc/echo-result.schema.json"
  * 
*/ @MCElement(name = "jsonRPCProtection") @@ -75,21 +83,24 @@ public class JsonRPCProtectionInterceptor extends AbstractInterceptor { private static final Logger log = LoggerFactory.getLogger(JsonRPCProtectionInterceptor.class); private static final ObjectMapper OM = new ObjectMapper(); + private static final String RESPONSE_VALIDATION_CONTEXT = JsonRPCProtectionInterceptor.class.getName() + ".responseValidationContext"; private BatchRule batchRule = new BatchRule(); private List methods = List.of(); private JsonRPCParams params = new JsonRPCParams(); + private JsonRPCResult result = new JsonRPCResult(); private JsonRPCValidator validator; public JsonRPCProtectionInterceptor() { name = "JSON-RPC protection"; - setAppliedFlow(of(REQUEST)); + setAppliedFlow(of(REQUEST, RESPONSE)); } @Override public void init() { super.init(); params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); + result.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); validator = createValidator(); } @@ -104,7 +115,7 @@ public Outcome handleRequest(Exchange exc) { } if (!exc.getRequest().isJSON()) { - return reject(exc, new ValidationError( + return rejectRequest(exc, new ValidationError( payloadType(exc.getRequest().getBodyAsStringDecoded()), null, 415, @@ -113,10 +124,28 @@ public Outcome handleRequest(Exchange exc) { )); } - return reject(exc, getValidator().validate(exc.getRequest().getBodyAsStringDecoded())); + RequestValidationResult validation = getValidator().validateRequest(exc.getRequest().getBodyAsStringDecoded()); + if (validation.responseValidationContext() != null) { + exc.setProperty(RESPONSE_VALIDATION_CONTEXT, validation.responseValidationContext()); + } + return rejectRequest(exc, validation.error()); + } + + @Override + public Outcome handleResponse(Exchange exc) { + if (exc.getResponse() == null) { + return CONTINUE; + } + + ResponseValidationContext context = exc.getProperty(RESPONSE_VALIDATION_CONTEXT, ResponseValidationContext.class); + if (context == null) { + return CONTINUE; + } + + return rejectResponse(exc, getValidator().validateResponse(exc.getResponse().getBodyAsStringDecoded(), context)); } - private Outcome reject(Exchange exc, ValidationError error) { + private Outcome rejectRequest(Exchange exc, ValidationError error) { if (error == null) { return CONTINUE; } @@ -125,6 +154,15 @@ private Outcome reject(Exchange exc, ValidationError error) { return RETURN; } + private Outcome rejectResponse(Exchange exc, ValidationError error) { + if (error == null) { + return CONTINUE; + } + log.info("Rejected JSON-RPC response: {}", error.message()); + exc.setResponse(createErrorResponse(error)); + return RETURN; + } + /** * @description Configures whether JSON-RPC batch requests are allowed and how many request objects one batch may contain. */ @@ -156,6 +194,19 @@ public void setParams(JsonRPCParams params) { this.params = params; } + /** + * @description + *

Configures JSON Schema files for validating successful JSON-RPC result + * payloads per method name.

+ * + *

The keys are exact JSON-RPC method names. Values must be schema paths or URLs; inline + * schemas are not supported.

+ */ + @MCChildElement(order = 3) + public void setResult(JsonRPCResult result) { + this.result = result; + } + public BatchRule getBatch() { return batchRule; } @@ -168,6 +219,10 @@ public JsonRPCParams getParams() { return params; } + public JsonRPCResult getResult() { + return result; + } + private JsonRPCValidator getValidator() { if (validator == null) { validator = createValidator(); @@ -177,7 +232,8 @@ private JsonRPCValidator getValidator() { private JsonRPCValidator createValidator() { params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); - return new JsonRPCValidator(batchRule, methods, params); + result.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); + return new JsonRPCValidator(batchRule, methods, params, result); } private Response createErrorResponse(ValidationError error) { @@ -185,26 +241,19 @@ private Response createErrorResponse(ValidationError error) { 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())))) + .body(OM.writeValueAsString(List.of(JSONRPCResponse.error(error.responseId(), error.code(), error.message())))) .build(); } return statusCode(error.httpStatus()) .contentType(APPLICATION_JSON) - .body(JSONRPCResponse.error(responseId(error.request()), error.code(), error.message()).toJson()) + .body(JSONRPCResponse.error(error.responseId(), 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 JsonRPCValidator.PayloadType payloadType(String body) { if (body == null) { return SINGLE; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java new file mode 100644 index 0000000000..ae7a95eb55 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java @@ -0,0 +1,29 @@ +/* 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.MCElement; + +/** + * @description + *

Maps JSON-RPC method names to JSON Schema locations for validating the + * result member of a successful response.

+ * + *

The configuration is expressed as a map from the exact method name to the schema location. + * Inline schemas are not supported.

+ */ +@MCElement(name = "result", component = false) +public class JsonRPCResult extends AbstractJsonRPCMethodSchemas { +} 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 0a3db6b96a..e073c41c67 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,14 +20,19 @@ import com.fasterxml.jackson.databind.node.NullNode; import com.networknt.schema.Error; 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; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +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 com.predic8.membrane.core.jsonrpc.JSONRPCResponse.*; @@ -39,90 +44,169 @@ public class JsonRPCValidator { private final BatchRule batchRule; private final List rules; private final JsonRPCParams params; + private final JsonRPCResult result; - public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCParams params) { + public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCParams params, JsonRPCResult result) { this.batchRule = batchRule; this.rules = rules; this.params = params; + this.result = result; } public ValidationError validate(String body) { + return validateRequest(body).error(); + } + + public RequestValidationResult validateRequest(String body) { if (body == null || body.isBlank()) { - return null; + return new RequestValidationResult(null, null); } try { var payloadType = getPayloadType(body); var root = om.readTree(body); - return validate(root, payloadType); + return validateRequest(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" - ); + log.debug("Invalid JSON-RPC request payload.", e); + return invalidRequestResult(getPayloadType(body), "Invalid JSON-RPC payload"); } catch (RuntimeException e) { - log.debug("Invalid JSON-RPC payload.", e); - return new ValidationError( - SINGLE, - null, - 400, - ERR_INVALID_REQUEST, - "Invalid JSON-RPC payload" - ); + log.debug("Invalid JSON-RPC request payload.", e); + return invalidRequestResult(SINGLE, "Invalid JSON-RPC payload"); + } + } + + public ValidationError validateResponse(String body, ResponseValidationContext context) { + if (context == null || !context.expectsResponses() || result.isEmpty()) { + return null; + } + if (body == null || body.isBlank()) { + return invalidResponse(context.payloadType(), null, "JSON-RPC response must not be empty."); + } + + try { + var root = om.readTree(body); + return validateResponse(root, context); + } catch (Exception e) { + log.debug("Invalid JSON-RPC response payload.", e); + return invalidResponse(context.payloadType(), null, "Invalid JSON-RPC response payload"); } } - private ValidationError validate(JsonNode root, PayloadType payloadType) { + private RequestValidationResult validateRequest(JsonNode root, PayloadType payloadType) { if (root.isObject()) { - return validateSingle(root); + return validateSingleRequest(root); } if (root.isArray()) { - return validateBatch(root); + return validateBatchRequest(root); } - return new ValidationError(payloadType, null, 400, ERR_INVALID_REQUEST, "JSON-RPC payload must be an object or batch array."); + return invalidRequestResult(payloadType, "JSON-RPC payload must be an object or batch array."); } - private ValidationError validateSingle(JsonNode node) { + private RequestValidationResult validateSingleRequest(JsonNode node) { try { - return validateMethod(JSONRPCRequest.fromNode(node), SINGLE); + JSONRPCRequest request = JSONRPCRequest.fromNode(node); + ValidationError error = validateMethod(request, SINGLE); + return new RequestValidationResult(error, createResponseValidationContext(SINGLE, request)); } catch (IOException e) { - return new ValidationError(SINGLE, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request: " + e.getMessage()); + return invalidRequestResult(SINGLE, "Invalid JSON-RPC request: " + e.getMessage()); } } - private ValidationError validateBatch(JsonNode batch) { + private RequestValidationResult validateBatchRequest(JsonNode batch) { if (!batchRule.isEnabled()) { - return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests are disabled."); + return invalidRequestResult(BATCH, "Batch requests are disabled."); } if (batch.isEmpty()) { - return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Batch requests must not be empty."); + return invalidRequestResult(BATCH, "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() + "." - ); + return invalidRequestResult(BATCH, "Batch request exceeds maxSize of " + batchRule.getMaxSize() + "."); } + Map methodsById = new LinkedHashMap<>(); 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."); + return invalidRequestResult(BATCH, "Each batch entry must be a JSON-RPC request object."); } try { - ValidationError error = validateMethod(JSONRPCRequest.fromNode(requestNode), PayloadType.BATCH); + JSONRPCRequest request = JSONRPCRequest.fromNode(requestNode); + ValidationError error = validateMethod(request, BATCH); if (error != null) { - return error; + return new RequestValidationResult(error, null); } + rememberResponseMethod(methodsById, request); } catch (IOException e) { - return new ValidationError(PayloadType.BATCH, null, 400, ERR_INVALID_REQUEST, "Invalid JSON-RPC request in batch: " + e.getMessage()); + return invalidRequestResult(BATCH, "Invalid JSON-RPC request in batch: " + e.getMessage()); + } + } + return new RequestValidationResult(null, createResponseValidationContext(BATCH, methodsById)); + } + + private ValidationError validateResponse(JsonNode root, ResponseValidationContext context) { + if (context.payloadType() == SINGLE) { + if (!root.isObject()) { + return invalidResponse(SINGLE, null, "JSON-RPC response must be an object."); + } + return validateSingleResponse(root, context, SINGLE); + } + + if (!root.isArray()) { + return invalidResponse(BATCH, null, "JSON-RPC batch response must be an array."); + } + return validateBatchResponse(root, context); + } + + private ValidationError validateSingleResponse(JsonNode node, ResponseValidationContext context, PayloadType payloadType) { + JSONRPCResponse response; + try { + response = parse(node.toString()); + } catch (IOException e) { + return invalidResponse(payloadType, null, "Invalid JSON-RPC response: " + e.getMessage()); + } + + if (response.isError()) { + return null; + } + + String methodName = context.methodFor(response.getId()); + if (methodName == null) { + return invalidResponse(payloadType, response.getId(), "JSON-RPC response id '%s' does not match any request.".formatted(response.getId())); + } + + var schema = result.getSchema(methodName); + if (schema == null) { + return null; + } + + var errors = schema.validate(getResultNode(response)); + if (errors.isEmpty()) { + return null; + } + + return invalidResponse( + payloadType, + response.getId(), + "Invalid result for method '%s': %s".formatted( + methodName, + errors.stream().map(Error::getMessage).collect(Collectors.joining("; ")) + ) + ); + } + + private ValidationError validateBatchResponse(JsonNode batch, ResponseValidationContext context) { + if (batch.isEmpty()) { + return invalidResponse(BATCH, null, "Batch responses must not be empty."); + } + + for (JsonNode responseNode : batch) { + if (!responseNode.isObject()) { + return invalidResponse(BATCH, null, "Each batch entry must be a JSON-RPC response object."); + } + + ValidationError error = validateSingleResponse(responseNode, context, BATCH); + if (error != null) { + return error; } } return null; @@ -138,7 +222,7 @@ private ValidationError validateMethod(JSONRPCRequest request, PayloadType paylo } return new ValidationError( payloadType, - request, + responseId(request), 403, ERR_METHOD_NOT_FOUND, "JSON-RPC method '%s' is not allowed.".formatted(request.getMethod()) @@ -160,7 +244,7 @@ private ValidationError validateParams(JSONRPCRequest request, PayloadType paylo return new ValidationError( payloadType, - request, + responseId(request), 400, ERR_INVALID_PARAMS, "Invalid params for method '%s': %s".formatted( @@ -170,6 +254,41 @@ private ValidationError validateParams(JSONRPCRequest request, PayloadType paylo ); } + private RequestValidationResult invalidRequestResult(PayloadType payloadType, String message) { + return new RequestValidationResult(new ValidationError(payloadType, null, 400, ERR_INVALID_REQUEST, message), null); + } + + private ValidationError invalidResponse(PayloadType payloadType, Object responseId, String message) { + return new ValidationError(payloadType, responseId, 500, ERR_INTERNAL_ERROR, message); + } + + private ResponseValidationContext createResponseValidationContext(PayloadType payloadType, JSONRPCRequest request) { + Map methodsById = new LinkedHashMap<>(); + rememberResponseMethod(methodsById, request); + return createResponseValidationContext(payloadType, methodsById); + } + + private ResponseValidationContext createResponseValidationContext(PayloadType payloadType, Map methodsById) { + if (result.isEmpty() || methodsById.isEmpty()) { + return null; + } + return new ResponseValidationContext(payloadType, Collections.unmodifiableMap(new LinkedHashMap<>(methodsById))); + } + + private void rememberResponseMethod(Map methodsById, JSONRPCRequest request) { + if (request == null || request.isNotification()) { + return; + } + methodsById.putIfAbsent(request.getId(), request.getMethod()); + } + + private Object responseId(JSONRPCRequest request) { + if (request == null || request.isNotification()) { + return null; + } + return request.getId(); + } + private JsonNode getParamsNode(JSONRPCRequest request) { Object params = request.getParams(); if (params == null) { @@ -178,11 +297,19 @@ private JsonNode getParamsNode(JSONRPCRequest request) { return om.valueToTree(params); } + private JsonNode getResultNode(JSONRPCResponse response) { + Object result = response.getResult(); + if (result == null) { + return NullNode.instance; + } + return om.valueToTree(result); + } + private PayloadType getPayloadType(String body) { if (body == null) { return SINGLE; } - return body.trim().startsWith("[") ? PayloadType.BATCH : SINGLE; + return body.trim().startsWith("[") ? BATCH : SINGLE; } public enum PayloadType { @@ -190,9 +317,28 @@ public enum PayloadType { BATCH } + public record RequestValidationResult( + ValidationError error, + ResponseValidationContext responseValidationContext + ) { + } + + public record ResponseValidationContext( + PayloadType payloadType, + Map methodsById + ) { + public boolean expectsResponses() { + return !methodsById.isEmpty(); + } + + public String methodFor(Object responseId) { + return methodsById.get(responseId); + } + } + public record ValidationError( PayloadType payloadType, - JSONRPCRequest request, + Object responseId, int httpStatus, int code, String message 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 b5d6983990..04a1d9dd79 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 @@ -120,7 +120,7 @@ void nonJsonContentTypeIsRejected() throws Exception { @Test void paramsValidation() throws Exception { JsonRPCParams params = new JsonRPCParams(); - params.setParams(Map.of( + params.setMappings(Map.of( "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" )); var interceptor = interceptor(List.of(), params); @@ -142,7 +142,7 @@ void paramsValidation() throws Exception { @Test void paramsValidationUsesExactMethodName() throws Exception { JsonRPCParams params = new JsonRPCParams(); - params.setParams(Map.of( + params.setMappings(Map.of( "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json" )); var interceptor = interceptor(List.of(), params); @@ -157,7 +157,7 @@ void paramsValidationUsesExactMethodName() throws Exception { @Test void paramsValidationDoesNotMatchDifferentMethodNames() throws Exception { JsonRPCParams params = new JsonRPCParams(); - params.setParams(Map.of( + params.setMappings(Map.of( "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" )); var interceptor = interceptor(List.of(), params); @@ -169,16 +169,6 @@ void paramsValidationDoesNotMatchDifferentMethodNames() throws Exception { 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(); @@ -194,19 +184,124 @@ void xmlStyleParamMappingsAreSupported() throws Exception { assertEquals(CONTINUE, interceptor.handleRequest(exc)); } + @Test + void resultValidation() throws Exception { + JsonRPCResult result = new JsonRPCResult(); + result.setMappings(Map.of( + "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" + )); + var interceptor = interceptor(List.of(), new JsonRPCParams(), result); + + var exc = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + exc.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"result":{"message":"hello"}} + """)); + assertEquals(CONTINUE, interceptor.handleResponse(exc)); + + var exc2 = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc2)); + exc2.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"result":{}} + """)); + assertEquals(RETURN, interceptor.handleResponse(exc2)); + assertErrorContains(exc2.getResponse(), 500, "Invalid result for method 'rpc.echo'"); + } + + @Test + void resultValidationDoesNotMatchDifferentMethodNames() throws Exception { + JsonRPCResult result = new JsonRPCResult(); + result.setMappings(Map.of( + "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" + )); + var interceptor = interceptor(List.of(), new JsonRPCParams(), result); + + var exc = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + exc.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"result":{}} + """)); + assertEquals(CONTINUE, interceptor.handleResponse(exc)); + } + + @Test + void batchResultValidationUsesRequestIdToResolveMethod() throws Exception { + JsonRPCResult result = new JsonRPCResult(); + result.setMappings(Map.of( + "rpc.echo", "classpath:/json/rpc/echo-params.schema.json", + "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json" + )); + var interceptor = interceptor(List.of(), new JsonRPCParams(), result); + + var exc = exchange(""" + [ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"}, + {"jsonrpc":"2.0","id":2,"method":"rpc.health"} + ] + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + exc.setResponse(jsonResponse(""" + [ + {"jsonrpc":"2.0","id":2,"result":{"code":1}}, + {"jsonrpc":"2.0","id":1,"result":{}} + ] + """)); + assertEquals(RETURN, interceptor.handleResponse(exc)); + assertBatchErrorContains(exc.getResponse(), 500, "Invalid result for method 'rpc.echo'"); + assertBatchErrorId(exc.getResponse(), 1); + } + + @Test + void xmlStyleResultMappingsAreSupported() throws Exception { + JsonRPCResult result = new JsonRPCResult(); + result.setParamMappings(List.of( + new JsonRPCResult.Param("rpc.echo", "classpath:/json/rpc/echo-params.schema.json") + )); + var interceptor = interceptor(List.of(), new JsonRPCParams(), result); + + var exc = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + exc.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"result":{"message":"hello"}} + """)); + assertEquals(CONTINUE, interceptor.handleResponse(exc)); + } + private JsonRPCProtectionInterceptor interceptor(List rules) { - return interceptor(rules, new JsonRPCParams()); + return interceptor(rules, new JsonRPCParams(), new JsonRPCResult()); } private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params) { - return interceptor(rules, params, new BatchRule()); + return interceptor(rules, params, new JsonRPCResult()); + } + + private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, JsonRPCResult result) { + return interceptor(rules, params, result, new BatchRule()); } private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, BatchRule batchRule) { + return interceptor(rules, params, new JsonRPCResult(), batchRule); + } + + private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, JsonRPCResult result, BatchRule batchRule) { var interceptor = new JsonRPCProtectionInterceptor(); interceptor.setBatch(batchRule); interceptor.setMethods(rules); interceptor.setParams(params); + interceptor.setResult(result); interceptor.init(new DefaultRouter()); return interceptor; } @@ -218,6 +313,12 @@ private com.predic8.membrane.core.exchange.Exchange exchange(String body) throws .buildExchange(); } + private Response jsonResponse(String body) { + return Response.ok() + .json(body) + .build(); + } + private Allow allow(String pattern) { Allow allow = new Allow(); allow.setPattern(pattern); @@ -249,4 +350,17 @@ private void assertBatchError(Response response, int statusCode, String message) assertEquals(1, node.size()); assertEquals(message, node.get(0).path("error").path("message").asText()); } + + private void assertBatchErrorContains(Response response, int statusCode, String messagePart) throws Exception { + assertEquals(statusCode, response.getStatusCode()); + JsonNode node = OM.readTree(response.getBodyAsStringDecoded()); + assertTrue(node.isArray()); + assertEquals(1, node.size()); + assertTrue(node.get(0).path("error").path("message").asText().contains(messagePart)); + } + + private void assertBatchErrorId(Response response, long id) throws Exception { + JsonNode node = OM.readTree(response.getBodyAsStringDecoded()); + assertEquals(id, node.get(0).path("id").asLong()); + } } From f2232109b2065323141fd7b6d24f2f9f96278429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Mon, 1 Jun 2026 15:47:58 +0200 Subject: [PATCH 20/38] Add JSON-RPC result schema validation for `rpc.echo` with test updates and documentation adjustments --- .../json/JsonRpcProtectionTutorialTest.java | 25 ++++++++++++++++++- .../json/30-JSON-RPC-Protection.yaml | 17 ++++++++++--- .../tutorials/json/echo-result.schema.json | 15 +++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 distribution/tutorials/json/echo-result.schema.json 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 index e9809749eb..97a448d04a 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java @@ -16,6 +16,8 @@ import org.junit.jupiter.api.Test; +import java.math.BigDecimal; + import static io.restassured.RestAssured.given; import static io.restassured.http.ContentType.JSON; import static org.hamcrest.Matchers.containsString; @@ -42,7 +44,28 @@ void allowsConfiguredMethods() { .then() .statusCode(200) .contentType(JSON) - .body("accepted", equalTo(true)); + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo("1")) + .body("result.status", equalTo("ok")); + // @formatter:on + } + + @Test + void validatesConfiguredResultSchema() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":4,"method":"rpc.echo","params":{"message":"Hello"}} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo("4")) + .body("result.message", equalTo("Hello")); // @formatter:on } diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml index b29442599a..a988b14c2a 100644 --- a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml +++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml @@ -5,12 +5,13 @@ # Protect a JSON-RPC endpoint by: # - allowing only selected methods # - rejecting all other methods -# - validating params with JSON Schema +# - validating params and result with JSON Schema # - limiting batch requests # # Notes: # - Method rules are evaluated top-down. # - The first matching rule wins. +# - The keys in params/result are exact JSON-RPC method names. # # 1.) Send an allowed request # curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.health"}' -H "Content-Type: application/json" http://localhost:2000 @@ -28,7 +29,8 @@ # 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. +# The request should be forwarded to the mock upstream on port 2001 and the +# JSON-RPC result should pass response validation. # # 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 @@ -48,6 +50,8 @@ api: - deny: '.*' # Default deny for everything else params: 'rpc.echo': echo-params.schema.json # Validate params for rpc.echo + result: + 'rpc.echo': echo-result.schema.json # Validate result for rpc.echo target: url: http://localhost:2001 @@ -63,8 +67,15 @@ api: contentType: application/json pretty: true src: | + <% def req = new groovy.json.JsonSlurper().parseText(exc.request.bodyAsStringDecoded) %> { - "accepted": true + "jsonrpc": "2.0", + "id": <%= groovy.json.JsonOutput.toJson(req.id) %>, + "result": <% if (req.method == 'rpc.echo') { %>{ + "message": <%= groovy.json.JsonOutput.toJson(req.params?.message) %> + }<% } else { %>{ + "status": "ok" + }<% } %> } - return: status: 200 diff --git a/distribution/tutorials/json/echo-result.schema.json b/distribution/tutorials/json/echo-result.schema.json new file mode 100644 index 0000000000..0b9ad4703d --- /dev/null +++ b/distribution/tutorials/json/echo-result.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 92bb54730f5af150f32ba98f4ebd129e43e83cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Mon, 1 Jun 2026 15:50:42 +0200 Subject: [PATCH 21/38] disable response handling when result mappings are empty --- .../core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b53dbca497..0d469b64a4 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 @@ -133,7 +133,7 @@ public Outcome handleRequest(Exchange exc) { @Override public Outcome handleResponse(Exchange exc) { - if (exc.getResponse() == null) { + if (exc.getResponse() == null || result.getMappings().isEmpty()) { return CONTINUE; } From 0b4ed0c7de60b0f64c07ace9f608e3f14b522e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Mon, 1 Jun 2026 16:12:02 +0200 Subject: [PATCH 22/38] Update JSON-RPC protection tutorial and tests to use consistent `id` values in examples and responses --- .../json/JsonRpcProtectionTutorialTest.java | 18 ++++---- .../json/30-JSON-RPC-Protection.yaml | 43 +++++++++---------- 2 files changed, 28 insertions(+), 33 deletions(-) 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 index 97a448d04a..1f0c8e49b9 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java @@ -16,8 +16,6 @@ import org.junit.jupiter.api.Test; -import java.math.BigDecimal; - import static io.restassured.RestAssured.given; import static io.restassured.http.ContentType.JSON; import static org.hamcrest.Matchers.containsString; @@ -45,8 +43,8 @@ void allowsConfiguredMethods() { .statusCode(200) .contentType(JSON) .body("jsonrpc", equalTo("2.0")) - .body("id", equalTo("1")) - .body("result.status", equalTo("ok")); + .body("id", equalTo(1)) + .body("result.message", equalTo("Hello")); // @formatter:on } @@ -56,7 +54,7 @@ void validatesConfiguredResultSchema() { given() .contentType(JSON) .body(""" - {"jsonrpc":"2.0","id":4,"method":"rpc.echo","params":{"message":"Hello"}} + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"Hello"}} """) .when() .post("http://localhost:2000") @@ -64,7 +62,7 @@ void validatesConfiguredResultSchema() { .statusCode(200) .contentType(JSON) .body("jsonrpc", equalTo("2.0")) - .body("id", equalTo("4")) + .body("id", equalTo(1)) .body("result.message", equalTo("Hello")); // @formatter:on } @@ -75,7 +73,7 @@ void rejectsMethodsOutsideAllowlist() { given() .contentType(JSON) .body(""" - {"jsonrpc":"2.0","id":2,"method":"rpc.admin.shutdown"} + {"jsonrpc":"2.0","id":1,"method":"rpc.admin.shutdown"} """) .when() .post("http://localhost:2000") @@ -83,7 +81,7 @@ void rejectsMethodsOutsideAllowlist() { .statusCode(403) .contentType(JSON) .body("jsonrpc", equalTo("2.0")) - .body("id", equalTo(2)) + .body("id", equalTo(1)) .body("error.code", equalTo(-32601)) .body("error.message", containsString("rpc.admin.shutdown")); // @formatter:on @@ -95,7 +93,7 @@ void validatesParamsAgainstSchema() { given() .contentType(JSON) .body(""" - {"jsonrpc":"2.0","id":3,"method":"rpc.echo","params":{}} + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}} """) .when() .post("http://localhost:2000") @@ -103,7 +101,7 @@ void validatesParamsAgainstSchema() { .statusCode(400) .contentType(JSON) .body("jsonrpc", equalTo("2.0")) - .body("id", equalTo(3)) + .body("id", equalTo(1)) .body("error.code", equalTo(-32602)) .body("error.message", containsString("Invalid params for method 'rpc.echo'")); // @formatter:on diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml index a988b14c2a..8ed1effd7e 100644 --- a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml +++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml @@ -12,22 +12,23 @@ # - Method rules are evaluated top-down. # - The first matching rule wins. # - The keys in params/result are exact JSON-RPC method names. +# - The mock upstream below returns one fixed JSON-RPC success response. # # 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 +# curl -d '{"jsonrpc":"2.0","id":1,"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 +# curl -d '{"jsonrpc":"2.0","id":1,"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 +# curl -d '{"jsonrpc":"2.0","id":1,"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 and the # JSON-RPC result should pass response validation. @@ -40,18 +41,17 @@ 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 - result: - 'rpc.echo': echo-result.schema.json # Validate result for rpc.echo + - 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 + result: + 'rpc.echo': echo-result.schema.json # Validate result for rpc.echo target: url: http://localhost:2001 @@ -63,19 +63,16 @@ api: - log: message: "Accepted JSON-RPC request reached the upstream." - response: - - template: + - static: contentType: application/json pretty: true src: | - <% def req = new groovy.json.JsonSlurper().parseText(exc.request.bodyAsStringDecoded) %> { "jsonrpc": "2.0", - "id": <%= groovy.json.JsonOutput.toJson(req.id) %>, - "result": <% if (req.method == 'rpc.echo') { %>{ - "message": <%= groovy.json.JsonOutput.toJson(req.params?.message) %> - }<% } else { %>{ - "status": "ok" - }<% } %> + "id": 1, + "result": { + "message": "Hello" + } } - return: status: 200 From 2519939c15d7c7a11b79dc2c7b1b8e8849a7eb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 14:57:13 +0200 Subject: [PATCH 23/38] Add schema validation support for JSON-RPC `params`, `response`, and `error` with updated tests and supporting classes --- .../annot/generator/JsonSchemaGenerator.java | 25 +++- .../kubernetes/model/SchemaObject.java | 16 ++- .../annot/model/OtherAttributesInfo.java | 9 +- .../membrane/annot/yaml/ParsingContext.java | 14 ++ .../yaml/error/LineYamlErrorRenderer.java | 45 ++++--- .../annot/yaml/parsing/MethodSetter.java | 6 +- .../parsing/binding/CollectionBinder.java | 2 +- .../parsing/binding/ScalarValueConverter.java | 37 +++++- .../ComponentDefinitionExtractor.java | 4 +- .../membrane/annot/YAMLParsingErrorTest.java | 124 ++++++++++++++++++ .../rpc/AbstractJsonRPCMethodSchemas.java | 2 +- .../json/rpc/JsonRPCErrorValidation.java | 19 +++ .../json/rpc/JsonRPCInlineSchema.java | 24 ++++ .../json/rpc/JsonRPCMethodDefinitions.java | 24 ++++ .../json/rpc/JsonRPCParamValidation.java | 7 + .../rpc/JsonRPCProtectionInterceptor.java | 14 ++ .../json/rpc/JsonRPCResponseValidation.java | 7 + .../json/rpc/JsonRPCSchemaValidation.java | 29 ++++ .../interceptor/json/rpc/JsonRPCSchemas.java | 30 +++++ .../interceptor/json/rpc/SchemaSetter.java | 28 ++++ .../rpc/JsonRPCProtectionInterceptorTest.java | 51 +++++++ 21 files changed, 482 insertions(+), 35 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java 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 5f45f943fa..ffae5a3654 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 @@ -247,7 +247,7 @@ private AbstractSchema createCollapsedInlineParser(ElementInfo ei, String par private SchemaObject getParserSchemaObject(ElementInfo elementInfo, String parserName) { return object(parserName) - .additionalProperties(elementInfo.isString()) + .additionalProperties(false) .description(getDescriptionContent(elementInfo)); } @@ -312,6 +312,29 @@ private void collectProperties(Model model, MainInfo main, ElementInfo elementIn processMCAttributes(elementInfo, parserSchema); collectTextContent(elementInfo, parserSchema); processMCChilds(model, main, elementInfo, parserSchema); + processMCOtherAttributes(model, main, elementInfo, parserSchema); + } + + private void processMCOtherAttributes(Model model, MainInfo main, ElementInfo elementInfo, SchemaObject parserSchema) { + var otherAttributes = elementInfo.getOai(); + if (otherAttributes == null) { + return; + } + + if (otherAttributes.getValueType() == OtherAttributesInfo.ValueType.STRING) { + parserSchema.additionalProperties(from("string")); + return; + } + + var valueElementInfo = main.getElements().get(otherAttributes.getMapValueType()); + if (valueElementInfo == null) { + parserSchema.additionalProperties(true); + return; + } + + parserSchema.additionalProperties( + ref("additionalProperties").ref(defsRefPath(valueElementInfo.getXSDTypeName(model))) + ); } private void collectTextContent(ElementInfo elementInfo, SchemaObject parserSchema) { diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java b/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java index 61b82be03a..5e94ca0b8a 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java @@ -22,7 +22,8 @@ public class SchemaObject extends AbstractSchema { - private boolean additionalProperties; + private Boolean additionalProperties; + private AbstractSchema additionalPropertiesSchema; // Java Properties (@MCAttributes, @MCChildElement) protected final List> properties = new ArrayList<>(); @@ -54,7 +55,9 @@ public ObjectNode json(ObjectNode node) { if (minProperties != null) node.put("minProperties", minProperties); if (maxProperties != null) node.put("maxProperties", maxProperties); - if (!additionalProperties && isObject()) { + if (additionalPropertiesSchema != null && isObject()) { + node.set("additionalProperties", additionalPropertiesSchema.json(jnf.objectNode())); + } else if (Boolean.FALSE.equals(additionalProperties) && isObject()) { node.put("additionalProperties", false); } @@ -75,6 +78,13 @@ public SchemaObject property(AbstractSchema as) { public SchemaObject additionalProperties(boolean additionalProperties) { this.additionalProperties = additionalProperties; + this.additionalPropertiesSchema = null; + return this; + } + + public SchemaObject additionalProperties(AbstractSchema additionalPropertiesSchema) { + this.additionalProperties = null; + this.additionalPropertiesSchema = additionalPropertiesSchema; return this; } @@ -192,4 +202,4 @@ public SchemaObject allOf(List> allOf) { public boolean hasProperty(String name) { return properties.stream().anyMatch(p -> name.equals(p.getName())); } -} \ No newline at end of file +} diff --git a/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java b/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java index 06a28a2a1f..55b471d9fd 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java +++ b/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java @@ -54,10 +54,11 @@ public ValueType getValueType() { if (mapValueType.getQualifiedName().toString().equals("java.lang.String")) { return ValueType.STRING; } - if (mapValueType.getQualifiedName().toString().equals("java.lang.Object")) { - return ValueType.OBJECT; - } - throw new ProcessingException("Not supported: @McOtherAttributes void setAttr(Map attrs) where T is neither String nor Object."); + return ValueType.OBJECT; + } + + public TypeElement getMapValueType() { + return mapValueType; } public enum ValueType { diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java index 346c1ab543..483842acc2 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java @@ -38,6 +38,10 @@ public ParsingContext addPath(String path) { return new ParsingContext(context, registry,grammar,topLevel, this.path + path,key); } + public ParsingContext addProperty(String property) { + return new ParsingContext(context, registry, grammar, topLevel, path + toJsonPathProperty(property), key); + } + public ParsingContext child(String childContext, String pathSegment) { return new ParsingContext(childContext, registry, grammar, topLevel, path + pathSegment, null); } @@ -84,6 +88,16 @@ public String getPath() { return path; } + private static String toJsonPathProperty(String property) { + if (property.matches("[A-Za-z_][A-Za-z0-9_]*")) { + return "." + property; + } + return "['" + property + .replace("\\", "\\\\") + .replace("'", "\\'") + + "']"; + } + @Override public @NotNull String toString() { return """ diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java index 59ef350627..6edc63abc0 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java @@ -283,31 +283,40 @@ private static int getIndentation(String line) { } private static String getParentPath(String jsonPath) { - // Handle both $.parent.child and $.parent[0] formats - int lastDot = jsonPath.lastIndexOf('.'); - int lastBracket = jsonPath.lastIndexOf('['); + return jsonPath.substring(0, findLastSegmentStart(jsonPath)); + } - if (lastBracket > lastDot) { - // Last segment is array index like [0] - return jsonPath.substring(0, lastBracket); - } else { - // Last segment is object key like .field - return jsonPath.substring(0, lastDot); + private static String getLastSegment(String jsonPath) { + int start = findLastSegmentStart(jsonPath); + String segment = jsonPath.substring(start); + + if (segment.startsWith(".")) { + return segment.substring(1); + } + + if (segment.startsWith("['") && segment.endsWith("']")) { + return segment.substring(2, segment.length() - 2) + .replace("\\'", "'") + .replace("\\\\", "\\"); } + + if (segment.startsWith("[") && segment.endsWith("]")) { + return segment.substring(1, segment.length() - 1); + } + + throw new IllegalArgumentException("Unsupported JSONPath segment: " + segment); } - private static String getLastSegment(String jsonPath) { - // Handle both $.parent.child and $.parent[0] formats - int lastDot = jsonPath.lastIndexOf('.'); + private static int findLastSegmentStart(String jsonPath) { int lastBracket = jsonPath.lastIndexOf('['); + int lastDot = jsonPath.lastIndexOf('.'); if (lastBracket > lastDot) { - // Array index like [0] - String bracket = jsonPath.substring(lastBracket); - return bracket.substring(1, bracket.length() - 1); // Extract "0" from "[0]" - } else { - // Object key like .field - return jsonPath.substring(lastDot + 1); + return lastBracket; + } + if (lastDot >= 0) { + return lastDot; } + throw new IllegalArgumentException("Cannot determine parent path of: " + jsonPath); } } diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java index 1447d3638a..4b4f424ca4 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java @@ -75,8 +75,8 @@ private Object resolveSetterValue(ParsingContext ctx, JsonNode node, String k // Structured objects if (McYamlIntrospector.isStructured(setter)) { if (beanClass != null) - return ObjectBinder.bind(ctx.updateContext(key).addPath("." + key), beanClass, node); - return ObjectBinder.bind(ctx.updateContext(key).addPath("." + key), wanted, node); + return ObjectBinder.bind(ctx.updateContext(key).addProperty(key), beanClass, node); + return ObjectBinder.bind(ctx.updateContext(key).addProperty(key), wanted, node); } return coerceScalarOrReference(ctx, node, key, wanted); @@ -103,7 +103,7 @@ Object coerceScalarOrReference(ParsingContext ctx, JsonNode node, String key, return null; Class elemType = getCollectionElementType(setter); - List list = CollectionBinder.parseListIncludingStartEvent(ctx.addPath("." + key), node, elemType); + List list = CollectionBinder.parseListIncludingStartEvent(ctx.addProperty(key), node, elemType); if (elemType != null) { for (Object o : list) { if (o == null) continue; diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java index 8b89f09611..1225eb1848 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java @@ -80,7 +80,7 @@ private static Object parseMapToObj(ParsingContext pc, JsonNode node) { private static Object parseMapToObj(ParsingContext ctx, JsonNode node, String key) { if ("$ref".equals(key)) return REFERENCE_RESOLVER.resolveReferencedObject(ctx, node.asText(), key); - var childContext = ctx.addPath("." + key); + var childContext = ctx.addProperty(key); return ObjectBinder.bind(childContext.updateContext(key), childContext.resolveClass(key), node); } diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java index 19227c55b3..6903c2f586 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java @@ -17,11 +17,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.predic8.membrane.annot.yaml.ConfigurationParsingException; +import com.predic8.membrane.annot.yaml.McYamlIntrospector; import com.predic8.membrane.annot.yaml.ParsingContext; import com.predic8.membrane.annot.yaml.WrongEnumConstantException; import com.predic8.membrane.annot.yaml.parsing.support.SpelEvaluator; import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.Map; import static com.predic8.membrane.annot.yaml.McYamlIntrospector.hasOtherAttributes; @@ -73,7 +76,7 @@ private Object coerceTextual(ParsingContext ctx, Method setter, JsonNode node if (isNumber(wanted)) return parseNumericOrThrow(ctx, key, wanted, evaluated, node); if (wanted == Map.class && setter != null && hasOtherAttributes(setter)) - return Map.of(key, evaluated); + return Map.of(key, convertAnySetterValue(ctx, setter, node, key)); if (isBeanReference(wanted)) return referenceResolver.resolveReference(ctx, value, key, wanted); if (setter != null && isReferenceAttribute(setter)) @@ -92,12 +95,42 @@ private Object coerceNonTextual(ParsingContext ctx, Method setter, JsonNode n if (isBoolean(wanted)) return node.isBoolean() ? node.booleanValue() : parseBoolean(node.asText()); if (wanted.equals(Map.class) && setter != null && hasOtherAttributes(setter)) - return Map.of(key, node.asText()); + return Map.of(key, convertAnySetterValue(ctx, setter, node, key)); if (setter != null && isReferenceAttribute(setter)) return resolveRegistryReference(ctx, node.asText(), key); throw unsupported(wanted, key, node); } + private Object convertAnySetterValue(ParsingContext ctx, Method setter, JsonNode node, String key) { + Class valueType = getMapValueType(setter); + if (valueType == null || valueType == Object.class) { + return SCALAR_MAPPER.convertValue(node, Object.class); + } + if (valueType == String.class) { + return node.isTextual() ? evaluateSpelForString(key, node.asText()) : node.asText(); + } + return ObjectBinder.bind( + ctx.updateContext(McYamlIntrospector.getElementName(valueType)).addProperty(key), + valueType, + node + ); + } + + private static Class getMapValueType(Method setter) { + Type genericType = setter.getGenericParameterTypes()[0]; + if (!(genericType instanceof ParameterizedType parameterizedType)) { + return Object.class; + } + Type valueType = parameterizedType.getActualTypeArguments()[1]; + if (valueType instanceof Class clazz) { + return clazz; + } + if (valueType instanceof ParameterizedType nested && nested.getRawType() instanceof Class clazz) { + return clazz; + } + return Object.class; + } + private Object resolveRegistryReference(ParsingContext ctx, String ref, String key) { if (ctx == null || ctx.getRegistry() == null) throw new ConfigurationParsingException("Cannot resolve reference: " + ref, null, ctx == null ? null : ctx.key(key)); diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java index 96a9fc9ce4..9572f8a422 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java @@ -49,9 +49,9 @@ public List extract(ParseSession session, ParsingContext pc, String componentRef = "#/components/" + id; if (!session.componentIds().add(componentRef)) - throw new ConfigurationParsingException("Duplicate component id '%s'. Component ids must be unique across all included files.".formatted(componentRef), null, pc.addPath("." + id)); + throw new ConfigurationParsingException("Duplicate component id '%s'. Component ids must be unique across all included files.".formatted(componentRef), null, pc.addProperty(id)); - ensureSingleKey(pc.addPath("." + id), def); + ensureSingleKey(pc.addProperty(id), def); String componentKind = def.fieldNames().next(); ObjectNode wrapped = JsonNodeFactory.instance.objectNode(); diff --git a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java index 0e1f83e9b4..db56dabcb7 100644 --- a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java +++ b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java @@ -20,6 +20,8 @@ import org.jetbrains.annotations.*; import org.junit.jupiter.api.*; +import java.util.Map; + import static com.predic8.membrane.annot.SpringConfigurationXSDGeneratingAnnotationProcessorTest.*; import static com.predic8.membrane.annot.util.CompilerHelper.*; import static org.junit.jupiter.api.Assertions.*; @@ -405,6 +407,128 @@ public class ValidatorElement { } } + @Test + void otherAttributesKeyWithDotWrongFieldRendersErrorReport() throws Exception { + var result = compileMethodMapSources(); + + try { + parseYAML(result, """ + api: + methods: + 'rpc.echo': + wrong: 1 + """); + fail(); + } catch (RuntimeException e) { + var c = getCause(e); + var pc = c.getParsingContext(); + assertEquals("$.api.methods['rpc.echo']", pc.getPath()); + assertEquals("wrong", pc.getKey()); + + String report = c.getFormattedReport(); + assertTrue(report.contains("rpc.echo")); + assertTrue(report.contains("wrong")); + } + } + + @Test + void otherAttributesMapValueUsesLocalContextForChildren() throws Exception { + var result = compileMethodMapSources(); + + var registry = parseYAML(result, """ + api: + methods: + 'rpc.echo': + params: + location: tmp.schema.json + """); + + Object api = registry.getBeans().stream() + .filter(bean -> bean.getClass().getSimpleName().equals("ApiElement")) + .findFirst() + .orElseThrow(); + + Object methodDefinitions = api.getClass().getMethod("getMethods").invoke(api); + @SuppressWarnings("unchecked") + Map methods = (Map) methodDefinitions.getClass().getMethod("getMethods").invoke(methodDefinitions); + + Object method = methods.get("rpc.echo"); + assertNotNull(method); + + Object params = method.getClass().getMethod("getParams").invoke(method); + assertNotNull(params); + assertEquals("MethodParams", params.getClass().getSimpleName()); + assertEquals("tmp.schema.json", params.getClass().getMethod("getLocation").invoke(params)); + } + + private static CompilerResult compileMethodMapSources() { + var sources = splitSources(MC_MAIN_DEMO + """ + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + @MCElement(name="api", topLevel=true, component=false) + public class ApiElement { + private MethodDefinitions methods; + + @MCChildElement + public void setMethods(MethodDefinitions methods) { this.methods = methods; } + public MethodDefinitions getMethods() { return methods; } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + import java.util.LinkedHashMap; + import java.util.Map; + + @MCElement(name="methods", component=false) + public class MethodDefinitions { + private final Map methods = new LinkedHashMap<>(); + + @MCOtherAttributes + public void setMethods(Map methods) { + this.methods.putAll(methods); + } + + public Map getMethods() { return methods; } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + + @MCElement(name="method", component=false) + public class MethodElement { + private MethodParams params; + + @MCChildElement + public void setParams(MethodParams params) { this.params = params; } + public MethodParams getParams() { return params; } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + + @MCElement(name="params", component=false) + public class GlobalParams { + @MCAttribute + public void setOther(String other) { } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + + @MCElement(name="params", component=false, id="method-params") + public class MethodParams { + private String location; + + @MCAttribute + public void setLocation(String location) { this.location = location; } + public String getLocation() { return location; } + } + """); + var result = compile(sources, false); + assertCompilerResult(true, result); + return result; + } + private static @NotNull ConfigurationParsingException getCause(RuntimeException e) { return (ConfigurationParsingException) ExceptionUtil.getRootCause(e); } 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 index 85063deac4..4118585a26 100644 --- 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 @@ -167,7 +167,7 @@ private List getEffectiveMappings() { .toList(); } - @MCElement(name = "param", component = false) + @MCElement(name = "param", component = false, id = "old-param") public static class Param { private String method; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java new file mode 100644 index 0000000000..5afe0e8c2a --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java @@ -0,0 +1,19 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.MCElement; + +@MCElement(name = "error", component = false, collapsed = true) +public class JsonRPCErrorValidation { + + private String location; + + @MCAttribute + public void setLocation(String location) { + this.location = location; + } + + public String getLocation() { + return location; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java new file mode 100644 index 0000000000..d6a6f361b6 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java @@ -0,0 +1,24 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.annot.MCOtherAttributes; + +import java.util.LinkedHashMap; +import java.util.Map; + +@MCElement(name = "schema", component = false, id = "json-rpc-inline-schema") +public class JsonRPCInlineSchema { + + private final Map properties = new LinkedHashMap<>(); + + @MCOtherAttributes + public void setProperties(Map properties) { + if (properties != null) { + this.properties.putAll(properties); + } + } + + public Map getProperties() { + return properties; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java new file mode 100644 index 0000000000..dbd593c54d --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java @@ -0,0 +1,24 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.annot.MCOtherAttributes; + +import java.util.LinkedHashMap; +import java.util.Map; + +@MCElement(name = "methods", component = false, id = "json-rpc-method-definitions") +public class JsonRPCMethodDefinitions { + + private final Map methods = new LinkedHashMap<>(); + + @MCOtherAttributes + public void setMethods(Map methods) { + if (methods != null) { + this.methods.putAll(methods); + } + } + + public Map getMethods() { + return methods; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java new file mode 100644 index 0000000000..af57ca812e --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java @@ -0,0 +1,7 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; + +@MCElement(name = "params", component = false, id = "json-rpc-method-params-validation") +public class JsonRPCParamValidation extends SchemaSetter { +} 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 0d469b64a4..baf55c5f29 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 @@ -91,6 +91,8 @@ public class JsonRPCProtectionInterceptor extends AbstractInterceptor { private JsonRPCResult result = new JsonRPCResult(); private JsonRPCValidator validator; + private JsonRPCSchemaValidation schemaValidation; + public JsonRPCProtectionInterceptor() { name = "JSON-RPC protection"; setAppliedFlow(of(REQUEST, RESPONSE)); @@ -182,6 +184,18 @@ public void setMethods(List methods) { this.methods = methods; } + + + @MCChildElement(order = 4) + public void setSchemaValidation(JsonRPCSchemaValidation schemaValidation) { + this.schemaValidation = schemaValidation; + } + + public JsonRPCSchemaValidation getSchemaValidation() { + return schemaValidation; + } + + /** * @description *

Configures JSON Schema files for validating params per method name.

diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java new file mode 100644 index 0000000000..bcd217701a --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java @@ -0,0 +1,7 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; + +@MCElement(name = "response", component = false, id = "json-rpc-response-validation") +public class JsonRPCResponseValidation extends SchemaSetter { +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java new file mode 100644 index 0000000000..6d09d74cdb --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java @@ -0,0 +1,29 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCChildElement; +import com.predic8.membrane.annot.MCElement; + +@MCElement(name = "schemaValidation", component = false) +public class JsonRPCSchemaValidation { + + private JsonRPCErrorValidation errorValidation; + private JsonRPCMethodDefinitions methods = new JsonRPCMethodDefinitions(); + + public JsonRPCErrorValidation getErrorValidation() { + return errorValidation; + } + + @MCChildElement(order = 1) + public void setErrorValidation(JsonRPCErrorValidation errorValidation) { + this.errorValidation = errorValidation; + } + + @MCChildElement(order = 2) + public void setMethods(JsonRPCMethodDefinitions methods) { + this.methods = methods; + } + + public JsonRPCMethodDefinitions getMethods() { + return methods; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java new file mode 100644 index 0000000000..b9e95a9d29 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java @@ -0,0 +1,30 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCChildElement; +import com.predic8.membrane.annot.MCElement; + +@MCElement(name = "method", component = false, id = "json-rpc-method-schema") +public class JsonRPCSchemas { + + private JsonRPCParamValidation paramValidation; + + private JsonRPCResponseValidation responseValidation; + + @MCChildElement(order = 1) + public void setParams(JsonRPCParamValidation paramValidation) { + this.paramValidation = paramValidation; + } + + public JsonRPCParamValidation getParams() { + return paramValidation; + } + + @MCChildElement(order = 2) + public void setResponse(JsonRPCResponseValidation responseValidation) { + this.responseValidation = responseValidation; + } + + public JsonRPCResponseValidation getResponse() { + return responseValidation; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java new file mode 100644 index 0000000000..53d1797352 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java @@ -0,0 +1,28 @@ +package com.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.MCChildElement; + +public class SchemaSetter { + + protected String location; + protected JsonRPCInlineSchema schema; + + @MCAttribute + public void setLocation(String location) { + this.location = location; + } + + public String getLocation() { + return location; + } + + @MCChildElement(order = 1) + public void setSchema(JsonRPCInlineSchema schema) { + this.schema = schema; + } + + public JsonRPCInlineSchema 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 04a1d9dd79..999133a463 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 @@ -16,6 +16,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.predic8.membrane.annot.beanregistry.BeanRegistryImplementation; +import com.predic8.membrane.annot.yaml.ParsingContext; +import com.predic8.membrane.annot.yaml.parsing.GenericYamlParser; +import com.predic8.membrane.core.config.spring.GrammarAutoGenerated; import com.predic8.membrane.core.http.Request; import com.predic8.membrane.core.http.Response; import com.predic8.membrane.core.router.DefaultRouter; @@ -37,6 +42,7 @@ public class JsonRPCProtectionInterceptorTest { private static final ObjectMapper OM = new ObjectMapper(); + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); @Test void allowRuleWinsBeforeDenyRule() throws Exception { @@ -280,6 +286,51 @@ void xmlStyleResultMappingsAreSupported() throws Exception { assertEquals(CONTINUE, interceptor.handleResponse(exc)); } + @Test + void schemaValidationSupportsMethodsWithLocationAndInlineSchema() throws Exception { + JsonNode node = YAML.readTree(""" + jsonRPCProtection: + schemaValidation: + error: error.json + methods: + 'rpc.echo': + params: + location: echo-params.schema.json + response: + schema: + type: object + properties: + foo: + type: string + get: + params: + location: echo-params.schema.json + """); + + var grammar = new GrammarAutoGenerated(); + var registry = new BeanRegistryImplementation(grammar); + var interceptor = GenericYamlParser.createAndPopulateNode( + new ParsingContext<>("jsonRPCProtection", registry, grammar, node, "$.jsonRPCProtection", null), + JsonRPCProtectionInterceptor.class, + node.get("jsonRPCProtection") + ); + + JsonRPCSchemaValidation schemaValidation = interceptor.getSchemaValidation(); + assertNotNull(schemaValidation); + assertEquals("error.json", schemaValidation.getErrorValidation().getLocation()); + + Map methods = schemaValidation.getMethods().getMethods(); + assertEquals("echo-params.schema.json", methods.get("rpc.echo").getParams().getLocation()); + assertEquals("echo-params.schema.json", methods.get("get").getParams().getLocation()); + + Map responseSchema = methods.get("rpc.echo").getResponse().getSchema().getProperties(); + assertEquals("object", responseSchema.get("type")); + assertEquals( + "string", + ((Map) ((Map) responseSchema.get("properties")).get("foo")).get("type") + ); + } + private JsonRPCProtectionInterceptor interceptor(List rules) { return interceptor(rules, new JsonRPCParams(), new JsonRPCResult()); } From 79f985a259f581779a4d2f316ea476158a422ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 15:18:19 +0200 Subject: [PATCH 24/38] Remove deprecated JSON-RPC `params` and `result` schema handling, replace with `schemaValidation` supporting `params`, `response`, and `error` validation. Update tests and tutorials accordingly. --- .../rpc/AbstractJsonRPCMethodSchemas.java | 212 -------------- .../interceptor/json/rpc/JsonRPCParams.java | 33 --- .../rpc/JsonRPCProtectionInterceptor.java | 88 ++---- .../interceptor/json/rpc/JsonRPCResult.java | 29 -- .../json/rpc/JsonRPCSchemaValidation.java | 271 +++++++++++++++++- .../json/rpc/JsonRPCValidator.java | 46 ++- .../rpc/JsonRPCProtectionInterceptorTest.java | 159 ++++++---- .../test/resources/json/rpc/error.schema.json | 22 ++ .../json/30-JSON-RPC-Protection.yaml | 15 +- 9 files changed, 471 insertions(+), 404 deletions(-) delete mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/AbstractJsonRPCMethodSchemas.java delete mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java delete mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java create mode 100644 core/src/test/resources/json/rpc/error.schema.json 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 deleted file mode 100644 index 4118585a26..0000000000 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/AbstractJsonRPCMethodSchemas.java +++ /dev/null @@ -1,212 +0,0 @@ -/* 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.

- * - * @example "rpc.echo": "classpath:/json/rpc/echo-params.schema.json" - */ - @MCOtherAttributes - public void setMappings(Map mappings) { - this.mappings = mappings == null ? new LinkedHashMap<>() : new LinkedHashMap<>(mappings); - } - - public Map getMappings() { - return mappings; - } - - /** - * @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 boolean isEmpty() { - return mappings.isEmpty() && paramMappings.isEmpty(); - } - - 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 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 schema mapping for method '%s'.".formatted(methodName)); - } - resolvedSchemas.put(methodName, - loadSchema(methodName, entry.getSchema(), resolver, uriFactory, beanBaseLocation)); - } - schemas = Collections.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 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 schema for method '%s' from '%s'.".formatted(methodName, schemaPath), e); - } catch (RuntimeException e) { - throw new ConfigurationException("Cannot create JSON-RPC 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 method name must not be empty."); - } - return methodName.trim(); - } - - private List getEffectiveMappings() { - if (!mappings.isEmpty() && !paramMappings.isEmpty()) { - throw new ConfigurationException("Configure JSON-RPC schemas either as a YAML map or as XML child elements, not both."); - } - if (!paramMappings.isEmpty()) { - return paramMappings; - } - return mappings.entrySet().stream() - .map(entry -> new Param(entry.getKey(), entry.getValue())) - .toList(); - } - - @MCElement(name = "param", component = false, id = "old-param") - 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 payload 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 for the configured method. - * @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/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 deleted file mode 100644 index eca96c6c85..0000000000 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParams.java +++ /dev/null @@ -1,33 +0,0 @@ -/* 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.MCElement; - -import java.util.Map; - -/** - * @description - *

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 exact method name to schema location. - * In XML, use repeated param child elements with method and - * schema attributes. Each method name can be configured once. Inline schemas are - * not supported.

- */ -@MCElement(name = "params", component = false) -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 baf55c5f29..d8d429782e 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 @@ -48,19 +48,15 @@ * @description *

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.

+ * request parameters and responses 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 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.

- * - *

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.

+ *

Schema validation is configured under schemaValidation. Per-method + * params and response schemas can use either + * location for external JSON Schema files or schema for inline + * schema definitions.

* * @yaml *

@@ -70,12 +66,20 @@
  *       maxSize: 50
  *     methods:
  *       - allow: "^rpc\\.(health|echo)$"
- *       - deny: "^rpc\\..*$"
- *       - deny: * # Switch to default-deny behavior
- *     params:
- *       "rpc.echo": "classpath:/json/rpc/echo-params.schema.json"
- *     result:
- *       "rpc.echo": "classpath:/json/rpc/echo-result.schema.json"
+ *       - deny: ".*"
+ *     schemaValidation:
+ *       error: classpath:/json/rpc/error.schema.json
+ *       methods:
+ *         "rpc.echo":
+ *           params:
+ *             location: classpath:/json/rpc/echo-params.schema.json
+ *           response:
+ *             schema:
+ *               type: object
+ *               required: [message]
+ *               properties:
+ *                 message:
+ *                   type: string
  * 
*/ @MCElement(name = "jsonRPCProtection") @@ -87,12 +91,9 @@ public class JsonRPCProtectionInterceptor extends AbstractInterceptor { private BatchRule batchRule = new BatchRule(); private List methods = List.of(); - private JsonRPCParams params = new JsonRPCParams(); - private JsonRPCResult result = new JsonRPCResult(); + private JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); private JsonRPCValidator validator; - private JsonRPCSchemaValidation schemaValidation; - public JsonRPCProtectionInterceptor() { name = "JSON-RPC protection"; setAppliedFlow(of(REQUEST, RESPONSE)); @@ -101,8 +102,6 @@ public JsonRPCProtectionInterceptor() { @Override public void init() { super.init(); - params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); - result.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); validator = createValidator(); } @@ -135,13 +134,13 @@ public Outcome handleRequest(Exchange exc) { @Override public Outcome handleResponse(Exchange exc) { - if (exc.getResponse() == null || result.getMappings().isEmpty()) { + if (exc.getResponse() == null || !schemaValidation.hasResponseValidation()) { return CONTINUE; } ResponseValidationContext context = exc.getProperty(RESPONSE_VALIDATION_CONTEXT, ResponseValidationContext.class); - if (context == null) { - return CONTINUE; + if (context == null && schemaValidation.hasErrorValidation()) { + context = new ResponseValidationContext(payloadType(exc.getResponse().getBodyAsStringDecoded()), java.util.Map.of()); } return rejectResponse(exc, getValidator().validateResponse(exc.getResponse().getBodyAsStringDecoded(), context)); @@ -188,39 +187,13 @@ public void setMethods(List methods) { @MCChildElement(order = 4) public void setSchemaValidation(JsonRPCSchemaValidation schemaValidation) { - this.schemaValidation = schemaValidation; + this.schemaValidation = schemaValidation == null ? new JsonRPCSchemaValidation() : schemaValidation; } public JsonRPCSchemaValidation getSchemaValidation() { return schemaValidation; } - - /** - * @description - *

Configures JSON Schema files for validating params per method name.

- * - *

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) { - this.params = params; - } - - /** - * @description - *

Configures JSON Schema files for validating successful JSON-RPC result - * payloads per method name.

- * - *

The keys are exact JSON-RPC method names. Values must be schema paths or URLs; inline - * schemas are not supported.

- */ - @MCChildElement(order = 3) - public void setResult(JsonRPCResult result) { - this.result = result; - } - public BatchRule getBatch() { return batchRule; } @@ -229,14 +202,6 @@ public List getMethods() { return methods; } - public JsonRPCParams getParams() { - return params; - } - - public JsonRPCResult getResult() { - return result; - } - private JsonRPCValidator getValidator() { if (validator == null) { validator = createValidator(); @@ -245,9 +210,8 @@ private JsonRPCValidator getValidator() { } private JsonRPCValidator createValidator() { - params.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); - result.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); - return new JsonRPCValidator(batchRule, methods, params, result); + schemaValidation.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); + return new JsonRPCValidator(batchRule, methods, schemaValidation); } private Response createErrorResponse(ValidationError error) { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java deleted file mode 100644 index ae7a95eb55..0000000000 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResult.java +++ /dev/null @@ -1,29 +0,0 @@ -/* 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.MCElement; - -/** - * @description - *

Maps JSON-RPC method names to JSON Schema locations for validating the - * result member of a successful response.

- * - *

The configuration is expressed as a map from the exact method name to the schema location. - * Inline schemas are not supported.

- */ -@MCElement(name = "result", component = false) -public class JsonRPCResult extends AbstractJsonRPCMethodSchemas { -} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java index 6d09d74cdb..fcc70a7741 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java @@ -1,13 +1,39 @@ package com.predic8.membrane.core.interceptor.json.rpc; +import com.fasterxml.jackson.databind.ObjectMapper; +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.MCChildElement; import com.predic8.membrane.annot.MCElement; +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.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; @MCElement(name = "schemaValidation", component = false) public class JsonRPCSchemaValidation { + private static final ObjectMapper OM = new ObjectMapper(); + private JsonRPCErrorValidation errorValidation; private JsonRPCMethodDefinitions methods = new JsonRPCMethodDefinitions(); + private Schema errorSchema; + private Map paramSchemas = Map.of(); + private Map responseSchemas = Map.of(); public JsonRPCErrorValidation getErrorValidation() { return errorValidation; @@ -20,10 +46,253 @@ public void setErrorValidation(JsonRPCErrorValidation errorValidation) { @MCChildElement(order = 2) public void setMethods(JsonRPCMethodDefinitions methods) { - this.methods = methods; + this.methods = methods == null ? new JsonRPCMethodDefinitions() : methods; } public JsonRPCMethodDefinitions getMethods() { return methods; } + + public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { + if (resolver == null || uriFactory == null) { + throw new ConfigurationException("Cannot initialize JSON-RPC schema validation without resolver context."); + } + + SchemaRegistry registry = createSchemaRegistry(resolver); + errorSchema = resolveErrorSchema(registry, resolver, uriFactory, beanBaseLocation); + + Map resolvedParamSchemas = new LinkedHashMap<>(); + Map resolvedResponseSchemas = new LinkedHashMap<>(); + + for (Map.Entry entry : methods.getMethods().entrySet()) { + String methodName = validateMethodName(entry.getKey()); + JsonRPCSchemas definitions = requireDefinitions(methodName, entry.getValue()); + + resolveMethodSchema( + resolvedParamSchemas, + registry, + methodName, + "params", + definitions.getParams(), + resolver, + uriFactory, + beanBaseLocation + ); + resolveMethodSchema( + resolvedResponseSchemas, + registry, + methodName, + "response", + definitions.getResponse(), + resolver, + uriFactory, + beanBaseLocation + ); + } + + paramSchemas = Collections.unmodifiableMap(resolvedParamSchemas); + responseSchemas = Collections.unmodifiableMap(resolvedResponseSchemas); + } + + public boolean hasRequestValidation() { + return !paramSchemas.isEmpty(); + } + + public boolean hasMethodResponseValidation() { + return !responseSchemas.isEmpty(); + } + + public boolean hasErrorValidation() { + return errorSchema != null; + } + + public boolean hasResponseValidation() { + return hasMethodResponseValidation() || hasErrorValidation(); + } + + public boolean isEmpty() { + return !hasRequestValidation() && !hasResponseValidation(); + } + + public Schema getParamSchema(String methodName) { + if (methodName == null) { + return null; + } + return paramSchemas.get(methodName); + } + + public Schema getResponseSchema(String methodName) { + if (methodName == null) { + return null; + } + return responseSchemas.get(methodName); + } + + public Schema getErrorSchema() { + return errorSchema; + } + + private Schema resolveErrorSchema(SchemaRegistry registry, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { + if (errorValidation == null) { + return null; + } + + String location = normalizeLocation(errorValidation.getLocation()); + if (location == null) { + throw new ConfigurationException("JSON-RPC error schema validation must define a non-empty location."); + } + + return loadSchema( + "JSON-RPC error schema", + registry, + SchemaLocation.of(combine(uriFactory, beanBaseLocation, location)), + resolver, + location + ); + } + + private void resolveMethodSchema(Map target, + SchemaRegistry registry, + String methodName, + String schemaRole, + SchemaSetter definition, + Resolver resolver, + URIFactory uriFactory, + String beanBaseLocation) { + Schema schema = resolveConfiguredSchema(registry, methodName, schemaRole, definition, resolver, uriFactory, beanBaseLocation); + if (schema != null) { + target.put(methodName, schema); + } + } + + private Schema resolveConfiguredSchema(SchemaRegistry registry, + String methodName, + String schemaRole, + SchemaSetter definition, + Resolver resolver, + URIFactory uriFactory, + String beanBaseLocation) { + if (definition == null) { + return null; + } + + String location = normalizeLocation(definition.getLocation()); + boolean hasLocation = location != null; + boolean hasInlineSchema = definition.getSchema() != null; + + if (hasLocation == hasInlineSchema) { + throw new ConfigurationException( + "JSON-RPC %s schema for method '%s' must define exactly one of 'location' or 'schema'." + .formatted(schemaRole, methodName) + ); + } + + if (hasLocation) { + return loadSchema( + "JSON-RPC %s schema for method '%s'".formatted(schemaRole, methodName), + registry, + SchemaLocation.of(combine(uriFactory, beanBaseLocation, location)), + resolver, + location + ); + } + + return loadInlineSchema(registry, methodName, schemaRole, definition.getSchema(), uriFactory, beanBaseLocation); + } + + private Schema loadInlineSchema(SchemaRegistry registry, + String methodName, + String schemaRole, + JsonRPCInlineSchema inlineSchema, + URIFactory uriFactory, + String beanBaseLocation) { + if (inlineSchema == null || inlineSchema.getProperties().isEmpty()) { + throw new ConfigurationException( + "JSON-RPC %s schema for method '%s' must not be empty." + .formatted(schemaRole, methodName) + ); + } + + try { + Schema schema = registry.getSchema( + SchemaLocation.of(createInlineSchemaLocation(methodName, schemaRole, uriFactory, beanBaseLocation)), + OM.valueToTree(inlineSchema.getProperties()) + ); + schema.initializeValidators(); + return schema; + } catch (RuntimeException e) { + throw new ConfigurationException( + "Cannot create inline JSON-RPC %s schema for method '%s'." + .formatted(schemaRole, methodName), + e + ); + } + } + + private String createInlineSchemaLocation(String methodName, String schemaRole, URIFactory uriFactory, String beanBaseLocation) { + String syntheticFile = "__jsonrpc_%s_%s.schema.json".formatted( + sanitize(methodName), + sanitize(schemaRole) + ); + if (beanBaseLocation == null || beanBaseLocation.isBlank()) { + return "membrane:%s".formatted(syntheticFile); + } + return combine(uriFactory, beanBaseLocation, syntheticFile); + } + + private static String sanitize(String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private Schema loadSchema(String description, + SchemaRegistry registry, + SchemaLocation schemaLocation, + Resolver resolver, + String configuredLocation) { + try (var in = resolver.resolve(schemaLocation.getAbsoluteIri().toString())) { + Schema schema = registry.getSchema(schemaLocation, in, getSchemaFormat(schemaLocation.getAbsoluteIri().toString())); + schema.initializeValidators(); + return schema; + } catch (IOException e) { + throw new ConfigurationException("Cannot read %s from '%s'.".formatted(description, configuredLocation), e); + } catch (RuntimeException e) { + throw new ConfigurationException("Cannot create %s from '%s'.".formatted(description, configuredLocation), 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) { + String normalized = schemaLocation.toLowerCase(); + return normalized.endsWith(".yaml") || normalized.endsWith(".yml") ? YAML : JSON; + } + + private static JsonRPCSchemas requireDefinitions(String methodName, JsonRPCSchemas definitions) { + if (definitions == null) { + throw new ConfigurationException("JSON-RPC schema validation entry for method '%s' must not be null.".formatted(methodName)); + } + return definitions; + } + + private static String validateMethodName(String methodName) { + if (methodName == null || methodName.trim().isEmpty()) { + throw new ConfigurationException("JSON-RPC method name must not be empty."); + } + return methodName.trim(); + } + + private static String normalizeLocation(String location) { + if (location == null) { + return null; + } + String trimmed = location.trim(); + return trimmed.isEmpty() ? null : trimmed; + } } 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 e073c41c67..74735a99dd 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 @@ -43,14 +43,12 @@ public class JsonRPCValidator { private final BatchRule batchRule; private final List rules; - private final JsonRPCParams params; - private final JsonRPCResult result; + private final JsonRPCSchemaValidation schemaValidation; - public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCParams params, JsonRPCResult result) { + public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCSchemaValidation schemaValidation) { this.batchRule = batchRule; this.rules = rules; - this.params = params; - this.result = result; + this.schemaValidation = schemaValidation; } public ValidationError validate(String body) { @@ -76,7 +74,10 @@ public RequestValidationResult validateRequest(String body) { } public ValidationError validateResponse(String body, ResponseValidationContext context) { - if (context == null || !context.expectsResponses() || result.isEmpty()) { + if (context == null || !schemaValidation.hasResponseValidation()) { + return null; + } + if (!context.expectsResponses() && !schemaValidation.hasErrorValidation()) { return null; } if (body == null || body.isBlank()) { @@ -166,6 +167,10 @@ private ValidationError validateSingleResponse(JsonNode node, ResponseValidation } if (response.isError()) { + return validateErrorResponse(node, payloadType, response.getId()); + } + + if (!schemaValidation.hasMethodResponseValidation()) { return null; } @@ -174,7 +179,7 @@ private ValidationError validateSingleResponse(JsonNode node, ResponseValidation return invalidResponse(payloadType, response.getId(), "JSON-RPC response id '%s' does not match any request.".formatted(response.getId())); } - var schema = result.getSchema(methodName); + var schema = schemaValidation.getResponseSchema(methodName); if (schema == null) { return null; } @@ -232,7 +237,7 @@ private ValidationError validateMethod(JSONRPCRequest request, PayloadType paylo } private ValidationError validateParams(JSONRPCRequest request, PayloadType payloadType) { - var schema = params.getSchema(request.getMethod()); + var schema = schemaValidation.getParamSchema(request.getMethod()); if (schema == null) { return null; } @@ -269,12 +274,35 @@ private ResponseValidationContext createResponseValidationContext(PayloadType pa } private ResponseValidationContext createResponseValidationContext(PayloadType payloadType, Map methodsById) { - if (result.isEmpty() || methodsById.isEmpty()) { + if (!schemaValidation.hasResponseValidation()) { + return null; + } + if (methodsById.isEmpty() && !schemaValidation.hasErrorValidation()) { return null; } return new ResponseValidationContext(payloadType, Collections.unmodifiableMap(new LinkedHashMap<>(methodsById))); } + private ValidationError validateErrorResponse(JsonNode node, PayloadType payloadType, Object responseId) { + var schema = schemaValidation.getErrorSchema(); + if (schema == null) { + return null; + } + + var errors = schema.validate(node.path("error")); + if (errors.isEmpty()) { + return null; + } + + return invalidResponse( + payloadType, + responseId, + "Invalid error response: %s".formatted( + errors.stream().map(Error::getMessage).collect(Collectors.joining("; ")) + ) + ); + } + private void rememberResponseMethod(Map methodsById, JSONRPCRequest request) { if (request == null || request.isNotification()) { return; 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 999133a463..b417f119b4 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 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; @@ -77,7 +78,7 @@ void firstMatchingDenyRuleRejectsRequest() throws Exception { void batchRequestsCanBeDisabled() throws Exception { BatchRule batchRule = new BatchRule(); batchRule.setEnabled(false); - var interceptor = interceptor(List.of(), new JsonRPCParams(), batchRule); + var interceptor = interceptor(List.of(), batchRule); var exc = exchange(""" [{"jsonrpc":"2.0","id":1,"method":"rpc.health"}] @@ -91,7 +92,7 @@ void batchRequestsCanBeDisabled() throws Exception { void batchSizeIsLimited() throws Exception { BatchRule batchRule = new BatchRule(); batchRule.setMaxSize(1); - var interceptor = interceptor(List.of(), new JsonRPCParams(), batchRule); + var interceptor = interceptor(List.of(), batchRule); var exc = exchange(""" [ @@ -125,11 +126,10 @@ void nonJsonContentTypeIsRejected() throws Exception { @Test void paramsValidation() throws Exception { - JsonRPCParams params = new JsonRPCParams(); - params.setMappings(Map.of( - "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" + var interceptor = interceptor(List.of(), schemaValidation( + Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json"), + Map.of() )); - var interceptor = interceptor(List.of(), params); var exc = exchange(""" {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"hello"}} @@ -147,11 +147,10 @@ void paramsValidation() throws Exception { @Test void paramsValidationUsesExactMethodName() throws Exception { - JsonRPCParams params = new JsonRPCParams(); - params.setMappings(Map.of( - "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json" + var interceptor = interceptor(List.of(), schemaValidation( + Map.of("rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json"), + Map.of() )); - var interceptor = interceptor(List.of(), params); var exc = exchange(""" {"jsonrpc":"2.0","id":1,"method":"rpc.health","params":{"code":1}} @@ -162,11 +161,10 @@ void paramsValidationUsesExactMethodName() throws Exception { @Test void paramsValidationDoesNotMatchDifferentMethodNames() throws Exception { - JsonRPCParams params = new JsonRPCParams(); - params.setMappings(Map.of( - "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" + var interceptor = interceptor(List.of(), schemaValidation( + Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json"), + Map.of() )); - var interceptor = interceptor(List.of(), params); var exc = exchange(""" {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2","params":{"code":1}} @@ -176,27 +174,40 @@ void paramsValidationDoesNotMatchDifferentMethodNames() throws Exception { } @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); + void paramsValidationSupportsInlineSchema() throws Exception { + JsonRPCParamValidation params = new JsonRPCParamValidation(); + params.setSchema(inlineSchema(Map.of( + "type", "object", + "required", List.of("message"), + "properties", Map.of( + "message", Map.of("type", "string") + ) + ))); + + JsonRPCSchemas methodSchemas = new JsonRPCSchemas(); + methodSchemas.setParams(params); + var interceptor = interceptor(List.of(), schemaValidationForMethods(Map.of("rpc.echo", methodSchemas))); 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'"); } @Test void resultValidation() throws Exception { - JsonRPCResult result = new JsonRPCResult(); - result.setMappings(Map.of( - "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" + var interceptor = interceptor(List.of(), schemaValidation( + Map.of(), + Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json") )); - var interceptor = interceptor(List.of(), new JsonRPCParams(), result); var exc = exchange(""" {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} @@ -222,11 +233,10 @@ void resultValidation() throws Exception { @Test void resultValidationDoesNotMatchDifferentMethodNames() throws Exception { - JsonRPCResult result = new JsonRPCResult(); - result.setMappings(Map.of( - "rpc.echo", "classpath:/json/rpc/echo-params.schema.json" + var interceptor = interceptor(List.of(), schemaValidation( + Map.of(), + Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json") )); - var interceptor = interceptor(List.of(), new JsonRPCParams(), result); var exc = exchange(""" {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2"} @@ -241,12 +251,13 @@ void resultValidationDoesNotMatchDifferentMethodNames() throws Exception { @Test void batchResultValidationUsesRequestIdToResolveMethod() throws Exception { - JsonRPCResult result = new JsonRPCResult(); - result.setMappings(Map.of( - "rpc.echo", "classpath:/json/rpc/echo-params.schema.json", - "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json" + var interceptor = interceptor(List.of(), schemaValidation( + Map.of(), + Map.of( + "rpc.echo", "classpath:/json/rpc/echo-params.schema.json", + "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json" + ) )); - var interceptor = interceptor(List.of(), new JsonRPCParams(), result); var exc = exchange(""" [ @@ -268,12 +279,13 @@ void batchResultValidationUsesRequestIdToResolveMethod() throws Exception { } @Test - void xmlStyleResultMappingsAreSupported() throws Exception { - JsonRPCResult result = new JsonRPCResult(); - result.setParamMappings(List.of( - new JsonRPCResult.Param("rpc.echo", "classpath:/json/rpc/echo-params.schema.json") - )); - var interceptor = interceptor(List.of(), new JsonRPCParams(), result); + void errorValidation() throws Exception { + JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); + JsonRPCErrorValidation errorValidation = new JsonRPCErrorValidation(); + errorValidation.setLocation("classpath:/json/rpc/error.schema.json"); + schemaValidation.setErrorValidation(errorValidation); + + var interceptor = interceptor(List.of(), schemaValidation); var exc = exchange(""" {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} @@ -281,9 +293,20 @@ void xmlStyleResultMappingsAreSupported() throws Exception { assertEquals(CONTINUE, interceptor.handleRequest(exc)); exc.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"result":{"message":"hello"}} + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} """)); assertEquals(CONTINUE, interceptor.handleResponse(exc)); + + var exc2 = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc2)); + exc2.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """)); + assertEquals(RETURN, interceptor.handleResponse(exc2)); + assertErrorContains(exc2.getResponse(), 500, "Invalid error response"); } @Test @@ -332,31 +355,63 @@ void schemaValidationSupportsMethodsWithLocationAndInlineSchema() throws Excepti } private JsonRPCProtectionInterceptor interceptor(List rules) { - return interceptor(rules, new JsonRPCParams(), new JsonRPCResult()); - } - - private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params) { - return interceptor(rules, params, new JsonRPCResult()); + return interceptor(rules, new JsonRPCSchemaValidation(), new BatchRule()); } - private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, JsonRPCResult result) { - return interceptor(rules, params, result, new BatchRule()); + private JsonRPCProtectionInterceptor interceptor(List rules, BatchRule batchRule) { + return interceptor(rules, new JsonRPCSchemaValidation(), batchRule); } - private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, BatchRule batchRule) { - return interceptor(rules, params, new JsonRPCResult(), batchRule); + private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCSchemaValidation schemaValidation) { + return interceptor(rules, schemaValidation, new BatchRule()); } - private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCParams params, JsonRPCResult result, BatchRule batchRule) { + private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCSchemaValidation schemaValidation, BatchRule batchRule) { var interceptor = new JsonRPCProtectionInterceptor(); interceptor.setBatch(batchRule); interceptor.setMethods(rules); - interceptor.setParams(params); - interceptor.setResult(result); + interceptor.setSchemaValidation(schemaValidation); interceptor.init(new DefaultRouter()); return interceptor; } + private JsonRPCSchemaValidation schemaValidation(Map paramLocations, Map responseLocations) { + Map methods = new LinkedHashMap<>(); + + paramLocations.forEach((method, location) -> + methodSchemas(methods, method).setParams(withLocation(new JsonRPCParamValidation(), location)) + ); + responseLocations.forEach((method, location) -> + methodSchemas(methods, method).setResponse(withLocation(new JsonRPCResponseValidation(), location)) + ); + + return schemaValidationForMethods(methods); + } + + private JsonRPCSchemaValidation schemaValidationForMethods(Map methods) { + JsonRPCMethodDefinitions methodDefinitions = new JsonRPCMethodDefinitions(); + methodDefinitions.setMethods(methods); + + JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); + schemaValidation.setMethods(methodDefinitions); + return schemaValidation; + } + + private JsonRPCSchemas methodSchemas(Map methods, String methodName) { + return methods.computeIfAbsent(methodName, ignored -> new JsonRPCSchemas()); + } + + private T withLocation(T validation, String location) { + validation.setLocation(location); + return validation; + } + + private JsonRPCInlineSchema inlineSchema(Map schemaProperties) { + JsonRPCInlineSchema schema = new JsonRPCInlineSchema(); + schema.setProperties(schemaProperties); + return schema; + } + private com.predic8.membrane.core.exchange.Exchange exchange(String body) throws Exception { return Request.post("/") .contentType(APPLICATION_JSON) diff --git a/core/src/test/resources/json/rpc/error.schema.json b/core/src/test/resources/json/rpc/error.schema.json new file mode 100644 index 0000000000..260573dea9 --- /dev/null +++ b/core/src/test/resources/json/rpc/error.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["code", "message", "data"], + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string" + } + } + } + } +} diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml index 8ed1effd7e..ea8aea26f9 100644 --- a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml +++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml @@ -5,13 +5,13 @@ # Protect a JSON-RPC endpoint by: # - allowing only selected methods # - rejecting all other methods -# - validating params and result with JSON Schema +# - validating params and responses with JSON Schema # - limiting batch requests # # Notes: # - Method rules are evaluated top-down. # - The first matching rule wins. -# - The keys in params/result are exact JSON-RPC method names. +# - schemaValidation.methods keys are exact JSON-RPC method names. # - The mock upstream below returns one fixed JSON-RPC success response. # # 1.) Send an allowed request @@ -48,10 +48,13 @@ api: methods: - allow: '^rpc\.(health|echo)$' - deny: '.*' # Default deny for everything else - params: - 'rpc.echo': echo-params.schema.json # Validate params for rpc.echo - result: - 'rpc.echo': echo-result.schema.json # Validate result for rpc.echo + schemaValidation: + methods: + 'rpc.echo': + params: + location: echo-params.schema.json # Validate params for rpc.echo + response: + location: echo-result.schema.json # Validate result for rpc.echo target: url: http://localhost:2001 From f957fdfee096d717f920b5e09272a4481ef24759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 15:22:53 +0200 Subject: [PATCH 25/38] Remove redundant body empty check in `JsonRPCProtectionInterceptor` since JSON validation is sufficient --- .../interceptor/json/rpc/JsonRPCProtectionInterceptor.java | 4 ---- 1 file changed, 4 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 d8d429782e..28b62d77b6 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 @@ -111,10 +111,6 @@ public Outcome handleRequest(Exchange exc) { return CONTINUE; } - if (exc.getRequest().isBodyEmpty()) { - return CONTINUE; - } - if (!exc.getRequest().isJSON()) { return rejectRequest(exc, new ValidationError( payloadType(exc.getRequest().getBodyAsStringDecoded()), From 24db336b65cf5aef7d8e473de69ad87399188f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 15:33:44 +0200 Subject: [PATCH 26/38] Add copyright headers to JSON-RPC interceptor classes --- .../json/rpc/JsonRPCErrorValidation.java | 14 ++++++++++++++ .../interceptor/json/rpc/JsonRPCInlineSchema.java | 14 ++++++++++++++ .../json/rpc/JsonRPCMethodDefinitions.java | 14 ++++++++++++++ .../json/rpc/JsonRPCParamValidation.java | 14 ++++++++++++++ .../json/rpc/JsonRPCResponseValidation.java | 14 ++++++++++++++ .../json/rpc/JsonRPCSchemaValidation.java | 14 ++++++++++++++ .../core/interceptor/json/rpc/JsonRPCSchemas.java | 14 ++++++++++++++ .../core/interceptor/json/rpc/SchemaSetter.java | 14 ++++++++++++++ 8 files changed, 112 insertions(+) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java index 5afe0e8c2a..ae8603c7c0 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.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/JsonRPCInlineSchema.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java index d6a6f361b6..c02a06e05e 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.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.MCElement; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java index dbd593c54d..981f84aea8 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.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.MCElement; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java index af57ca812e..59c20c2b79 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.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.MCElement; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java index bcd217701a..498d1500de 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.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.MCElement; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java index fcc70a7741..88e0605222 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.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/interceptor/json/rpc/JsonRPCSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java index b9e95a9d29..bd7ae2d81d 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.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.MCChildElement; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java index 53d1797352..df8f4ab99b 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.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; From 7ba583f20b66e98a4db01df6183cc04a78da5f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 15:52:25 +0200 Subject: [PATCH 27/38] Add inline schema support for JSON-RPC `error` validation, enhance documentation with descriptions and examples, and update schema handling logic and tests --- .../annot/generator/JsonSchemaGenerator.java | 3 +- .../kubernetes/model/SchemaObject.java | 3 +- .../parsing/binding/ScalarValueConverter.java | 9 +- .../json/rpc/JsonRPCErrorValidation.java | 23 ++---- .../json/rpc/JsonRPCInlineSchema.java | 13 +++ .../json/rpc/JsonRPCMethodDefinitions.java | 14 ++++ .../json/rpc/JsonRPCParamValidation.java | 7 ++ .../rpc/JsonRPCProtectionInterceptor.java | 3 +- .../json/rpc/JsonRPCResponseValidation.java | 7 ++ .../json/rpc/JsonRPCSchemaValidation.java | 71 +++++++++++----- .../interceptor/json/rpc/JsonRPCSchemas.java | 13 +++ .../interceptor/json/rpc/SchemaSetter.java | 14 ++++ .../rpc/JsonRPCProtectionInterceptorTest.java | 82 ++++++++++++++++++- 13 files changed, 220 insertions(+), 42 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 ffae5a3654..53c4443da1 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 @@ -32,6 +32,7 @@ import static com.predic8.membrane.annot.Constants.JSON_SCHEMA_VERSION; import static com.predic8.membrane.annot.generator.kubernetes.model.SchemaFactory.*; import static com.predic8.membrane.annot.generator.util.SchemaGeneratorUtil.escapeJsonContent; +import static com.predic8.membrane.annot.model.OtherAttributesInfo.ValueType.STRING; import static javax.tools.StandardLocation.CLASS_OUTPUT; /** @@ -321,7 +322,7 @@ private void processMCOtherAttributes(Model model, MainInfo main, ElementInfo el return; } - if (otherAttributes.getValueType() == OtherAttributesInfo.ValueType.STRING) { + if (otherAttributes.getValueType() == STRING) { parserSchema.additionalProperties(from("string")); return; } diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java b/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java index 5e94ca0b8a..58b20baeb2 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java @@ -19,6 +19,7 @@ import java.util.*; import static com.predic8.membrane.annot.generator.kubernetes.model.SchemaFactory.*; +import static java.lang.Boolean.FALSE; public class SchemaObject extends AbstractSchema { @@ -57,7 +58,7 @@ public ObjectNode json(ObjectNode node) { if (additionalPropertiesSchema != null && isObject()) { node.set("additionalProperties", additionalPropertiesSchema.json(jnf.objectNode())); - } else if (Boolean.FALSE.equals(additionalProperties) && isObject()) { + } else if (FALSE.equals(additionalProperties) && isObject()) { node.put("additionalProperties", false); } diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java index 6903c2f586..73afaa101f 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.predic8.membrane.annot.yaml.ConfigurationParsingException; -import com.predic8.membrane.annot.yaml.McYamlIntrospector; import com.predic8.membrane.annot.yaml.ParsingContext; import com.predic8.membrane.annot.yaml.WrongEnumConstantException; import com.predic8.membrane.annot.yaml.parsing.support.SpelEvaluator; @@ -27,8 +26,8 @@ import java.lang.reflect.Type; import java.util.Map; -import static com.predic8.membrane.annot.yaml.McYamlIntrospector.hasOtherAttributes; -import static com.predic8.membrane.annot.yaml.McYamlIntrospector.isReferenceAttribute; +import static com.predic8.membrane.annot.yaml.McYamlIntrospector.*; +import static com.predic8.membrane.annot.yaml.parsing.binding.ObjectBinder.bind; import static java.lang.Boolean.parseBoolean; import static java.lang.Double.parseDouble; import static java.lang.Integer.parseInt; @@ -109,8 +108,8 @@ private Object convertAnySetterValue(ParsingContext ctx, Method setter, JsonN if (valueType == String.class) { return node.isTextual() ? evaluateSpelForString(key, node.asText()) : node.asText(); } - return ObjectBinder.bind( - ctx.updateContext(McYamlIntrospector.getElementName(valueType)).addProperty(key), + return bind( + ctx.updateContext(getElementName(valueType)).addProperty(key), valueType, node ); diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java index ae8603c7c0..118ace41f0 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java @@ -14,20 +14,15 @@ package com.predic8.membrane.core.interceptor.json.rpc; -import com.predic8.membrane.annot.MCAttribute; import com.predic8.membrane.annot.MCElement; -@MCElement(name = "error", component = false, collapsed = true) -public class JsonRPCErrorValidation { - - private String location; - - @MCAttribute - public void setLocation(String location) { - this.location = location; - } - - public String getLocation() { - return location; - } +/** + * @description + *

Configures a JSON Schema that validates JSON-RPC error objects.

+ * + *

Use either location to load an external schema or + * schema to define the schema inline.

+ */ +@MCElement(name = "error", component = false) +public class JsonRPCErrorValidation extends SchemaSetter { } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java index c02a06e05e..9d276b3709 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java @@ -20,11 +20,24 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * @description + *

Embeds a JSON Schema inline in the Membrane configuration.

+ * + *

The entries inside schema are copied verbatim into the generated + * JSON Schema document.

+ */ @MCElement(name = "schema", component = false, id = "json-rpc-inline-schema") public class JsonRPCInlineSchema { private final Map properties = new LinkedHashMap<>(); + /** + * @description + *

Defines the raw JSON Schema keywords for the inline schema.

+ * + * @example type: object + */ @MCOtherAttributes public void setProperties(Map properties) { if (properties != null) { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java index 981f84aea8..d7461b67a3 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java @@ -20,11 +20,25 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * @description + *

Maps JSON-RPC method names to their schema validation definitions.

+ * + *

In YAML, the entries are written as a map under schemaValidation.methods.

+ */ @MCElement(name = "methods", component = false, id = "json-rpc-method-definitions") public class JsonRPCMethodDefinitions { private final Map methods = new LinkedHashMap<>(); + /** + * @description + *

Defines the per-method schema validation entries.

+ * + *

Each key must match one JSON-RPC method value exactly.

+ * + * @example "rpc.echo": { params: { location: "classpath:/json/rpc/echo-params.schema.json" } } + */ @MCOtherAttributes public void setMethods(Map methods) { if (methods != null) { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java index 59c20c2b79..b91f9818cf 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java @@ -16,6 +16,13 @@ import com.predic8.membrane.annot.MCElement; +/** + * @description + *

Configures JSON Schema validation for the JSON-RPC params member of one method.

+ * + *

Use either location to load an external schema or schema + * to define the schema inline.

+ */ @MCElement(name = "params", component = false, id = "json-rpc-method-params-validation") public class JsonRPCParamValidation extends SchemaSetter { } 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 28b62d77b6..8dcf31de41 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 @@ -68,7 +68,8 @@ * - allow: "^rpc\\.(health|echo)$" * - deny: ".*" * schemaValidation: - * error: classpath:/json/rpc/error.schema.json + * error: + * location: classpath:/json/rpc/error.schema.json * methods: * "rpc.echo": * params: diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java index 498d1500de..1348761526 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java @@ -16,6 +16,13 @@ import com.predic8.membrane.annot.MCElement; +/** + * @description + *

Configures JSON Schema validation for successful JSON-RPC responses of one method.

+ * + *

The schema is applied to the JSON-RPC result value. Use either + * location or an inline schema.

+ */ @MCElement(name = "response", component = false, id = "json-rpc-response-validation") public class JsonRPCResponseValidation extends SchemaSetter { } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java index 88e0605222..f0daed911b 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java @@ -38,6 +38,19 @@ import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12; import static com.predic8.membrane.core.resolver.ResolverMap.combine; +/** + * @description + *

Configures JSON Schema validation for JSON-RPC request params, successful responses, + * and error responses.

+ * + *

Under methods, each key is matched against the exact JSON-RPC + * method value. For every method, params and + * response can define either a schema location or an + * inline schema.

+ * + *

The optional error entry validates JSON-RPC error + * objects returned by the upstream service.

+ */ @MCElement(name = "schemaValidation", component = false) public class JsonRPCSchemaValidation { @@ -53,11 +66,25 @@ public JsonRPCErrorValidation getErrorValidation() { return errorValidation; } + /** + * @description + *

Configures a JSON Schema for validating JSON-RPC error objects.

+ * + *

This applies to upstream responses that contain an error member + * instead of a successful result.

+ */ @MCChildElement(order = 1) public void setErrorValidation(JsonRPCErrorValidation errorValidation) { this.errorValidation = errorValidation; } + /** + * @description + *

Configures per-method JSON Schema validation rules.

+ * + *

The keys in this map are exact JSON-RPC method names such as + * rpc.echo.

+ */ @MCChildElement(order = 2) public void setMethods(JsonRPCMethodDefinitions methods) { this.methods = methods == null ? new JsonRPCMethodDefinitions() : methods; @@ -150,18 +177,15 @@ private Schema resolveErrorSchema(SchemaRegistry registry, Resolver resolver, UR if (errorValidation == null) { return null; } - - String location = normalizeLocation(errorValidation.getLocation()); - if (location == null) { - throw new ConfigurationException("JSON-RPC error schema validation must define a non-empty location."); - } - - return loadSchema( - "JSON-RPC error schema", + return resolveConfiguredSchema( registry, - SchemaLocation.of(combine(uriFactory, beanBaseLocation, location)), + "", + "error", + errorValidation.getLocation(), + errorValidation.getSchema(), resolver, - location + uriFactory, + beanBaseLocation ); } @@ -173,7 +197,19 @@ private void resolveMethodSchema(Map target, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { - Schema schema = resolveConfiguredSchema(registry, methodName, schemaRole, definition, resolver, uriFactory, beanBaseLocation); + if (definition == null) { + return; + } + Schema schema = resolveConfiguredSchema( + registry, + methodName, + schemaRole, + definition.getLocation(), + definition.getSchema(), + resolver, + uriFactory, + beanBaseLocation + ); if (schema != null) { target.put(methodName, schema); } @@ -182,17 +218,14 @@ private void resolveMethodSchema(Map target, private Schema resolveConfiguredSchema(SchemaRegistry registry, String methodName, String schemaRole, - SchemaSetter definition, + String configuredLocation, + JsonRPCInlineSchema inlineSchema, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { - if (definition == null) { - return null; - } - - String location = normalizeLocation(definition.getLocation()); + String location = normalizeLocation(configuredLocation); boolean hasLocation = location != null; - boolean hasInlineSchema = definition.getSchema() != null; + boolean hasInlineSchema = inlineSchema != null; if (hasLocation == hasInlineSchema) { throw new ConfigurationException( @@ -211,7 +244,7 @@ private Schema resolveConfiguredSchema(SchemaRegistry registry, ); } - return loadInlineSchema(registry, methodName, schemaRole, definition.getSchema(), uriFactory, beanBaseLocation); + return loadInlineSchema(registry, methodName, schemaRole, inlineSchema, uriFactory, beanBaseLocation); } private Schema loadInlineSchema(SchemaRegistry registry, diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java index bd7ae2d81d..7abf95651a 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java @@ -17,6 +17,13 @@ import com.predic8.membrane.annot.MCChildElement; import com.predic8.membrane.annot.MCElement; +/** + * @description + *

Defines the JSON Schema validation rules for one exact JSON-RPC method name.

+ * + *

Use params to validate the request payload and response + * to validate successful upstream responses.

+ */ @MCElement(name = "method", component = false, id = "json-rpc-method-schema") public class JsonRPCSchemas { @@ -24,6 +31,9 @@ public class JsonRPCSchemas { private JsonRPCResponseValidation responseValidation; + /** + * @description Validates the JSON-RPC params member for this method. + */ @MCChildElement(order = 1) public void setParams(JsonRPCParamValidation paramValidation) { this.paramValidation = paramValidation; @@ -33,6 +43,9 @@ public JsonRPCParamValidation getParams() { return paramValidation; } + /** + * @description Validates the successful JSON-RPC result payload for this method. + */ @MCChildElement(order = 2) public void setResponse(JsonRPCResponseValidation responseValidation) { this.responseValidation = responseValidation; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java index df8f4ab99b..c1b94ac050 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java @@ -22,6 +22,14 @@ public class SchemaSetter { protected String location; protected JsonRPCInlineSchema schema; + /** + * @description + *

References the JSON Schema by path or URL.

+ * + *

Configure either location or schema, but not both.

+ * + * @example classpath:/json/rpc/echo-params.schema.json + */ @MCAttribute public void setLocation(String location) { this.location = location; @@ -31,6 +39,12 @@ public String getLocation() { return location; } + /** + * @description + *

Defines the JSON Schema inline.

+ * + *

Configure either schema or location, but not both.

+ */ @MCChildElement(order = 1) public void setSchema(JsonRPCInlineSchema schema) { this.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 b417f119b4..2b274e4df0 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 @@ -309,12 +309,59 @@ void errorValidation() throws Exception { assertErrorContains(exc2.getResponse(), 500, "Invalid error response"); } + @Test + void errorValidationSupportsInlineSchema() throws Exception { + JsonRPCErrorValidation errorValidation = new JsonRPCErrorValidation(); + errorValidation.setSchema(inlineSchema(Map.of( + "type", "object", + "required", List.of("code", "message", "data"), + "properties", Map.of( + "code", Map.of("type", "integer"), + "message", Map.of("type", "string"), + "data", Map.of( + "type", "object", + "required", List.of("reason"), + "properties", Map.of( + "reason", Map.of("type", "string") + ) + ) + ) + ))); + + JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); + schemaValidation.setErrorValidation(errorValidation); + + var interceptor = interceptor(List.of(), schemaValidation); + + var exc = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + exc.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} + """)); + assertEquals(CONTINUE, interceptor.handleResponse(exc)); + + var exc2 = exchange(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """); + + assertEquals(CONTINUE, interceptor.handleRequest(exc2)); + exc2.setResponse(jsonResponse(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """)); + assertEquals(RETURN, interceptor.handleResponse(exc2)); + assertErrorContains(exc2.getResponse(), 500, "Invalid error response"); + } + @Test void schemaValidationSupportsMethodsWithLocationAndInlineSchema() throws Exception { JsonNode node = YAML.readTree(""" jsonRPCProtection: schemaValidation: - error: error.json + error: + location: error.json methods: 'rpc.echo': params: @@ -354,6 +401,39 @@ void schemaValidationSupportsMethodsWithLocationAndInlineSchema() throws Excepti ); } + @Test + void schemaValidationSupportsInlineErrorSchema() throws Exception { + JsonNode node = YAML.readTree(""" + jsonRPCProtection: + schemaValidation: + error: + schema: + type: object + required: + - code + properties: + code: + type: integer + """); + + var grammar = new GrammarAutoGenerated(); + var registry = new BeanRegistryImplementation(grammar); + var interceptor = GenericYamlParser.createAndPopulateNode( + new ParsingContext<>("jsonRPCProtection", registry, grammar, node, "$.jsonRPCProtection", null), + JsonRPCProtectionInterceptor.class, + node.get("jsonRPCProtection") + ); + + JsonRPCSchemaValidation schemaValidation = interceptor.getSchemaValidation(); + assertNotNull(schemaValidation); + assertNotNull(schemaValidation.getErrorValidation()); + assertNull(schemaValidation.getErrorValidation().getLocation()); + assertEquals( + "object", + schemaValidation.getErrorValidation().getSchema().getProperties().get("type") + ); + } + private JsonRPCProtectionInterceptor interceptor(List rules) { return interceptor(rules, new JsonRPCSchemaValidation(), new BatchRule()); } From f7d5cbd275a7f13103c3b1dee2642a6dfb1fdae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 15:55:37 +0200 Subject: [PATCH 28/38] Use inline schema in tutorial --- .../tutorials/json/30-JSON-RPC-Protection.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml index ea8aea26f9..50dd61fcbd 100644 --- a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml +++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml @@ -12,6 +12,7 @@ # - Method rules are evaluated top-down. # - The first matching rule wins. # - schemaValidation.methods keys are exact JSON-RPC method names. +# - This tutorial shows both schema styles: inline `schema` and file-based `location`. # - The mock upstream below returns one fixed JSON-RPC success response. # # 1.) Send an allowed request @@ -25,7 +26,7 @@ # 3.) Try invalid params # curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}}' -H "Content-Type: application/json" http://localhost:2000 # -# The schema requires params.message. +# The inline schema requires params.message. # # 4.) Send a valid request # curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"Hello"}}' -H "Content-Type: application/json" http://localhost:2000 @@ -52,7 +53,13 @@ api: methods: 'rpc.echo': params: - location: echo-params.schema.json # Validate params for rpc.echo + schema: # Inline schema for rpc.echo params + type: object + required: + - message + properties: + message: + type: string response: location: echo-result.schema.json # Validate result for rpc.echo target: From 7a1e798f3941ed48d8eea3b3880a463fc61e99f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Jun 2026 16:38:47 +0200 Subject: [PATCH 29/38] Add response schema validation for JSON-RPC methods, update tests with location-based and inline schema examples --- .../rpc/JsonRPCProtectionInterceptorTest.java | 1266 +++++++++++------ .../json/rpc/echo-result.schema.json | 13 + 2 files changed, 819 insertions(+), 460 deletions(-) create mode 100644 core/src/test/resources/json/rpc/echo-result.schema.json 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 2b274e4df0..ce476aff21 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 @@ -21,482 +21,781 @@ import com.predic8.membrane.annot.yaml.ParsingContext; import com.predic8.membrane.annot.yaml.parsing.GenericYamlParser; import com.predic8.membrane.core.config.spring.GrammarAutoGenerated; +import com.predic8.membrane.core.exchange.Exchange; import com.predic8.membrane.core.http.Request; import com.predic8.membrane.core.http.Response; +import com.predic8.membrane.core.interceptor.Outcome; import com.predic8.membrane.core.router.DefaultRouter; import com.predic8.membrane.core.util.ConfigurationException; -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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; +import java.util.stream.Stream; 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.http.Request.METHOD_GET; +import static com.predic8.membrane.core.http.Request.METHOD_POST; 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_INTERNAL_ERROR; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_PARAMS; +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 org.junit.jupiter.api.Assertions.*; -public class JsonRPCProtectionInterceptorTest { +class JsonRPCProtectionInterceptorTest { private static final ObjectMapper OM = new ObjectMapper(); private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); - @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 { - BatchRule batchRule = new BatchRule(); - batchRule.setEnabled(false); - var interceptor = interceptor(List.of(), 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 { - BatchRule batchRule = new BatchRule(); - batchRule.setMaxSize(1); - var interceptor = interceptor(List.of(), 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.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."); + private static final String PARAMS_LOCATION_CONFIG = """ + schemaValidation: + methods: + 'rpc.echo': + params: + location: classpath:/json/rpc/echo-params.schema.json + """; + private static final String PARAMS_INLINE_CONFIG = """ + schemaValidation: + methods: + 'rpc.inline': + params: + schema: + type: object + required: + - message + additionalProperties: false + properties: + message: + type: string + """; + private static final String RESPONSE_LOCATION_CONFIG = """ + schemaValidation: + methods: + 'rpc.echo': + response: + location: classpath:/json/rpc/echo-result.schema.json + """; + private static final String RESPONSE_INLINE_CONFIG = """ + schemaValidation: + methods: + 'rpc.inline': + response: + schema: + type: object + required: + - ok + additionalProperties: false + properties: + ok: + type: boolean + """; + private static final String BATCH_RESPONSE_CONFIG = """ + schemaValidation: + methods: + 'rpc.echo': + response: + location: classpath:/json/rpc/echo-result.schema.json + 'rpc.health': + response: + location: classpath:/json/rpc/generic-rpc-params.schema.json + """; + private static final String ERROR_LOCATION_CONFIG = """ + schemaValidation: + error: + location: classpath:/json/rpc/error.schema.json + """; + private static final String ERROR_INLINE_CONFIG = """ + schemaValidation: + error: + schema: + type: object + required: + - code + - message + - data + properties: + code: + type: integer + message: + type: string + data: + type: object + required: + - reason + properties: + reason: + type: string + """; + + @ParameterizedTest(name = "{0}") + @MethodSource("requestCases") + void validatesRequests(RequestCase testCase) throws Exception { + var interceptor = interceptor(testCase.config()); + var exc = exchange(testCase.method(), testCase.contentType(), testCase.body()); + + assertEquals(testCase.expectedOutcome(), interceptor.handleRequest(exc)); + + if (testCase.expectsRejection()) { + assertValidationError( + exc.getResponse(), + testCase.expectedStatus(), + testCase.expectedJsonRpcCode(), + testCase.expectedMessageSnippet(), + testCase.expectedId(), + testCase.batchErrorShape() + ); + return; + } + + assertNull(exc.getResponse()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("responseCases") + void validatesResponses(ResponseCase testCase) throws Exception { + var interceptor = interceptor(testCase.config()); + var exc = exchange(METHOD_POST, APPLICATION_JSON, testCase.requestBody()); + + if (testCase.requestBody() != null) { + assertEquals(CONTINUE, interceptor.handleRequest(exc), "response test setup must pass request validation"); + } + + exc.setResponse(jsonResponse(testCase.responseBody())); + assertEquals(testCase.expectedOutcome(), interceptor.handleResponse(exc)); + + if (testCase.expectsRejection()) { + assertValidationError( + exc.getResponse(), + testCase.expectedStatus(), + testCase.expectedJsonRpcCode(), + testCase.expectedMessageSnippet(), + testCase.expectedId(), + testCase.batchErrorShape() + ); + return; + } + + assertNotNull(exc.getResponse()); + assertTrue(exc.getResponse().getBodyAsStringDecoded().contains("\"jsonrpc\"")); } @Test - void paramsValidation() throws Exception { - var interceptor = interceptor(List.of(), schemaValidation( - Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json"), - Map.of() - )); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"hello"}} + void parsesSchemaValidationConfigFromYaml() throws Exception { + var interceptor = parseInterceptor(""" + schemaValidation: + error: + schema: + type: object + required: + - code + properties: + code: + type: integer + methods: + 'rpc.echo': + params: + location: classpath:/json/rpc/echo-params.schema.json + response: + schema: + type: object + properties: + message: + type: string + 'rpc.health': + response: + location: classpath:/json/rpc/generic-rpc-params.schema.json """); - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - - var exc2 = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}} - """); + JsonRPCSchemaValidation schemaValidation = interceptor.getSchemaValidation(); + assertNotNull(schemaValidation); + assertNotNull(schemaValidation.getErrorValidation()); + assertEquals( + "object", + schemaValidation.getErrorValidation().getSchema().getProperties().get("type") + ); - assertEquals(RETURN, interceptor.handleRequest(exc2)); - assertErrorContains(exc2.getResponse(), 400, "Invalid params for method 'rpc.echo'"); + Map methods = schemaValidation.getMethods().getMethods(); + assertEquals( + "classpath:/json/rpc/echo-params.schema.json", + methods.get("rpc.echo").getParams().getLocation() + ); + assertEquals( + "string", + ((Map) ((Map) methods.get("rpc.echo").getResponse().getSchema().getProperties().get("properties")).get("message")).get("type") + ); + assertEquals( + "classpath:/json/rpc/generic-rpc-params.schema.json", + methods.get("rpc.health").getResponse().getLocation() + ); } - @Test - void paramsValidationUsesExactMethodName() throws Exception { - var interceptor = interceptor(List.of(), schemaValidation( - Map.of("rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json"), - Map.of() - )); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.health","params":{"code":1}} - """); + @ParameterizedTest(name = "{0}") + @MethodSource("invalidConfigCases") + void rejectsInvalidConfigurations(InvalidConfigCase testCase) { + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> interceptor(testCase.config()) + ); - assertEquals(CONTINUE, interceptor.handleRequest(exc)); + assertTrue(containsMessage(exception, testCase.expectedMessageSnippet())); } @Test - void paramsValidationDoesNotMatchDifferentMethodNames() throws Exception { - var interceptor = interceptor(List.of(), schemaValidation( - Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json"), - Map.of() - )); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2","params":{"code":1}} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - } + void batchMaxSizeMustBePositive() { + ConfigurationException exception = assertThrows( + ConfigurationException.class, + () -> new BatchRule().setMaxSize(0) + ); - @Test - void paramsValidationSupportsInlineSchema() throws Exception { - JsonRPCParamValidation params = new JsonRPCParamValidation(); - params.setSchema(inlineSchema(Map.of( - "type", "object", - "required", List.of("message"), - "properties", Map.of( - "message", Map.of("type", "string") + assertTrue(exception.getMessage().contains("batch maxSize must be greater than 0")); + } + + private static Stream requestCases() { + return Stream.of( + requestContinues( + "allow rule wins before later deny", + """ + methods: + - allow: '^rpc\\.health$' + - deny: '^rpc\\..*$' + """, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """ + ), + requestRejects( + "first matching deny rule rejects request", + """ + methods: + - deny: '^rpc\\..*$' + - allow: '^rpc\\.health$' + """, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """, + 403, + ERR_METHOD_NOT_FOUND, + "JSON-RPC method 'rpc.health' is not allowed.", + 1 + ), + requestContinues( + "non-POST requests are ignored", + "", + METHOD_GET, + TEXT_PLAIN, + "not-json" + ), + requestRejects( + "non-JSON content type is rejected", + "", + METHOD_POST, + TEXT_PLAIN, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """, + 415, + ERR_INVALID_REQUEST, + "Content-Type text/plain is not supported. Expected application/json.", + null + ), + requestContinues( + "blank POST bodies are ignored", + "", + METHOD_POST, + APPLICATION_JSON, + " " + ), + requestRejects( + "invalid JSON payload is rejected", + "", + "not-json", + 400, + ERR_INVALID_REQUEST, + "Invalid JSON-RPC payload", + null + ), + requestRejects( + "payload must be an object or batch array", + "", + "1", + 400, + ERR_INVALID_REQUEST, + "JSON-RPC payload must be an object or batch array.", + null + ), + requestRejects( + "method must be textual", + "", + """ + {"jsonrpc":"2.0","id":1,"method":1} + """, + 400, + ERR_INVALID_REQUEST, + "'method' must be a string", + null + ), + requestRejects( + "params must be an object or array", + "", + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":1} + """, + 400, + ERR_INVALID_REQUEST, + "'params' must be array or object", + null + ), + requestRejects( + "batch requests can be disabled", + """ + batch: + enabled: false + """, + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.health"}] + """, + 400, + ERR_INVALID_REQUEST, + "Batch requests are disabled.", + null + ), + requestRejects( + "batch size is limited", + """ + batch: + maxSize: 1 + """, + """ + [ + {"jsonrpc":"2.0","id":1,"method":"rpc.one"}, + {"jsonrpc":"2.0","id":2,"method":"rpc.two"} + ] + """, + 400, + ERR_INVALID_REQUEST, + "Batch request exceeds maxSize of 1.", + null + ), + requestRejects( + "batch requests must not be empty", + "", + "[]", + 400, + ERR_INVALID_REQUEST, + "Batch requests must not be empty.", + null + ), + requestRejects( + "every batch entry must be an object", + "", + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.echo"},1] + """, + 400, + ERR_INVALID_REQUEST, + "Each batch entry must be a JSON-RPC request object.", + null + ), + requestContinues( + "location-based params schema accepts matching payload", + PARAMS_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"hello"}} + """ + ), + requestRejects( + "location-based params schema rejects invalid payload", + PARAMS_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}} + """, + 400, + ERR_INVALID_PARAMS, + "Invalid params for method 'rpc.echo'", + 1 + ), + requestContinues( + "inline params schema accepts matching payload", + PARAMS_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline","params":{"message":"hello"}} + """ + ), + requestRejects( + "inline params schema rejects invalid payload", + PARAMS_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline","params":{}} + """, + 400, + ERR_INVALID_PARAMS, + "Invalid params for method 'rpc.inline'", + 1 + ), + requestContinues( + "params validation uses exact method names", + PARAMS_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2","params":{}} + """ + ), + requestRejects( + "notifications are rejected without a response id when a deny rule matches", + """ + methods: + - deny: '^rpc\\..*$' + - allow: '^rpc\\.health$' + """, + """ + {"jsonrpc":"2.0","method":"rpc.health"} + """, + 403, + ERR_METHOD_NOT_FOUND, + "JSON-RPC method 'rpc.health' is not allowed.", + null ) - ))); - - JsonRPCSchemas methodSchemas = new JsonRPCSchemas(); - methodSchemas.setParams(params); - var interceptor = interceptor(List.of(), schemaValidationForMethods(Map.of("rpc.echo", methodSchemas))); - - 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'"); - } - - @Test - void resultValidation() throws Exception { - var interceptor = interceptor(List.of(), schemaValidation( - Map.of(), - Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json") - )); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - exc.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"result":{"message":"hello"}} - """)); - assertEquals(CONTINUE, interceptor.handleResponse(exc)); - - var exc2 = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc2)); - exc2.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"result":{}} - """)); - assertEquals(RETURN, interceptor.handleResponse(exc2)); - assertErrorContains(exc2.getResponse(), 500, "Invalid result for method 'rpc.echo'"); - } - - @Test - void resultValidationDoesNotMatchDifferentMethodNames() throws Exception { - var interceptor = interceptor(List.of(), schemaValidation( - Map.of(), - Map.of("rpc.echo", "classpath:/json/rpc/echo-params.schema.json") - )); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - exc.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"result":{}} - """)); - assertEquals(CONTINUE, interceptor.handleResponse(exc)); + ); } - @Test - void batchResultValidationUsesRequestIdToResolveMethod() throws Exception { - var interceptor = interceptor(List.of(), schemaValidation( - Map.of(), - Map.of( - "rpc.echo", "classpath:/json/rpc/echo-params.schema.json", - "rpc.health", "classpath:/json/rpc/generic-rpc-params.schema.json" + private static Stream responseCases() { + return Stream.of( + responseContinues( + "location-based response schema accepts matching result", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{"message":"hello"}} + """ + ), + responseRejects( + "location-based response schema rejects invalid result", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """, + "Invalid result for method 'rpc.echo'", + 1 + ), + responseContinues( + "inline response schema accepts matching result", + RESPONSE_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{"ok":true}} + """ + ), + responseRejects( + "inline response schema rejects invalid result", + RESPONSE_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """, + "Invalid result for method 'rpc.inline'", + 1 + ), + responseContinues( + "response validation uses exact method names", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """ + ), + responseRejects( + "single responses must be objects", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + "[]", + "JSON-RPC response must be an object.", + null + ), + responseRejects( + "unknown response ids are rejected", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":9,"result":{"message":"hello"}} + """, + "JSON-RPC response id '9' does not match any request.", + 9 + ), + responseRejects( + "batch response validation resolves methods by request id", + BATCH_RESPONSE_CONFIG, + """ + [ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"}, + {"jsonrpc":"2.0","id":2,"method":"rpc.health"} + ] + """, + """ + [ + {"jsonrpc":"2.0","id":2,"result":{"code":1}}, + {"jsonrpc":"2.0","id":1,"result":{}} + ] + """, + "Invalid result for method 'rpc.echo'", + 1 + ), + responseRejects( + "batch responses must not be empty", + BATCH_RESPONSE_CONFIG, + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.echo"}] + """, + "[]", + "Batch responses must not be empty.", + null + ), + responseRejects( + "every batch response entry must be an object", + BATCH_RESPONSE_CONFIG, + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.echo"}] + """, + "[1]", + "Each batch entry must be a JSON-RPC response object.", + null + ), + responseContinues( + "notification responses are ignored when only success schemas are configured", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """ + ), + responseContinues( + "location-based error schema accepts matching error payload", + ERROR_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} + """ + ), + responseRejects( + "location-based error schema rejects invalid error payload", + ERROR_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """, + "Invalid error response", + 1 + ), + responseContinues( + "inline error schema accepts matching error payload", + ERROR_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} + """ + ), + responseRejects( + "inline error schema rejects invalid error payload", + ERROR_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """, + "Invalid error response", + 1 + ), + responseRejects( + "error validation also works without prior request context", + ERROR_INLINE_CONFIG, + null, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """, + "Invalid error response", + 1 ) - )); - - var exc = exchange(""" - [ - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"}, - {"jsonrpc":"2.0","id":2,"method":"rpc.health"} - ] - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - exc.setResponse(jsonResponse(""" - [ - {"jsonrpc":"2.0","id":2,"result":{"code":1}}, - {"jsonrpc":"2.0","id":1,"result":{}} - ] - """)); - assertEquals(RETURN, interceptor.handleResponse(exc)); - assertBatchErrorContains(exc.getResponse(), 500, "Invalid result for method 'rpc.echo'"); - assertBatchErrorId(exc.getResponse(), 1); - } - - @Test - void errorValidation() throws Exception { - JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); - JsonRPCErrorValidation errorValidation = new JsonRPCErrorValidation(); - errorValidation.setLocation("classpath:/json/rpc/error.schema.json"); - schemaValidation.setErrorValidation(errorValidation); - - var interceptor = interceptor(List.of(), schemaValidation); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - exc.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} - """)); - assertEquals(CONTINUE, interceptor.handleResponse(exc)); - - var exc2 = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc2)); - exc2.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} - """)); - assertEquals(RETURN, interceptor.handleResponse(exc2)); - assertErrorContains(exc2.getResponse(), 500, "Invalid error response"); + ); } - @Test - void errorValidationSupportsInlineSchema() throws Exception { - JsonRPCErrorValidation errorValidation = new JsonRPCErrorValidation(); - errorValidation.setSchema(inlineSchema(Map.of( - "type", "object", - "required", List.of("code", "message", "data"), - "properties", Map.of( - "code", Map.of("type", "integer"), - "message", Map.of("type", "string"), - "data", Map.of( - "type", "object", - "required", List.of("reason"), - "properties", Map.of( - "reason", Map.of("type", "string") - ) - ) + private static Stream invalidConfigCases() { + return Stream.of( + invalidConfig( + "invalid regex patterns are rejected", + """ + methods: + - allow: '[*' + """, + "Invalid regex pattern: [*" + ), + invalidConfig( + "schema definitions must not use location and inline schema together", + """ + schemaValidation: + methods: + 'rpc.echo': + params: + location: classpath:/json/rpc/echo-params.schema.json + schema: + type: object + """, + "must define exactly one of 'location' or 'schema'" + ), + invalidConfig( + "inline schemas must not be empty", + """ + schemaValidation: + methods: + 'rpc.echo': + response: + schema: {} + """, + "must not be empty" + ), + invalidConfig( + "error schemas also require location or inline schema", + """ + schemaValidation: + error: {} + """, + "must define exactly one of 'location' or 'schema'" ) - ))); - - JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); - schemaValidation.setErrorValidation(errorValidation); - - var interceptor = interceptor(List.of(), schemaValidation); - - var exc = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc)); - exc.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} - """)); - assertEquals(CONTINUE, interceptor.handleResponse(exc)); - - var exc2 = exchange(""" - {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} - """); - - assertEquals(CONTINUE, interceptor.handleRequest(exc2)); - exc2.setResponse(jsonResponse(""" - {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} - """)); - assertEquals(RETURN, interceptor.handleResponse(exc2)); - assertErrorContains(exc2.getResponse(), 500, "Invalid error response"); - } - - @Test - void schemaValidationSupportsMethodsWithLocationAndInlineSchema() throws Exception { - JsonNode node = YAML.readTree(""" - jsonRPCProtection: - schemaValidation: - error: - location: error.json - methods: - 'rpc.echo': - params: - location: echo-params.schema.json - response: - schema: - type: object - properties: - foo: - type: string - get: - params: - location: echo-params.schema.json - """); - - var grammar = new GrammarAutoGenerated(); - var registry = new BeanRegistryImplementation(grammar); - var interceptor = GenericYamlParser.createAndPopulateNode( - new ParsingContext<>("jsonRPCProtection", registry, grammar, node, "$.jsonRPCProtection", null), - JsonRPCProtectionInterceptor.class, - node.get("jsonRPCProtection") - ); - - JsonRPCSchemaValidation schemaValidation = interceptor.getSchemaValidation(); - assertNotNull(schemaValidation); - assertEquals("error.json", schemaValidation.getErrorValidation().getLocation()); - - Map methods = schemaValidation.getMethods().getMethods(); - assertEquals("echo-params.schema.json", methods.get("rpc.echo").getParams().getLocation()); - assertEquals("echo-params.schema.json", methods.get("get").getParams().getLocation()); - - Map responseSchema = methods.get("rpc.echo").getResponse().getSchema().getProperties(); - assertEquals("object", responseSchema.get("type")); - assertEquals( - "string", - ((Map) ((Map) responseSchema.get("properties")).get("foo")).get("type") ); } - @Test - void schemaValidationSupportsInlineErrorSchema() throws Exception { - JsonNode node = YAML.readTree(""" - jsonRPCProtection: - schemaValidation: - error: - schema: - type: object - required: - - code - properties: - code: - type: integer - """); - - var grammar = new GrammarAutoGenerated(); - var registry = new BeanRegistryImplementation(grammar); - var interceptor = GenericYamlParser.createAndPopulateNode( - new ParsingContext<>("jsonRPCProtection", registry, grammar, node, "$.jsonRPCProtection", null), - JsonRPCProtectionInterceptor.class, - node.get("jsonRPCProtection") - ); - - JsonRPCSchemaValidation schemaValidation = interceptor.getSchemaValidation(); - assertNotNull(schemaValidation); - assertNotNull(schemaValidation.getErrorValidation()); - assertNull(schemaValidation.getErrorValidation().getLocation()); - assertEquals( - "object", - schemaValidation.getErrorValidation().getSchema().getProperties().get("type") + private static RequestCase requestContinues(String name, String config, String body) { + return requestContinues(name, config, METHOD_POST, APPLICATION_JSON, body); + } + + private static RequestCase requestContinues(String name, String config, String method, String contentType, String body) { + return new RequestCase(name, config, method, contentType, body, CONTINUE, null, null, null, null, false); + } + + private static RequestCase requestRejects(String name, + String config, + String body, + int expectedStatus, + int expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId) { + return requestRejects(name, config, METHOD_POST, APPLICATION_JSON, body, expectedStatus, expectedJsonRpcCode, expectedMessageSnippet, expectedId); + } + + private static RequestCase requestRejects(String name, + String config, + String method, + String contentType, + String body, + int expectedStatus, + int expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId) { + return new RequestCase( + name, + config, + method, + contentType, + body, + RETURN, + expectedStatus, + expectedJsonRpcCode, + expectedMessageSnippet, + expectedId, + payloadIsBatch(body) ); } - private JsonRPCProtectionInterceptor interceptor(List rules) { - return interceptor(rules, new JsonRPCSchemaValidation(), new BatchRule()); - } - - private JsonRPCProtectionInterceptor interceptor(List rules, BatchRule batchRule) { - return interceptor(rules, new JsonRPCSchemaValidation(), batchRule); + private static ResponseCase responseContinues(String name, String config, String requestBody, String responseBody) { + return new ResponseCase(name, config, requestBody, responseBody, CONTINUE, null, null, null, null, false); + } + + private static ResponseCase responseRejects(String name, + String config, + String requestBody, + String responseBody, + String expectedMessageSnippet, + Object expectedId) { + return new ResponseCase( + name, + config, + requestBody, + responseBody, + RETURN, + 500, + ERR_INTERNAL_ERROR, + expectedMessageSnippet, + expectedId, + payloadIsBatch(requestBody != null ? requestBody : responseBody) + ); } - private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCSchemaValidation schemaValidation) { - return interceptor(rules, schemaValidation, new BatchRule()); + private static InvalidConfigCase invalidConfig(String name, String config, String expectedMessageSnippet) { + return new InvalidConfigCase(name, config, expectedMessageSnippet); } - private JsonRPCProtectionInterceptor interceptor(List rules, JsonRPCSchemaValidation schemaValidation, BatchRule batchRule) { - var interceptor = new JsonRPCProtectionInterceptor(); - interceptor.setBatch(batchRule); - interceptor.setMethods(rules); - interceptor.setSchemaValidation(schemaValidation); + private JsonRPCProtectionInterceptor interceptor(String config) throws Exception { + JsonRPCProtectionInterceptor interceptor = parseInterceptor(config); interceptor.init(new DefaultRouter()); return interceptor; } - private JsonRPCSchemaValidation schemaValidation(Map paramLocations, Map responseLocations) { - Map methods = new LinkedHashMap<>(); - - paramLocations.forEach((method, location) -> - methodSchemas(methods, method).setParams(withLocation(new JsonRPCParamValidation(), location)) - ); - responseLocations.forEach((method, location) -> - methodSchemas(methods, method).setResponse(withLocation(new JsonRPCResponseValidation(), location)) + private JsonRPCProtectionInterceptor parseInterceptor(String config) throws Exception { + JsonNode node = YAML.readTree(wrapConfig(config)); + var grammar = new GrammarAutoGenerated(); + var registry = new BeanRegistryImplementation(grammar); + return GenericYamlParser.createAndPopulateNode( + new ParsingContext<>("jsonRPCProtection", registry, grammar, node, "$.jsonRPCProtection", null), + JsonRPCProtectionInterceptor.class, + node.get("jsonRPCProtection") ); - - return schemaValidationForMethods(methods); - } - - private JsonRPCSchemaValidation schemaValidationForMethods(Map methods) { - JsonRPCMethodDefinitions methodDefinitions = new JsonRPCMethodDefinitions(); - methodDefinitions.setMethods(methods); - - JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); - schemaValidation.setMethods(methodDefinitions); - return schemaValidation; } - private JsonRPCSchemas methodSchemas(Map methods, String methodName) { - return methods.computeIfAbsent(methodName, ignored -> new JsonRPCSchemas()); + private String wrapConfig(String config) { + if (config == null || config.isBlank()) { + return "jsonRPCProtection: {}\n"; + } + return "jsonRPCProtection:\n" + config.stripIndent().indent(2); } - private T withLocation(T validation, String location) { - validation.setLocation(location); - return validation; - } + private Exchange exchange(String method, String contentType, String body) { + Request.Builder builder = new Request.Builder() + .method(method) + .uri("/"); - private JsonRPCInlineSchema inlineSchema(Map schemaProperties) { - JsonRPCInlineSchema schema = new JsonRPCInlineSchema(); - schema.setProperties(schemaProperties); - return schema; - } + if (contentType != null) { + builder.contentType(contentType); + } + if (body != null) { + builder.body(body); + } - private com.predic8.membrane.core.exchange.Exchange exchange(String body) throws Exception { - return Request.post("/") - .contentType(APPLICATION_JSON) - .body(body) - .buildExchange(); + return builder.buildExchange(); } private Response jsonResponse(String body) { @@ -505,48 +804,95 @@ private Response jsonResponse(String body) { .build(); } - private Allow allow(String pattern) { - Allow allow = new Allow(); - allow.setPattern(pattern); - return allow; - } - - private Deny deny(String pattern) { - Deny deny = new Deny(); - deny.setPattern(pattern); - 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 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()); - assertTrue(node.isArray()); - assertEquals(1, node.size()); - assertEquals(message, node.get(0).path("error").path("message").asText()); - } - - private void assertBatchErrorContains(Response response, int statusCode, String messagePart) throws Exception { - assertEquals(statusCode, response.getStatusCode()); - JsonNode node = OM.readTree(response.getBodyAsStringDecoded()); - assertTrue(node.isArray()); - assertEquals(1, node.size()); - assertTrue(node.get(0).path("error").path("message").asText().contains(messagePart)); - } - - private void assertBatchErrorId(Response response, long id) throws Exception { - JsonNode node = OM.readTree(response.getBodyAsStringDecoded()); - assertEquals(id, node.get(0).path("id").asLong()); + private void assertValidationError(Response response, + int expectedStatus, + int expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId, + boolean batchShape) throws Exception { + assertNotNull(response); + assertEquals(expectedStatus, response.getStatusCode()); + + JsonNode root = OM.readTree(response.getBodyAsStringDecoded()); + if (batchShape) { + assertTrue(root.isArray()); + assertEquals(1, root.size()); + root = root.get(0); + } else { + assertTrue(root.isObject()); + } + + assertEquals("2.0", root.path("jsonrpc").asText()); + assertEquals(expectedJsonRpcCode, root.path("error").path("code").asInt()); + assertTrue(root.path("error").path("message").asText().contains(expectedMessageSnippet)); + + JsonNode idNode = root.get("id"); + assertNotNull(idNode); + if (expectedId == null) { + assertTrue(idNode.isNull()); + } else { + assertEquals(OM.valueToTree(expectedId), idNode); + } + } + + private static boolean payloadIsBatch(String payload) { + return payload != null && payload.trim().startsWith("["); + } + + private boolean containsMessage(Throwable throwable, String expectedMessageSnippet) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(expectedMessageSnippet)) { + return true; + } + } + return false; + } + + private record RequestCase(String name, + String config, + String method, + String contentType, + String body, + Outcome expectedOutcome, + Integer expectedStatus, + Integer expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId, + boolean batchErrorShape) { + private boolean expectsRejection() { + return expectedOutcome == RETURN; + } + + @Override + public String toString() { + return name; + } + } + + private record ResponseCase(String name, + String config, + String requestBody, + String responseBody, + Outcome expectedOutcome, + Integer expectedStatus, + Integer expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId, + boolean batchErrorShape) { + private boolean expectsRejection() { + return expectedOutcome == RETURN; + } + + @Override + public String toString() { + return name; + } + } + + private record InvalidConfigCase(String name, String config, String expectedMessageSnippet) { + @Override + public String toString() { + return name; + } } } diff --git a/core/src/test/resources/json/rpc/echo-result.schema.json b/core/src/test/resources/json/rpc/echo-result.schema.json new file mode 100644 index 0000000000..9922c4c7f9 --- /dev/null +++ b/core/src/test/resources/json/rpc/echo-result.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } +} From edc038e9d26571e15a53634d896dc28c0cede1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Mon, 8 Jun 2026 08:03:31 +0200 Subject: [PATCH 30/38] Rename JSON-RPC schema-related classes for clarity, refactor usage in validation logic and tests. --- ...Schemas.java => JsonRPCMethodSchemas.java} | 2 +- ...ns.java => JsonRPCMethodsDefinitions.java} | 8 +- .../json/rpc/JsonRPCSchemaValidation.java | 153 ++++-------------- .../rpc/JsonRPCProtectionInterceptorTest.java | 2 +- 4 files changed, 40 insertions(+), 125 deletions(-) rename core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/{JsonRPCSchemas.java => JsonRPCMethodSchemas.java} (98%) rename core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/{JsonRPCMethodDefinitions.java => JsonRPCMethodsDefinitions.java} (85%) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodSchemas.java similarity index 98% rename from core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java rename to core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodSchemas.java index 7abf95651a..d4f5490545 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodSchemas.java @@ -25,7 +25,7 @@ * to validate successful upstream responses.

*/ @MCElement(name = "method", component = false, id = "json-rpc-method-schema") -public class JsonRPCSchemas { +public class JsonRPCMethodSchemas { private JsonRPCParamValidation paramValidation; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodsDefinitions.java similarity index 85% rename from core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java rename to core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodsDefinitions.java index d7461b67a3..a967867ddc 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodsDefinitions.java @@ -27,9 +27,9 @@ *

In YAML, the entries are written as a map under schemaValidation.methods.

*/ @MCElement(name = "methods", component = false, id = "json-rpc-method-definitions") -public class JsonRPCMethodDefinitions { +public class JsonRPCMethodsDefinitions { - private final Map methods = new LinkedHashMap<>(); + private final Map methods = new LinkedHashMap<>(); /** * @description @@ -40,13 +40,13 @@ public class JsonRPCMethodDefinitions { * @example "rpc.echo": { params: { location: "classpath:/json/rpc/echo-params.schema.json" } } */ @MCOtherAttributes - public void setMethods(Map methods) { + public void setMethods(Map methods) { if (methods != null) { this.methods.putAll(methods); } } - public Map getMethods() { + public Map getMethods() { return methods; } } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java index f0daed911b..931ca02f4d 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java @@ -28,7 +28,6 @@ import com.predic8.membrane.core.util.URIFactory; import java.io.IOException; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -37,10 +36,10 @@ 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 - *

Configures JSON Schema validation for JSON-RPC request params, successful responses, + * @description

Configures JSON Schema validation for JSON-RPC request params, successful responses, * and error responses.

* *

Under methods, each key is matched against the exact JSON-RPC @@ -57,7 +56,7 @@ public class JsonRPCSchemaValidation { private static final ObjectMapper OM = new ObjectMapper(); private JsonRPCErrorValidation errorValidation; - private JsonRPCMethodDefinitions methods = new JsonRPCMethodDefinitions(); + private JsonRPCMethodsDefinitions methods = new JsonRPCMethodsDefinitions(); private Schema errorSchema; private Map paramSchemas = Map.of(); private Map responseSchemas = Map.of(); @@ -67,8 +66,7 @@ public JsonRPCErrorValidation getErrorValidation() { } /** - * @description - *

Configures a JSON Schema for validating JSON-RPC error objects.

+ * @description

Configures a JSON Schema for validating JSON-RPC error objects.

* *

This applies to upstream responses that contain an error member * instead of a successful result.

@@ -79,18 +77,17 @@ public void setErrorValidation(JsonRPCErrorValidation errorValidation) { } /** - * @description - *

Configures per-method JSON Schema validation rules.

+ * @description

Configures per-method JSON Schema validation rules.

* *

The keys in this map are exact JSON-RPC method names such as * rpc.echo.

*/ @MCChildElement(order = 2) - public void setMethods(JsonRPCMethodDefinitions methods) { - this.methods = methods == null ? new JsonRPCMethodDefinitions() : methods; + public void setMethods(JsonRPCMethodsDefinitions methods) { + this.methods = methods == null ? new JsonRPCMethodsDefinitions() : methods; } - public JsonRPCMethodDefinitions getMethods() { + public JsonRPCMethodsDefinitions getMethods() { return methods; } @@ -105,34 +102,20 @@ public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocati Map resolvedParamSchemas = new LinkedHashMap<>(); Map resolvedResponseSchemas = new LinkedHashMap<>(); - for (Map.Entry entry : methods.getMethods().entrySet()) { + resolveJsonRpcSchemas(resolver, uriFactory, beanBaseLocation, resolvedParamSchemas, registry, resolvedResponseSchemas); + + paramSchemas = unmodifiableMap(resolvedParamSchemas); + responseSchemas = unmodifiableMap(resolvedResponseSchemas); + } + + private void resolveJsonRpcSchemas(Resolver resolver, URIFactory uriFactory, String beanBaseLocation, Map resolvedParamSchemas, SchemaRegistry registry, Map resolvedResponseSchemas) { + for (Map.Entry entry : methods.getMethods().entrySet()) { String methodName = validateMethodName(entry.getKey()); - JsonRPCSchemas definitions = requireDefinitions(methodName, entry.getValue()); - - resolveMethodSchema( - resolvedParamSchemas, - registry, - methodName, - "params", - definitions.getParams(), - resolver, - uriFactory, - beanBaseLocation - ); - resolveMethodSchema( - resolvedResponseSchemas, - registry, - methodName, - "response", - definitions.getResponse(), - resolver, - uriFactory, - beanBaseLocation - ); - } + JsonRPCMethodSchemas definitions = requireDefinitions(methodName, entry.getValue()); - paramSchemas = Collections.unmodifiableMap(resolvedParamSchemas); - responseSchemas = Collections.unmodifiableMap(resolvedResponseSchemas); + resolveMethodSchema(resolvedParamSchemas, registry, methodName, "params", definitions.getParams(), resolver, uriFactory, beanBaseLocation); + resolveMethodSchema(resolvedResponseSchemas, registry, methodName, "response", definitions.getResponse(), resolver, uriFactory, beanBaseLocation); + } } public boolean hasRequestValidation() { @@ -177,110 +160,51 @@ private Schema resolveErrorSchema(SchemaRegistry registry, Resolver resolver, UR if (errorValidation == null) { return null; } - return resolveConfiguredSchema( - registry, - "", - "error", - errorValidation.getLocation(), - errorValidation.getSchema(), - resolver, - uriFactory, - beanBaseLocation - ); + return resolveConfiguredSchema(registry, "", "error", errorValidation.getLocation(), errorValidation.getSchema(), resolver, uriFactory, beanBaseLocation); } - private void resolveMethodSchema(Map target, - SchemaRegistry registry, - String methodName, - String schemaRole, - SchemaSetter definition, - Resolver resolver, - URIFactory uriFactory, - String beanBaseLocation) { + private void resolveMethodSchema(Map target, SchemaRegistry registry, String methodName, String schemaRole, SchemaSetter definition, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { if (definition == null) { return; } - Schema schema = resolveConfiguredSchema( - registry, - methodName, - schemaRole, - definition.getLocation(), - definition.getSchema(), - resolver, - uriFactory, - beanBaseLocation - ); + Schema schema = resolveConfiguredSchema(registry, methodName, schemaRole, definition.getLocation(), definition.getSchema(), resolver, uriFactory, beanBaseLocation); if (schema != null) { target.put(methodName, schema); } } - private Schema resolveConfiguredSchema(SchemaRegistry registry, - String methodName, - String schemaRole, - String configuredLocation, - JsonRPCInlineSchema inlineSchema, - Resolver resolver, - URIFactory uriFactory, - String beanBaseLocation) { + private Schema resolveConfiguredSchema(SchemaRegistry registry, String methodName, String schemaRole, String configuredLocation, JsonRPCInlineSchema inlineSchema, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { String location = normalizeLocation(configuredLocation); boolean hasLocation = location != null; boolean hasInlineSchema = inlineSchema != null; if (hasLocation == hasInlineSchema) { - throw new ConfigurationException( - "JSON-RPC %s schema for method '%s' must define exactly one of 'location' or 'schema'." - .formatted(schemaRole, methodName) - ); + throw new ConfigurationException("JSON-RPC %s schema for method '%s' must define exactly one of 'location' or 'schema'.".formatted(schemaRole, methodName)); } if (hasLocation) { - return loadSchema( - "JSON-RPC %s schema for method '%s'".formatted(schemaRole, methodName), - registry, - SchemaLocation.of(combine(uriFactory, beanBaseLocation, location)), - resolver, - location - ); + return loadSchema("JSON-RPC %s schema for method '%s'".formatted(schemaRole, methodName), registry, SchemaLocation.of(combine(uriFactory, beanBaseLocation, location)), resolver, location); } return loadInlineSchema(registry, methodName, schemaRole, inlineSchema, uriFactory, beanBaseLocation); } - private Schema loadInlineSchema(SchemaRegistry registry, - String methodName, - String schemaRole, - JsonRPCInlineSchema inlineSchema, - URIFactory uriFactory, - String beanBaseLocation) { + private Schema loadInlineSchema(SchemaRegistry registry, String methodName, String schemaRole, JsonRPCInlineSchema inlineSchema, URIFactory uriFactory, String beanBaseLocation) { if (inlineSchema == null || inlineSchema.getProperties().isEmpty()) { - throw new ConfigurationException( - "JSON-RPC %s schema for method '%s' must not be empty." - .formatted(schemaRole, methodName) - ); + throw new ConfigurationException("JSON-RPC %s schema for method '%s' must not be empty.".formatted(schemaRole, methodName)); } try { - Schema schema = registry.getSchema( - SchemaLocation.of(createInlineSchemaLocation(methodName, schemaRole, uriFactory, beanBaseLocation)), - OM.valueToTree(inlineSchema.getProperties()) - ); + Schema schema = registry.getSchema(SchemaLocation.of(createInlineSchemaLocation(methodName, schemaRole, uriFactory, beanBaseLocation)), OM.valueToTree(inlineSchema.getProperties())); schema.initializeValidators(); return schema; } catch (RuntimeException e) { - throw new ConfigurationException( - "Cannot create inline JSON-RPC %s schema for method '%s'." - .formatted(schemaRole, methodName), - e - ); + throw new ConfigurationException("Cannot create inline JSON-RPC %s schema for method '%s'.".formatted(schemaRole, methodName), e); } } private String createInlineSchemaLocation(String methodName, String schemaRole, URIFactory uriFactory, String beanBaseLocation) { - String syntheticFile = "__jsonrpc_%s_%s.schema.json".formatted( - sanitize(methodName), - sanitize(schemaRole) - ); + String syntheticFile = "__jsonrpc_%s_%s.schema.json".formatted(sanitize(methodName), sanitize(schemaRole)); if (beanBaseLocation == null || beanBaseLocation.isBlank()) { return "membrane:%s".formatted(syntheticFile); } @@ -291,11 +215,7 @@ private static String sanitize(String value) { return value.replaceAll("[^A-Za-z0-9._-]", "_"); } - private Schema loadSchema(String description, - SchemaRegistry registry, - SchemaLocation schemaLocation, - Resolver resolver, - String configuredLocation) { + private Schema loadSchema(String description, SchemaRegistry registry, SchemaLocation schemaLocation, Resolver resolver, String configuredLocation) { try (var in = resolver.resolve(schemaLocation.getAbsoluteIri().toString())) { Schema schema = registry.getSchema(schemaLocation, in, getSchemaFormat(schemaLocation.getAbsoluteIri().toString())); schema.initializeValidators(); @@ -308,12 +228,7 @@ private Schema loadSchema(String description, } 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()) - ); + 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) { @@ -321,7 +236,7 @@ private static InputFormat getSchemaFormat(String schemaLocation) { return normalized.endsWith(".yaml") || normalized.endsWith(".yml") ? YAML : JSON; } - private static JsonRPCSchemas requireDefinitions(String methodName, JsonRPCSchemas definitions) { + private static JsonRPCMethodSchemas requireDefinitions(String methodName, JsonRPCMethodSchemas definitions) { if (definitions == null) { throw new ConfigurationException("JSON-RPC schema validation entry for method '%s' must not be null.".formatted(methodName)); } 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 ce476aff21..e9be96361a 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 @@ -218,7 +218,7 @@ void parsesSchemaValidationConfigFromYaml() throws Exception { schemaValidation.getErrorValidation().getSchema().getProperties().get("type") ); - Map methods = schemaValidation.getMethods().getMethods(); + Map methods = schemaValidation.getMethods().getMethods(); assertEquals( "classpath:/json/rpc/echo-params.schema.json", methods.get("rpc.echo").getParams().getLocation() From 941bb1158ae287816aed011e1f7bc82eeb5e5674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Mon, 8 Jun 2026 08:42:41 +0200 Subject: [PATCH 31/38] Refactor JSONPath segment processing logic, enhance error rendering with new test cases, and improve conversion handling in ScalarValueConverter. --- .../membrane/annot/yaml/ParsingContext.java | 4 ++ .../yaml/error/LineYamlErrorRenderer.java | 38 +++++++++++++++---- .../parsing/binding/ScalarValueConverter.java | 5 +++ .../membrane/annot/YAMLParsingErrorTest.java | 24 ++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java index 483842acc2..7d181e4974 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java @@ -88,6 +88,10 @@ public String getPath() { return path; } + /** + * Encodes a property name so it can be appended to a Jayway JSONPath. + * Simple identifiers stay in dot notation, while keys with dots or quotes switch to bracket notation. + */ private static String toJsonPathProperty(String property) { if (property.matches("[A-Za-z_][A-Za-z0-9_]*")) { return "." + property; diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java index 6edc63abc0..754993e395 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java @@ -286,27 +286,49 @@ private static String getParentPath(String jsonPath) { return jsonPath.substring(0, findLastSegmentStart(jsonPath)); } + /** + * Returns the last part of a JSONPath created by {@link ParsingContext}. + * Examples: + * `$.api.methods` -> `methods` + * `$.api.methods['rpc.echo']` -> `rpc.echo` + * `$.api.methods[0]` -> `0` + */ private static String getLastSegment(String jsonPath) { - int start = findLastSegmentStart(jsonPath); - String segment = jsonPath.substring(start); + String segment = jsonPath.substring(findLastSegmentStart(jsonPath)); - if (segment.startsWith(".")) { + if (isPropertySegment(segment)) { return segment.substring(1); } - if (segment.startsWith("['") && segment.endsWith("']")) { - return segment.substring(2, segment.length() - 2) - .replace("\\'", "'") - .replace("\\\\", "\\"); + if (isQuotedPropertySegment(segment)) { + return decodeQuotedPropertySegment(segment); } - if (segment.startsWith("[") && segment.endsWith("]")) { + if (isArrayIndexSegment(segment)) { return segment.substring(1, segment.length() - 1); } throw new IllegalArgumentException("Unsupported JSONPath segment: " + segment); } + private static boolean isPropertySegment(String segment) { + return segment.startsWith("."); + } + + private static boolean isQuotedPropertySegment(String segment) { + return segment.startsWith("['") && segment.endsWith("']"); + } + + private static String decodeQuotedPropertySegment(String segment) { + return segment.substring(2, segment.length() - 2) + .replace("\\'", "'") + .replace("\\\\", "\\"); + } + + private static boolean isArrayIndexSegment(String segment) { + return segment.startsWith("[") && segment.endsWith("]"); + } + private static int findLastSegmentStart(String jsonPath) { int lastBracket = jsonPath.lastIndexOf('['); int lastDot = jsonPath.lastIndexOf('.'); diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java index 73afaa101f..36615a333d 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java @@ -100,6 +100,11 @@ private Object coerceNonTextual(ParsingContext ctx, Method setter, JsonNode n throw unsupported(wanted, key, node); } + /** + * Converts one value from an {@code @MCOtherAttributes} map. + * Plain scalar values stay plain values. If the map stores typed child objects, + * they are bound as children of the current local element. + */ private Object convertAnySetterValue(ParsingContext ctx, Method setter, JsonNode node, String key) { Class valueType = getMapValueType(setter); if (valueType == null || valueType == Object.class) { diff --git a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java index db56dabcb7..5ee2c797db 100644 --- a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java +++ b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java @@ -431,6 +431,30 @@ void otherAttributesKeyWithDotWrongFieldRendersErrorReport() throws Exception { } } + @Test + void otherAttributesKeyWithQuoteWrongFieldRendersErrorReport() throws Exception { + var result = compileMethodMapSources(); + + try { + parseYAML(result, """ + api: + methods: + "rpc'echo": + wrong: 1 + """); + fail(); + } catch (RuntimeException e) { + var c = getCause(e); + var pc = c.getParsingContext(); + assertEquals("$.api.methods['rpc\\'echo']", pc.getPath()); + assertEquals("wrong", pc.getKey()); + + String report = c.getFormattedReport(); + assertTrue(report.contains("rpc'echo")); + assertTrue(report.contains("wrong")); + } + } + @Test void otherAttributesMapValueUsesLocalContextForChildren() throws Exception { var result = compileMethodMapSources(); From 742b3677c16623900d2fd85b50fc414cdbc3797d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Mon, 8 Jun 2026 08:50:55 +0200 Subject: [PATCH 32/38] Improve documentation for JSONPath property encoding and `@MCOtherAttributes` value conversion --- .../com/predic8/membrane/annot/yaml/ParsingContext.java | 7 +++++-- .../annot/yaml/parsing/binding/ScalarValueConverter.java | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java index 7d181e4974..791ed2df82 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java @@ -89,8 +89,11 @@ public String getPath() { } /** - * Encodes a property name so it can be appended to a Jayway JSONPath. - * Simple identifiers stay in dot notation, while keys with dots or quotes switch to bracket notation. + * Appends a property name to a Jayway JSONPath. + * `foo` becomes `.foo`. + * `rpc.echo` becomes `['rpc.echo']`. + * The `[]` form is needed when the property name itself contains characters + * such as `.` or `'` that JSONPath would otherwise interpret as syntax. */ private static String toJsonPathProperty(String property) { if (property.matches("[A-Za-z_][A-Za-z0-9_]*")) { diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java index 36615a333d..bffcf2c1b7 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java @@ -101,9 +101,10 @@ private Object coerceNonTextual(ParsingContext ctx, Method setter, JsonNode n } /** - * Converts one value from an {@code @MCOtherAttributes} map. - * Plain scalar values stay plain values. If the map stores typed child objects, - * they are bound as children of the current local element. + * Converts the value of one entry from an {@code @MCOtherAttributes} map. + * Example: for `methods: { 'rpc.echo': { params: ... } }` the key is `rpc.echo`. + * The nested object is then bound as the configured Java type for the map value, + * not left as a raw map. Plain scalar values such as `timeout: 5` stay plain values. */ private Object convertAnySetterValue(ParsingContext ctx, Method setter, JsonNode node, String key) { Class valueType = getMapValueType(setter); From 0d3a074b16fbaf2a973e256e3961757a1aac254a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Tue, 9 Jun 2026 09:49:35 +0200 Subject: [PATCH 33/38] Fix formatting in JSON-RPC-Protection tutorial configuration --- distribution/tutorials/json/30-JSON-RPC-Protection.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml index 50dd61fcbd..44ff22956d 100644 --- a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml +++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml @@ -48,7 +48,7 @@ api: maxSize: 2 # At most two calls per batch methods: - allow: '^rpc\.(health|echo)$' - - deny: '.*' # Default deny for everything else + - deny: '.*' # deny for everything else schemaValidation: methods: 'rpc.echo': From c917dd16ccb3e1c3982be339f3d9ae3beec863ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Tue, 9 Jun 2026 11:05:46 +0200 Subject: [PATCH 34/38] Restructure JSON-RPC tutorial: adjust file hierarchy, refine documentation, and add Docker run scripts for examples --- distribution/tutorials/README.md | 2 +- distribution/tutorials/json/README.md | 3 --- .../json-rpc}/30-JSON-RPC-Protection.yaml | 0 .../json-rpc}/echo-params.schema.json | 0 .../json-rpc}/echo-result.schema.json | 0 .../tutorials/security/json-rpc/membrane.cmd | 24 +++++++++++++++++++ .../tutorials/security/json-rpc/membrane.sh | 21 ++++++++++++++++ .../security/json-rpc/run-docker.cmd | 15 ++++++++++++ .../tutorials/security/json-rpc/run-docker.sh | 14 +++++++++++ 9 files changed, 75 insertions(+), 4 deletions(-) rename distribution/tutorials/{json => security/json-rpc}/30-JSON-RPC-Protection.yaml (100%) rename distribution/tutorials/{json => security/json-rpc}/echo-params.schema.json (100%) rename distribution/tutorials/{json => security/json-rpc}/echo-result.schema.json (100%) create mode 100644 distribution/tutorials/security/json-rpc/membrane.cmd create mode 100755 distribution/tutorials/security/json-rpc/membrane.sh create mode 100644 distribution/tutorials/security/json-rpc/run-docker.cmd create mode 100755 distribution/tutorials/security/json-rpc/run-docker.sh diff --git a/distribution/tutorials/README.md b/distribution/tutorials/README.md index 2f76fdc3bf..06fc21b22d 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, how to use JsonPath to extract information or compute values, and how to protect JSON-RPC endpoints. +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. ## [XML](xml) diff --git a/distribution/tutorials/json/README.md b/distribution/tutorials/json/README.md index 2186d50737..b5d142588c 100644 --- a/distribution/tutorials/json/README.md +++ b/distribution/tutorials/json/README.md @@ -4,8 +4,5 @@ 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. diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection.yaml similarity index 100% rename from distribution/tutorials/json/30-JSON-RPC-Protection.yaml rename to distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection.yaml diff --git a/distribution/tutorials/json/echo-params.schema.json b/distribution/tutorials/security/json-rpc/echo-params.schema.json similarity index 100% rename from distribution/tutorials/json/echo-params.schema.json rename to distribution/tutorials/security/json-rpc/echo-params.schema.json diff --git a/distribution/tutorials/json/echo-result.schema.json b/distribution/tutorials/security/json-rpc/echo-result.schema.json similarity index 100% rename from distribution/tutorials/json/echo-result.schema.json rename to distribution/tutorials/security/json-rpc/echo-result.schema.json diff --git a/distribution/tutorials/security/json-rpc/membrane.cmd b/distribution/tutorials/security/json-rpc/membrane.cmd new file mode 100644 index 0000000000..8d2d64e9cf --- /dev/null +++ b/distribution/tutorials/security/json-rpc/membrane.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +set "dir=%SCRIPT_DIR%" + +:search_up +if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found +for %%A in ("%dir%\..") do set "next=%%~fA" +if /I "%next%"=="%dir%" goto notfound +set "dir=%next%" +goto search_up + +:found +set "MEMBRANE_HOME=%dir%" +set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%" +call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %* +exit /b %ERRORLEVEL% + +:notfound +>&2 echo Could not locate Membrane root. Ensure directory structure is correct. +exit /b 1 diff --git a/distribution/tutorials/security/json-rpc/membrane.sh b/distribution/tutorials/security/json-rpc/membrane.sh new file mode 100755 index 0000000000..195dae51ec --- /dev/null +++ b/distribution/tutorials/security/json-rpc/membrane.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml +# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged). +# Examples: +# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml' +# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml' + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) + +dir="$SCRIPT_DIR" +while [ "$dir" != "/" ]; do + if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then + export MEMBRANE_HOME="$dir" + export MEMBRANE_CALLER_DIR="$SCRIPT_DIR" + exec sh "$dir/scripts/run-membrane.sh" "$@" + fi + dir=$(dirname "$dir") +done + +echo "Could not locate Membrane root. Ensure directory structure is correct." >&2 +exit 1 \ No newline at end of file diff --git a/distribution/tutorials/security/json-rpc/run-docker.cmd b/distribution/tutorials/security/json-rpc/run-docker.cmd new file mode 100644 index 0000000000..871ae08922 --- /dev/null +++ b/distribution/tutorials/security/json-rpc/run-docker.cmd @@ -0,0 +1,15 @@ +@echo off +setlocal enabledelayedexpansion + +set "DIR=%~dp0" +set "IMAGE=predic8/membrane:7.2.2" + +for /f "delims=" %%i in ('docker create -p 2000-2010:2000-2010 %IMAGE% %*') do set "CID=%%i" + +set "CLEANUP_CMD=docker rm -f %CID% >nul 2>nul" + +docker cp "%DIR%." "%CID%:/opt/membrane/" >nul +docker start -a "%CID%" + +%CLEANUP_CMD% +endlocal diff --git a/distribution/tutorials/security/json-rpc/run-docker.sh b/distribution/tutorials/security/json-rpc/run-docker.sh new file mode 100755 index 0000000000..914b5b7236 --- /dev/null +++ b/distribution/tutorials/security/json-rpc/run-docker.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +cid="$(docker create -it -p 2000-2010:2000-2010 predic8/membrane:7.2.2 "$@")" + +cleanup() { + docker rm -f "$cid" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker cp "${DIR}/." "${cid}:/opt/membrane/" +docker start -a "$cid" From 98d72233a0d297945bdc7e84fe6936f1b1f18f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Tue, 9 Jun 2026 11:24:13 +0200 Subject: [PATCH 35/38] Update JSON-RPC tutorials: restructure file hierarchy, rename configurations, refine examples, improve schema validation coverage, and update tests --- .../json/JsonRpcProtectionTutorialTest.java | 7 +- distribution/tutorials/README.md | 5 ++ ...N-RPC-Allow-Deny-and-Batch-Validation.yaml | 65 +++++++++++++++++++ ...PC-Protection-with-Schema-Validation.yaml} | 2 +- 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 distribution/tutorials/security/json-rpc/20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml rename distribution/tutorials/security/json-rpc/{30-JSON-RPC-Protection.yaml => 30-JSON-RPC-Protection-with-Schema-Validation.yaml} (98%) 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 index 1f0c8e49b9..8c4c1b2b4d 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java @@ -24,9 +24,14 @@ public class JsonRpcProtectionTutorialTest extends AbstractJsonTutorialTest { + @Override + protected String getTutorialDir() { + return "security/json-rpc"; + } + @Override protected String getTutorialYaml() { - return "30-JSON-RPC-Protection.yaml"; + return "30-JSON-RPC-Protection-with-Schema-Validation.yaml"; } @Test diff --git a/distribution/tutorials/README.md b/distribution/tutorials/README.md index 06fc21b22d..f13a59b1e9 100644 --- a/distribution/tutorials/README.md +++ b/distribution/tutorials/README.md @@ -25,6 +25,11 @@ If your APIs use XML as input or output, this tutorial provides useful configura Learn how to use Membrane in more advanced scenarios. Topics include path rewriting, scripting, conditions and more. +## [Security](security) + +This tutorial covers transport and message protection topics such as TLS termination, authentication, ACLs, JSON/XML/GraphQL protection, and a dedicated JSON-RPC tutorial with allow/deny, batch validation, and schema validation examples. + + ## [AI / MCP](ai/mcp) Expose Membrane as an MCP server for AI clients, inspect recent API traffic, and protect the MCP endpoint with an API key. diff --git a/distribution/tutorials/security/json-rpc/20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml b/distribution/tutorials/security/json-rpc/20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml new file mode 100644 index 0000000000..49122b38b9 --- /dev/null +++ b/distribution/tutorials/security/json-rpc/20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml @@ -0,0 +1,65 @@ +# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.2.json +# +# Tutorial: JSON-RPC Allow/Deny and Batch Validation +# +# Small JSON-RPC protection example: +# - allows only the methods `ping` and `sum` +# - rejects every other method +# - limits batch requests to at most 2 calls +# +# Notes: +# - Method rules are evaluated top-down. +# - The first matching rule wins. +# - The allowed methods use plain names instead of regex-style method patterns. +# - Accepted requests get a generated JSON-RPC success response so the example +# stays focused on protection rules. +# +# Try: +# +# 1.) Allowed request +# curl -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -H "Content-Type: application/json" http://localhost:2000 +# +# 2.) Blocked method +# curl -d '{"jsonrpc":"2.0","id":1,"method":"shutdown"}' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC error explaining that the method is not allowed. +# +# 3.) Valid batch with 2 calls +# curl -d '[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]}]' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC batch response with one success entry per request. +# +# 4.) Batch too large +# curl -d '[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]},{"jsonrpc":"2.0","id":3,"method":"ping"}]' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC batch error because maxSize is 2. + +api: + port: 2000 + flow: + - jsonRPCProtection: + batch: + enabled: true + maxSize: 2 + methods: + - allow: ping + - allow: sum + - deny: .* + - groovy: + src: | + import groovy.json.JsonOutput + import groovy.json.JsonSlurper + + def payload = new JsonSlurper().parseText(message.bodyAsStringDecoded) + def success = { id -> + [jsonrpc: "2.0", id: id, result: [ok: true]] + } + + def responseBody = payload instanceof List + ? JsonOutput.toJson(payload.collect { success(it.id) }) + : JsonOutput.toJson(success(payload.id)) + + Response.ok() + .contentType("application/json") + .body(responseBody) + .build() diff --git a/distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection.yaml b/distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection-with-Schema-Validation.yaml similarity index 98% rename from distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection.yaml rename to distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection-with-Schema-Validation.yaml index 44ff22956d..7dd1828650 100644 --- a/distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection.yaml +++ b/distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection-with-Schema-Validation.yaml @@ -1,6 +1,6 @@ # yaml-language-server: $schema=https://www.membrane-api.io/v7.2.2.json # -# Tutorial: JSON-RPC Protection +# Tutorial: JSON-RPC Protection with Schema Validation # # Protect a JSON-RPC endpoint by: # - allowing only selected methods From 37713aab6db09d871a4139b44cfb55d658f03047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Tue, 9 Jun 2026 11:31:42 +0200 Subject: [PATCH 36/38] Add new tests for JSON-RPC allow/deny methods, batch validation, and schema validation tutorials, restructure tutorial file hierarchy --- ...lowDenyAndBatchValidationTutorialTest.java | 126 ++++++++++++++++++ .../JsonRpcProtectionTutorialTest.java | 5 +- ...-RPC-Allow-Deny-and-Batch-Validation.yaml} | 0 ...PC-Protection-with-Schema-Validation.yaml} | 0 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java rename distribution/src/test/java/com/predic8/membrane/tutorials/{json => security}/JsonRpcProtectionTutorialTest.java (95%) rename distribution/tutorials/security/json-rpc/{20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml => 10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml} (100%) rename distribution/tutorials/security/json-rpc/{30-JSON-RPC-Protection-with-Schema-Validation.yaml => 20-JSON-RPC-Protection-with-Schema-Validation.yaml} (100%) diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java new file mode 100644 index 0000000000..afe625584b --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java @@ -0,0 +1,126 @@ +/* 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.security; + +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 JsonRpcAllowDenyAndBatchValidationTutorialTest extends AbstractSecurityTutorialTest { + + @Override + protected String getTutorialDir() { + return "security/json-rpc"; + } + + @Override + protected String getTutorialYaml() { + return "10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml"; + } + + @Test + void allowsConfiguredMethod_ping() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"ping"} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("result.ok", equalTo(true)); + // @formatter:on + } + + @Test + void rejectsMethodOutsideAllowlist() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"shutdown"} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(403) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("error.code", equalTo(-32601)) + .body("error.message", containsString("shutdown")); + // @formatter:on + } + + @Test + void acceptsBatchUpToMaxSize() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + [ + {"jsonrpc":"2.0","id":1,"method":"ping"}, + {"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]} + ] + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("size()", equalTo(2)) + .body("[0].jsonrpc", equalTo("2.0")) + .body("[0].id", equalTo(1)) + .body("[0].result.ok", equalTo(true)) + .body("[1].jsonrpc", equalTo("2.0")) + .body("[1].id", equalTo(2)) + .body("[1].result.ok", equalTo(true)); + // @formatter:on + } + + @Test + void rejectsBatchThatExceedsMaxSize() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + [ + {"jsonrpc":"2.0","id":1,"method":"ping"}, + {"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]}, + {"jsonrpc":"2.0","id":3,"method":"ping"} + ] + """) + .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 + } +} diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcProtectionTutorialTest.java similarity index 95% rename from distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java rename to distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcProtectionTutorialTest.java index 8c4c1b2b4d..1299d1e6be 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcProtectionTutorialTest.java @@ -12,8 +12,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -package com.predic8.membrane.tutorials.json; +package com.predic8.membrane.tutorials.security; +import com.predic8.membrane.tutorials.json.AbstractJsonTutorialTest; import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; @@ -31,7 +32,7 @@ protected String getTutorialDir() { @Override protected String getTutorialYaml() { - return "30-JSON-RPC-Protection-with-Schema-Validation.yaml"; + return "20-JSON-RPC-Protection-with-Schema-Validation.yaml"; } @Test diff --git a/distribution/tutorials/security/json-rpc/20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml similarity index 100% rename from distribution/tutorials/security/json-rpc/20-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml rename to distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml diff --git a/distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection-with-Schema-Validation.yaml b/distribution/tutorials/security/json-rpc/20-JSON-RPC-Protection-with-Schema-Validation.yaml similarity index 100% rename from distribution/tutorials/security/json-rpc/30-JSON-RPC-Protection-with-Schema-Validation.yaml rename to distribution/tutorials/security/json-rpc/20-JSON-RPC-Protection-with-Schema-Validation.yaml From 6017a0e778286c79c2e4027dd8c0a4c64134bdbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Tue, 9 Jun 2026 11:37:24 +0200 Subject: [PATCH 37/38] Refactor JSON-RPC Allow/Deny and Batch Validation tutorial: simplify response handling, replace Groovy script with static response definitions for improved clarity and maintainability. --- ...N-RPC-Allow-Deny-and-Batch-Validation.yaml | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml index 49122b38b9..513d31023d 100644 --- a/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml +++ b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml @@ -10,9 +10,6 @@ # Notes: # - Method rules are evaluated top-down. # - The first matching rule wins. -# - The allowed methods use plain names instead of regex-style method patterns. -# - Accepted requests get a generated JSON-RPC success response so the example -# stays focused on protection rules. # # Try: # @@ -45,21 +42,42 @@ api: - allow: ping - allow: sum - deny: .* - - groovy: - src: | - import groovy.json.JsonOutput - import groovy.json.JsonSlurper - - def payload = new JsonSlurper().parseText(message.bodyAsStringDecoded) - def success = { id -> - [jsonrpc: "2.0", id: id, result: [ok: true]] - } - - def responseBody = payload instanceof List - ? JsonOutput.toJson(payload.collect { success(it.id) }) - : JsonOutput.toJson(success(payload.id)) - - Response.ok() - .contentType("application/json") - .body(responseBody) - .build() + - if: + test: body.toString().trim().startsWith('[') + flow: + - static: + contentType: application/json + pretty: true + src: | + [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": true + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "ok": true + } + } + ] + - return: + status: 200 + else: + - static: + contentType: application/json + pretty: true + src: | + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": true + } + } + - return: + status: 200 From d6373ab310093c530e7d9ab75589876ca0c77f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 12 Jun 2026 08:33:01 +0200 Subject: [PATCH 38/38] Update JSON-RPC Allow/Deny and Batch Validation tutorial: add demo backend configuration with static success responses and clarify target URL setup. --- .../10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml index 513d31023d..838486fcee 100644 --- a/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml +++ b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml @@ -42,6 +42,15 @@ api: - allow: ping - allow: sum - deny: .* + target: + url: http://localhost:3000 + +--- +# Demo backend. +# Returns a success response for every request (depending on the request type single or batch). +api: + port: 3000 + flow: - if: test: body.toString().trim().startsWith('[') flow: