From 955e07abb063a778225fd6843e36c1a8f10be966 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sun, 15 Feb 2026 14:23:33 +0100 Subject: [PATCH 01/20] fix: compute URL expressions in the target after request flow in the http client interceptor --- .../predic8/membrane/core/http/Message.java | 19 ++ .../predic8/membrane/core/http/Request.java | 18 +- .../interceptor/DispatchingInterceptor.java | 21 +- .../interceptor/HTTPClientInterceptor.java | 86 ++----- .../session/AbstractUserDataProvider.java | 14 ++ .../authentication/session/User.java | 14 ++ .../core/lang/CommonBuiltInFunctions.java | 18 ++ .../lang/groovy/GroovyBuiltInFunctions.java | 22 +- .../spel/functions/SpELBuiltInFunctions.java | 14 +- .../core/proxies/AbstractServiceProxy.java | 1 - .../predic8/membrane/core/proxies/Target.java | 43 ++-- .../predic8/membrane/core/util/URLUtil.java | 128 ++++++++--- .../security/BasicAuthenticationUtil.java | 14 ++ .../core/http/PatchWithoutBodyTest.java | 14 ++ .../HTTPClientInterceptorTest.java | 33 ++- .../interceptor/acl/targets/TargetTest.java | 33 ++- .../proxies/AbstractServiceProxyTest.java | 16 +- .../core/proxies/TargetURLExpressionTest.java | 18 +- .../membrane/core/util/URLUtilTest.java | 211 +++++++++++++----- .../security/BasicAuthenticationUtilTest.java | 14 ++ 20 files changed, 530 insertions(+), 221 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/http/Message.java b/core/src/main/java/com/predic8/membrane/core/http/Message.java index a4e99afbf7..81eed7e18e 100644 --- a/core/src/main/java/com/predic8/membrane/core/http/Message.java +++ b/core/src/main/java/com/predic8/membrane/core/http/Message.java @@ -81,6 +81,25 @@ public void discardBody() { } } + /** + * Empties the body of the message. + * This method ensures the message contents accurately reflect a state where there is no body, + * and updates the headers accordingly to maintain consistency. + */ + public void emptyBody() { + try { + if (isBodyEmpty()) + return; + } catch (IOException e) { + log.warn("", e); + } + discardBody(); // Read body before we replace it. Maybe there is one but it is not read + body = new EmptyBody(); + header.removeFields(CONTENT_LENGTH); + header.removeFields(CONTENT_TYPE); + header.removeFields(CONTENT_ENCODING); + } + public AbstractBody getBody() { return body; } diff --git a/core/src/main/java/com/predic8/membrane/core/http/Request.java b/core/src/main/java/com/predic8/membrane/core/http/Request.java index 0dfc902bb7..14c60ba68a 100644 --- a/core/src/main/java/com/predic8/membrane/core/http/Request.java +++ b/core/src/main/java/com/predic8/membrane/core/http/Request.java @@ -18,6 +18,7 @@ import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.transport.http.*; import com.predic8.membrane.core.util.*; +import org.slf4j.*; import java.io.*; import java.net.*; @@ -31,6 +32,8 @@ public class Request extends Message { + private static final Logger log = LoggerFactory.getLogger(Request.class); + private static final Pattern pattern = Pattern.compile("(.+?) (.+?) HTTP/(.+?)$"); private static final Pattern stompPattern = Pattern.compile("^(.+?)$"); @@ -166,6 +169,19 @@ public T createSnapshot(Runnable bodyUpdatedCallback, BodyCo return (T) result; } + public void changeMethod(String newMethod) { + if (method.equalsIgnoreCase(newMethod)) + return; + + log.debug("Changing method from {} to {}", this.method, newMethod); + this.method = newMethod; + + if (!newMethod.equalsIgnoreCase(METHOD_GET)) + return; + + emptyBody(); + } + public final void writeSTOMP(OutputStream out, boolean retainBody) throws IOException { out.write(getMethod().getBytes(UTF_8)); out.write(10); @@ -257,7 +273,7 @@ public Builder header(String headerName, String headerValue) { } public Builder contentType(String value) { - req.getHeader().setContentType( value); + req.getHeader().setContentType(value); return this; } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java index ba516326d6..2bc333d0e9 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java @@ -14,21 +14,20 @@ package com.predic8.membrane.core.interceptor; import com.predic8.membrane.annot.*; -import com.predic8.membrane.core.exceptions.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.openapi.util.*; import com.predic8.membrane.core.proxies.*; +import com.predic8.membrane.core.util.*; import org.jetbrains.annotations.*; import org.slf4j.*; import java.net.*; +import java.net.URI; import java.util.*; import static com.predic8.membrane.core.exceptions.ProblemDetails.*; import static com.predic8.membrane.core.exchange.Exchange.*; -import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.Set.*; -import static com.predic8.membrane.core.interceptor.Outcome.ABORT; import static com.predic8.membrane.core.interceptor.Outcome.*; /** @@ -44,7 +43,7 @@ @MCElement(name = "dispatching", excludeFromFlow = true) public class DispatchingInterceptor extends AbstractInterceptor { - private static final Logger log = LoggerFactory.getLogger(DispatchingInterceptor.class.getName()); + private static final Logger log = LoggerFactory.getLogger(DispatchingInterceptor.class); public DispatchingInterceptor() { name = "dispatching interceptor"; @@ -106,12 +105,14 @@ private String getForwardingDestination(Exchange exc) throws Exception { } protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLException, URISyntaxException { - AbstractServiceProxy p = (AbstractServiceProxy) exc.getProxy(); + var proxy = (AbstractServiceProxy) exc.getProxy(); - if (p.getTargetURL() != null) { - String targetURL = p.getTarget().compileUrl(exc, REQUEST); + if (proxy.getTargetURL() != null) { + var targetURL = proxy.getTarget().getUrl(); if (targetURL.startsWith("http") || targetURL.startsWith("internal")) { - String basePath = UriUtil.getPathFromURL(router.getConfiguration().getUriFactory(), targetURL); + // Here illegal character as $ { } are allowed in the URI to make URL expressions possible. + // The URL is from the target in the configuration, that is maintained by the admin + var basePath = UriUtil.getPathFromURL(new URIFactory(true), targetURL); if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { URI base = new URI(targetURL); // Resolve and normalize slashes consistently with the branch below. @@ -120,8 +121,8 @@ protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLEx } return targetURL; } - if (p.getTargetHost() != null) { - return new URL(p.getTargetScheme(), p.getTargetHost(), p.getTargetPort(), getUri(exc)).toString(); + if (proxy.getTargetHost() != null) { + return new URL(proxy.getTargetScheme(), proxy.getTargetHost(), proxy.getTargetPort(), getUri(exc)).toString(); } // That's fine. Maybe it is a without a target diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java index 2c96663857..b6c7580d40 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java @@ -13,32 +13,19 @@ limitations under the License. */ package com.predic8.membrane.core.interceptor; -import com.predic8.membrane.annot.MCAttribute; -import com.predic8.membrane.annot.MCChildElement; -import com.predic8.membrane.annot.MCElement; -import com.predic8.membrane.core.exchange.Exchange; -import com.predic8.membrane.core.http.EmptyBody; -import com.predic8.membrane.core.http.Request; -import com.predic8.membrane.core.proxies.AbstractServiceProxy; -import com.predic8.membrane.core.transport.http.HttpClient; -import com.predic8.membrane.core.transport.http.ProtocolUpgradeDeniedException; -import com.predic8.membrane.core.transport.http.client.HttpClientConfiguration; -import com.predic8.membrane.core.util.URLUtil; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.net.ConnectException; -import java.net.MalformedURLException; -import java.net.SocketTimeoutException; -import java.net.UnknownHostException; +import com.predic8.membrane.annot.*; +import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.proxies.*; +import com.predic8.membrane.core.transport.http.*; +import com.predic8.membrane.core.transport.http.client.*; +import com.predic8.membrane.core.util.*; +import org.slf4j.*; + +import java.net.*; import static com.predic8.membrane.core.exceptions.ProblemDetails.*; -import static com.predic8.membrane.core.http.Header.*; -import static com.predic8.membrane.core.http.Request.METHOD_GET; -import static com.predic8.membrane.core.interceptor.Interceptor.Flow.Set.REQUEST_FLOW; -import static com.predic8.membrane.core.interceptor.Outcome.ABORT; -import static com.predic8.membrane.core.interceptor.Outcome.RETURN; +import static com.predic8.membrane.core.interceptor.Interceptor.Flow.Set.*; +import static com.predic8.membrane.core.interceptor.Outcome.*; /** * @description The httpClient sends the request of an exchange to a Web @@ -48,16 +35,15 @@ * its outgoing HTTP connection that is different from the global * configuration in the transport. */ -@MCElement(name = "httpClient", excludeFromFlow= true) +@MCElement(name = "httpClient", excludeFromFlow = true) public class HTTPClientInterceptor extends AbstractInterceptor { private static final Logger log = LoggerFactory.getLogger(HTTPClientInterceptor.class.getName()); - private static final String PROXIES_HINT = " Maybe the target is only reachable over an HTTP proxy server. Please check proxy settings in conf/proxies.xml."; + private static final String PROXIES_HINT = " Maybe the target is only reachable over an HTTP proxy server. Please check proxy settings in conf/apies.xml."; // null => inherit from HttpClientConfiguration unless explicitly set here private Boolean failOverOn5XX; - private Boolean adjustHostHeader; private HttpClientConfiguration httpClientConfig = new HttpClientConfiguration(); @@ -77,24 +63,19 @@ public void init() { httpClientConfig.getRetryHandler().setFailOverOn5XX(failOverOn5XX); } - if (adjustHostHeader != null) { - httpClientConfig.setAdjustHostHeader(adjustHostHeader); - } - hc = router.getHttpClientFactory().createClient(httpClientConfig); hc.setStreamPumpStats(getRouter().getStatistics().getStreamPumpStats()); } @Override public Outcome handleRequest(Exchange exc) { - changeMethod(exc); - + applyTargetModifications(exc); try { hc.call(exc); return RETURN; } catch (ConnectException e) { String msg = "Target %s is not reachable.".formatted(getDestination(exc)); - log.warn(msg + PROXIES_HINT); + log.warn("{} {}",msg,PROXIES_HINT); gateway(router.getConfiguration().isProduction(), getDisplayName()) .addSubSee("connect") .status(502) @@ -110,7 +91,7 @@ public Outcome handleRequest(Exchange exc) { return ABORT; } catch (UnknownHostException e) { String msg = "Target host %s of API %s is unknown. DNS was unable to resolve host name.".formatted(URLUtil.getHost(getDestination(exc)), exc.getProxy().getName()); - log.warn(msg + PROXIES_HINT); + log.warn("{} {}",msg,PROXIES_HINT); gateway(router.getConfiguration().isProduction(), getDisplayName()) .addSubSee("unknown-host") .status(502) @@ -118,13 +99,13 @@ public Outcome handleRequest(Exchange exc) { .buildAndSetResponse(exc); return ABORT; } catch (MalformedURLException e) { - log.info("Malformed URL. Requested path is: {} {}",exc.getRequest().getUri() , e.getMessage()); - log.debug("",e); + log.info("Malformed URL. Requested path is: {} {}", exc.getRequest().getUri(), e.getMessage()); + log.debug("", e); user(router.getConfiguration().isProduction(), getDisplayName()) .title("Request path or 'Host' header is malformed") .addSubSee("malformed-url") .internal("proxy", exc.getProxy().getName()) - .internal("url",exc.getRequest().getUri()) + .internal("url", exc.getRequest().getUri()) .internal("hostHeader", exc.getRequest().getHeader().getHost()) .detail(e.getMessage()) .buildAndSetResponse(exc); @@ -137,7 +118,7 @@ public Outcome handleRequest(Exchange exc) { .addSubSee("denied-protocol-upgrade") .internal("hint", "Protocol upgrades are supported by Membrane for 'websocket' and 'tcp', but have to be allowed in the configuration explicitly.") .internal("proxy", exc.getProxy().getName()) - .internal("url",exc.getRequest().getUri()) + .internal("url", exc.getRequest().getUri()) .buildAndSetResponse(exc); return ABORT; } catch (Exception e) { @@ -151,36 +132,15 @@ public Outcome handleRequest(Exchange exc) { } /** - * Makes it possible to change the method by specifying + * Manipulates the target URL according to the target (change of method, URL expression) * * @param exc */ - private static void changeMethod(Exchange exc) { + void applyTargetModifications(Exchange exc) { if (!(exc.getProxy() instanceof AbstractServiceProxy asp) || asp.getTarget() == null) return; - String newMethod = asp.getTarget().getMethod(); - if (newMethod == null || newMethod.equalsIgnoreCase(exc.getRequest().getMethod())) - return; - - log.debug("Changing method from {} to {}", exc.getRequest().getMethod(), newMethod); - exc.getRequest().setMethod(newMethod); - - if (newMethod.equalsIgnoreCase(METHOD_GET)) { - handleBodyContentWhenChangingToGET(exc); - } - } - - private static void handleBodyContentWhenChangingToGET(Exchange exc) { - Request req = exc.getRequest(); - try { - req.readBody(); - } catch (IOException ignored) { - } - req.setBody(new EmptyBody()); - req.getHeader().removeFields(CONTENT_LENGTH); - req.getHeader().removeFields(CONTENT_TYPE); - req.getHeader().removeFields(CONTENT_ENCODING); + asp.getTarget().applyModifications(exc, asp, getRouter()); } private String getDestination(Exchange exc) { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/AbstractUserDataProvider.java b/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/AbstractUserDataProvider.java index 35e8638282..9f26a55b43 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/AbstractUserDataProvider.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/AbstractUserDataProvider.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.authentication.session; import com.predic8.membrane.annot.*; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/User.java b/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/User.java index b0d9a76bf9..1a6c568827 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/User.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/authentication/session/User.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.authentication.session; import java.util.*; diff --git a/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java index 31a54288c6..3e51e0679b 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java @@ -22,6 +22,7 @@ import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.interceptor.Interceptor.Flow; import com.predic8.membrane.core.security.*; +import com.predic8.membrane.core.util.*; import com.predic8.membrane.core.util.xml.*; import com.predic8.membrane.core.util.xml.parser.*; import org.jetbrains.annotations.*; @@ -30,6 +31,7 @@ import javax.xml.namespace.*; import javax.xml.xpath.*; +import java.net.*; import java.util.*; import java.util.concurrent.*; import java.util.function.Predicate; @@ -253,4 +255,20 @@ public static String env(String name) { return getenv(name); } + public static String urlEncode(String s) { + return URLEncoder.encode(s, UTF_8); + } + + /** + * Encodes the given string value as a URI-safe path segment. + * This method performs percent-encoding according to RFC 3986, ensuring that the encoded string + * is safe to use as a single path segment in URIs. Characters outside the unreserved set + * {@code A-Z, a-z, 0-9, -, ., _, ~} are encoded as {@code %HH} sequences. + * + * @param segment the string value to encode; must not be null + * @return a percent-encoded string safe for use as a single URI path segment + */ + public static String pathSeg(String segment) { + return URLUtil.pathSeg(segment); + } } diff --git a/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java index 1b2b005cb7..81ae3800f7 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java @@ -15,14 +15,13 @@ package com.predic8.membrane.core.lang.groovy; import com.predic8.membrane.core.config.xml.*; -import com.predic8.membrane.core.exchange.Exchange; -import com.predic8.membrane.core.interceptor.Interceptor.Flow; -import com.predic8.membrane.core.lang.CommonBuiltInFunctions; +import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.interceptor.Interceptor.*; +import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.router.*; -import groovy.lang.Binding; +import groovy.lang.*; -import java.util.List; -import java.util.Map; +import java.util.*; /** * Helper class for built-in functions that delegates to the implementation CommonBuiltInFunctions. @@ -90,7 +89,6 @@ public boolean isXML() { } - public boolean isJSON() { return CommonBuiltInFunctions.isJSON(exchange, flow); } @@ -119,9 +117,17 @@ public String env(String s) { return CommonBuiltInFunctions.env(s); } + public String urlEncode(String s) { + return CommonBuiltInFunctions.urlEncode(s); + } + + public String pathSeg(String segment) { + return CommonBuiltInFunctions.pathSeg(segment); + } + /** * Post-Process values before they are written to the output. - * + *

* This gives subclasses the option to perform escaping (e.g. JSON/XML). */ public Object escape(Object o) { diff --git a/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java index aed5530851..af168d0473 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java @@ -111,6 +111,14 @@ public String env(String s, SpELExchangeEvaluationContext ignored) { return CommonBuiltInFunctions.env(s); } + public String urlEncode(String s, SpELExchangeEvaluationContext ignored) { + return CommonBuiltInFunctions.urlEncode(s); + } + + public String pathSeg(String segment, SpELExchangeEvaluationContext ignored) { + return CommonBuiltInFunctions.pathSeg(segment); + } + public static List getBuiltInFunctionNames() { return Arrays.stream(SpELBuiltInFunctions.class.getDeclaredMethods()) .filter(m -> isPublic(m.getModifiers())) @@ -127,8 +135,8 @@ private static boolean lastParamIsSpELExchangeEvaluationContext(Method m) { } private XmlConfig getXmlConfig(Router router) { - if (router == null || router.getRegistry() == null) - return null; - return router.getRegistry().getBean(XmlConfig.class).orElse(null); + if (router == null || router.getRegistry() == null) + return null; + return router.getRegistry().getBean(XmlConfig.class).orElse(null); } } diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java b/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java index 974be3b8aa..3b726d2dbb 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java @@ -28,7 +28,6 @@ public void init() { target.setPort(target.getSslParser() != null ? 443 : 80); if (target.getSslParser() != null) setSslOutboundContext(new StaticSSLContext(target.getSslParser(), router.getResolverMap(), router.getConfiguration().getBaseLocation())); - target.init(router); } public String getHost() { diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index 24e70c3eff..17a0512944 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -22,6 +22,7 @@ import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.router.*; +import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; /** @@ -39,29 +40,11 @@ public class Target implements XMLSupport { protected String url; private boolean adjustHostHeader = true; private ExchangeExpression.Language language = SPEL; - private ExchangeExpression exchangeExpression; private SSLParser sslParser; protected XmlConfig xmlConfig; - public void init(Router router) { - if (url == null) - return; - exchangeExpression = TemplateExchangeExpression.newInstance(new ExchangeExpression.InterceptorAdapter(router, xmlConfig), language, url, router); - } - - public String compileUrl(Exchange exc, Interceptor.Flow flow) { - /* - * Will always evaluate on every call. This is fine as SpEL is fast enough and performs its own optimizations. - * 1.000.000 calls ~10ms - */ - if (exchangeExpression != null) { - return exchangeExpression.evaluate(exc, flow, String.class); - } - return url; - } - public Target() { } @@ -74,6 +57,26 @@ public Target(String host, int port) { setPort(port); } + public void applyModifications(Exchange exc, AbstractServiceProxy asp, Router router) { + computeDestinationExpressions(exc, asp, router); + + // Changing the method must be the last step cause it can empty the body! + if (asp.getTarget().getMethod() != null) { + exc.getRequest().changeMethod(asp.getTarget().getMethod()); + } + } + + private static void computeDestinationExpressions(Exchange exc, AbstractServiceProxy asp, Router router) { + var target = asp.getTarget(); + + var dests = exc.getDestinations().stream().map(url -> { + var exp = TemplateExchangeExpression.newInstance(new ExchangeExpression.InterceptorAdapter(router, target.getXmlConfig()), target.getLanguage(), url, router); + return exp.evaluate(exc, REQUEST, String.class); + }).toList(); + + exc.setDestinations(dests); + } + public String getHost() { return host; } @@ -150,10 +153,6 @@ public void setMethod(String method) { this.method = method; } - public ExchangeExpression getExchangeExpression() { - return exchangeExpression; - } - public ExchangeExpression.Language getLanguage() { return language; } diff --git a/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java index 77c6495dca..3a1956a197 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java @@ -16,40 +16,104 @@ import java.net.*; +import static java.lang.Character.*; +import static java.nio.charset.StandardCharsets.*; + public class URLUtil { - public static String getHost(String uri) { - int i = uri.indexOf(":") + 1; - while (uri.charAt(i) == '/') - i++; - int j = uri.indexOf("/", i); - return j == -1 ? uri.substring(i) : uri.substring(i, j); - } - - public static String getPathQuery(URIFactory uriFactory, String uri) throws URISyntaxException { - URI u = uriFactory.create(uri); - String query = u.getRawQuery(); - String path = u.getRawPath(); - return (path.isEmpty() ? "/" : path) + (query == null ? "" : "?" + query); - } - - /** - * Extracts and returns the name component from the path of a URI. The name - * corresponds to the substring after the last '/' in the path. If no '/' is - * found, the entire path is returned. - * - * @param uriFactory An instance of {@code URIFactory} used to create the {@code URI} object. - * @param uri The URI string to process. - * @return The name component extracted from the URI's path. - * @throws URISyntaxException If the URI string is invalid and cannot be converted into a {@code URI}. - */ - public static String getNameComponent(URIFactory uriFactory, String uri) throws URISyntaxException { + public static String getHost(String uri) { + int i = uri.indexOf(":") + 1; + while (uri.charAt(i) == '/') + i++; + int j = uri.indexOf("/", i); + return j == -1 ? uri.substring(i) : uri.substring(i, j); + } + + public static String getPathQuery(URIFactory uriFactory, String uri) throws URISyntaxException { + URI u = uriFactory.create(uri); + String query = u.getRawQuery(); + String path = u.getRawPath(); + return (path.isEmpty() ? "/" : path) + (query == null ? "" : "?" + query); + } + + /** + * Extracts and returns the name component from the path of a URI. The name + * corresponds to the substring after the last '/' in the path. If no '/' is + * found, the entire path is returned. + * + * @param uriFactory An instance of {@code URIFactory} used to create the {@code URI} object. + * @param uri The URI string to process. + * @return The name component extracted from the URI's path. + * @throws URISyntaxException If the URI string is invalid and cannot be converted into a {@code URI}. + */ + public static String getNameComponent(URIFactory uriFactory, String uri) throws URISyntaxException { var p = uriFactory.create(uri).getPath(); - int i = p.lastIndexOf('/'); - return i == -1 ? p : p.substring(i+1); - } + int i = p.lastIndexOf('/'); + return i == -1 ? p : p.substring(i + 1); + } + + public static int getPortFromURL(URL loc2) { + return loc2.getPort() == -1 ? loc2.getDefaultPort() : loc2.getPort(); + } + + /** + * Encodes the given value so it can be safely used as a single URI path segment. + * + *

The method performs percent-encoding according to RFC 3986 for + * path segment context. All characters except the unreserved set + * {@code A-Z a-z 0-9 - . _ ~} are UTF-8 encoded and emitted as {@code %HH} + * sequences.

+ * + *

This guarantees that the returned string:

+ *
    + *
  • cannot introduce additional path separators ({@code /})
  • + *
  • cannot inject query or fragment delimiters ({@code ?, #, &})
  • + *
  • does not rely on {@code +} for spaces (spaces become {@code %20})
  • + *
  • is safe to concatenate into {@code ".../foo/" + pathSeg(value)}
  • + *
+ * + *

The input is converted using {@link Object#toString()} and encoded as UTF-8. + * A {@code null} value results in an empty string.

+ * + *

Example:

+ *
{@code
+     * pathSeg("a/b & c")  -> "a%2Fb%20%26%20c"
+     * pathSeg("ä")        -> "%C3%A4"
+     * pathSeg(123)        -> "123"
+     * }
+ * + *

Note: This method is intended for encoding a single + * path segment only. It must not be used for whole URLs, query strings, + * or already structured paths. For those cases, use a URI builder or + * context-specific encoding.

+ * + * @param value the value to encode as a path segment; may be {@code null} + * @return a percent-encoded string safe for use as one URI path segment + */ + public static String pathSeg(Object value) { + if (value == null) return ""; + + byte[] bytes = value.toString().getBytes(UTF_8); + var out = new StringBuilder(bytes.length * 3); + + for (byte b : bytes) { + int c = b & 0xff; + + // RFC 3986 unreserved characters + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~') { + + out.append((char) c); + } else { + out.append('%'); + char hex1 = toUpperCase(forDigit((c >> 4) & 0xF, 16)); + char hex2 = toUpperCase(forDigit(c & 0xF, 16)); + out.append(hex1).append(hex2); + } + } - public static int getPortFromURL(URL loc2) { - return loc2.getPort() == -1 ? loc2.getDefaultPort() : loc2.getPort(); - } + return out.toString(); + } } diff --git a/core/src/main/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtil.java b/core/src/main/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtil.java index ad34b75dc9..fb66686f65 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtil.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.security; import com.predic8.membrane.core.exchange.*; diff --git a/core/src/test/java/com/predic8/membrane/core/http/PatchWithoutBodyTest.java b/core/src/test/java/com/predic8/membrane/core/http/PatchWithoutBodyTest.java index 5295d6249a..8e3ce2d954 100644 --- a/core/src/test/java/com/predic8/membrane/core/http/PatchWithoutBodyTest.java +++ b/core/src/test/java/com/predic8/membrane/core/http/PatchWithoutBodyTest.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.http; import com.predic8.membrane.core.exchange.*; diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 675402fd78..cd5a14f0a7 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -14,7 +14,7 @@ package com.predic8.membrane.core.interceptor; -import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.proxies.*; import com.predic8.membrane.core.router.*; import org.junit.jupiter.api.*; @@ -28,27 +28,27 @@ class HTTPClientInterceptorTest { HTTPClientInterceptor hci; + Router router; @BeforeEach void setUp() { hci = new HTTPClientInterceptor(); + router = new DefaultRouter(); } @Test void protocolUpgradeRejected() throws URISyntaxException { - DefaultRouter r = new DefaultRouter(); + hci.init(router); - hci.init(r); - - Exchange e = get("http://localhost:2000/") + var exc = get("http://localhost:2000/") .header(CONNECTION, "upgrade") .header(UPGRADE, "rejected") .buildExchange(); - e.setProxy(new NullProxy()); + exc.setProxy(new NullProxy()); - hci.handleRequest(e); + hci.handleRequest(exc); - assertEquals(401, e.getResponse().getStatusCode()); + assertEquals(401, exc.getResponse().getStatusCode()); } @Test @@ -64,4 +64,21 @@ void passFailOverOn500() { assertTrue(hci.getHttpClientConfig().getRetryHandler().isFailOverOn5XX()); } + @Test + void computeTargetUrl() throws Exception { + var target = new Target(); + target.setUrl("http://localhost/foo/${urlEncode(header.foo)}"); + + var api = new APIProxy(); + api.setTarget(target); + + var exc = get("/foo").header("foo","% ${}").buildExchange(); + exc.setProxy(api); + hci.init(router); + new DispatchingInterceptor().handleRequest(exc); + hci.applyTargetModifications(exc); + assertEquals(1, exc.getDestinations().size()); + assertEquals("http://localhost/foo/%25+%24%7B%7D", exc.getDestinations().getFirst()); + } + } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java index bffbaf7f06..0294b002dc 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java @@ -14,11 +14,17 @@ package com.predic8.membrane.core.interceptor.acl.targets; -import com.predic8.membrane.core.interceptor.acl.IpAddress; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import com.predic8.membrane.core.interceptor.*; +import com.predic8.membrane.core.interceptor.acl.*; +import com.predic8.membrane.core.openapi.serviceproxy.*; +import com.predic8.membrane.core.router.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.*; +import org.junit.jupiter.params.provider.*; +import java.net.*; + +import static com.predic8.membrane.core.http.Request.*; import static org.junit.jupiter.api.Assertions.*; class TargetTest { @@ -73,4 +79,23 @@ void byMatch_rejects_invalid_ipv4_ipv6_and_invalid_hostname_regex() { assertThrows(IllegalArgumentException.class, () -> Target.byMatch("[")); } + @Test + void targetWithExpression() throws URISyntaxException { + var exc = get("http://localhost:2000/").buildExchange(); + var api = new APIProxy() {{ + setTarget(new com.predic8.membrane.core.proxies.Target() {{ + // { and } are illegal characters in URLs. Make sure they are accepted at that point + setUrl("http://localhost/${1+2}"); + }}); + }}; + + var di = new DispatchingInterceptor(); + exc.setProxy(api); + di.handleRequest(exc); + assertEquals(1, exc.getDestinations().size()); + + // Expression should not be evaluated at this point. + assertEquals("http://localhost/${1+2}", exc.getDestinations().getFirst()); + } + } diff --git a/core/src/test/java/com/predic8/membrane/core/proxies/AbstractServiceProxyTest.java b/core/src/test/java/com/predic8/membrane/core/proxies/AbstractServiceProxyTest.java index 55927f4ee4..faac91c48f 100644 --- a/core/src/test/java/com/predic8/membrane/core/proxies/AbstractServiceProxyTest.java +++ b/core/src/test/java/com/predic8/membrane/core/proxies/AbstractServiceProxyTest.java @@ -14,6 +14,7 @@ package com.predic8.membrane.core.proxies; import com.predic8.membrane.*; +import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.interceptor.flow.*; import com.predic8.membrane.core.interceptor.lang.*; import com.predic8.membrane.core.openapi.serviceproxy.*; @@ -23,6 +24,7 @@ import java.io.*; +import static com.predic8.membrane.core.http.Request.METHOD_POST; import static com.predic8.membrane.core.interceptor.flow.invocation.FlowTestInterceptors.*; import static io.restassured.RestAssured.*; @@ -39,17 +41,16 @@ void getToPost() throws IOException { .get("http://localhost:2000") .then() .statusCode(200) - .header("X-Called-Method", "POST"); + .header("X-Called-Method", METHOD_POST); } private static @NotNull AbstractServiceProxy getAPI() { - AbstractServiceProxy proxy = new AbstractServiceProxy() {}; + var proxy = new AbstractServiceProxy() {}; proxy.setKey(new ServiceProxyKey(2000)); proxy.getFlow().add(A); - var target = new Target() { - }; - target.setMethod("POST"); + var target = new Target() {}; + target.setMethod(METHOD_POST); target.setHost("localhost"); target.setPort(2010); @@ -58,14 +59,13 @@ void getToPost() throws IOException { } private static @NotNull APIProxy getBackend() { - APIProxy p = new APIProxy(); + var p = new APIProxy(); p.key = new APIProxyKey(2010); - SetHeaderInterceptor sh = new SetHeaderInterceptor(); + var sh = new SetHeaderInterceptor(); sh.setFieldName("X-Called-Method"); sh.setValue("${method}"); p.getFlow().add(sh); p.getFlow().add(new ReturnInterceptor()); return p; } - } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/proxies/TargetURLExpressionTest.java b/core/src/test/java/com/predic8/membrane/core/proxies/TargetURLExpressionTest.java index 97d2b5d0fd..6b51b9e707 100644 --- a/core/src/test/java/com/predic8/membrane/core/proxies/TargetURLExpressionTest.java +++ b/core/src/test/java/com/predic8/membrane/core/proxies/TargetURLExpressionTest.java @@ -13,7 +13,6 @@ limitations under the License. */ package com.predic8.membrane.core.proxies; -import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.router.*; @@ -43,19 +42,18 @@ void tearDown() { @Test void targetWithExpression() throws URISyntaxException { - Exchange rq = get("http://localhost:2000/").buildExchange(); + var exc = get("http://localhost:2000/").buildExchange(); APIProxy api = new APIProxy() {{ setTarget(new Target() {{ - setUrl("http://localhost:${2000 + 1000}"); + setUrl("http://localhost/${1+2}"); }}); }}; - rq.setProxy(api); + var di = new DispatchingInterceptor(); + exc.setProxy(api); api.init(router); - - DispatchingInterceptor di = new DispatchingInterceptor(); - di.init(router); - di.handleRequest(rq); - - assertEquals("http://localhost:3000/", rq.getDestinations().getFirst()); + di.handleRequest(exc); + assertEquals(1, exc.getDestinations().size()); + assertEquals("http://localhost/${1+2}", exc.getDestinations().getFirst()); } + } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java index 1bd1dc3136..8377053acc 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java @@ -16,70 +16,179 @@ package com.predic8.membrane.core.util; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.*; +import org.junit.jupiter.params.provider.*; import java.net.*; +import java.util.stream.*; import static com.predic8.membrane.core.util.URLParamUtil.DuplicateKeyOrInvalidFormStrategy.*; import static com.predic8.membrane.core.util.URLParamUtil.*; import static com.predic8.membrane.core.util.URLUtil.*; -import static com.predic8.membrane.core.util.URLUtil.getNameComponent; import static org.junit.jupiter.api.Assertions.*; public class URLUtilTest { - @Test - void host() { - assertEquals("a", getHost("internal:a")); - assertEquals("a", getHost("internal://a")); - assertEquals("a", getHost("a")); - assertEquals("a", getHost("a/b")); - assertEquals("a", getHost("internal:a/b")); - assertEquals("a", getHost("internal://a/b")); - } - - @Test - void testCreateQueryString() { - assertEquals("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", - createQueryString("endpoint", "http://node1.clustera", - "cluster","c1")); - - } - - @Test - void testParseQueryString() { - assertEquals("http://node1.clustera", parseQueryString("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", ERROR).get("endpoint")); - assertEquals("c1", parseQueryString("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", ERROR).get("cluster")); - } - - @Test - void testParamsWithoutValueString() { - assertEquals("jim", parseQueryString("name=jim&male", ERROR).get("name")); - assertEquals("", parseQueryString("name=jim&male", ERROR).get("male")); - assertEquals("", parseQueryString("name=anna&age=", ERROR).get("age")); - } - - @Test - void testDecodePath() throws Exception{ - URI u = new URI(true,"/path/to%20my/resource"); - assertEquals("/path/to my/resource", u.getPath()); - assertEquals("/path/to%20my/resource",u.getRawPath()); - } + @Test + void host() { + assertEquals("a", getHost("internal:a")); + assertEquals("a", getHost("internal://a")); + assertEquals("a", getHost("a")); + assertEquals("a", getHost("a/b")); + assertEquals("a", getHost("internal:a/b")); + assertEquals("a", getHost("internal://a/b")); + } + + @Test + void testCreateQueryString() { + assertEquals("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", + createQueryString("endpoint", "http://node1.clustera", + "cluster", "c1")); + + } + + @Test + void testParseQueryString() { + assertEquals("http://node1.clustera", parseQueryString("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", ERROR).get("endpoint")); + assertEquals("c1", parseQueryString("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", ERROR).get("cluster")); + } + + @Test + void testParamsWithoutValueString() { + assertEquals("jim", parseQueryString("name=jim&male", ERROR).get("name")); + assertEquals("", parseQueryString("name=jim&male", ERROR).get("male")); + assertEquals("", parseQueryString("name=anna&age=", ERROR).get("age")); + } + + @Test + void testDecodePath() throws Exception { + URI u = new URI(true, "/path/to%20my/resource"); + assertEquals("/path/to my/resource", u.getPath()); + assertEquals("/path/to%20my/resource", u.getRawPath()); + } @Test void getPortFromURLTest() throws MalformedURLException { - assertEquals(2000, getPortFromURL(new URL("http://localhost:2000"))); - assertEquals(80, getPortFromURL(new URL("http://localhost"))); - assertEquals(443, getPortFromURL(new URL("https://api.predic8.de"))); + assertEquals(2000, getPortFromURL(new URL("http://localhost:2000"))); + assertEquals(80, getPortFromURL(new URL("http://localhost"))); + assertEquals(443, getPortFromURL(new URL("https://api.predic8.de"))); } - @Test - void testGetNameComponent() throws Exception { - assertEquals("", getNameComponent(new URIFactory(), "")); - assertEquals("", getNameComponent(new URIFactory(), "/")); - assertEquals("foo", getNameComponent(new URIFactory(), "foo")); - assertEquals("foo", getNameComponent(new URIFactory(), "/foo")); - assertEquals("bar", getNameComponent(new URIFactory(), "/foo/bar")); - assertEquals("bar", getNameComponent(new URIFactory(), "foo/bar")); - assertEquals("", getNameComponent(new URIFactory(), "foo/bar/")); - } + @Test + void testGetNameComponent() throws Exception { + assertEquals("", getNameComponent(new URIFactory(), "")); + assertEquals("", getNameComponent(new URIFactory(), "/")); + assertEquals("foo", getNameComponent(new URIFactory(), "foo")); + assertEquals("foo", getNameComponent(new URIFactory(), "/foo")); + assertEquals("bar", getNameComponent(new URIFactory(), "/foo/bar")); + assertEquals("bar", getNameComponent(new URIFactory(), "foo/bar")); + assertEquals("", getNameComponent(new URIFactory(), "foo/bar/")); + } + + @Nested + class PathSeg { + + record Case(Object in, String expected) { + } + + static Stream cases() { + return Stream.of( + // null + empties + new Case(null, ""), + new Case("", ""), + + // unreserved kept + new Case("AZaz09-._~", "AZaz09-._~"), + + // common reserved characters + new Case(" ", "%20"), + new Case("a b", "a%20b"), + new Case("&", "%26"), + new Case("a&b", "a%26b"), + new Case("/", "%2F"), + new Case("a/b", "a%2Fb"), + new Case("?", "%3F"), + new Case("#", "%23"), + new Case("=", "%3D"), + new Case(":", "%3A"), + new Case("@", "%40"), + new Case(";", "%3B"), + new Case(",", "%2C"), + new Case("+", "%2B"), + + // traversal-like input should not create subpaths + new Case("../", "..%2F"), + new Case("../../admin", "..%2F..%2Fadmin"), + + // percent must be encoded (prevents smuggling pre-encoded delimiters) + new Case("%", "%25"), + new Case("%2F", "%252F"), + new Case("100% legit", "100%25%20legit"), + + // control characters (log safety) + new Case("a\nb", "a%0Ab"), + new Case("a\rb", "a%0Db"), + new Case("\t", "%09"), + + // quotes and braces + new Case("\"", "%22"), + new Case("'", "%27"), + new Case("{", "%7B"), + new Case("}", "%7D"), + + // utf-8 + new Case("ä", "%C3%A4"), + new Case("€", "%E2%82%AC"), + new Case("😀", "%F0%9F%98%80"), + new Case("日本", "%E6%97%A5%E6%9C%AC"), + + // non-string object + new Case(123, "123") + ); + } + + @ParameterizedTest(name = "[{index}] in={0} => {1}") + @MethodSource("cases") + @DisplayName("pathSeg encodes as RFC3986 path segment") + void encodesExpected(Case c) { + assertEquals(c.expected(), URLUtil.pathSeg(c.in())); + } + + record AllowedCase(Object in) { + } + + static Stream allowedCases() { + return Stream.of( + new AllowedCase("simple"), + new AllowedCase("a/b c?d=e&f#g"), + new AllowedCase("../../admin"), + new AllowedCase("100% legit"), + new AllowedCase("äöü😀\n\r\t") + ); + } + + @ParameterizedTest(name = "[{index}] allowed charset for in={0}") + @MethodSource("allowedCases") + @DisplayName("pathSeg output contains only unreserved characters or percent-escapes") + void outputAllowedCharactersOnly(AllowedCase c) { + String out = URLUtil.pathSeg(c.in()); + assertTrue(out.matches("[A-Za-z0-9\\-._~%]*"), out); + + // If '%' appears, it must be followed by two hex digits + for (int i = 0; i < out.length(); i++) { + if (out.charAt(i) == '%') { + assertTrue(i + 2 < out.length(), "Dangling % at end: " + out); + assertTrue(isHex(out.charAt(i + 1)) && isHex(out.charAt(i + 2)), + "Invalid percent-escape at pos " + i + ": " + out); + i += 2; + } + } + } + + private static boolean isHex(char ch) { + return (ch >= '0' && ch <= '9') || + (ch >= 'A' && ch <= 'F') || + (ch >= 'a' && ch <= 'f'); + } + } } diff --git a/core/src/test/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtilTest.java index 88b6269ffb..2ef5220016 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/security/BasicAuthenticationUtilTest.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.security; import com.predic8.membrane.core.exchange.*; From e11eae903cd49b2f62e0a90ff2d0a8c30e70ca15 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sun, 15 Feb 2026 15:50:09 +0100 Subject: [PATCH 02/20] refactor(core): use `getAuthority` instead of `getHost` for improved URL handling and modify target configurations - Introduced `computeDestinationExpressions` to streamline target URL modification logic. - Improved `DispatchingInterceptor` handling of base path resolution with illegal characters. - Enhanced `urlEncode` and `pathSeg` utility functions for safety and clarity. - Refactored `HTTPClientInterceptor` to adjust the target flow and error messages. - Added extensive URI tests, including custom-parsed and relative path resolution scenarios. - Updated `applyTargetModifications` for streamlined behavior during request processing. --- .../predic8/membrane/core/http/Message.java | 3 +- .../predic8/membrane/core/http/Request.java | 6 +- .../interceptor/DispatchingInterceptor.java | 13 +-- .../interceptor/HTTPClientInterceptor.java | 8 +- .../InternalRoutingInterceptor.java | 2 +- .../core/lang/CommonBuiltInFunctions.java | 3 +- .../membrane/core/proxies/RuleManager.java | 2 +- .../predic8/membrane/core/proxies/Target.java | 25 ++-- .../com/predic8/membrane/core/util/URI.java | 24 +++- .../predic8/membrane/core/util/URLUtil.java | 2 +- .../DispatchingInterceptorTest.java | 16 ++- .../HTTPClientInterceptorTest.java | 4 +- .../predic8/membrane/core/util/URITest.java | 110 ++++++++++++++++++ .../membrane/core/util/URLUtilTest.java | 17 +-- docs/ROADMAP.md | 8 ++ 15 files changed, 197 insertions(+), 46 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/http/Message.java b/core/src/main/java/com/predic8/membrane/core/http/Message.java index 81eed7e18e..f28b56cea7 100644 --- a/core/src/main/java/com/predic8/membrane/core/http/Message.java +++ b/core/src/main/java/com/predic8/membrane/core/http/Message.java @@ -91,13 +91,14 @@ public void emptyBody() { if (isBodyEmpty()) return; } catch (IOException e) { - log.warn("", e); + log.debug("", e); } discardBody(); // Read body before we replace it. Maybe there is one but it is not read body = new EmptyBody(); header.removeFields(CONTENT_LENGTH); header.removeFields(CONTENT_TYPE); header.removeFields(CONTENT_ENCODING); + header.removeFields(TRANSFER_ENCODING); } public AbstractBody getBody() { diff --git a/core/src/main/java/com/predic8/membrane/core/http/Request.java b/core/src/main/java/com/predic8/membrane/core/http/Request.java index 14c60ba68a..3b82ca3f69 100644 --- a/core/src/main/java/com/predic8/membrane/core/http/Request.java +++ b/core/src/main/java/com/predic8/membrane/core/http/Request.java @@ -176,10 +176,8 @@ public void changeMethod(String newMethod) { log.debug("Changing method from {} to {}", this.method, newMethod); this.method = newMethod; - if (!newMethod.equalsIgnoreCase(METHOD_GET)) - return; - - emptyBody(); + if (methodsWithoutBody.contains(newMethod.toUpperCase())) + emptyBody(); } public final void writeSTOMP(OutputStream out, boolean retainBody) throws IOException { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java index 2bc333d0e9..c07ac1bd91 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java @@ -22,7 +22,6 @@ import org.slf4j.*; import java.net.*; -import java.net.URI; import java.util.*; import static com.predic8.membrane.core.exceptions.ProblemDetails.*; @@ -44,6 +43,7 @@ public class DispatchingInterceptor extends AbstractInterceptor { private static final Logger log = LoggerFactory.getLogger(DispatchingInterceptor.class); + public static final URIFactory URI_FACTORY_ALLOW_ILLEGAL = new URIFactory(true); public DispatchingInterceptor() { name = "dispatching interceptor"; @@ -105,18 +105,17 @@ private String getForwardingDestination(Exchange exc) throws Exception { } protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLException, URISyntaxException { - var proxy = (AbstractServiceProxy) exc.getProxy(); + if (!(exc.getProxy() instanceof AbstractServiceProxy proxy)) + return null; if (proxy.getTargetURL() != null) { - var targetURL = proxy.getTarget().getUrl(); + var targetURL = proxy.getTargetURL(); if (targetURL.startsWith("http") || targetURL.startsWith("internal")) { // Here illegal character as $ { } are allowed in the URI to make URL expressions possible. // The URL is from the target in the configuration, that is maintained by the admin - var basePath = UriUtil.getPathFromURL(new URIFactory(true), targetURL); + var basePath = UriUtil.getPathFromURL(URI_FACTORY_ALLOW_ILLEGAL, targetURL); if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { - URI base = new URI(targetURL); - // Resolve and normalize slashes consistently with the branch below. - return base.resolve(getUri(exc)).toString(); + return URI_FACTORY_ALLOW_ILLEGAL.create(targetURL).resolve(URI_FACTORY_ALLOW_ILLEGAL.create(getUri(exc))).toString(); } } return targetURL; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java index b6c7580d40..86e1c4fd6f 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java @@ -40,7 +40,7 @@ public class HTTPClientInterceptor extends AbstractInterceptor { private static final Logger log = LoggerFactory.getLogger(HTTPClientInterceptor.class.getName()); - private static final String PROXIES_HINT = " Maybe the target is only reachable over an HTTP proxy server. Please check proxy settings in conf/apies.xml."; + private static final String PROXIES_HINT = " Maybe the target is only reachable over an HTTP proxy server. Please check proxy settings in apis.yaml or proxies.xml."; // null => inherit from HttpClientConfiguration unless explicitly set here private Boolean failOverOn5XX; @@ -69,8 +69,8 @@ public void init() { @Override public Outcome handleRequest(Exchange exc) { - applyTargetModifications(exc); try { + applyTargetModifications(exc); hc.call(exc); return RETURN; } catch (ConnectException e) { @@ -90,7 +90,7 @@ public Outcome handleRequest(Exchange exc) { .buildAndSetResponse(exc); return ABORT; } catch (UnknownHostException e) { - String msg = "Target host %s of API %s is unknown. DNS was unable to resolve host name.".formatted(URLUtil.getHost(getDestination(exc)), exc.getProxy().getName()); + String msg = "Target host %s of API %s is unknown. DNS was unable to resolve host name.".formatted(URLUtil.getAuthority(getDestination(exc)), exc.getProxy().getName()); log.warn("{} {}",msg,PROXIES_HINT); gateway(router.getConfiguration().isProduction(), getDisplayName()) .addSubSee("unknown-host") @@ -140,7 +140,7 @@ void applyTargetModifications(Exchange exc) { if (!(exc.getProxy() instanceof AbstractServiceProxy asp) || asp.getTarget() == null) return; - asp.getTarget().applyModifications(exc, asp, getRouter()); + asp.getTarget().applyModifications(exc, getRouter()); } private String getDestination(Exchange exc) { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/InternalRoutingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/InternalRoutingInterceptor.java index b926e5d40e..334f0a1ec1 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/InternalRoutingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/InternalRoutingInterceptor.java @@ -149,7 +149,7 @@ private boolean isTargetInternal(Exchange exc) { } private AbstractServiceProxy getRuleByDest(Exchange exchange) { - Proxy proxy = router.getRuleManager().getRuleByName(getHost(exchange.getDestinations().getFirst()), Proxy.class); + Proxy proxy = router.getRuleManager().getRuleByName(getAuthority(exchange.getDestinations().getFirst()), Proxy.class); if (proxy == null) throw new RuntimeException("No api found for destination " + exchange.getDestinations().getFirst()); if (proxy instanceof AbstractServiceProxy sp) diff --git a/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java index 3e51e0679b..e2a0a74204 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java @@ -256,6 +256,7 @@ public static String env(String name) { } public static String urlEncode(String s) { + if (s == null) return ""; return URLEncoder.encode(s, UTF_8); } @@ -265,7 +266,7 @@ public static String urlEncode(String s) { * is safe to use as a single path segment in URIs. Characters outside the unreserved set * {@code A-Z, a-z, 0-9, -, ., _, ~} are encoded as {@code %HH} sequences. * - * @param segment the string value to encode; must not be null + * @param segment the string value to encode * @return a percent-encoded string safe for use as a single URI path segment */ public static String pathSeg(String segment) { diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/RuleManager.java b/core/src/main/java/com/predic8/membrane/core/proxies/RuleManager.java index 9b81b61308..d0f603ef74 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/RuleManager.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/RuleManager.java @@ -203,7 +203,7 @@ public Proxy getMatchingRule(Exchange exc) { // Prevent matches before DispatchingInterceptor was called if (exc.getDestinations().isEmpty()) continue; - String serviceName = URLUtil.getHost(exc.getDestinations().getFirst()); + String serviceName = URLUtil.getAuthority(exc.getDestinations().getFirst()); if (!proxy.getName().equals(serviceName)) continue; } diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index 17a0512944..f9ea541d8b 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -20,8 +20,11 @@ import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.lang.*; +import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.router.*; +import java.util.*; + import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; @@ -57,24 +60,22 @@ public Target(String host, int port) { setPort(port); } - public void applyModifications(Exchange exc, AbstractServiceProxy asp, Router router) { - computeDestinationExpressions(exc, asp, router); + public void applyModifications(Exchange exc, Router router) { + exc.setDestinations(computeDestinationExpressions(exc, router)); // Changing the method must be the last step cause it can empty the body! - if (asp.getTarget().getMethod() != null) { - exc.getRequest().changeMethod(asp.getTarget().getMethod()); + if (method != null && !method.isEmpty()) { + exc.getRequest().changeMethod(method); } } - private static void computeDestinationExpressions(Exchange exc, AbstractServiceProxy asp, Router router) { - var target = asp.getTarget(); - - var dests = exc.getDestinations().stream().map(url -> { - var exp = TemplateExchangeExpression.newInstance(new ExchangeExpression.InterceptorAdapter(router, target.getXmlConfig()), target.getLanguage(), url, router); - return exp.evaluate(exc, REQUEST, String.class); - }).toList(); + private List computeDestinationExpressions(Exchange exc, Router router) { + var adapter = new InterceptorAdapter(router, xmlConfig); + return exc.getDestinations().stream().map(url -> evaluateTemplate(exc, router, url, adapter)).toList(); + } - exc.setDestinations(dests); + private String evaluateTemplate(Exchange exc, Router router, String url, InterceptorAdapter adapter) { + return TemplateExchangeExpression.newInstance(adapter, language, url, router).evaluate(exc, REQUEST, String.class); } public String getHost() { diff --git a/core/src/main/java/com/predic8/membrane/core/util/URI.java b/core/src/main/java/com/predic8/membrane/core/util/URI.java index 11605dc6dd..ddcebb6bcf 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URI.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URI.java @@ -14,8 +14,6 @@ package com.predic8.membrane.core.util; -import com.predic8.membrane.core.http.xml.Host; - import java.net.*; import java.util.regex.*; @@ -100,7 +98,8 @@ private void processAuthority(String rawAuthority) { hostPort = parseHostPort(rawAuthority); } - record HostPort(String host, int port) {} + record HostPort(String host, int port) { + } static HostPort parseHostPort(String rawAuthority) { if (rawAuthority == null) @@ -142,7 +141,7 @@ static HostPort parseIpv6(String hostAndPort) { if (end < 0) { throw new IllegalArgumentException("Invalid IPv6 bracket literal: missing ']'."); } - String ipv6 = hostAndPort.substring(0, end+1); + String ipv6 = hostAndPort.substring(0, end + 1); if (ipv6.length() <= 2) { throw new IllegalArgumentException("Host must not be empty."); @@ -283,6 +282,23 @@ public String getPathWithQuery() { return r.toString(); } + public URI resolve(URI relative) throws URISyntaxException { + if (uri != null) { + java.net.URI resolved = uri.resolve(relative.uri != null ? relative.uri : new java.net.URI(relative.toString())); + return new URI(false, resolved.toString()); + } + // Custom-parsed: scheme://authority + relative path + String resolvedPath = relative.getRawPath(); + if (resolvedPath == null || resolvedPath.isEmpty()) { + resolvedPath = this.getRawPath(); + } + String result = scheme + "://" + authority + resolvedPath; + if (relative.getRawQuery() != null) { + result += "?" + relative.getRawQuery(); + } + return new URI(true, result); + } + @Override public String toString() { if (uri != null) diff --git a/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java index 3a1956a197..49e58da077 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java @@ -21,7 +21,7 @@ public class URLUtil { - public static String getHost(String uri) { + public static String getAuthority(String uri) { int i = uri.indexOf(":") + 1; while (uri.charAt(i) == '/') i++; diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java index 307c69c54b..2b0cb468a1 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java @@ -26,7 +26,6 @@ import static com.predic8.membrane.core.exceptions.ProblemDetails.*; import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.interceptor.Outcome.*; - import static com.predic8.membrane.core.router.DummyTestRouter.*; import static org.junit.jupiter.api.Assertions.*; @@ -150,6 +149,21 @@ private void addRequest(String uri) throws Exception { exc.setRequest(get(uri).build()); } + @Test + void getAddressFromTargetElement() throws Exception { + var api = new APIProxy(); + api.setTarget(new Target() {{ + setUrl("https://${property.host}:8080"); // Has illegal characters $ { } in base path + }}); + + var exc = get("/foo").buildExchange(); + exc.setProperty("host", "predic8.de"); + exc.setProxy(api); + + var url = dispatcher.getAddressFromTargetElement(exc); + System.out.println(url); + } + @Nested class ErrorHandling { diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index cd5a14f0a7..d3f3c6ee2a 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -53,14 +53,14 @@ void protocolUpgradeRejected() throws URISyntaxException { @Test void passFailOverOn500Default() { - hci.init(new DefaultRouter()); + hci.init(router); assertFalse(hci.getHttpClientConfig().getRetryHandler().isFailOverOn5XX()); } @Test void passFailOverOn500() { hci.setFailOverOn5XX(true); - hci.init(new DefaultRouter()); + hci.init(router); assertTrue(hci.getHttpClientConfig().getRetryHandler().isFailOverOn5XX()); } diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index 4fb2fb6e57..c519bc9bb8 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -546,4 +546,114 @@ void emptyHostIsRejected() { assertThrows(IllegalArgumentException.class, () -> new URI("http://[]/", true)); } } + + @Nested + class ResolveTests { + + @Test + @DisplayName("Resolve relative path against standard URI base") + void resolveStandardBase() throws URISyntaxException { + URI base = new URI(false, "http://example.com"); + URI relative = new URI(false, "/foo/bar"); + assertEquals("http://example.com/foo/bar", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve relative path against standard URI base with trailing slash") + void resolveStandardBaseTrailingSlash() throws URISyntaxException { + URI base = new URI(false, "http://example.com/"); + URI relative = new URI(false, "/foo/bar"); + assertEquals("http://example.com/foo/bar", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve with query string on relative URI") + void resolveWithQuery() throws URISyntaxException { + URI base = new URI(false, "http://example.com"); + URI relative = new URI(false, "/foo?q=1"); + assertEquals("http://example.com/foo?q=1", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve empty relative path - standard URI returns base with trailing slash") + void resolveEmptyRelativeStandard() throws URISyntaxException { + // java.net.URI.resolve("") on "http://example.com/basepath" returns "http://example.com/" + URI base = new URI(false, "http://example.com/basepath"); + URI relative = new URI(false, ""); + assertEquals("http://example.com/", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve with port in base URI") + void resolveWithPort() throws URISyntaxException { + URI base = new URI(false, "http://example.com:8080"); + URI relative = new URI(false, "/api/test"); + assertEquals("http://example.com:8080/api/test", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve relative path against custom-parsed base with illegal characters (placeholder)") + void resolveCustomParsedPlaceholderHost() throws URISyntaxException { + URI base = new URI("http://${placeholder}", true); + URI relative = new URI("/foo/bar", true); + assertEquals("http://${placeholder}/foo/bar", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve with query against custom-parsed base with illegal characters") + void resolveCustomParsedPlaceholderWithQuery() throws URISyntaxException { + URI base = new URI("http://${placeholder}", true); + URI relative = new URI("/foo?q=1", true); + assertEquals("http://${placeholder}/foo?q=1", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve empty relative keeps base path in custom parsing mode") + void resolveCustomParsedEmptyRelative() throws URISyntaxException { + URI base = new URI("http://${placeholder}/basepath", true); + URI relative = new URI("", true); + assertEquals("http://${placeholder}/basepath", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve with port in custom-parsed base with illegal characters") + void resolveCustomParsedPlaceholderWithPort() throws URISyntaxException { + URI base = new URI("http://${placeholder}:8080", true); + URI relative = new URI("/api/test", true); + assertEquals("http://${placeholder}:8080/api/test", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve using URIFactory with allowIllegalCharacters") + void resolveViaURIFactory() throws URISyntaxException { + URIFactory factory = new URIFactory(true); + URI base = factory.create("http://${host}"); + URI relative = factory.create("/path"); + assertEquals("http://${host}/path", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve with curly braces in path of base") + void resolveCustomParsedCurlyBracesInPath() throws URISyntaxException { + URI base = new URI("http://example.com/${version}", true); + URI relative = new URI("/foo", true); + assertEquals("http://example.com/foo", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Resolve standard base with relative that has query and fragment is ignored") + void resolveStandardWithQueryOnRelative() throws URISyntaxException { + URI base = new URI(false, "https://api.example.com"); + URI relative = new URI(false, "/v1/resource?key=value"); + assertEquals("https://api.example.com/v1/resource?key=value", base.resolve(relative).toString()); + } + + @Test + @DisplayName("Custom-parsed resolve preserves scheme correctly for https") + void resolveCustomParsedHttps() throws URISyntaxException { + URI base = new URI("https://${host}", true); + URI relative = new URI("/secure/path", true); + assertEquals("https://${host}/secure/path", base.resolve(relative).toString()); + } + } } diff --git a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java index 8377053acc..adf5e73f7e 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java @@ -30,13 +30,16 @@ public class URLUtilTest { @Test - void host() { - assertEquals("a", getHost("internal:a")); - assertEquals("a", getHost("internal://a")); - assertEquals("a", getHost("a")); - assertEquals("a", getHost("a/b")); - assertEquals("a", getHost("internal:a/b")); - assertEquals("a", getHost("internal://a/b")); + void authority() { + assertEquals("a", getAuthority("internal:a")); + assertEquals("a", getAuthority("internal://a")); + assertEquals("a", getAuthority("a")); + assertEquals("a", getAuthority("a/b")); + assertEquals("a", getAuthority("internal:a/b")); + assertEquals("a", getAuthority("internal://a/b")); + assertEquals("localhost", getAuthority("http://localhost")); + assertEquals("localhost:8080", getAuthority("http://localhost:8080")); + assertEquals("localhost:80", getAuthority("http://localhost:80/foo")); } @Test diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 153a24bff5..6fe67bea81 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,6 +4,14 @@ - Correct YAML example on GitHub README +# 7.1.1 + +# Improvements +- Move URL template evaluation after the request flow has been processed. Before expressions in the target were evaluated before the request flow was processed. + +# Features +- urlEncode(), pathSeg() functions of SpEL and Groovy + # 7.X PRIO 1: From 6eb7add70733e16351e4d74e9fa380d082aa16f4 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sun, 15 Feb 2026 19:23:58 +0100 Subject: [PATCH 03/20] feat(core): add URL encoding support for template evaluation in target URLs - Introduced `TemplateUtil` with a method to check for template markers. - Enhanced `TemplateExchangeExpression` to support URL encoding for dynamic templates. - Updated `Target` to skip unnecessary evaluation for URLs without template markers. - Added extensive tests to verify correct URL encoding across various languages (Groovy, SpEL, XPath, JSONPath). --- .../core/lang/TemplateExchangeExpression.java | 35 +++++++----- .../predic8/membrane/core/proxies/Target.java | 13 ++++- .../membrane/core/util/TemplateUtil.java | 33 +++++++++++ .../DispatchingInterceptorTest.java | 3 +- .../HTTPClientInterceptorTest.java | 55 +++++++++++++++++-- .../SetHeaderInterceptorJsonpathTest.java | 7 ++- .../lang/TemplateExchangeExpressionTest.java | 31 +++++++++-- .../membrane/core/util/TemplateUtilTest.java | 31 +++++++++++ 8 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java create mode 100644 core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java diff --git a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java index 05025dd0c4..1de44a663e 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java @@ -16,22 +16,21 @@ import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.interceptor.Interceptor.*; -import com.predic8.membrane.core.lang.spel.*; import com.predic8.membrane.core.router.*; import org.slf4j.*; import java.util.*; +import java.util.function.*; import java.util.regex.*; -import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static com.predic8.membrane.core.lang.ExchangeExpression.*; -import static com.predic8.membrane.core.lang.spel.DollarTemplateParserContext.*; +import static java.util.function.Function.*; public class TemplateExchangeExpression extends AbstractExchangeExpression { private static final Logger log = LoggerFactory.getLogger(TemplateExchangeExpression.class); - private Router router; + private Function encoder; /** * For parsing strings with expressions inside ${} e.g. "foo ${property.bar} baz" @@ -41,16 +40,18 @@ public class TemplateExchangeExpression extends AbstractExchangeExpression { private final List tokens; public static ExchangeExpression newInstance(Interceptor interceptor, Language language, String expression, Router router) { - // SpEL comes with its own templating - if (language == SPEL) { - return new SpELExchangeExpression(expression, DOLLAR_TEMPLATE_PARSER_CONTEXT, router); - } - return new TemplateExchangeExpression(interceptor, language, expression, router); + return newInstance(interceptor,language,expression,router, identity()); + } + + public static ExchangeExpression newInstance(Interceptor interceptor, Language language, String expression, Router router, Function encoder) { + // SpEL can take expressions like "a: ${..} b: ${..}" as input. We do not use that feature and tokenize the expression ourselves to enable encoding + return new TemplateExchangeExpression(interceptor, language, expression, router,encoder); } - protected TemplateExchangeExpression(Interceptor interceptor, Language language, String expression, Router router) { + protected TemplateExchangeExpression(Interceptor interceptor, Language language, String expression, Router router, Function encoder) { super(expression, router); - tokens = parseTokens(interceptor,language, expression); + this.encoder = encoder; + tokens = parseTokens(interceptor,language); } @Override @@ -76,10 +77,16 @@ private Object evaluateToObject(Exchange exchange, Flow flow) { } private String evaluateToString(Exchange exchange, Flow flow) { - StringBuilder line = new StringBuilder(); + var line = new StringBuilder(); for(Token token : tokens) { try { - line.append(token.eval(exchange, flow, String.class)); + var value = token.eval(exchange, flow, String.class); + if (token instanceof Expression) { + line.append(encoder.apply(value)); + } else { + // For text tokens we trust the configuration + line.append(value); + } } catch (Exception e) { throw new ExchangeExpressionException(token.toString(),e); } @@ -87,7 +94,7 @@ private String evaluateToString(Exchange exchange, Flow flow) { return line.toString(); } - protected static List parseTokens(Interceptor interceptor, Language language, String expression) { + protected List parseTokens(Interceptor interceptor, Language language) { log.debug("Parsing: {}",expression); List tokens = new ArrayList<>(); diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index f9ea541d8b..de12e29024 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -23,10 +23,13 @@ import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.router.*; +import java.net.*; import java.util.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; +import static com.predic8.membrane.core.util.TemplateUtil.*; +import static java.nio.charset.StandardCharsets.*; /** * @description

@@ -75,7 +78,15 @@ private List computeDestinationExpressions(Exchange exc, Router router) } private String evaluateTemplate(Exchange exc, Router router, String url, InterceptorAdapter adapter) { - return TemplateExchangeExpression.newInstance(adapter, language, url, router).evaluate(exc, REQUEST, String.class); + // If the url does not contain ${ we do not have to evaluate the expression + if (!containsTemplateMarker(url)) { + return url; + } + return TemplateExchangeExpression.newInstance(adapter, + language, + url, + router, + s -> URLEncoder.encode(s, UTF_8)).evaluate(exc, REQUEST, String.class); } public String getHost() { diff --git a/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java new file mode 100644 index 0000000000..fb874b2f39 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java @@ -0,0 +1,33 @@ +/* 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; + +public class TemplateUtil { + + /** + * Checks if the provided string contains the template marker "${" + * Fast implementation. + * @param s the string to be checked for the presence of a template marker + * @return true if the string contains a template marker, false otherwise + */ + public static boolean containsTemplateMarker(String s) { + for (int i = 0, len = s.length() - 1; i < len; i++) { + if (s.charAt(i) == '$' && s.charAt(i + 1) == '{') { + return true; + } + } + return false; + } +} diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java index 2b0cb468a1..0b093866b7 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java @@ -160,8 +160,7 @@ void getAddressFromTargetElement() throws Exception { exc.setProperty("host", "predic8.de"); exc.setProxy(api); - var url = dispatcher.getAddressFromTargetElement(exc); - System.out.println(url); + assertEquals("https://${property.host}:8080/foo", dispatcher.getAddressFromTargetElement(exc)); } @Nested diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index d3f3c6ee2a..1934bed286 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -14,6 +14,8 @@ package com.predic8.membrane.core.interceptor; +import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.proxies.*; import com.predic8.membrane.core.router.*; @@ -23,6 +25,7 @@ import static com.predic8.membrane.core.http.Header.*; import static com.predic8.membrane.core.http.Request.*; +import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static org.junit.jupiter.api.Assertions.*; class HTTPClientInterceptorTest { @@ -65,20 +68,62 @@ void passFailOverOn500() { } @Test - void computeTargetUrl() throws Exception { + void computeTargetUrlWithEncodingGroovy() throws Exception { + var exc = get("/foo") + .header("foo", "% ${}") + .header("bar", "$&:/)") + .buildExchange(); + extracted(GROOVY, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + } + + @Test + void computeTargetUrlWithEncodingSpEL() throws Exception { + var exc = get("/foo") + .header("foo", "% ${}") + .header("bar", "$&:/)") + .buildExchange(); + extracted(SPEL, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + } + + @Test + void computeTargetUrlWithEncodingJsonPath() throws Exception { + var exc = post("/foo") + .json(""" + { + "foo": "% ${}", + "bar": "$&:/)" + } + """) + .buildExchange(); + extracted(JSONPATH, exc, "http://localhost/foo/${$.foo}: {}${$.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + } + + @Test + void computeTargetUrlWithEncodingXPath() throws Exception { + var exc = post("/foo") + .json(""" + + % ${} + $&:/) + + """) + .buildExchange(); + extracted(XPATH, exc, "http://localhost/foo/${//foo}: {}${//bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + } + + private void extracted(Language language, Exchange exc, String url, String expected) { var target = new Target(); - target.setUrl("http://localhost/foo/${urlEncode(header.foo)}"); + target.setUrl(url); + target.setLanguage(language); var api = new APIProxy(); api.setTarget(target); - - var exc = get("/foo").header("foo","% ${}").buildExchange(); exc.setProxy(api); hci.init(router); new DispatchingInterceptor().handleRequest(exc); hci.applyTargetModifications(exc); assertEquals(1, exc.getDestinations().size()); - assertEquals("http://localhost/foo/%25+%24%7B%7D", exc.getDestinations().getFirst()); + assertEquals(expected, exc.getDestinations().getFirst()); } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java index 5804c6f4d7..9476551e1e 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java @@ -23,6 +23,7 @@ import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertTrue; class SetHeaderInterceptorJsonpathTest extends AbstractSetHeaderInterceptorTest { @@ -114,7 +115,9 @@ void list() { interceptor.init(router); interceptor.handleRequest(exchange); var tags = getHeader("tags"); - System.out.println(tags); + assertEquals(3, tags.split(",").length); + assertTrue(tags.contains("PRIVATE")); + assertTrue(tags.contains("BUSINESS")); } @Test @@ -124,7 +127,7 @@ void map() { interceptor.init(router); interceptor.handleRequest(exchange); var s = getHeader("map"); - Assertions.assertTrue(s.contains("3141592")); + assertTrue(s.contains("3141592")); assertTrue(s.contains("Manaus")); } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/lang/TemplateExchangeExpressionTest.java b/core/src/test/java/com/predic8/membrane/core/lang/TemplateExchangeExpressionTest.java index 104abe6927..422043618f 100644 --- a/core/src/test/java/com/predic8/membrane/core/lang/TemplateExchangeExpressionTest.java +++ b/core/src/test/java/com/predic8/membrane/core/lang/TemplateExchangeExpressionTest.java @@ -14,16 +14,21 @@ package com.predic8.membrane.core.lang; import com.predic8.membrane.core.exchange.*; -import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.router.*; +import com.predic8.membrane.core.util.*; import org.junit.jupiter.api.*; +import java.io.*; +import java.net.*; +import java.nio.charset.*; import java.util.*; -import static com.predic8.membrane.core.http.Request.get; +import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; -import static com.predic8.membrane.core.lang.ExchangeExpression.Language.SPEL; +import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static com.predic8.membrane.core.lang.TemplateExchangeExpression.*; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.function.Function.*; import static org.junit.jupiter.api.Assertions.*; class TemplateExchangeExpressionTest { @@ -31,6 +36,8 @@ class TemplateExchangeExpressionTest { Exchange exc; Language language; DefaultRouter router; + TemplateExchangeExpression expression; + InterceptorAdapter adapter; @BeforeEach void setUp() throws Exception { @@ -38,11 +45,13 @@ void setUp() throws Exception { exc.setProperty("prop1", "Mars"); language = SPEL; router = new DefaultRouter(); + adapter = new InterceptorAdapter(router); + expression = new TemplateExchangeExpression(adapter, language, "aaa", router, identity()); } @Test void text() { - assertIterableEquals(List.of(new Text("aaa")), parseTokens(new InterceptorAdapter(router),language,"aaa")); + assertIterableEquals(List.of(new Text("aaa")), expression.parseTokens(new InterceptorAdapter(router),language)); } @Test @@ -65,7 +74,19 @@ void multiple() { assertEquals("Mars - 42 - 6 7", eval("${property.prop1} - ${header.bar} - ${2*3} ${7}")); } + @Test + void encoding() { + var expr = TemplateExchangeExpression.newInstance(adapter, + GROOVY, + "a: ${property.a} b: ${property.b}", + router, + s -> URLEncoder.encode(s, UTF_8)); + exc.setProperty("a", "$%&/"); + exc.setProperty("b", "{}a§!"); + assertEquals("a: %24%25%26%2F b: %7B%7Da%C2%A7%21", expr.evaluate(exc, REQUEST,String.class)); + } + private String eval(String expr) { - return new TemplateExchangeExpression(new InterceptorAdapter(router), language, expr, router).evaluate(exc, REQUEST,String.class); + return new TemplateExchangeExpression(new InterceptorAdapter(router), language, expr, router, identity()).evaluate(exc, REQUEST,String.class); } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java new file mode 100644 index 0000000000..7096200f02 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java @@ -0,0 +1,31 @@ +/* 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; + +import org.junit.jupiter.api.*; + +import static com.predic8.membrane.core.util.TemplateUtil.containsTemplateMarker; +import static org.junit.jupiter.api.Assertions.*; + +class TemplateUtilTest { + + @Test + void test() { + assertFalse(containsTemplateMarker("")); + assertFalse(containsTemplateMarker("foo")); + assertTrue(containsTemplateMarker("foo${bar")); + assertFalse(containsTemplateMarker("foo$x{bar")); + } +} \ No newline at end of file From 583504a5c61028114fef702fa70d7b619633b9cb Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sun, 15 Feb 2026 19:26:46 +0100 Subject: [PATCH 04/20] docs(roadmap): update URL encoding details for template evaluation in target URLs - Documented the change to URL encode expressions inside `${}` in target URLs. - Updated tests to improve validation coverage and logging for clearer output. --- .../membrane/core/lang/TemplateExchangeExpression.java | 4 ++-- .../tutorials/getting_started/SetBodyTutorialTest.java | 9 +++------ docs/ROADMAP.md | 1 + 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java index 1de44a663e..10054587ba 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java @@ -30,7 +30,7 @@ public class TemplateExchangeExpression extends AbstractExchangeExpression { private static final Logger log = LoggerFactory.getLogger(TemplateExchangeExpression.class); - private Function encoder; + private final Function encoder; /** * For parsing strings with expressions inside ${} e.g. "foo ${property.bar} baz" @@ -94,7 +94,7 @@ private String evaluateToString(Exchange exchange, Flow flow) { return line.toString(); } - protected List parseTokens(Interceptor interceptor, Language language) { + List parseTokens(Interceptor interceptor, Language language) { log.debug("Parsing: {}",expression); List tokens = new ArrayList<>(); diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/getting_started/SetBodyTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/getting_started/SetBodyTutorialTest.java index 4ce01f6de4..9b602c87f4 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/getting_started/SetBodyTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/getting_started/SetBodyTutorialTest.java @@ -33,13 +33,10 @@ void path_and_headers_list() { .when() .get("http://localhost:2000/spel") .then() + .log().ifValidationFails() .statusCode(200) - .body(allOf( - containsString("Path: /spel"), - containsString("Headers: Accept,"), - containsString("User-Agent"), - containsString("Host") - )); + .body(containsString("Path: /spel")) + .body(containsString("Host")); // @formatter:on } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6fe67bea81..3645591631 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,6 +8,7 @@ # Improvements - Move URL template evaluation after the request flow has been processed. Before expressions in the target were evaluated before the request flow was processed. +- In a target/url with an expression like "a: ${propery.a} b: ${property.b}" the evaluation result of ${} is now URL encoded. # Features - urlEncode(), pathSeg() functions of SpEL and Groovy From fd737e8c1694c617d3c211736dec0d87e2b08567 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sun, 15 Feb 2026 21:25:02 +0100 Subject: [PATCH 05/20] feat(core): optimize expression handling for dynamic URLs with caching - Introduced a `templateExpressionCache` in `Target` to avoid redundant parsing and compilation of frequently used expressions. - Improved token parsing and evaluation logic in `TemplateExchangeExpression`, enhancing clarity and performance. - Renamed test helper method `extracted` to `testExpression` for better readability. - Updated test cases across multiple classes to cover new enhancements and ensure consistent behavior. --- .../core/lang/TemplateExchangeExpression.java | 42 ++++++++++--------- .../predic8/membrane/core/proxies/Target.java | 15 +++++-- .../HTTPClientInterceptorTest.java | 12 +++--- .../SetHeaderInterceptorJsonpathTest.java | 1 - .../membrane/core/util/TemplateUtilTest.java | 5 ++- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java index 10054587ba..70f90ea4a5 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java @@ -30,7 +30,7 @@ public class TemplateExchangeExpression extends AbstractExchangeExpression { private static final Logger log = LoggerFactory.getLogger(TemplateExchangeExpression.class); - private final Function encoder; + private final Function encoder; /** * For parsing strings with expressions inside ${} e.g. "foo ${property.bar} baz" @@ -40,18 +40,18 @@ public class TemplateExchangeExpression extends AbstractExchangeExpression { private final List tokens; public static ExchangeExpression newInstance(Interceptor interceptor, Language language, String expression, Router router) { - return newInstance(interceptor,language,expression,router, identity()); + return newInstance(interceptor, language, expression, router, identity()); } - public static ExchangeExpression newInstance(Interceptor interceptor, Language language, String expression, Router router, Function encoder) { + public static ExchangeExpression newInstance(Interceptor interceptor, Language language, String expression, Router router, Function encoder) { // SpEL can take expressions like "a: ${..} b: ${..}" as input. We do not use that feature and tokenize the expression ourselves to enable encoding - return new TemplateExchangeExpression(interceptor, language, expression, router,encoder); + return new TemplateExchangeExpression(interceptor, language, expression, router, encoder); } - protected TemplateExchangeExpression(Interceptor interceptor, Language language, String expression, Router router, Function encoder) { + protected TemplateExchangeExpression(Interceptor interceptor, Language language, String expression, Router router, Function encoder) { super(expression, router); this.encoder = encoder; - tokens = parseTokens(interceptor,language); + tokens = parseTokens(interceptor, language); } @Override @@ -65,37 +65,40 @@ public T evaluate(Exchange exchange, Flow flow, Class type) { } return type.cast(evaluateToObject(exchange, flow)); } - return type.cast( evaluateToString(exchange, flow)); + return type.cast(evaluateToString(exchange, flow)); } private Object evaluateToObject(Exchange exchange, Flow flow) { try { - return tokens.getFirst().eval(exchange, flow,Object.class); + return tokens.getFirst().eval(exchange, flow, Object.class); } catch (Exception e) { - throw new ExchangeExpressionException(tokens.getFirst().toString(),e); + throw new ExchangeExpressionException(tokens.getFirst().toString(), e); } } private String evaluateToString(Exchange exchange, Flow flow) { var line = new StringBuilder(); - for(Token token : tokens) { + for (var token : tokens) { try { var value = token.eval(exchange, flow, String.class); - if (token instanceof Expression) { - line.append(encoder.apply(value)); - } else { - // For text tokens we trust the configuration + if (token instanceof Text) { line.append(value); + continue; + } + if (value == null) { + line.append("null"); + } else { + line.append(encoder.apply(value)); } } catch (Exception e) { - throw new ExchangeExpressionException(token.toString(),e); + throw new ExchangeExpressionException(token.toString(), e); } } return line.toString(); } List parseTokens(Interceptor interceptor, Language language) { - log.debug("Parsing: {}",expression); + log.debug("Parsing: {}", expression); List tokens = new ArrayList<>(); Matcher m = scriptPattern.matcher(expression); @@ -114,7 +117,8 @@ List parseTokens(Interceptor interceptor, Language language) { } interface Token { - T eval(Exchange exchange, Flow flow, Class type); + T eval(Exchange exchange, Flow flow, Class type); + String getExpression(); } @@ -127,7 +131,7 @@ public Text(String value) { } @Override - public T eval(Exchange exchange, Flow flow, Class type) { + public T eval(Exchange exchange, Flow flow, Class type) { return type.cast(value); } @@ -159,7 +163,7 @@ public Expression(ExchangeExpression exchangeExpression) { } @Override - public T eval(Exchange exchange, Flow flow, Class type) { + public T eval(Exchange exchange, Flow flow, Class type) { return exchangeExpression.evaluate(exchange, flow, type); } diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index de12e29024..204509242a 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -51,6 +51,14 @@ public class Target implements XMLSupport { protected XmlConfig xmlConfig; + /** + * A cache mapping template strings to precomputed {@link ExchangeExpression} instances. + * + * This cache is used to optimize the evaluation of dynamic expressions by avoiding the repeated + * parsing and compilation of the same template. + */ + private Map templateExpressionCache = new HashMap<>(); + public Target() { } @@ -82,11 +90,12 @@ private String evaluateTemplate(Exchange exc, Router router, String url, Interce if (!containsTemplateMarker(url)) { return url; } - return TemplateExchangeExpression.newInstance(adapter, + + return templateExpressionCache.computeIfAbsent(url, k -> TemplateExchangeExpression.newInstance(adapter, language, - url, + k, router, - s -> URLEncoder.encode(s, UTF_8)).evaluate(exc, REQUEST, String.class); + s -> URLEncoder.encode(s, UTF_8))).evaluate(exc, REQUEST, String.class); } public String getHost() { diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 1934bed286..313436a6e5 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -73,7 +73,7 @@ void computeTargetUrlWithEncodingGroovy() throws Exception { .header("foo", "% ${}") .header("bar", "$&:/)") .buildExchange(); - extracted(GROOVY, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(GROOVY, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); } @Test @@ -82,7 +82,7 @@ void computeTargetUrlWithEncodingSpEL() throws Exception { .header("foo", "% ${}") .header("bar", "$&:/)") .buildExchange(); - extracted(SPEL, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(SPEL, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); } @Test @@ -95,23 +95,23 @@ void computeTargetUrlWithEncodingJsonPath() throws Exception { } """) .buildExchange(); - extracted(JSONPATH, exc, "http://localhost/foo/${$.foo}: {}${$.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(JSONPATH, exc, "http://localhost/foo/${$.foo}: {}${$.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); } @Test void computeTargetUrlWithEncodingXPath() throws Exception { var exc = post("/foo") - .json(""" + .xml(""" % ${} $&:/) """) .buildExchange(); - extracted(XPATH, exc, "http://localhost/foo/${//foo}: {}${//bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(XPATH, exc, "http://localhost/foo/${//foo}: {}${//bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); } - private void extracted(Language language, Exchange exc, String url, String expected) { + private void testExpression(Language language, Exchange exc, String url, String expected) { var target = new Target(); target.setUrl(url); target.setLanguage(language); diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java index 9476551e1e..c36c17f9ce 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetHeaderInterceptorJsonpathTest.java @@ -23,7 +23,6 @@ import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertTrue; class SetHeaderInterceptorJsonpathTest extends AbstractSetHeaderInterceptorTest { diff --git a/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java index 7096200f02..59118f70e9 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java @@ -16,13 +16,16 @@ import org.junit.jupiter.api.*; -import static com.predic8.membrane.core.util.TemplateUtil.containsTemplateMarker; +import static com.predic8.membrane.core.util.TemplateUtil.*; import static org.junit.jupiter.api.Assertions.*; class TemplateUtilTest { @Test void test() { + assertFalse(containsTemplateMarker("$")); + assertTrue(containsTemplateMarker("${")); + assertFalse(containsTemplateMarker("foo$")); assertFalse(containsTemplateMarker("")); assertFalse(containsTemplateMarker("foo")); assertTrue(containsTemplateMarker("foo${bar")); From 58959bf05c1eeb74b75e4e71bee3a1b9f532cd8c Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sun, 15 Feb 2026 22:34:21 +0100 Subject: [PATCH 06/20] refactor(core): remove template expression caching from `Target` and simplify logic - Eliminated `templateExpressionCache` to reduce complexity and improve maintainability. - Updated `computeDestinationExpressions` to use `Collectors.toList()` for a mutable list. - Simplified `evaluateTemplate` by removing cache-related logic and comments on minor performance differences. --- .../predic8/membrane/core/proxies/Target.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index 204509242a..8e852a66d9 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -25,6 +25,7 @@ import java.net.*; import java.util.*; +import java.util.stream.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; @@ -51,14 +52,6 @@ public class Target implements XMLSupport { protected XmlConfig xmlConfig; - /** - * A cache mapping template strings to precomputed {@link ExchangeExpression} instances. - * - * This cache is used to optimize the evaluation of dynamic expressions by avoiding the repeated - * parsing and compilation of the same template. - */ - private Map templateExpressionCache = new HashMap<>(); - public Target() { } @@ -82,7 +75,8 @@ public void applyModifications(Exchange exc, Router router) { private List computeDestinationExpressions(Exchange exc, Router router) { var adapter = new InterceptorAdapter(router, xmlConfig); - return exc.getDestinations().stream().map(url -> evaluateTemplate(exc, router, url, adapter)).toList(); + return exc.getDestinations().stream().map(url -> evaluateTemplate(exc, router, url, adapter)) + .collect(Collectors.toList()); // Collectors.toList() generates mutable List .toList() => immutable } private String evaluateTemplate(Exchange exc, Router router, String url, InterceptorAdapter adapter) { @@ -91,11 +85,13 @@ private String evaluateTemplate(Exchange exc, Router router, String url, Interce return url; } - return templateExpressionCache.computeIfAbsent(url, k -> TemplateExchangeExpression.newInstance(adapter, + // Without caching 1_000_000 => 37s with ConcurrentHashMap as Cache => 34s + // Cache is probably not worth the effort and complexity + return TemplateExchangeExpression.newInstance(adapter, language, - k, + url, router, - s -> URLEncoder.encode(s, UTF_8))).evaluate(exc, REQUEST, String.class); + s -> URLEncoder.encode(s, UTF_8)).evaluate(exc, REQUEST, String.class); } public String getHost() { From 428cb83735762299b2f9341237cda2c0248b6fd1 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Mon, 16 Feb 2026 09:52:28 +0100 Subject: [PATCH 07/20] feat(core): introduce `Escaping` enum for customizable URL encoding in `Target` - Added `Escaping` enum with options `NONE`, `URL`, and `SEGMENT` to enhance placeholder escaping in URLs. - Updated `Target` to include configurable escaping behavior and a helper method `getEscapingFunction()`. - Extended test cases to validate new escaping options in various scenarios. --- .../predic8/membrane/core/proxies/Target.java | 41 ++++++++++++++++++- .../HTTPClientInterceptorTest.java | 30 +++++++++++--- .../interceptor/acl/targets/TargetTest.java | 2 +- .../membrane/core/proxies/TargetTest.java | 14 +++++++ 4 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index 8e852a66d9..04d1df9455 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -22,9 +22,12 @@ import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.router.*; +import com.predic8.membrane.core.transport.ws.interceptors.*; +import com.predic8.membrane.core.util.*; import java.net.*; import java.util.*; +import java.util.function.*; import java.util.stream.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; @@ -41,17 +44,27 @@ */ @MCElement(name = "target", component = false) public class Target implements XMLSupport { + private String host; private int port = -1; private String method; + protected String url; - private boolean adjustHostHeader = true; private ExchangeExpression.Language language = SPEL; + private Escaping escaping = Escaping.URL; + + private boolean adjustHostHeader = true; private SSLParser sslParser; protected XmlConfig xmlConfig; + public enum Escaping { + NONE, + URL, + SEGMENT + } + public Target() { } @@ -91,7 +104,15 @@ private String evaluateTemplate(Exchange exc, Router router, String url, Interce language, url, router, - s -> URLEncoder.encode(s, UTF_8)).evaluate(exc, REQUEST, String.class); + getEscapingFunction()).evaluate(exc, REQUEST, String.class); + } + + private Function getEscapingFunction() { + return switch (escaping) { + case NONE -> Function.identity(); + case URL -> s -> URLEncoder.encode(s, UTF_8); + case SEGMENT -> URLUtil::pathSeg; + }; } public String getHost() { @@ -184,6 +205,22 @@ public void setLanguage(ExchangeExpression.Language language) { this.language = language; } + public Escaping getEscaping() { + return escaping; + } + + /** + * @description When url contains placeholders ${}, the computed values should be escaped + * to prevent injection attacks. + * + * @default URL + * @param escaping NONE, URL, SEGMENT + */ + @MCAttribute + public void setEscaping(Escaping escaping) { + this.escaping = escaping; + } + /** * XML Configuration e.g. declaration of XML namespaces for XPath expressions, ... * diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 313436a6e5..210a85a213 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -18,6 +18,7 @@ import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.proxies.*; +import com.predic8.membrane.core.proxies.Target.*; import com.predic8.membrane.core.router.*; import org.junit.jupiter.api.*; @@ -26,6 +27,8 @@ import static com.predic8.membrane.core.http.Header.*; import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; +import static com.predic8.membrane.core.proxies.Target.Escaping.NONE; +import static com.predic8.membrane.core.proxies.Target.Escaping.SEGMENT; import static org.junit.jupiter.api.Assertions.*; class HTTPClientInterceptorTest { @@ -73,7 +76,7 @@ void computeTargetUrlWithEncodingGroovy() throws Exception { .header("foo", "% ${}") .header("bar", "$&:/)") .buildExchange(); - testExpression(GROOVY, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(GROOVY, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29", Escaping.URL); } @Test @@ -82,7 +85,7 @@ void computeTargetUrlWithEncodingSpEL() throws Exception { .header("foo", "% ${}") .header("bar", "$&:/)") .buildExchange(); - testExpression(SPEL, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(SPEL, exc, "http://localhost/foo/${header.foo}: {}${header.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29", Escaping.URL); } @Test @@ -95,7 +98,7 @@ void computeTargetUrlWithEncodingJsonPath() throws Exception { } """) .buildExchange(); - testExpression(JSONPATH, exc, "http://localhost/foo/${$.foo}: {}${$.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(JSONPATH, exc, "http://localhost/foo/${$.foo}: {}${$.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29", Escaping.URL); } @Test @@ -108,13 +111,30 @@ void computeTargetUrlWithEncodingXPath() throws Exception { """) .buildExchange(); - testExpression(XPATH, exc, "http://localhost/foo/${//foo}: {}${//bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29"); + testExpression(XPATH, exc, "http://localhost/foo/${//foo}: {}${//bar}", + "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29", Escaping.URL); } - private void testExpression(Language language, Exchange exc, String url, String expected) { + @Test + void computeNoneEscaping() throws Exception { + var exc = post("/foo").buildExchange(); + testExpression(SPEL, exc, "http://localhost/foo/${'&?äöü!\"=:#/\\'}", + "http://localhost/foo/&?äöü!\"=:#/\\", NONE); + } + + @Test + void computeSegmentEscaping() throws Exception { + var exc = post("/foo").buildExchange(); + testExpression(SPEL, exc, "http://localhost/foo/${'&?äöü!\"=:#/\\'}", + "http://localhost/foo/%26%3F%C3%A4%C3%B6%C3%BC%21%22%3D%3A%23%2F%5C", SEGMENT); + } + + + private void testExpression(Language language, Exchange exc, String url, String expected, Escaping escaping) { var target = new Target(); target.setUrl(url); target.setLanguage(language); + target.setEscaping(escaping); var api = new APIProxy(); api.setTarget(target); diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java index 0294b002dc..ba7206c8b2 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/acl/targets/TargetTest.java @@ -17,7 +17,7 @@ import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.interceptor.acl.*; import com.predic8.membrane.core.openapi.serviceproxy.*; -import com.predic8.membrane.core.router.*; +import com.predic8.membrane.core.proxies.Target.*; import org.junit.jupiter.api.*; import org.junit.jupiter.params.*; import org.junit.jupiter.params.provider.*; diff --git a/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java new file mode 100644 index 0000000000..742020a1f2 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java @@ -0,0 +1,14 @@ +package com.predic8.membrane.core.proxies; + +import com.predic8.membrane.core.proxies.Target.*; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TargetTest { + + @Test + void defaultEscaping() { + assertEquals(new Target().getEscaping(), Escaping.URL); + } +} \ No newline at end of file From 684e6c28f67de443220a0b06846325a67bfcf03b Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Mon, 16 Feb 2026 12:01:24 +0100 Subject: [PATCH 08/20] refactor(core): enhance URI handling and extend test coverage - Updated `URI` to leverage `var` for better readability and added JavaDoc for `resolve` method. - Improved `ResolverMap` handling of URI schemes, including `classpath:` and `internal:`. - Added new tests in `HTTPClientInterceptorTest` for complete path computation, including URL encoding. --- .../membrane/core/resolver/ResolverMap.java | 5 ++--- .../com/predic8/membrane/core/util/URI.java | 11 +++++++++-- .../HTTPClientInterceptorTest.java | 19 +++++++++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java index 99671a6e78..e35e2e9ae8 100644 --- a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java +++ b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java @@ -95,10 +95,9 @@ private static String combineInternal(String... locations) { } if (parent.contains(":/")) { try { - if (parent.startsWith("http")) - return new URI(parent).resolve(prepare4Uri(relativeChild)).toString(); - if (parent.startsWith("classpath:")) + if (parent.startsWith("http") || parent.startsWith("classpath:") || parent.startsWith("internal:")) return new URI(parent).resolve(prepare4Uri(relativeChild)).toString(); + // Assume file parent is a file path either file:, or c:\, or /, or a/b, ... return convertPath2FileURI(parent).resolve(prepare4Uri(relativeChild)).toString(); } catch (Exception e) { throw new RuntimeException(e); diff --git a/core/src/main/java/com/predic8/membrane/core/util/URI.java b/core/src/main/java/com/predic8/membrane/core/util/URI.java index ddcebb6bcf..3acf713441 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URI.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URI.java @@ -282,17 +282,24 @@ public String getPathWithQuery() { return r.toString(); } + /** + * Use ResolverMap to combine URIs. Only resort to this function if it is not possible to use ResolverMap e.g. + * for URIs with invalid characters like $ { } in the DispatchingInterceptor + * @param relative URI + * @return Combined URI + * @throws URISyntaxException + */ public URI resolve(URI relative) throws URISyntaxException { if (uri != null) { java.net.URI resolved = uri.resolve(relative.uri != null ? relative.uri : new java.net.URI(relative.toString())); return new URI(false, resolved.toString()); } // Custom-parsed: scheme://authority + relative path - String resolvedPath = relative.getRawPath(); + var resolvedPath = relative.getRawPath(); if (resolvedPath == null || resolvedPath.isEmpty()) { resolvedPath = this.getRawPath(); } - String result = scheme + "://" + authority + resolvedPath; + var result = scheme + "://" + authority + resolvedPath; if (relative.getRawQuery() != null) { result += "?" + relative.getRawQuery(); } diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 210a85a213..c9ee8d0f98 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -27,8 +27,7 @@ import static com.predic8.membrane.core.http.Header.*; import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; -import static com.predic8.membrane.core.proxies.Target.Escaping.NONE; -import static com.predic8.membrane.core.proxies.Target.Escaping.SEGMENT; +import static com.predic8.membrane.core.proxies.Target.Escaping.*; import static org.junit.jupiter.api.Assertions.*; class HTTPClientInterceptorTest { @@ -129,6 +128,22 @@ void computeSegmentEscaping() throws Exception { "http://localhost/foo/%26%3F%C3%A4%C3%B6%C3%BC%21%22%3D%3A%23%2F%5C", SEGMENT); } + @Test + void computeCompletePath() throws Exception { + var completePath = "https://predic8.com/foo?bar=baz"; + var exc = post("/foo") + .header("X-URL", completePath) + .buildExchange(); + testExpression(SPEL, exc, "${header['X-URL']}", completePath, NONE); + } + + @Test + void computeCompletePathURLEncoded() throws Exception { + var exc = post("/foo").buildExchange(); + testExpression(SPEL, exc, "${'&?äöü!'}", + "%26%3F%C3%A4%C3%B6%C3%BC%21", Escaping.URL); + } + private void testExpression(Language language, Exchange exc, String url, String expected, Escaping escaping) { var target = new Target(); From 76ffa6de18282f381971db1a425ca8199f8a175d Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Tue, 17 Feb 2026 21:51:33 +0100 Subject: [PATCH 09/20] feat(core): improve handling of illegal characters in URLs and enhance template evaluation - Added `UriIllegalCharacterDetector` for validating URI components with RFC 3986 compliance and optional extensions. - Updated `Target` to skip template evaluation for URLs without placeholders and log warnings for insecure configurations. - Enhanced `TemplateUtil.containsTemplateMarker` with null safety. - Improved `RewriteInterceptor` to handle query parsing errors with more informative logging. - Refactored tests and added new ones to validate changes. --- .../interceptor/DispatchingInterceptor.java | 18 +- .../rewrite/RewriteInterceptor.java | 4 +- .../core/proxies/AbstractServiceProxy.java | 1 + .../predic8/membrane/core/proxies/Target.java | 43 +- .../membrane/core/resolver/ResolverMap.java | 33 +- .../membrane/core/util/TemplateUtil.java | 1 + .../com/predic8/membrane/core/util/URI.java | 260 +++++++--- .../membrane/core/util/URIFactory.java | 12 +- .../membrane/core/util/URIValidationUtil.java | 86 +++ .../util/UriIllegalCharacterDetector.java | 171 ++++++ .../DispatchingInterceptorTest.java | 14 +- .../HTTPClientInterceptorTest.java | 63 ++- .../rewrite/RewriteInterceptorTest.java | 3 +- .../server/WSDLPublisherInterceptorTest.java | 4 +- .../ExplodeFalseArrayQueryParameterTest.java | 5 +- .../ExplodedArrayQueryParameterTest.java | 4 +- .../core/openapi/util/UriUtilTest.java | 8 +- .../core/resolver/ResolverMapCombineTest.java | 4 +- .../predic8/membrane/core/util/URITest.java | 491 +++++++++--------- .../membrane/core/util/URLUtilTest.java | 2 +- distribution/examples/configuration/apis.yaml | 5 +- 21 files changed, 860 insertions(+), 372 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java create mode 100644 core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java index c07ac1bd91..c12ad614f8 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java @@ -14,6 +14,7 @@ package com.predic8.membrane.core.interceptor; import com.predic8.membrane.annot.*; +import com.predic8.membrane.core.exceptions.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.openapi.util.*; import com.predic8.membrane.core.proxies.*; @@ -55,11 +56,14 @@ public Outcome handleRequest(Exchange exc) { exc.getDestinations().clear(); try { exc.getDestinations().add(getForwardingDestination(exc)); + } catch (IllegalArgumentException e) { + createInvalidCharacterProblemDetails(exc) + .detail(e.getMessage()) + .buildAndSetResponse(exc); + return ABORT; } catch (URISyntaxException e) { - var pd = user(getRouter().getConfiguration().isProduction(), "invalid-path") - .title("Request path contains an invalid character.") + var pd = createInvalidCharacterProblemDetails(exc) .detail(getMessageForURISyntaxException(exc, e)) - .internal("path", exc.getRequest().getUri()) .internal("destination", e.getInput()); if (e.getIndex() >= 0) pd.internal("index", e.getIndex()); @@ -79,6 +83,12 @@ public Outcome handleRequest(Exchange exc) { return CONTINUE; } + private ProblemDetails createInvalidCharacterProblemDetails(Exchange exc) { + return user(getRouter().getConfiguration().isProduction(), "invalid-path") + .title("Request path contains an invalid character.") + .internal("path", exc.getRequest().getUri()); + } + private static @NotNull String getMessageForURISyntaxException(Exchange exc, URISyntaxException e) { var uri = exc.getOriginalRelativeURI(); if (e.getIndex() >= 0 && e.getIndex() < uri.length()) { @@ -115,7 +125,7 @@ protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLEx // The URL is from the target in the configuration, that is maintained by the admin var basePath = UriUtil.getPathFromURL(URI_FACTORY_ALLOW_ILLEGAL, targetURL); if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { - return URI_FACTORY_ALLOW_ILLEGAL.create(targetURL).resolve(URI_FACTORY_ALLOW_ILLEGAL.create(getUri(exc))).toString(); + return router.getResolverMap().combine(router.getConfiguration().getUriFactory(),targetURL,getUri(exc)); } } return targetURL; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptor.java index 1b283228c4..f928278ffa 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptor.java @@ -202,12 +202,12 @@ public Outcome handleRequest(Exchange exc) { private String getPathQueryOrSetError(URIFactory factory, String destination, Exchange exc) { try { return URLUtil.getPathQuery(factory, destination); - } catch (URISyntaxException ignore) { + } catch (URISyntaxException | IllegalArgumentException e) { log.info("Can't parse query: {}", destination); user(false,getDisplayName()) .addSubType("path") .title("The path does not follow the URI specification. Confirm the validity of the provided URL.") - .detail("Check the URL: " + destination) + .detail(e.getMessage()) .internal("component", "rewrite") .internal("path", destination) .buildAndSetResponse(exc); diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java b/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java index 3b726d2dbb..974be3b8aa 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/AbstractServiceProxy.java @@ -28,6 +28,7 @@ public void init() { target.setPort(target.getSslParser() != null ? 443 : 80); if (target.getSslParser() != null) setSslOutboundContext(new StaticSSLContext(target.getSslParser(), router.getResolverMap(), router.getConfiguration().getBaseLocation())); + target.init(router); } public String getHost() { diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index 04d1df9455..d509eb2b1a 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -22,8 +22,9 @@ import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.router.*; -import com.predic8.membrane.core.transport.ws.interceptors.*; import com.predic8.membrane.core.util.*; +import com.predic8.membrane.core.util.text.*; +import org.slf4j.*; import java.net.*; import java.util.*; @@ -33,6 +34,7 @@ import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static com.predic8.membrane.core.util.TemplateUtil.*; +import static com.predic8.membrane.core.util.text.TerminalColors.*; import static java.nio.charset.StandardCharsets.*; /** @@ -45,6 +47,8 @@ @MCElement(name = "target", component = false) public class Target implements XMLSupport { + private static final Logger log = LoggerFactory.getLogger(Target.class); + private String host; private int port = -1; private String method; @@ -53,10 +57,14 @@ public class Target implements XMLSupport { private ExchangeExpression.Language language = SPEL; private Escaping escaping = Escaping.URL; + /** + * If exchangeExpressions should be evaluated. + */ + private boolean evaluateExpressions = false; + private boolean adjustHostHeader = true; private SSLParser sslParser; - protected XmlConfig xmlConfig; public enum Escaping { @@ -77,6 +85,18 @@ public Target(String host, int port) { setPort(port); } + public void init(Router router) { + // URL Template evaluation is only activated when there are template markers ${ in the URL + if (!containsTemplateMarker(url)) + return; + + if (router.getConfiguration().getUriFactory().isAllowIllegalCharacters()) { + log.warn("{}Url templates are disabled for security.{} Disable configuration/uriFactory/allowIllegalCharacters to enable them. Illegal characters in templates may lead to injection attacks.", TerminalColors.BRIGHT_RED(), RESET()); + } else { + evaluateExpressions = true; + } + } + public void applyModifications(Exchange exc, Router router) { exc.setDestinations(computeDestinationExpressions(exc, router)); @@ -93,6 +113,10 @@ private List computeDestinationExpressions(Exchange exc, Router router) } private String evaluateTemplate(Exchange exc, Router router, String url, InterceptorAdapter adapter) { + // Only evaluate if the target url contains a template marker ${} + if (!evaluateExpressions) + return url; + // If the url does not contain ${ we do not have to evaluate the expression if (!containsTemplateMarker(url)) { return url; @@ -107,11 +131,11 @@ private String evaluateTemplate(Exchange exc, Router router, String url, Interce getEscapingFunction()).evaluate(exc, REQUEST, String.class); } - private Function getEscapingFunction() { + private Function getEscapingFunction() { return switch (escaping) { - case NONE -> Function.identity(); - case URL -> s -> URLEncoder.encode(s, UTF_8); - case SEGMENT -> URLUtil::pathSeg; + case NONE -> Function.identity(); + case URL -> s -> URLEncoder.encode(s, UTF_8); + case SEGMENT -> URLUtil::pathSeg; }; } @@ -119,6 +143,10 @@ public String getHost() { return host; } + public boolean isEvaluateExpressions() { + return evaluateExpressions; + } + /** * @description Host address of the target. * @example localhost, 192.168.1.1 @@ -210,11 +238,10 @@ public Escaping getEscaping() { } /** + * @param escaping NONE, URL, SEGMENT * @description When url contains placeholders ${}, the computed values should be escaped * to prevent injection attacks. - * * @default URL - * @param escaping NONE, URL, SEGMENT */ @MCAttribute public void setEscaping(Escaping escaping) { diff --git a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java index e35e2e9ae8..eabe5441af 100644 --- a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java +++ b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java @@ -33,6 +33,7 @@ import java.security.*; import java.util.*; +import static com.predic8.membrane.core.util.URIFactory.DEFAULT_URI_FACTORY; import static com.predic8.membrane.core.util.URIUtil.*; /** @@ -58,13 +59,17 @@ public class ResolverMap implements Cloneable, Resolver { * @param locations List of relative paths * @return combined path */ - public static String combine(String... locations) { - String resolved = combineInternal(locations); + public static String combine(URIFactory factory, String... locations) { + String resolved = combineInternal(factory,locations); log.debug("Resolved locations: {} to: {}", locations, resolved); return resolved; } - private static String combineInternal(String... locations) { + public static String combine(String... locations) { + return combine(DEFAULT_URI_FACTORY,locations); + } + + private static String combineInternal(URIFactory factory,String... locations) { if (locations.length < 2) throw new InvalidParameterException(); @@ -75,14 +80,17 @@ private static String combineInternal(String... locations) { return combine(combine(l), locations[locations.length - 1]); } - String parent = locations[0]; - String relativeChild = locations[1]; + return combineInternal2( factory, locations,locations[1], locations[0]); + } + + private static String combineInternal2(URIFactory uriFactory, String[] locations, String relativeChild, String parent) { if (relativeChild.contains(":/") || relativeChild.contains(":\\") || parent == null || parent.isEmpty()) return relativeChild; - if (parent.startsWith("file://")) { + + if (parent.startsWith("file:/")) { if (relativeChild.startsWith("\\") || relativeChild.startsWith("/")) { - return convertPath2FilePathString( new File(relativeChild).getAbsolutePath()); + return convertPath2FilePathString(new File(relativeChild).getAbsolutePath()); } File parentFile = new File(pathFromFileURI(parent)); if (!parent.endsWith("/") && !parent.endsWith("\\")) @@ -93,12 +101,12 @@ private static String combineInternal(String... locations) { throw new RuntimeException("Error combining: " + Arrays.toString(locations), e); } } + if (parent.contains(":/")) { try { - if (parent.startsWith("http") || parent.startsWith("classpath:") || parent.startsWith("internal:")) - return new URI(parent).resolve(prepare4Uri(relativeChild)).toString(); - // Assume file parent is a file path either file:, or c:\, or /, or a/b, ... - return convertPath2FileURI(parent).resolve(prepare4Uri(relativeChild)).toString(); + if (parent.startsWith("http") || parent.startsWith("classpath:") || parent.startsWith("internal:")) { + return uriFactory.create(parent).resolve(uriFactory.create(prepare4Uri(relativeChild)),uriFactory).toString(); + } } catch (Exception e) { throw new RuntimeException(e); } @@ -133,8 +141,7 @@ private static String combineInternal(String... locations) { */ protected static String prepare4Uri(String path) { path = path.replaceAll("\\\\", "/"); - path = path.replaceAll(" ", "%20"); - return path; + return path.replaceAll(" ", "%20"); } protected static @NotNull String keepTrailingSlash(File parentFile, String relativeChild) throws URISyntaxException { diff --git a/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java index fb874b2f39..c430b92505 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java @@ -23,6 +23,7 @@ public class TemplateUtil { * @return true if the string contains a template marker, false otherwise */ public static boolean containsTemplateMarker(String s) { + if (s == null) return false; for (int i = 0, len = s.length() - 1; i < len; i++) { if (s.charAt(i) == '$' && s.charAt(i + 1) == '{') { return true; diff --git a/core/src/main/java/com/predic8/membrane/core/util/URI.java b/core/src/main/java/com/predic8/membrane/core/util/URI.java index 3acf713441..8422b73059 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URI.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URI.java @@ -17,13 +17,13 @@ import java.net.*; import java.util.regex.*; +import static com.predic8.membrane.core.util.URIValidationUtil.*; import static java.nio.charset.StandardCharsets.*; /** * Same behavior as {@link java.net.URI}, but accommodates '{' in paths. */ public class URI { - private java.net.URI uri; private String input; private String path; @@ -41,28 +41,22 @@ public class URI { // raw authority string as it appeared in the input (may include user-info) private String authority; + private boolean allowIllegalCharacters = false; + private static final Pattern PATTERN = Pattern.compile("^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?"); // 12 3 4 5 6 7 8 9 // if defined, the groups are: // 2: scheme, 4: authority, 5: path, 7: query, 9: fragment - URI(boolean allowCustomParsing, String s) throws URISyntaxException { - try { - uri = new java.net.URI(s); - } catch (URISyntaxException e) { - if (allowCustomParsing && customInit(s)) - return; - throw e; - } + URI(String s) throws URISyntaxException { + if (!customInit(s)) + throw new URISyntaxException(s, "URI did not match regular expression."); } - URI(String s, boolean useCustomParsing) throws URISyntaxException { - if (useCustomParsing) { - if (!customInit(s)) - throw new URISyntaxException(s, "URI did not match regular expression."); - } else { - uri = new java.net.URI(s); - } + URI(String s, boolean allowIllegalCharacters) throws URISyntaxException { + this.allowIllegalCharacters = allowIllegalCharacters; + if (!customInit(s)) + throw new URISyntaxException(s, "URI did not match regular expression."); } private boolean customInit(String s) { @@ -79,6 +73,12 @@ private boolean customInit(String s) { path = m.group(5); query = m.group(7); fragment = m.group(9); + + if (!allowIllegalCharacters) { + var options = new UriIllegalCharacterDetector.Options(); + UriIllegalCharacterDetector.validateAll(this, options); + } + return true; } @@ -101,12 +101,12 @@ private void processAuthority(String rawAuthority) { record HostPort(String host, int port) { } - static HostPort parseHostPort(String rawAuthority) { + HostPort parseHostPort(String rawAuthority) { if (rawAuthority == null) throw new IllegalArgumentException("rawAuthority is null."); String hostAndPort = stripUserInfo(rawAuthority); - if (isIPLiteral(hostAndPort)) { + if (isIP6Literal(hostAndPort)) { return parseIpv6(hostAndPort); } @@ -118,7 +118,7 @@ static String stripUserInfo(String authority) { return at >= 0 ? authority.substring(at + 1) : authority; } - static HostPort parseIPv4OrHostname(String hostAndPort) { + HostPort parseIPv4OrHostname(String hostAndPort) { String host; int port; int colon = hostAndPort.indexOf(':'); @@ -133,6 +133,8 @@ static HostPort parseIPv4OrHostname(String hostAndPort) { if (host.isEmpty()) { throw new IllegalArgumentException("Host must not be empty."); } + if (!allowIllegalCharacters) + validateHost( host); return new HostPort(host, port); } @@ -147,11 +149,13 @@ static HostPort parseIpv6(String hostAndPort) { throw new IllegalArgumentException("Host must not be empty."); } + validateIP6Address(ipv6); + int port = parsePort(hostAndPort.substring(end + 1)); return new HostPort(ipv6, port); } - static boolean isIPLiteral(String hostAndPort) { + static boolean isIP6Literal(String hostAndPort) { return hostAndPort.startsWith("["); } @@ -166,20 +170,17 @@ static int parsePort(String restOfAuthority) { } private static int validatePortDigits(String p) { - if (!p.isEmpty()) { - if (!p.matches("\\d{1,5}")) - throw new IllegalArgumentException("Invalid port: " + p); - int candidate = Integer.parseInt(p); - if (candidate < 0 || candidate > 65535) - throw new IllegalArgumentException("Port out of range: " + candidate); - return candidate; - } - throw new IllegalArgumentException("Invalid port: ''."); + if (p.isEmpty()) + throw new IllegalArgumentException("Invalid port: ''."); + + validateDigits(p); + int i = Integer.parseInt(p); + if (i > 65535) + throw new IllegalArgumentException("Invalid port: '%s'.".formatted(p)); + return i; } public String getScheme() { - if (uri != null) - return uri.getScheme(); return scheme; } @@ -189,43 +190,30 @@ public String getScheme() { * - might return something like "[fe80::1%25eth0]". */ public String getHost() { - if (uri != null) - return uri.getHost(); return hostPort.host; } public int getPort() { - if (uri != null) { - return uri.getPort(); - } return hostPort.port; } public String getPath() { - if (uri != null) - return uri.getPath(); if (pathDecoded == null) pathDecoded = decode(path); return pathDecoded; } public String getRawPath() { - if (uri != null) - return uri.getRawPath(); return path; } public String getQuery() { - if (uri != null) - return uri.getQuery(); if (queryDecoded == null) queryDecoded = decode(query); return queryDecoded; } public String getRawFragment() { - if (uri != null) - return uri.getRawFragment(); return fragment; } @@ -233,8 +221,6 @@ public String getRawFragment() { * Returns the fragment (the part after '#'), decoded like {@link #getPath()} and {@link #getQuery()}. */ public String getFragment() { - if (uri != null) - return uri.getFragment(); if (fragmentDecoded == null) fragmentDecoded = decode(fragment); return fragmentDecoded; @@ -248,19 +234,16 @@ public String getFragment() { * Returns {@code null} if no authority is present (e.g. "mailto:"). */ public String getAuthority() { - if (uri != null) return uri.getAuthority(); return authority; } private String decode(String string) { if (string == null) - return string; + return null; return URLDecoder.decode(string, UTF_8); } public String getRawQuery() { - if (uri != null) - return uri.getRawQuery(); return query; } @@ -268,7 +251,7 @@ public String getRawQuery() { * Fragments are client side only and should not be propagated to the backend. */ public String getPathWithQuery() { - StringBuilder r = new StringBuilder(100); + var r = new StringBuilder(100); if (getRawPath() != null && !getRawPath().isBlank()) { r.append(getRawPath()); @@ -276,40 +259,181 @@ public String getPathWithQuery() { r.append("/"); } - if (getRawQuery() != null && !getRawQuery().isBlank()) { - r.append('?').append(getRawQuery()); - } - return r.toString(); + if (getRawQuery() == null) + return r.toString(); + + return r.append('?').append(getRawQuery()).toString(); + } + + /** + * Returns the first part of the URI till the first slash or # or + * + * @return + */ + public String getWithoutPath() { + return getScheme() + "://" + getAuthority(); } /** * Use ResolverMap to combine URIs. Only resort to this function if it is not possible to use ResolverMap e.g. * for URIs with invalid characters like $ { } in the DispatchingInterceptor + * * @param relative URI * @return Combined URI * @throws URISyntaxException */ public URI resolve(URI relative) throws URISyntaxException { - if (uri != null) { - java.net.URI resolved = uri.resolve(relative.uri != null ? relative.uri : new java.net.URI(relative.toString())); - return new URI(false, resolved.toString()); + return resolve(relative, new URIFactory(true)); + } + + public URI resolve(URI relative, URIFactory factory) throws URISyntaxException { + // RFC 3986, Section 5.2.2 - resolve a relative reference against a base URI. + // Uses getter methods to read components regardless of parsing mode. + + String rScheme = relative.getScheme(); + String rAuthority = relative.getAuthority(); + String rPath = relative.getRawPath(); + String rQuery = relative.getRawQuery(); + String rFragment = relative.getRawFragment(); + + String tScheme, tAuthority, tPath, tQuery, tFragment; + + if (rScheme != null) { + tScheme = rScheme; + tAuthority = rAuthority; + tPath = removeDotSegments(rPath); + tQuery = rQuery; + } else { + if (rAuthority != null) { + tScheme = this.getScheme(); + tAuthority = rAuthority; + tPath = removeDotSegments(rPath); + tQuery = rQuery; + } else { + if (rPath == null || rPath.isEmpty()) { + tPath = this.getRawPath(); + tQuery = rQuery != null ? rQuery : this.getRawQuery(); + } else { + if (rPath.startsWith("/")) { + tPath = removeDotSegments(rPath); + } else { + var merged = merge(this.getAuthority(), this.getRawPath(), rPath); + if (this.getScheme().equals("classpath")) { + tPath = merged; + } else { + tPath = removeDotSegments(merged); + } + } + tQuery = rQuery; + } + tScheme = this.getScheme(); + tAuthority = this.getAuthority(); + } } - // Custom-parsed: scheme://authority + relative path - var resolvedPath = relative.getRawPath(); - if (resolvedPath == null || resolvedPath.isEmpty()) { - resolvedPath = this.getRawPath(); + tFragment = rFragment; + + // Recompose per RFC 3986, Section 5.3 + var result = new StringBuilder(); + if (tScheme != null) { + result.append(tScheme).append(':'); + } + if (tAuthority != null) { + result.append("//").append(tAuthority); + } + if (tPath != null) { + result.append(tPath); + } + if (tQuery != null) { + result.append('?').append(tQuery); + } + if (tFragment != null) { + result.append('#').append(tFragment); + } + + return factory.create(result.toString()); + } + + /** + * RFC 3986, Section 5.2.3 - Merge base path with relative reference. + */ + private static String merge(String baseAuthority, String basePath, String relativePath) { + if (baseAuthority != null && (basePath == null || basePath.isEmpty())) { + return "/" + relativePath; + } + if (basePath == null) { + return relativePath; } - var result = scheme + "://" + authority + resolvedPath; - if (relative.getRawQuery() != null) { - result += "?" + relative.getRawQuery(); + int lastSlash = basePath.lastIndexOf('/'); + if (lastSlash >= 0) { + return basePath.substring(0, lastSlash + 1) + relativePath; + } + return relativePath; + } + + /** + * RFC 3986, Section 5.2.4 - Remove dot segments from a path. + */ + public static String removeDotSegments(String path) { + if (path == null || path.isEmpty()) { + return path; + } + + StringBuilder out = new StringBuilder(); + int i = 0; + while (i < path.length()) { + // A: remove prefix "../" or "./" + if (path.startsWith("../", i)) { + i += 3; + } else if (path.startsWith("./", i)) { + i += 2; + } + // B: remove prefix "/./" or "/."(end) + else if (path.startsWith("/./", i)) { + i += 2; + } else if (i + 2 == path.length() && path.startsWith("/.", i)) { + out.append('/'); + i += 2; + } + // C: remove prefix "/../" or "/.."(end), and remove last output segment + else if (path.startsWith("/../", i)) { + i += 3; + removeLastSegment(out); + } else if (i + 3 == path.length() && path.startsWith("/..", i)) { + removeLastSegment(out); + out.append('/'); + i += 3; + } + // D: "." or ".." only + else if ((i == path.length() - 1 && path.charAt(i) == '.') || + (i == path.length() - 2 && path.charAt(i) == '.' && path.charAt(i + 1) == '.')) { + i = path.length(); + } + // E: move first path segment (including initial "/" if any) to output + else { + if (path.charAt(i) == '/') { + out.append('/'); + i++; + } + while (i < path.length() && path.charAt(i) != '/') { + out.append(path.charAt(i)); + i++; + } + } + } + return out.toString(); + } + + private static void removeLastSegment(StringBuilder out) { + int lastSlash = out.lastIndexOf("/"); + if (lastSlash >= 0) { + out.setLength(lastSlash); + } else { + out.setLength(0); } - return new URI(true, result); } @Override public String toString() { - if (uri != null) - return uri.toString(); return input; } } diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java b/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java index aa6a5b6a39..0e177badf8 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java @@ -14,10 +14,9 @@ package com.predic8.membrane.core.util; -import java.net.URISyntaxException; +import com.predic8.membrane.annot.*; -import com.predic8.membrane.annot.MCAttribute; -import com.predic8.membrane.annot.MCElement; +import java.net.*; @MCElement(name = "uriFactory") public class URIFactory { @@ -25,6 +24,9 @@ public class URIFactory { private boolean allowIllegalCharacters; private boolean autoEscapeBackslashes = true; + public static final URIFactory DEFAULT_URI_FACTORY = new URIFactory(); + public static final URIFactory ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY = new URIFactory(true); + public URIFactory() { this(false); } @@ -55,7 +57,7 @@ public URI create(String uri) throws URISyntaxException { if (autoEscapeBackslashes && uri.contains("\\")) uri = uri.replaceAll("\\\\", "%5C"); - return new URI(allowIllegalCharacters, uri); + return new URI(uri,allowIllegalCharacters); } /** @@ -66,7 +68,7 @@ public URI createWithoutException(String uri) { if (autoEscapeBackslashes && uri.contains("\\")) uri = uri.replaceAll("\\\\", "%5C"); try { - return new URI(allowIllegalCharacters, uri); + return new URI(uri,allowIllegalCharacters); } catch (URISyntaxException e) { throw new IllegalArgumentException(e); } diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java new file mode 100644 index 0000000000..3deda0e7b1 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java @@ -0,0 +1,86 @@ +package com.predic8.membrane.core.util; + +public class URIValidationUtil { + + public static void validateDigits(String port) { + for (int i = 0; i < port.length(); i++) { + if (!isDigit(port.charAt(i))) + throw new IllegalArgumentException("Invalid port: " + port); + } + } + + public static boolean isPchar(char c) { + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + return isUnreserved(c) || isSubDelims(c) || c == ':' || c == '@'; + } + + public static boolean isUnreserved(char c) { + return isAlpha(c) || isDigit(c) || c == '-' || c == '.' || c == '_' || c == '~'; + } + + public static boolean isSubDelims(char c) { + return c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')' || + c == '*' || c == '+' || c == ',' || c == ';' || c == '='; + } + + public static boolean isAlpha(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + public static boolean isDigit(char c) { + return (c >= '0' && c <= '9'); + } + + public static boolean isHex(char c) { + return (c >= '0' && c <= '9') || + (c >= 'A' && c <= 'F') || + (c >= 'a' && c <= 'f'); + } + + /** + * Security focused validation only: allowed characters for an IPv6 address text. + * Does not validate IPv6 semantics. Intended for bracket hosts like "[...]" where ':' is expected. + * + * Allowed: HEX, ':', '.', '%', unreserved, sub-delims, '[' and ']'. + * '%' is allowed because zone IDs and percent-encoded sequences may appear (validation of %HH is done elsewhere). + */ + public static void validateIP6Address(String s) { + if (s == null || s.isEmpty()) + throw new IllegalArgumentException("Invalid IPv6 address: empty."); + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + + if (isHex(c) || c == ':' || c == '.' || c == '%' || c == '[' || c == ']') + continue; + + if (isUnreserved(c) || isSubDelims(c)) + continue; + + throw new IllegalArgumentException("Invalid character in IPv6 address: '" + c + "'"); + } + } + + /** + * Security focused validation only: host may be a reg-name or IPv4-ish or contain IPv6 literals. + * Does not validate correctness of IP addresses. Only enforces allowed characters. + * + * Allowed: unreserved, sub-delims, '.', '%', ':', '[', ']'. + */ + public static void validateHost(String s) { + if (s == null || s.isEmpty()) + throw new IllegalArgumentException("Host must not be empty."); + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + + if (c == '.' || c == '%' || c == ':' || c == '[' || c == ']') + continue; + + if (isUnreserved(c) || isSubDelims(c)) + continue; + + throw new IllegalArgumentException("Invalid character in host: '" + c + "'"); + } + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java new file mode 100644 index 0000000000..2353913302 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java @@ -0,0 +1,171 @@ +package com.predic8.membrane.core.util; + +import java.util.*; + +import static com.predic8.membrane.core.util.URIValidationUtil.*; + +/** + * Validates URI components (RFC 3986) with an optional extension to allow '{' and '}' in paths. + *

+ * Intended for use with {@link URI} which keeps raw components. + */ +public final class UriIllegalCharacterDetector { + + private UriIllegalCharacterDetector() { + } + + public static void validateAll(URI uri, Options options) { + validateAll(uri.getScheme(), uri.getAuthority(), uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment(), options); + } + + public static void validateAll(String scheme, + String authority, + String rawPath, + String rawQuery, + String rawFragment, + Options options) { + Objects.requireNonNull(options, "options"); + + if (options.skipAllValidation) { + return; + } + + // Safety-critical checks can still be applied even if relaxed. + validateNoControlsOrSpaces(rawPath, "path"); + validateNoControlsOrSpaces(rawQuery, "query"); + validateNoControlsOrSpaces(rawFragment, "fragment"); + validateNoControlsOrSpaces(authority, "authority"); + validateNoControlsOrSpaces(scheme, "scheme"); + + validatePctEncoding(rawPath, "path"); + validatePctEncoding(rawQuery, "query"); + validatePctEncoding(rawFragment, "fragment"); + validatePctEncoding(authority, "authority"); + // scheme never contains '%' in valid RFC 3986; no need to check percent there. + + if (options.strictRfc3986) { + validateScheme(scheme); + // Authority is not validated here, cause it is done in URI with HostAndPort + validateRfc3986Path(rawPath, options.allowBracesInPath); + validateRfc3986QueryOrFragment(rawQuery, "query", options); + validateRfc3986QueryOrFragment(rawFragment, "fragment", options); + } + } + + public static final class Options { + /** + * If true, no checks are performed. + */ + public boolean skipAllValidation = false; + + /** + * If true, apply RFC 3986 component character rules (plus configured extensions). + * If false, only control/space + percent-encoding checks are applied. + */ + public boolean strictRfc3986 = true; + + /** + * Custom extension used by Membrane: allow '{' and '}' in path. + */ + public boolean allowBracesInPath = false; + + /** + * If true, allow '{' and '}' also in query/fragment (default false). + */ + public boolean allowBracesInQueryAndFragment = false; + + /** + * If true, allow '{' and '}' in user-info too (default false). + */ + public boolean allowBracesInUserInfo = false; + } + + private static void validateScheme(String scheme) { + if (scheme == null) return; + + // RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + if (scheme.isEmpty()) { + throw new IllegalArgumentException("Illegal scheme: empty."); + } + char first = scheme.charAt(0); + if (!isAlpha(first)) { + throw new IllegalArgumentException("Illegal scheme: must start with ALPHA: " + scheme); + } + for (int i = 1; i < scheme.length(); i++) { + char c = scheme.charAt(i); + if (!(isAlpha(c) || isDigit(c) || c == '+' || c == '-' || c == '.')) { + throw new IllegalArgumentException("Illegal character in scheme at index %d: '%s'".formatted(i, c)); + } + } + } + + private static void validateUserInfo(String userInfo, Options options) { + if (userInfo == null) return; + + // RFC 3986: userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) + for (int i = 0; i < userInfo.length(); i++) { + char c = userInfo.charAt(i); + if (c == '%') continue; + if (options.allowBracesInUserInfo && (c == '{' || c == '}')) continue; + if (!(isUnreserved(c) || isSubDelims(c) || c == ':')) { + throw new IllegalArgumentException("Illegal character in user-info at index " + i + ": '" + c + "'"); + } + } + } + + private static void validateNoControlsOrSpaces(String s, String component) { + if (s == null) return; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + // Reject CTLs and space. (Includes tabs, newlines, etc.) + if (c <= 0x20 || c == 0x7F) { + throw new IllegalArgumentException("Illegal character in %s at index %d: 0x%s" + .formatted(component, i, Integer.toHexString(c))); + } + } + } + + private static void validatePctEncoding(String s, String component) { + if (s == null) return; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '%') { + if (i + 2 >= s.length() || !isHex(s.charAt(i + 1)) || !isHex(s.charAt(i + 2))) { + throw new IllegalArgumentException("Invalid percent-encoding in %s at index %d".formatted(component, i)); + } + i += 2; + } + } + } + + private static void validateRfc3986Path(String path, boolean allowBracesInPath) { + if (path == null) return; + + // path = *( "/" / pchar ) + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '/') continue; + if (c == '%') continue; // pct-encoding validated structurally + if (allowBracesInPath && (c == '{' || c == '}')) continue; + if (!isPchar(c)) { + throw new IllegalArgumentException("Illegal character in path at index " + i + ": '" + c + "'"); + } + } + } + + private static void validateRfc3986QueryOrFragment(String s, String component, Options options) { + if (s == null) return; + + // query/fragment = *( pchar / "/" / "?" ) + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '/' || c == '?') continue; + if (c == '%') continue; + if (options.allowBracesInQueryAndFragment && (c == '{' || c == '}')) continue; + if (!isPchar(c)) { + throw new IllegalArgumentException("Illegal character in " + component + " at index " + i + ": '" + c + "'"); + } + } + } + + +} diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java index 0b093866b7..fb28651d22 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java @@ -27,6 +27,7 @@ import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.interceptor.Outcome.*; import static com.predic8.membrane.core.router.DummyTestRouter.*; +import static com.predic8.membrane.core.util.URIFactory.ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY; import static org.junit.jupiter.api.Assertions.*; class DispatchingInterceptorTest { @@ -35,14 +36,17 @@ class DispatchingInterceptorTest { DispatchingInterceptor dispatcher; ServiceProxy serviceProxy; - Exchange exc; + Router defaultRouter; + Router routerAllowIllegal; @BeforeEach void setUp() { - DefaultRouter router = new DefaultRouter(); + routerAllowIllegal = new DefaultRouter(); + routerAllowIllegal.getConfiguration().setUriFactory(ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY); + defaultRouter = new DefaultRouter(); dispatcher = new DispatchingInterceptor(); - dispatcher.init(router); + dispatcher.init(defaultRouter); exc = new Exchange(null); serviceProxy = new ServiceProxy(new ServiceProxyKey("localhost", ".*", ".*", 3011), "thomas-bayer.com", 80); } @@ -155,9 +159,10 @@ void getAddressFromTargetElement() throws Exception { api.setTarget(new Target() {{ setUrl("https://${property.host}:8080"); // Has illegal characters $ { } in base path }}); + api.init(routerAllowIllegal); + dispatcher.init(routerAllowIllegal); var exc = get("/foo").buildExchange(); - exc.setProperty("host", "predic8.de"); exc.setProxy(api); assertEquals("https://${property.host}:8080/foo", dispatcher.getAddressFromTargetElement(exc)); @@ -177,7 +182,6 @@ void invalidUriErrorMessage() throws Exception { var jn = om.readTree(r.getBodyAsStringDecoded()); assertTrue(jn.get(TITLE).asText().contains("invalid character")); assertEquals("https://membrane-api.io/problems/user", jn.get(TYPE).asText()); - assertEquals(4, jn.get("index").asInt()); assertEquals("/foo{invalidUri}", jn.get("path").asText()); } diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index c9ee8d0f98..1cf2aa129d 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -15,11 +15,13 @@ package com.predic8.membrane.core.interceptor; import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.proxies.*; import com.predic8.membrane.core.proxies.Target.*; import com.predic8.membrane.core.router.*; +import com.predic8.membrane.core.util.*; import org.junit.jupiter.api.*; import java.net.*; @@ -144,21 +146,76 @@ void computeCompletePathURLEncoded() throws Exception { "%26%3F%C3%A4%C3%B6%C3%BC%21", Escaping.URL); } + @Nested + class injection { + + @Test + void deactivateEvaluationOfURLTemplatesWhenIllegalCharactersAreAllowed() { + allowIllegalURICharacters(); + var exc = new Request.Builder().method(METHOD_GET).uri("/foo/${555}").buildExchange(); + invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL); + if (!(exc.getProxy() instanceof APIProxy apiProxy)) { + fail(); + return; + } + assertFalse(apiProxy.getTarget().isEvaluateExpressions()); + assertEquals(1, exc.getDestinations().size()); + + // The template should not be evaluated, cause illegal characters are allowed! + assertEquals("https://${'hostname'}/foo/${555}", exc.getDestinations().getFirst()); + } + + @Test + void illegalCharacterWithoutTemplate() { + allowIllegalURICharacters(); + var exc = new Request.Builder().method(METHOD_GET).uri("/foo/${555}").buildExchange(); + invokeDispatching(SPEL, exc, "https://localhost", Escaping.URL); + if (!(exc.getProxy() instanceof APIProxy apiProxy)) { + fail(); + return; + } + assertEquals(false, apiProxy.getTarget().isEvaluateExpressions()); + assertEquals(1, exc.getDestinations().size()); + + // The template should not be evaluated, cause illegal characters are allowed! + assertEquals("https://localhost/foo/${555}", exc.getDestinations().getFirst()); + } + + @Test + void uriTemplateAndIllegalCharacters() throws URISyntaxException { + allowIllegalURICharacters(); + var exc = get("/foo").buildExchange(); + invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL); + // The template should not be evaluated, cause illegal characters are allowed! + assertEquals("https://${'hostname'}/foo", exc.getDestinations().getFirst()); + } + } + + private void allowIllegalURICharacters() { + router.getConfiguration().setUriFactory(new URIFactory(true)); + } private void testExpression(Language language, Exchange exc, String url, String expected, Escaping escaping) { + invokeDispatching(language, exc, url, escaping); + assertEquals(1, exc.getDestinations().size()); + assertEquals(expected, exc.getDestinations().getFirst()); + } + + private void invokeDispatching(Language language, Exchange exc, String url, Escaping escaping) { var target = new Target(); target.setUrl(url); target.setLanguage(language); target.setEscaping(escaping); + target.init(router); var api = new APIProxy(); api.setTarget(target); exc.setProxy(api); hci.init(router); - new DispatchingInterceptor().handleRequest(exc); + var di = new DispatchingInterceptor(); + di.init(router); + di.handleRequest(exc); hci.applyTargetModifications(exc); - assertEquals(1, exc.getDestinations().size()); - assertEquals(expected, exc.getDestinations().getFirst()); } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptorTest.java index bfaa89ced6..2e557adec3 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptorTest.java @@ -99,6 +99,7 @@ void invalidURI() throws Exception { assertEquals("https://membrane-api.io/problems/user/path",json.get("type").asText()); assertEquals("The path does not follow the URI specification. Confirm the validity of the provided URL.",json.get("title").asText()); - assertTrue(json.get("detail").asText().contains("/buy/banana/%")); + assertTrue(json.get("path").asText().contains("/buy/banana/%")); + assertTrue(json.get("component").asText().contains("rewrite")); } } diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/server/WSDLPublisherInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/server/WSDLPublisherInterceptorTest.java index 9e626a8f01..d0dca27811 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/server/WSDLPublisherInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/server/WSDLPublisherInterceptorTest.java @@ -37,7 +37,7 @@ static List getPorts() { void before(String wsdlLocation, int port) throws Exception { router = new TestRouter(); - ServiceProxy sp2 = new ServiceProxy(new ServiceProxyKey("*", "*", ".*", port), "", -1); + ServiceProxy sp2 = new ServiceProxy(new ServiceProxyKey("*", "*", ".*", port), "localhost", -1); WSDLPublisherInterceptor wi = new WSDLPublisherInterceptor(); wi.setWsdl(wsdlLocation); wi.init(router); @@ -55,7 +55,7 @@ void after() { void doit(String wsdlLocation, int port) throws Exception { before(wsdlLocation, port); // this recursively fetches 5 documents (1 WSDL + 4 XSD) - assertEquals(5, WSDLTestUtil.countWSDLandXSDs("http://localhost:" + port + "/articles/?wsdl")); + assertEquals(5, WSDLTestUtil.countWSDLandXSDs("http://localhost:%d/articles/?wsdl".formatted(port))); after(); } } diff --git a/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java index 11fc3f5192..77fbb5234d 100644 --- a/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java +++ b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java @@ -16,7 +16,6 @@ import com.predic8.membrane.core.openapi.*; import com.predic8.membrane.core.openapi.serviceproxy.*; -import com.predic8.membrane.core.openapi.validators.*; import com.predic8.membrane.core.util.*; import org.junit.jupiter.api.*; import org.junit.jupiter.params.*; @@ -112,7 +111,9 @@ void rawQueryIsUsedToSplitParameters() { @Test void valuesUTF8() { - assertEquals(0, validator.validate(get().path("/array?const=foo,äöü,baz")).size()); + var err = validator.validate(get().path("/array?const=foo,äöü,baz")); + assertEquals(1, err.size()); + assertTrue(err.get(0).getMessage().contains("Invalid query string")); } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodedArrayQueryParameterTest.java b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodedArrayQueryParameterTest.java index 1169d22ad1..a54dac7b51 100644 --- a/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodedArrayQueryParameterTest.java +++ b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodedArrayQueryParameterTest.java @@ -121,7 +121,9 @@ void zeroErrorsForValidStrings(String caseName, String path) { @Test void valuesUTF8() { - assertEquals(0, validator.validate(get().path("/array?const=foo&const=äöü&const=baz")).size()); + var err = validator.validate(get().path("/array?const=foo&const=äöü&const=baz")); + assertEquals(1, err.size()); + assertTrue(err.get(0).getMessage().contains("Illegal character")); } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java b/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java index a8776292b4..cd4e358a34 100644 --- a/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java @@ -84,14 +84,14 @@ public void rewriteStartsWithHttps() throws URISyntaxException { @Test public void rewriteWithoutHttp() throws URISyntaxException { - assertEquals("http://predic8.de:2000", doRewrite("localhost:3000", "http", "predic8.de", 2000)); - assertEquals("http://predic8.de", doRewrite("localhost:3000", "http", "predic8.de", 80)); + assertEquals("http://predic8.de:2000", doRewrite("http://localhost:3000", "http", "predic8.de", 2000)); + assertEquals("http://predic8.de", doRewrite("http://localhost:3000", "http", "predic8.de", 80)); } @Test public void rewriteWithoutHttps() throws URISyntaxException { - assertEquals("https://predic8.de:2000", doRewrite("localhost:3000", "https", "predic8.de", 2000)); - assertEquals("https://predic8.de", doRewrite("localhost:3000", "https", "predic8.de", 443)); + assertEquals("https://predic8.de:2000", doRewrite("http://localhost:3000", "https", "predic8.de", 2000)); + assertEquals("https://predic8.de", doRewrite("http://localhost:3000", "https", "predic8.de", 443)); } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java b/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java index 79cdfded5e..47c6f0e9ab 100644 --- a/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java +++ b/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java @@ -20,6 +20,7 @@ import static com.predic8.membrane.core.resolver.ResolverMap.*; import static com.predic8.membrane.core.util.OSUtil.wl; +import static com.predic8.membrane.core.util.URIFactory.DEFAULT_URI_FACTORY; import static org.junit.jupiter.api.Assertions.*; public class ResolverMapCombineTest { @@ -153,7 +154,8 @@ void combineParentWithNonFileProtocolAndRelativeChild() { @Test void combineParentWithInvalidURI() { - assertThrows(RuntimeException.class, () -> combine("http://invalid:\\path", "array.yml")); + assertThrows(RuntimeException.class, () -> + combine("http://invalid:path{", "array.yml")); } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index c519bc9bb8..6a92e848f3 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -19,167 +19,136 @@ import java.net.*; import static com.predic8.membrane.core.util.URI.*; -import static com.predic8.membrane.core.util.URI.isIPLiteral; -import static com.predic8.membrane.core.util.URI.parsePort; -import static com.predic8.membrane.core.util.URI.stripUserInfo; import static org.junit.jupiter.api.Assertions.*; + class URITest { - @Test - void doit() { - assertSame("http://predic8.de/?a=query"); - assertSame("http://predic8.de/#foo"); - assertSame("http://predic8.de/path/file"); - assertSame("http://predic8.de/path/file?a=query"); - assertSame("http://predic8.de/path/file#foo"); - assertSame("http://predic8.de/path/file?a=query#foo"); - assertSame("http://foo:bar@predic8.de/path/file?a=query#foo"); - assertSame("//predic8.de/path/file?a=query#foo"); - assertSame("/path/file?a=query#foo"); - assertSame("scheme:/path/file?a=query#foo"); - assertSame("path/file?a=query#foo"); - assertSame("scheme:path/file?a=query#foo", true); // considered 'opaque' by java.net.URI - we don't support that - assertSame("file?a=query#foo", true); // opaque - assertSame("scheme:file?a=query#foo", true); // opaque - assertSame("?a=query#foo"); - assertSame("scheme:?a=query#foo", true); // opaque - } - - @SuppressWarnings("UnnecessaryUnicodeEscape") - @Test - void encoding() { - assertSame("http://predic8.de/path/file?a=quer\u00E4y#foo"); - assertSame("http://predic8.de/path/file?a=quer%C3%A4y#foo%C3%A4"); - assertSame("http://predic8.de/path/fi\u00E4le?a=query#foo"); - assertSame("http://predic8.de/path/fi%C3%A4le?a=query#foo"); - assertSame("http://predic8.de/pa\u00E4th/file?a=query#foo"); - assertSame("http://predic8.de/pa%C3%A4th/file?a=query#foo"); - assertSame("http://predic8.d\u00E4e/path/file?a=query#foo"); - assertSame("http://predic8.d%C3%A4e/path/file?a=query#foo"); - assertError("htt\u00E4p://predic8.de/path/file?a=query#foo", "/path/file", "a=query"); - assertError("htt%C3%A4p://predic8.de/path/file?a=query#foo", "/path/file", "a=query"); - } - - @Test - void illegalCharacter() { - assertError("http://predic8.de/test?a=q{uery#foo", "/test", "a=q{uery"); - assertError("http://predic8.de/te{st?a=query#foo", "/te{st", "a=query"); - assertError("http://pre{dic8.de/test?a=query#foo", "/test", "a=query"); - } - - @Test - void getScheme() throws URISyntaxException { - checkGetSchemeCustomParsing(false); - } - - @Test - void getSchemeCustom() throws URISyntaxException { - checkGetSchemeCustomParsing(true); - } - - private void checkGetSchemeCustomParsing(boolean custom) throws URISyntaxException { - assertEquals("http", new URI("http://predic8.de",custom).getScheme()); - assertEquals("https", new URI("https://predic8.de",custom).getScheme()); - } + private static URI URI_ALLOW_ILLEGAL; + + @BeforeAll + static void init() throws URISyntaxException { + URI_ALLOW_ILLEGAL = new URI("dummy", true); + } + + @SuppressWarnings("UnnecessaryUnicodeEscape") + @Test + void testEncoding() { + assertError("htt\u00E4p://predic8.de/path/file?a=query#foo", "/path/file", "a=query"); + assertError("htt%C3%A4p://predic8.de/path/file?a=query#foo", "/path/file", "a=query"); + } + + @Test + void illegalCharacter() { + assertError("http://predic8.de/test?a=q{uery#foo", "/test", "a=q{uery"); + assertError("http://predic8.de/te{st?a=query#foo", "/te{st", "a=query"); + } + + @Test + void getScheme() throws URISyntaxException { + checkGetSchemeCustomParsing(false); + } + + @Test + void getSchemeCustom() throws URISyntaxException { + checkGetSchemeCustomParsing(true); + } + + private void checkGetSchemeCustomParsing(boolean custom) throws URISyntaxException { + assertEquals("http", new URI("http://predic8.de", custom).getScheme()); + assertEquals("https", new com.predic8.membrane.core.util.URI("https://predic8.de", custom).getScheme()); + } @Test void getHost() throws URISyntaxException { - checkGetHost(false); + checkGetHost(false); + } + + @Test + void getHostCustom() throws URISyntaxException { + checkGetHost(true); + } + + private void checkGetHost(boolean custom) throws URISyntaxException { + assertEquals("predic8.de", new URI("http://predic8.de/foo", custom).getHost()); + assertEquals("predic8.de", new com.predic8.membrane.core.util.URI("http://user:pwd@predic8.de:8080/foo", custom).getHost()); + assertEquals("predic8.de", new com.predic8.membrane.core.util.URI("http://predic8.de:8080/foo", custom).getHost()); + assertEquals("predic8.de", new com.predic8.membrane.core.util.URI("https://predic8.de/foo", custom).getHost()); + assertEquals("predic8.de", new URI("https://predic8.de:8443/foo", custom).getHost()); + } + + @Test + void getPort() throws URISyntaxException { + getPortCustomParsing(false); + } + + @Test + void getPortCustom() throws URISyntaxException { + getPortCustomParsing(true); + } + + /** + * Default port should be returned as unknown. + */ + @Test + void urlStandardBehaviour() throws URISyntaxException { + assertEquals(-1, new java.net.URI("http://predic8.de/foo").getPort()); } - @Test - void getHostCustom() throws URISyntaxException { - checkGetHost(true); - } - - private void checkGetHost(boolean custom) throws URISyntaxException { - assertEquals("predic8.de", new URI("http://predic8.de/foo",custom).getHost()); - assertEquals("predic8.de", new URI("http://user:pwd@predic8.de:8080/foo",custom).getHost()); - assertEquals("predic8.de", new URI("http://predic8.de:8080/foo",custom).getHost()); - assertEquals("predic8.de", new URI("https://predic8.de/foo",custom).getHost()); - assertEquals("predic8.de", new URI("https://predic8.de:8443/foo",custom).getHost()); - } - - @Test - void getPort() throws URISyntaxException { - getPortCustomParsing(false); - } - - @Test - void getPortCustom() throws URISyntaxException { - getPortCustomParsing(true); - } - - /** - * Default port should be returned as unknown. - */ - @Test - void urlStandardBehaviour() throws URISyntaxException { - assertEquals(-1, new java.net.URI("http://predic8.de/foo").getPort()); - } - - private void getPortCustomParsing(boolean custom) throws URISyntaxException { - assertEquals(-1, new URI("http://predic8.de/foo",custom).getPort()); - assertEquals(-1, new URI("https://predic8.de/foo",custom).getPort()); - assertEquals(8090, new URI("http://predic8.de:8090/foo",custom).getPort()); - assertEquals(8443, new URI("https://predic8.de:8443/foo",custom).getPort()); - assertEquals(8090, new URI("http://user:pwd@predic8.de:8090/foo",custom).getPort()); - assertEquals(8443, new URI("https://user:pwd@predic8.de:8443/foo",custom).getPort()); - } - - private void assertSame(String uri) { - assertSame(uri, false); - } - - private void assertSame(String uri, boolean mayDiffer) { - try { - URI u1 = new URI(uri, false); - URI u2 = new URI(uri, true); - - if (!mayDiffer) { - assertEquals(u1.getPath(), u2.getPath()); - assertEquals(u1.getQuery(), u2.getQuery()); - assertEquals(u1.getRawQuery(), u2.getRawQuery()); - assertEquals(u1.getRawFragment(), u2.getRawFragment()); - } - assertEquals(u1.toString(), u2.toString()); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } - - private void assertError(String uri, String path, String query) { - try { - new URI(uri, false); - fail("Expected URISyntaxException."); - } catch (URISyntaxException e) { - // do nothing - } - try { - URI u = new URI(uri, true); - assertEquals(path, u.getPath()); - assertEquals(query, u.getQuery()); - u.getRawQuery(); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } + private void getPortCustomParsing(boolean custom) throws URISyntaxException { + assertEquals(-1, new com.predic8.membrane.core.util.URI("http://predic8.de/foo", custom).getPort()); + assertEquals(-1, new com.predic8.membrane.core.util.URI("https://predic8.de/foo", custom).getPort()); + assertEquals(8090, new com.predic8.membrane.core.util.URI("http://predic8.de:8090/foo", custom).getPort()); + assertEquals(8443, new URI("https://predic8.de:8443/foo", custom).getPort()); + assertEquals(8090, new URI("http://user:pwd@predic8.de:8090/foo", custom).getPort()); + assertEquals(8443, new URI("https://user:pwd@predic8.de:8443/foo", custom).getPort()); + } + + private void assertError(String uri, String path, String query) { + try { + new com.predic8.membrane.core.util.URI(uri); + fail("Expected URISyntaxException."); + } catch (URISyntaxException | IllegalArgumentException e) { + // do nothing + } + try { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI(uri, true); + assertEquals(path, u.getPath()); + assertEquals(query, u.getQuery()); + u.getRawQuery(); + } catch (URISyntaxException | IllegalArgumentException e) { + throw new RuntimeException(e); + } + } @SuppressWarnings("UnnecessaryUnicodeEscape") @Test - public void testEncoding() { - assertSame("http://predic8.de/path/file?a=quer\u00E4y#foo"); - assertSame("http://predic8.de/path/file?a=quer%C3%A4y#foo%C3%A4"); - assertSame("http://predic8.de/path/fi\u00E4le?a=query#foo"); - assertSame("http://predic8.de/path/fi%C3%A4le?a=query#foo"); - assertSame("http://predic8.de/pa\u00E4th/file?a=query#foo"); - assertSame("http://predic8.de/pa%C3%A4th/file?a=query#foo"); - assertSame("http://predic8.d\u00E4e/path/file?a=query#foo"); - assertSame("http://predic8.d%C3%A4e/path/file?a=query#foo"); + void encoding() { assertError("htt\u00E4p://predic8.de/path/file?a=query#foo", "/path/file", "a=query"); assertError("htt%C3%A4p://predic8.de/path/file?a=query#foo", "/path/file", "a=query"); } + @Test + void withoutPath() throws URISyntaxException { + URIFactory uf = new URIFactory(true); + assertEquals("http://localhost", uf.create("http://localhost").getWithoutPath()); + assertEquals("http://localhost:8080", uf.create("http://localhost:8080").getWithoutPath()); + assertEquals("http://localhost:8080", uf.create("http://localhost:8080/foo").getWithoutPath()); + assertEquals("http://localhost:8080", uf.create("http://localhost:8080#foo").getWithoutPath()); + assertEquals("http://localhost", uf.create("http://localhost/foo").getWithoutPath()); + assertEquals("http://localhost", uf.create("http://localhost/foo").getWithoutPath()); + assertEquals("http://localhost", uf.create("http://localhost/foo").getWithoutPath()); + } + + @Test + void testRemoveDotSegments() { + assertEquals("a", removeDotSegments("../a")); + assertEquals("a", removeDotSegments("./a")); + assertEquals("a/b", removeDotSegments("a/./b")); + assertEquals("/b", removeDotSegments("a/../b")); + assertEquals("a/c", removeDotSegments("a/b/../c")); + assertEquals("/a", removeDotSegments("/../a")); + } + @Nested class Authority { @Test @@ -194,14 +163,14 @@ void getAuthorityDefault() throws URISyntaxException { private void checkGetAuthority(boolean custom) throws URISyntaxException { // plain host - assertEquals("predic8.de", new URI("http://predic8.de/foo", custom).getAuthority()); + assertEquals("predic8.de", new com.predic8.membrane.core.util.URI("http://predic8.de/foo", custom).getAuthority()); // host + port assertEquals("predic8.de:8080", new URI("http://predic8.de:8080/foo", custom).getAuthority()); // with userinfo assertEquals("user:pwd@predic8.de:8080", - new URI("http://user:pwd@predic8.de:8080/foo", custom).getAuthority()); + new com.predic8.membrane.core.util.URI("http://user:pwd@predic8.de:8080/foo", custom).getAuthority()); // https with port assertEquals("predic8.de:8443", new URI("https://predic8.de:8443/foo", custom).getAuthority()); @@ -210,48 +179,48 @@ private void checkGetAuthority(boolean custom) throws URISyntaxException { assertEquals("predic8.de", new URI("https://predic8.de/foo", custom).getAuthority()); // IPv6 with port - assertEquals("[2001:db8::1]:8080", new URI("http://[2001:db8::1]:8080/foo", custom).getAuthority()); + assertEquals("[2001:db8::1]:8080", new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:8080/foo", custom).getAuthority()); // no authority present (mailto) - assertNull(new URI("mailto:alice@example.com", custom).getAuthority()); + assertNull(new com.predic8.membrane.core.util.URI("mailto:alice@example.com", custom).getAuthority()); // IPv6 with port and userinfo assertEquals("user:pwd@[2001:db8::1]:9090", - new URI("http://user:pwd@[2001:db8::1]:9090/foo", custom).getAuthority()); + new com.predic8.membrane.core.util.URI("http://user:pwd@[2001:db8::1]:9090/foo", custom).getAuthority()); } // No IPv6 support in custom parsing @Test void getAuthorityIPv6Custom() throws URISyntaxException { assertEquals("[2001:db8::1]", new URI("http://[2001:db8::1]/foo", false).getAuthority()); - assertEquals("[2001:db8::1]:8080", new URI("http://[2001:db8::1]:8080/foo", false).getAuthority()); + assertEquals("[2001:db8::1]:8080", new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:8080/foo", false).getAuthority()); } } - @Test - void getPathWithQuery() throws URISyntaxException { - assertEquals("/", new URIFactory().create("").getPathWithQuery()); - assertEquals("/foo", new URIFactory().create("http://localhost/foo").getPathWithQuery()); - assertEquals("/foo?q=1", new URIFactory().create("/foo?q=1").getPathWithQuery()); - assertEquals("/", new URIFactory().create("http://localhost").getPathWithQuery()); - } - - @Test - @DisplayName("Fragments should be removed and not propagated to backend") - void removeFragment() throws URISyntaxException { - assertEquals("/foo", new URIFactory().create("http://localhost:777/foo#frag").getPathWithQuery()); - assertEquals("/", new URIFactory().create("#frag").getPathWithQuery()); - assertEquals("/foo?q=1", new URIFactory().create("/foo?q=1#frag").getPathWithQuery()); - } - - @Test - void getPathWithQuery_keep_raw() throws URISyntaxException { - assertEquals("/foo?q=a%20b", new URIFactory().create("/foo?q=a%20b").getPathWithQuery()); - assertEquals("/", new URIFactory().create("#a%20b").getPathWithQuery()); - assertEquals("/foo?q=a+b", new URIFactory().create("/foo?q=a+b").getPathWithQuery()); // '+' must remain '+' - assertEquals("/foo", new URIFactory().create("/foo#c%2Fd").getPathWithQuery()); // '/' in fragment is encoded - } + @Test + void getPathWithQuery() throws URISyntaxException { + assertEquals("/", new URIFactory().create("").getPathWithQuery()); + assertEquals("/foo", new URIFactory().create("http://localhost/foo").getPathWithQuery()); + assertEquals("/foo?q=1", new URIFactory().create("/foo?q=1").getPathWithQuery()); + assertEquals("/", new URIFactory().create("http://localhost").getPathWithQuery()); + assertEquals("/foo?", new URIFactory().create("/foo?").getPathWithQuery()); + } + + @Test + @DisplayName("Fragments should be removed and not propagated to backend") + void removeFragment() throws URISyntaxException { + assertEquals("/foo", new URIFactory().create("http://localhost:777/foo#frag").getPathWithQuery()); + assertEquals("/", new URIFactory().create("#frag").getPathWithQuery()); + assertEquals("/foo?q=1", new URIFactory().create("/foo?q=1#frag").getPathWithQuery()); + } + @Test + void getPathWithQuery_keep_raw() throws URISyntaxException { + assertEquals("/foo?q=a%20b", new URIFactory().create("/foo?q=a%20b").getPathWithQuery()); + assertEquals("/", new URIFactory().create("#a%20b").getPathWithQuery()); + assertEquals("/foo?q=a+b", new URIFactory().create("/foo?q=a+b").getPathWithQuery()); // '+' must remain '+' + assertEquals("/foo", new URIFactory().create("/foo#c%2Fd").getPathWithQuery()); // '/' in fragment is encoded + } @Nested class ParsingUtilitiesTests { @@ -276,9 +245,9 @@ void stripUserInfoWorks() { @Test void isIPv6() { - assertTrue(isIPLiteral("[::1]")); - assertTrue(isIPLiteral("[::1")); - assertFalse(isIPLiteral("::1")); + assertTrue(isIP6Literal("[::1]")); + assertTrue(isIP6Literal("[::1")); + assertFalse(isIP6Literal("::1")); } } @@ -287,27 +256,27 @@ class HostPortParsingTests { @Test void parseHostPortNullOrEmpty() { - assertThrows(IllegalArgumentException.class, () -> parseHostPort(null)); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort(null)); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("")); } @Test void parseHostPortWithIPv4AndPort() { - URI.HostPort hp = parseHostPort("example.com:8080"); + com.predic8.membrane.core.util.URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("example.com:8080"); assertEquals("example.com", hp.host()); assertEquals(8080, hp.port()); } @Test void parseHostPortWithIPv6() { - URI.HostPort hp = parseHostPort("[2001:db8::1]:9090"); + com.predic8.membrane.core.util.URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("[2001:db8::1]:9090"); assertEquals("[2001:db8::1]", hp.host()); assertEquals(9090, hp.port()); } @Test void parseIpv6WithoutPort() { - URI.HostPort hp = parseIpv6("[::1]"); + com.predic8.membrane.core.util.URI.HostPort hp = parseIpv6("[::1]"); assertEquals("[::1]", hp.host()); assertEquals(-1, hp.port()); } @@ -329,98 +298,98 @@ void parseIpv6InvalidCases() { @Test void parseHostPortIpv4WithoutPort() { - URI.HostPort hp = parseIPv4OrHostname("example.com"); + com.predic8.membrane.core.util.URI.HostPort hp = URI_ALLOW_ILLEGAL.parseIPv4OrHostname("example.com"); assertEquals("example.com", hp.host()); assertEquals(-1, hp.port()); } @Test void parseHostPortIpv4InvalidCases() { - assertThrows(IllegalArgumentException.class, () -> parseIPv4OrHostname(":8080")); - assertThrows(IllegalArgumentException.class, () -> parseIPv4OrHostname("example.com:")); - assertThrows(IllegalArgumentException.class, () -> parseIPv4OrHostname("example.com:abc")); - assertThrows(IllegalArgumentException.class, () -> parseIPv4OrHostname("host:1:2")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname(":8080")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname("example.com:")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname("example.com:abc")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname("host:1:2")); } @Test void parseHostPortStripsUserInfoForIpv4() { - URI.HostPort hp = parseHostPort("user:pwd@example.com:8080"); + URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("user:pwd@example.com:8080"); assertEquals("example.com", hp.host()); assertEquals(8080, hp.port()); } @Test void parseHostPortStripsUserInfoForIpv6() { - URI.HostPort hp = parseHostPort("user:pwd@[2001:db8::1]:443"); + URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("user:pwd@[2001:db8::1]:443"); assertEquals("[2001:db8::1]", hp.host()); assertEquals(443, hp.port()); } @Test void parseHostPortRejectsEmptyHostAfterUserInfo() { - assertThrows(IllegalArgumentException.class, () -> parseHostPort("user@")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("user@:8080")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("user@")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("user@:8080")); } @Test void parseHostPortIpv4NoPortReturnsNoPort() { - URI.HostPort hp = parseHostPort("example.com"); + URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("example.com"); assertEquals("example.com", hp.host()); assertEquals(-1, hp.port()); } @Test void parseHostPortInvalidMultipleColons() { - assertThrows(IllegalArgumentException.class, () -> parseHostPort("host:1:2")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("[::1]:1:2")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("host:1:2")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]:1:2")); } @Test void parseHostPortIpv4EmptyPortOrHost() { - assertThrows(IllegalArgumentException.class, () -> parseHostPort(":8080")); // empty host - assertThrows(IllegalArgumentException.class, () -> parseHostPort("example.com:")); // empty port + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort(":8080")); // empty host + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("example.com:")); // empty port } @Test void parseHostPortIpv4PortBoundsAndFormats() { - assertEquals(0, parseHostPort("example.com:0").port()); - assertEquals(65535, parseHostPort("example.com:65535").port()); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("example.com:-1")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("example.com:65536")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("example.com:abc")); + assertEquals(0, URI_ALLOW_ILLEGAL.parseHostPort("example.com:0").port()); + assertEquals(65535, URI_ALLOW_ILLEGAL.parseHostPort("example.com:65535").port()); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("example.com:-1")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("example.com:65536")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("example.com:abc")); } @Test void parseHostPortIpv6WithoutPort() { - URI.HostPort hp = parseHostPort("[2001:db8::1]"); + com.predic8.membrane.core.util.URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("[2001:db8::1]"); assertEquals("[2001:db8::1]", hp.host()); assertEquals(-1, hp.port()); } @Test void parseHostPortIpv6WithZoneIdNormalization() { - URI.HostPort hp = parseHostPort("[fe80::1%25eth0]:1234"); + com.predic8.membrane.core.util.URI.HostPort hp = URI_ALLOW_ILLEGAL.parseHostPort("[fe80::1%25eth0]:1234"); assertEquals("[fe80::1%25eth0]", hp.host()); assertEquals(1234, hp.port()); } @Test void parseHostPortIpv6BadPortAndJunk() { - assertThrows(IllegalArgumentException.class, () -> parseHostPort("[::1]:")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("[::1]:bad")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("[::1]x123")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]:")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]:bad")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]x123")); } @Test void parseHostPortIpv6EmptyHostRejected() { - assertThrows(IllegalArgumentException.class, () -> parseHostPort("[]")); - assertThrows(IllegalArgumentException.class, () -> parseHostPort("[]:80")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[]")); + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[]:80")); } @Test void parseHostPortRespectsUppercaseHexAndCompressed() { - assertEquals("[2001:DB8:0:0::1]", parseHostPort("[2001:DB8:0:0::1]").host()); - assertEquals("[2001:db8::1]", parseHostPort("[2001:db8::1]:8080").host()); + assertEquals("[2001:DB8:0:0::1]", URI_ALLOW_ILLEGAL.parseHostPort("[2001:DB8:0:0::1]").host()); + assertEquals("[2001:db8::1]", URI_ALLOW_ILLEGAL.parseHostPort("[2001:db8::1]:8080").host()); } } @@ -437,14 +406,14 @@ void withoutPort() throws URISyntaxException { @Test void withPort() throws URISyntaxException { - URI u = new URI("http://[2001:db8::1]:8080", true); + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:8080", true); assertEquals("[2001:db8::1]", u.getHost()); assertEquals(8080, u.getPort()); } @Test void withPath() throws URISyntaxException { - URI u = new URI("http://[2001:db8::1]/foo", true); + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[2001:db8::1]/foo", true); assertEquals("[2001:db8::1]", u.getHost()); assertEquals(-1, u.getPort()); assertEquals("/foo", u.getPath()); @@ -452,12 +421,12 @@ void withPath() throws URISyntaxException { @Test void invalid() { - assertThrows(IllegalArgumentException.class, () -> new URI("http://[2001:db8::1/foo", true)); + assertThrows(IllegalArgumentException.class, () -> new com.predic8.membrane.core.util.URI("http://[2001:db8::1/foo", true)); } @Test void withPortAndPath() throws URISyntaxException { - URI u = new URI("http://[2001:db8::1]:8080/foo", true); + com.predic8.membrane.core.util.URI u = new URI("http://[2001:db8::1]:8080/foo", true); assertEquals("[2001:db8::1]", u.getHost()); assertEquals(8080, u.getPort()); assertEquals("/foo", u.getPath()); @@ -465,7 +434,7 @@ void withPortAndPath() throws URISyntaxException { @Test void withUserInfo() throws URISyntaxException { - URI u = new URI("http://user:pwd@[2001:db8::1]:8080/foo", false); + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://user:pwd@[2001:db8::1]:8080/foo", false); assertEquals("[2001:db8::1]", u.getHost()); assertEquals(8080, u.getPort()); assertEquals("/foo", u.getPath()); @@ -475,7 +444,7 @@ void withUserInfo() throws URISyntaxException { @Test void withoutUserInfo() throws URISyntaxException { - URI u = new URI("http://[2001:db8::1]:8080/foo", true); + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:8080/foo", true); assertEquals("[2001:db8::1]", u.getHost()); assertEquals(8080, u.getPort()); assertEquals("/foo", u.getPath()); @@ -493,7 +462,7 @@ void withZoneIdNormalized() throws URISyntaxException { @Test void withZoneIdNormalized2() throws URISyntaxException { - URI u = new URI("http://[fe80::1%25eth0]:1234/foo", false); + com.predic8.membrane.core.util.URI u = new URI("http://[fe80::1%25eth0]:1234/foo", false); assertEquals("[fe80::1%25eth0]", u.getHost()); assertEquals(1234, u.getPort()); assertEquals("/foo", u.getPath()); @@ -502,8 +471,8 @@ void withZoneIdNormalized2() throws URISyntaxException { @Test void authorityFormattingWithAndWithoutPort() throws URISyntaxException { - assertEquals("[2001:db8::1]", new URI("http://[2001:db8::1]/x", true).getAuthority()); - assertEquals("[2001:db8::1]:8080", new URI("http://[2001:db8::1]:8080/x", true).getAuthority()); + assertEquals("[2001:db8::1]", new com.predic8.membrane.core.util.URI("http://[2001:db8::1]/x", true).getAuthority()); + assertEquals("[2001:db8::1]:8080", new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:8080/x", true).getAuthority()); } @Test @@ -517,14 +486,14 @@ void portLowerAndUpperBounds() throws URISyntaxException { URI u1 = new URI("http://[2001:db8::1]:0/foo", true); assertEquals(0, u1.getPort()); - URI u2 = new URI("http://[2001:db8::1]:65535/foo", true); + URI u2 = new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:65535/foo", true); assertEquals(65535, u2.getPort()); } @Test void portOutOfRangeOrNonNumeric() { assertThrows(IllegalArgumentException.class, () -> new URI("http://[2001:db8::1]:65536/foo", true)); - assertThrows(IllegalArgumentException.class, () -> new URI("http://[2001:db8::1]:-1/foo", true)); + assertThrows(IllegalArgumentException.class, () -> new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:-1/foo", true)); assertThrows(IllegalArgumentException.class, () -> new URI("http://[2001:db8::1]:abcd/foo", true)); } @@ -553,73 +522,73 @@ class ResolveTests { @Test @DisplayName("Resolve relative path against standard URI base") void resolveStandardBase() throws URISyntaxException { - URI base = new URI(false, "http://example.com"); - URI relative = new URI(false, "/foo/bar"); + URI base = new com.predic8.membrane.core.util.URI( "http://example.com",false); + URI relative = new com.predic8.membrane.core.util.URI( "/foo/bar",false); assertEquals("http://example.com/foo/bar", base.resolve(relative).toString()); } @Test @DisplayName("Resolve relative path against standard URI base with trailing slash") void resolveStandardBaseTrailingSlash() throws URISyntaxException { - URI base = new URI(false, "http://example.com/"); - URI relative = new URI(false, "/foo/bar"); + com.predic8.membrane.core.util.URI base = new com.predic8.membrane.core.util.URI( "http://example.com/",false); + com.predic8.membrane.core.util.URI relative = new URI( "/foo/bar",false); assertEquals("http://example.com/foo/bar", base.resolve(relative).toString()); } @Test @DisplayName("Resolve with query string on relative URI") void resolveWithQuery() throws URISyntaxException { - URI base = new URI(false, "http://example.com"); - URI relative = new URI(false, "/foo?q=1"); + com.predic8.membrane.core.util.URI base = new URI("http://example.com",false); + URI relative = new URI( "/foo?q=1",false); assertEquals("http://example.com/foo?q=1", base.resolve(relative).toString()); } @Test @DisplayName("Resolve empty relative path - standard URI returns base with trailing slash") void resolveEmptyRelativeStandard() throws URISyntaxException { - // java.net.URI.resolve("") on "http://example.com/basepath" returns "http://example.com/" - URI base = new URI(false, "http://example.com/basepath"); - URI relative = new URI(false, ""); - assertEquals("http://example.com/", base.resolve(relative).toString()); + URI base = new URI( "http://example.com/basepath",false); + com.predic8.membrane.core.util.URI relative = new URI( ""); + // Behaviour according to RFC 3986. Deviates from java.net.URI + assertEquals("http://example.com/basepath", base.resolve(relative).toString()); } @Test @DisplayName("Resolve with port in base URI") void resolveWithPort() throws URISyntaxException { - URI base = new URI(false, "http://example.com:8080"); - URI relative = new URI(false, "/api/test"); + URI base = new URI("http://example.com:8080"); + URI relative = new URI( "/api/test"); assertEquals("http://example.com:8080/api/test", base.resolve(relative).toString()); } @Test @DisplayName("Resolve relative path against custom-parsed base with illegal characters (placeholder)") void resolveCustomParsedPlaceholderHost() throws URISyntaxException { - URI base = new URI("http://${placeholder}", true); - URI relative = new URI("/foo/bar", true); + com.predic8.membrane.core.util.URI base = new com.predic8.membrane.core.util.URI("http://${placeholder}", true); + URI relative = new com.predic8.membrane.core.util.URI("/foo/bar", true); assertEquals("http://${placeholder}/foo/bar", base.resolve(relative).toString()); } @Test @DisplayName("Resolve with query against custom-parsed base with illegal characters") void resolveCustomParsedPlaceholderWithQuery() throws URISyntaxException { - URI base = new URI("http://${placeholder}", true); - URI relative = new URI("/foo?q=1", true); + URI base = new com.predic8.membrane.core.util.URI("http://${placeholder}", true); + com.predic8.membrane.core.util.URI relative = new com.predic8.membrane.core.util.URI("/foo?q=1", true); assertEquals("http://${placeholder}/foo?q=1", base.resolve(relative).toString()); } @Test @DisplayName("Resolve empty relative keeps base path in custom parsing mode") void resolveCustomParsedEmptyRelative() throws URISyntaxException { - URI base = new URI("http://${placeholder}/basepath", true); - URI relative = new URI("", true); + com.predic8.membrane.core.util.URI base = new URI("http://${placeholder}/basepath", true); + URI relative = new com.predic8.membrane.core.util.URI("", true); assertEquals("http://${placeholder}/basepath", base.resolve(relative).toString()); } @Test @DisplayName("Resolve with port in custom-parsed base with illegal characters") void resolveCustomParsedPlaceholderWithPort() throws URISyntaxException { - URI base = new URI("http://${placeholder}:8080", true); - URI relative = new URI("/api/test", true); + URI base = new com.predic8.membrane.core.util.URI("http://${placeholder}:8080", true); + com.predic8.membrane.core.util.URI relative = new com.predic8.membrane.core.util.URI("/api/test", true); assertEquals("http://${placeholder}:8080/api/test", base.resolve(relative).toString()); } @@ -627,7 +596,7 @@ void resolveCustomParsedPlaceholderWithPort() throws URISyntaxException { @DisplayName("Resolve using URIFactory with allowIllegalCharacters") void resolveViaURIFactory() throws URISyntaxException { URIFactory factory = new URIFactory(true); - URI base = factory.create("http://${host}"); + com.predic8.membrane.core.util.URI base = factory.create("http://${host}"); URI relative = factory.create("/path"); assertEquals("http://${host}/path", base.resolve(relative).toString()); } @@ -635,25 +604,45 @@ void resolveViaURIFactory() throws URISyntaxException { @Test @DisplayName("Resolve with curly braces in path of base") void resolveCustomParsedCurlyBracesInPath() throws URISyntaxException { - URI base = new URI("http://example.com/${version}", true); - URI relative = new URI("/foo", true); + URI base = new com.predic8.membrane.core.util.URI("http://example.com/${version}", true); + com.predic8.membrane.core.util.URI relative = new URI("/foo", true); assertEquals("http://example.com/foo", base.resolve(relative).toString()); } @Test - @DisplayName("Resolve standard base with relative that has query and fragment is ignored") void resolveStandardWithQueryOnRelative() throws URISyntaxException { - URI base = new URI(false, "https://api.example.com"); - URI relative = new URI(false, "/v1/resource?key=value"); + URI base = new URI("https://api.example.com"); + com.predic8.membrane.core.util.URI relative = new com.predic8.membrane.core.util.URI( "/v1/resource?key=value"); assertEquals("https://api.example.com/v1/resource?key=value", base.resolve(relative).toString()); } @Test - @DisplayName("Custom-parsed resolve preserves scheme correctly for https") void resolveCustomParsedHttps() throws URISyntaxException { - URI base = new URI("https://${host}", true); - URI relative = new URI("/secure/path", true); + com.predic8.membrane.core.util.URI base = new com.predic8.membrane.core.util.URI("https://${host}", true); + com.predic8.membrane.core.util.URI relative = new com.predic8.membrane.core.util.URI("/secure/path", true); assertEquals("https://${host}/secure/path", base.resolve(relative).toString()); } + + @Test + void resolveRelativeWithPathBack() throws URISyntaxException { + com.predic8.membrane.core.util.URI base = new URI( "http://localhost/validation"); + URI relative = new URI( "../validation/ArticleType.xsd"); + assertEquals("http://localhost/validation/ArticleType.xsd", base.resolve(relative).toString()); + } + + @Test + void resolveRelativeWithPathBackClasspath() throws URISyntaxException { + URI base = new com.predic8.membrane.core.util.URI( "classpath://authority/validation"); + URI relative = new URI("../validation/ArticleType.xsd"); + assertEquals("classpath://authority/../validation/ArticleType.xsd", base.resolve(relative).toString()); + } + + @Test + void resolveRelativeBackClasspath() throws URISyntaxException { + URI base = new URI("classpath://validation"); + URI relative = new com.predic8.membrane.core.util.URI("../validation/ArticleType.xsd"); + // getRessource() can deal with that + assertEquals("classpath://validation/../validation/ArticleType.xsd", base.resolve(relative).toString()); + } } } diff --git a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java index adf5e73f7e..c5e689f419 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java @@ -65,7 +65,7 @@ void testParamsWithoutValueString() { @Test void testDecodePath() throws Exception { - URI u = new URI(true, "/path/to%20my/resource"); + URI u = new com.predic8.membrane.core.util.URI( "/path/to%20my/resource",true); assertEquals("/path/to my/resource", u.getPath()); assertEquals("/path/to%20my/resource", u.getRawPath()); } diff --git a/distribution/examples/configuration/apis.yaml b/distribution/examples/configuration/apis.yaml index 84f998cfe5..2787cfb0d6 100644 --- a/distribution/examples/configuration/apis.yaml +++ b/distribution/examples/configuration/apis.yaml @@ -20,7 +20,10 @@ configuration: jmxRouterName: demo # Sets the JMX name for the router uriFactory: - allowIllegalCharacters: true # Allow non-RFC-compliant characters in URIs + # Allow non-RFC-compliant characters like {, } in URIs + # Attention: This may lead to security vulnerabilities! Use with care! + # - ${expression} in the URI will be evaluated + allowIllegalCharacters: true autoEscapeBackslashes: true # Escape backslashes in incoming URIs (\\ -> %5C) --- From 620871d61e25797b48d8b69e38a17b0f05a69a7c Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Tue, 17 Feb 2026 21:54:47 +0100 Subject: [PATCH 10/20] refactor(core): simplify `URI` logic and enhance test readability - Refactored `removeLastSegment` method for concise logic using `Math.max()` to avoid redundant checks. - Replaced `assertEquals(false, ...)` with `assertFalse(...)` for improved readability in tests. - Cleaned up unused imports in `ResolverMapCombineTest`. --- core/src/main/java/com/predic8/membrane/core/util/URI.java | 7 +------ .../core/interceptor/HTTPClientInterceptorTest.java | 2 +- .../membrane/core/resolver/ResolverMapCombineTest.java | 3 +-- .../test/java/com/predic8/membrane/core/util/URITest.java | 1 - 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/util/URI.java b/core/src/main/java/com/predic8/membrane/core/util/URI.java index 8422b73059..79fab10938 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URI.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URI.java @@ -424,12 +424,7 @@ else if ((i == path.length() - 1 && path.charAt(i) == '.') || } private static void removeLastSegment(StringBuilder out) { - int lastSlash = out.lastIndexOf("/"); - if (lastSlash >= 0) { - out.setLength(lastSlash); - } else { - out.setLength(0); - } + out.setLength(Math.max(out.lastIndexOf("/"), 0)); } @Override diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 1cf2aa129d..820fd1fe6d 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -174,7 +174,7 @@ void illegalCharacterWithoutTemplate() { fail(); return; } - assertEquals(false, apiProxy.getTarget().isEvaluateExpressions()); + assertFalse(apiProxy.getTarget().isEvaluateExpressions()); assertEquals(1, exc.getDestinations().size()); // The template should not be evaluated, cause illegal characters are allowed! diff --git a/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java b/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java index 47c6f0e9ab..b95b6fb06a 100644 --- a/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java +++ b/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java @@ -19,8 +19,7 @@ import java.security.*; import static com.predic8.membrane.core.resolver.ResolverMap.*; -import static com.predic8.membrane.core.util.OSUtil.wl; -import static com.predic8.membrane.core.util.URIFactory.DEFAULT_URI_FACTORY; +import static com.predic8.membrane.core.util.OSUtil.*; import static org.junit.jupiter.api.Assertions.*; public class ResolverMapCombineTest { diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index 6a92e848f3..0cb0dddb88 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -114,7 +114,6 @@ private void assertError(String uri, String path, String query) { com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI(uri, true); assertEquals(path, u.getPath()); assertEquals(query, u.getQuery()); - u.getRawQuery(); } catch (URISyntaxException | IllegalArgumentException e) { throw new RuntimeException(e); } From f52cf95819e636166a6d3e7801a3b9667099847a Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Wed, 18 Feb 2026 10:35:02 +0100 Subject: [PATCH 11/20] feat(core): enhance URI handling and replace `pathSeg` with `pathEncode` - Replaced `pathSeg` method with `pathEncode` for improved naming clarity and functionality in line with RFC 3986. - Introduced `IPv6Util` for robust validation of IPv6 address text, ensuring only allowed characters are accepted. - Refactored `Target` to include URL encoding strategy through a dedicated `escapingFunction` and removed redundant logic. - Streamlined URI combination logic in `ResolverMap` with utility methods for handling slashes and file URIs. - Added extensive tests covering URL path encoding, IPv6 address validation, and robustification of URI logic for edge cases. --- .../interceptor/DispatchingInterceptor.java | 16 +-- .../core/lang/CommonBuiltInFunctions.java | 6 +- .../core/lang/TemplateExchangeExpression.java | 8 +- .../lang/groovy/GroovyBuiltInFunctions.java | 4 +- .../spel/functions/SpELBuiltInFunctions.java | 4 +- .../predic8/membrane/core/proxies/Target.java | 45 ++++--- .../membrane/core/resolver/ResolverMap.java | 91 ++++++-------- .../predic8/membrane/core/util/FileUtil.java | 51 ++++++++ .../com/predic8/membrane/core/util/URI.java | 21 ++-- .../membrane/core/util/URIFactory.java | 3 +- .../predic8/membrane/core/util/URIUtil.java | 5 - .../membrane/core/util/URIValidationUtil.java | 24 ---- .../predic8/membrane/core/util/URLUtil.java | 63 ---------- .../util/UriIllegalCharacterDetector.java | 5 +- .../membrane/core/util/ip/IPv6Util.java | 30 +++++ .../membrane/core/util/uri/EscapingUtil.java | 96 ++++++++++++++ .../DispatchingInterceptorTest.java | 10 +- .../HTTPClientInterceptorTest.java | 19 +-- .../membrane/core/proxies/TargetTest.java | 6 +- .../membrane/core/util/FileUtilTest.java | 22 ++++ .../predic8/membrane/core/util/URITest.java | 10 +- .../membrane/core/util/URIUtilTest.java | 6 +- .../membrane/core/util/URLUtilTest.java | 6 +- .../core/util/uri/URIVsJavaNetURITest.java | 117 ++++++++++++++++++ 24 files changed, 442 insertions(+), 226 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java create mode 100644 core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java create mode 100644 core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java index c12ad614f8..814f68bae9 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java @@ -29,6 +29,7 @@ import static com.predic8.membrane.core.exchange.Exchange.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.Set.*; import static com.predic8.membrane.core.interceptor.Outcome.*; +import static com.predic8.membrane.core.util.URIFactory.ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY; /** * @description This interceptor adds the destination specified in the target @@ -44,7 +45,6 @@ public class DispatchingInterceptor extends AbstractInterceptor { private static final Logger log = LoggerFactory.getLogger(DispatchingInterceptor.class); - public static final URIFactory URI_FACTORY_ALLOW_ILLEGAL = new URIFactory(true); public DispatchingInterceptor() { name = "dispatching interceptor"; @@ -115,23 +115,23 @@ private String getForwardingDestination(Exchange exc) throws Exception { } protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLException, URISyntaxException { - if (!(exc.getProxy() instanceof AbstractServiceProxy proxy)) + if (!(exc.getProxy() instanceof AbstractServiceProxy asp)) return null; - if (proxy.getTargetURL() != null) { - var targetURL = proxy.getTargetURL(); + if (asp.getTargetURL() != null) { + var targetURL = asp.getTargetURL(); if (targetURL.startsWith("http") || targetURL.startsWith("internal")) { // Here illegal character as $ { } are allowed in the URI to make URL expressions possible. - // The URL is from the target in the configuration, that is maintained by the admin - var basePath = UriUtil.getPathFromURL(URI_FACTORY_ALLOW_ILLEGAL, targetURL); + // The URL is from the target in the configuration, maintained by admin + var basePath = UriUtil.getPathFromURL(ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY, targetURL); if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { return router.getResolverMap().combine(router.getConfiguration().getUriFactory(),targetURL,getUri(exc)); } } return targetURL; } - if (proxy.getTargetHost() != null) { - return new URL(proxy.getTargetScheme(), proxy.getTargetHost(), proxy.getTargetPort(), getUri(exc)).toString(); + if (asp.getTargetHost() != null) { + return new URL(asp.getTargetScheme(), asp.getTargetHost(), asp.getTargetPort(), getUri(exc)).toString(); } // That's fine. Maybe it is a without a target diff --git a/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java index e2a0a74204..cccf035fdd 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java @@ -22,7 +22,7 @@ import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.interceptor.Interceptor.Flow; import com.predic8.membrane.core.security.*; -import com.predic8.membrane.core.util.*; +import com.predic8.membrane.core.util.uri.*; import com.predic8.membrane.core.util.xml.*; import com.predic8.membrane.core.util.xml.parser.*; import org.jetbrains.annotations.*; @@ -269,7 +269,7 @@ public static String urlEncode(String s) { * @param segment the string value to encode * @return a percent-encoded string safe for use as a single URI path segment */ - public static String pathSeg(String segment) { - return URLUtil.pathSeg(segment); + public static String pathEncode(String segment) { + return EscapingUtil.pathEncode(segment); } } diff --git a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java index 70f90ea4a5..49fe5db470 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/TemplateExchangeExpression.java @@ -30,6 +30,9 @@ public class TemplateExchangeExpression extends AbstractExchangeExpression { private static final Logger log = LoggerFactory.getLogger(TemplateExchangeExpression.class); + /** + * Plugable encoder to apply various encoding strategies like URL, or path segment encoding. + */ private final Function encoder; /** @@ -87,9 +90,9 @@ private String evaluateToString(Exchange exchange, Flow flow) { } if (value == null) { line.append("null"); - } else { - line.append(encoder.apply(value)); + continue; } + line.append(encoder.apply(value)); } catch (Exception e) { throw new ExchangeExpressionException(token.toString(), e); } @@ -118,7 +121,6 @@ List parseTokens(Interceptor interceptor, Language language) { interface Token { T eval(Exchange exchange, Flow flow, Class type); - String getExpression(); } diff --git a/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java index 81ae3800f7..33629bc302 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java @@ -121,8 +121,8 @@ public String urlEncode(String s) { return CommonBuiltInFunctions.urlEncode(s); } - public String pathSeg(String segment) { - return CommonBuiltInFunctions.pathSeg(segment); + public String pathEncode(String segment) { + return CommonBuiltInFunctions.pathEncode(segment); } /** diff --git a/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java index af168d0473..0367cc021e 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/spel/functions/SpELBuiltInFunctions.java @@ -115,8 +115,8 @@ public String urlEncode(String s, SpELExchangeEvaluationContext ignored) { return CommonBuiltInFunctions.urlEncode(s); } - public String pathSeg(String segment, SpELExchangeEvaluationContext ignored) { - return CommonBuiltInFunctions.pathSeg(segment); + public String pathEncode(String segment, SpELExchangeEvaluationContext ignored) { + return CommonBuiltInFunctions.pathEncode(segment); } public static List getBuiltInFunctionNames() { diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index d509eb2b1a..ca03e00005 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -19,14 +19,15 @@ import com.predic8.membrane.core.config.xml.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.interceptor.*; -import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.lang.ExchangeExpression.*; +import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.router.*; import com.predic8.membrane.core.util.*; import com.predic8.membrane.core.util.text.*; +import com.predic8.membrane.core.util.uri.*; +import com.predic8.membrane.core.util.uri.EscapingUtil.*; import org.slf4j.*; -import java.net.*; import java.util.*; import java.util.function.*; import java.util.stream.*; @@ -35,7 +36,7 @@ import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static com.predic8.membrane.core.util.TemplateUtil.*; import static com.predic8.membrane.core.util.text.TerminalColors.*; -import static java.nio.charset.StandardCharsets.*; +import static com.predic8.membrane.core.util.uri.EscapingUtil.getEscapingFunction; /** * @description

@@ -54,8 +55,13 @@ public class Target implements XMLSupport { private String method; protected String url; - private ExchangeExpression.Language language = SPEL; + private Language language = SPEL; + + /** + * Escaping strategy for URL placeholders. + */ private Escaping escaping = Escaping.URL; + private Function escapingFunction; /** * If exchangeExpressions should be evaluated. @@ -67,14 +73,7 @@ public class Target implements XMLSupport { private SSLParser sslParser; protected XmlConfig xmlConfig; - public enum Escaping { - NONE, - URL, - SEGMENT - } - - public Target() { - } + public Target() {} public Target(String host) { setHost(host); @@ -92,8 +91,16 @@ public void init(Router router) { if (router.getConfiguration().getUriFactory().isAllowIllegalCharacters()) { log.warn("{}Url templates are disabled for security.{} Disable configuration/uriFactory/allowIllegalCharacters to enable them. Illegal characters in templates may lead to injection attacks.", TerminalColors.BRIGHT_RED(), RESET()); + throw new ConfigurationException(""" + URL Templating and Illegal URL Characters + + Url templating expressions and enablement of illegal characters in URLs are mutually exclusive. Either disable + illegal characters in the configuration (configuration/uriFactory/allowIllegalCharacters) or remove the + templating expression %s from the target URL. + """.formatted(url)); } else { evaluateExpressions = true; + escapingFunction = getEscapingFunction(escaping); } } @@ -128,15 +135,7 @@ private String evaluateTemplate(Exchange exc, Router router, String url, Interce language, url, router, - getEscapingFunction()).evaluate(exc, REQUEST, String.class); - } - - private Function getEscapingFunction() { - return switch (escaping) { - case NONE -> Function.identity(); - case URL -> s -> URLEncoder.encode(s, UTF_8); - case SEGMENT -> URLUtil::pathSeg; - }; + escapingFunction).evaluate(exc, REQUEST, String.class); } public String getHost() { @@ -219,7 +218,7 @@ public void setMethod(String method) { this.method = method; } - public ExchangeExpression.Language getLanguage() { + public Language getLanguage() { return language; } @@ -229,7 +228,7 @@ public ExchangeExpression.Language getLanguage() { * @example SpEL, groovy, jsonpath, xpath */ @MCAttribute - public void setLanguage(ExchangeExpression.Language language) { + public void setLanguage(Language language) { this.language = language; } diff --git a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java index eabe5441af..99b38f5787 100644 --- a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java +++ b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java @@ -23,7 +23,6 @@ import com.predic8.membrane.core.util.*; import com.predic8.membrane.core.util.functionalInterfaces.*; import com.predic8.xml.util.*; -import org.jetbrains.annotations.*; import org.slf4j.*; import org.w3c.dom.ls.*; @@ -51,6 +50,34 @@ public class ResolverMap implements Cloneable, Resolver { private static final Logger log = LoggerFactory.getLogger(ResolverMap.class.getName()); + int count = 0; + private String[] schemas; + private SchemaResolver[] resolvers; + + public ResolverMap() { + this(null, null); + } + + public ResolverMap(HttpClientFactory httpClientFactory, KubernetesClientFactory kubernetesClientFactory) { + schemas = new String[10]; + resolvers = new SchemaResolver[10]; + + // the default config + addSchemaResolver(new ClasspathSchemaResolver()); + addSchemaResolver(new HTTPSchemaResolver(httpClientFactory)); + addSchemaResolver(new KubernetesSchemaResolver(kubernetesClientFactory)); + addSchemaResolver(new FileSchemaResolver()); + } + + private ResolverMap(ResolverMap other) { + count = other.count; + schemas = new String[other.schemas.length]; + resolvers = new SchemaResolver[other.resolvers.length]; + + System.arraycopy(other.schemas, 0, schemas, 0, count); + System.arraycopy(other.resolvers, 0, resolvers, 0, count); + } + /** * First param is the parent. The following params will be combined to one path * e.g. "/foo/bar", "baz/x.yaml" ", "soo" => "/foo/bar/baz/soo" @@ -60,7 +87,7 @@ public class ResolverMap implements Cloneable, Resolver { * @return combined path */ public static String combine(URIFactory factory, String... locations) { - String resolved = combineInternal(factory,locations); + var resolved = combineInternal(factory,locations); log.debug("Resolved locations: {} to: {}", locations, resolved); return resolved; } @@ -88,20 +115,19 @@ private static String combineInternal2(URIFactory uriFactory, String[] locations if (relativeChild.contains(":/") || relativeChild.contains(":\\") || parent == null || parent.isEmpty()) return relativeChild; + // parent is file if (parent.startsWith("file:/")) { - if (relativeChild.startsWith("\\") || relativeChild.startsWith("/")) { + if (FileUtil.startsWithSlash(relativeChild)) { return convertPath2FilePathString(new File(relativeChild).getAbsolutePath()); } - File parentFile = new File(pathFromFileURI(parent)); - if (!parent.endsWith("/") && !parent.endsWith("\\")) - parentFile = parentFile.getParentFile(); try { - return keepTrailingSlash(parentFile, relativeChild); + return FileUtil.resolve(FileUtil.getDirectoryPart(URIUtil.pathFromFileURI(parent)), relativeChild); } catch (URISyntaxException e) { - throw new RuntimeException("Error combining: " + Arrays.toString(locations), e); + throw new RuntimeException("Error combining: " + locations, e); } } + // parent is http or classpath or internal if (parent.contains(":/")) { try { if (parent.startsWith("http") || parent.startsWith("classpath:") || parent.startsWith("internal:")) { @@ -111,6 +137,8 @@ private static String combineInternal2(URIFactory uriFactory, String[] locations throw new RuntimeException(e); } } + + // parent is absolute path if (parent.startsWith("/")) { try { return pathFromFileURI(convertPath2FileURI(parent).resolve(relativeChild)); @@ -123,11 +151,10 @@ private static String combineInternal2(URIFactory uriFactory, String[] locations """.formatted(parent, relativeChild)); } } - File parentFile = new File(parent); - if (!parent.endsWith("/") && !parent.endsWith("\\")) - parentFile = parentFile.getParentFile(); + + // assume file paths try { - return new File(parentFile, relativeChild).getCanonicalPath(); + return new File(FileUtil.getDirectoryPart(parent), relativeChild).getCanonicalPath(); } catch (IOException e) { throw new RuntimeException(e); } @@ -144,45 +171,6 @@ protected static String prepare4Uri(String path) { return path.replaceAll(" ", "%20"); } - protected static @NotNull String keepTrailingSlash(File parentFile, String relativeChild) throws URISyntaxException { - String res = toFileURIString(new File(parentFile, relativeChild)); - if (endsWithSlash(relativeChild)) - return res + "/"; - return res; - } - - private static boolean endsWithSlash(String path) { - return path.endsWith("/") || path.endsWith("\\"); - } - - int count = 0; - private String[] schemas; - private SchemaResolver[] resolvers; - - public ResolverMap() { - this(null, null); - } - - public ResolverMap(HttpClientFactory httpClientFactory, KubernetesClientFactory kubernetesClientFactory) { - schemas = new String[10]; - resolvers = new SchemaResolver[10]; - - // the default config - addSchemaResolver(new ClasspathSchemaResolver()); - addSchemaResolver(new HTTPSchemaResolver(httpClientFactory)); - addSchemaResolver(new KubernetesSchemaResolver(kubernetesClientFactory)); - addSchemaResolver(new FileSchemaResolver()); - } - - private ResolverMap(ResolverMap other) { - count = other.count; - schemas = new String[other.schemas.length]; - resolvers = new SchemaResolver[other.resolvers.length]; - - System.arraycopy(other.schemas, 0, schemas, 0, count); - System.arraycopy(other.resolvers, 0, resolvers, 0, count); - } - @Override public ResolverMap clone() { return new ResolverMap(this); @@ -319,6 +307,5 @@ protected InputStream resolveViaHttp(Object url) { } }; } - } } diff --git a/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java b/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java index c5695b516b..d2c4f80af5 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java @@ -15,8 +15,10 @@ package com.predic8.membrane.core.util; import org.apache.commons.io.*; +import org.jetbrains.annotations.*; import java.io.*; +import java.net.*; import static java.util.Objects.*; import static java.util.stream.Collectors.*; @@ -53,4 +55,53 @@ public static boolean isJson(String location) { return false; return JSON.equalsIgnoreCase(FilenameUtils.getExtension(location)); } + + /** + * Checks if string starts / or \ + * @param filepath + * @return boolean true if path starts with / or \ + */ + public static boolean startsWithSlash(String filepath) { + return filepath.startsWith("\\") || filepath.startsWith("/"); + } + + public static String toFileURIString(File f) throws URISyntaxException { + return URIUtil.convertPath2FileURI(f.getAbsolutePath()).toString(); + } + + public static boolean endsWithSlash(String filepath) { + return filepath.endsWith("/") || filepath.endsWith("\\"); + } + + /** + * Resolves the absolute URI string of a file given a parent directory + * and a relative child path. If the relative child path ends with a slash, + * the returned URI string will also end with a slash. + * + * @param parent the parent directory as a {@link File} object + * @param relativeChild the relative child path as a {@link String} + * @return the resolved absolute URI string of the file + * @throws URISyntaxException if an error occurs while converting the file path to a URI string + */ + public static @NotNull String resolve(File parent, String relativeChild) throws URISyntaxException { + var res = toFileURIString(new File(parent, relativeChild)); + if (endsWithSlash(relativeChild)) + return res + "/"; + return res; + } + + /** + * Retrieves the filepath directory of the given file path. + * foo/ => foo/ + * foo/bar.txt => foo/ + * + * @param filepath the file path as a string + * @return a {@link File} object representing the filepath directory or the file itself if the path ends with a slash + */ + public static File getDirectoryPart(String filepath) { + var parentFile = new File(filepath); + if (!endsWithSlash(filepath)) + return parentFile.getParentFile(); + return parentFile; + } } diff --git a/core/src/main/java/com/predic8/membrane/core/util/URI.java b/core/src/main/java/com/predic8/membrane/core/util/URI.java index 79fab10938..bdefc5a7fd 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URI.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URI.java @@ -14,6 +14,8 @@ package com.predic8.membrane.core.util; +import com.predic8.membrane.core.util.ip.*; + import java.net.*; import java.util.regex.*; @@ -94,17 +96,15 @@ private void processAuthority(String rawAuthority) { if (at >= 0) { userInfo = rawAuthority.substring(0, at); } - hostPort = parseHostPort(rawAuthority); } - record HostPort(String host, int port) { - } + record HostPort(String host, int port) {} HostPort parseHostPort(String rawAuthority) { if (rawAuthority == null) throw new IllegalArgumentException("rawAuthority is null."); - String hostAndPort = stripUserInfo(rawAuthority); + var hostAndPort = stripUserInfo(rawAuthority); if (isIP6Literal(hostAndPort)) { return parseIpv6(hostAndPort); @@ -143,13 +143,13 @@ static HostPort parseIpv6(String hostAndPort) { if (end < 0) { throw new IllegalArgumentException("Invalid IPv6 bracket literal: missing ']'."); } - String ipv6 = hostAndPort.substring(0, end + 1); + var ipv6 = hostAndPort.substring(0, end + 1); if (ipv6.length() <= 2) { throw new IllegalArgumentException("Host must not be empty."); } - validateIP6Address(ipv6); + IPv6Util.validateIP6Address(ipv6); int port = parsePort(hostAndPort.substring(end + 1)); return new HostPort(ipv6, port); @@ -237,6 +237,10 @@ public String getAuthority() { return authority; } + public String getUserInfo() { + return userInfo; + } + private String decode(String string) { if (string == null) return null; @@ -318,6 +322,9 @@ public URI resolve(URI relative, URIFactory factory) throws URISyntaxException { tPath = removeDotSegments(rPath); } else { var merged = merge(this.getAuthority(), this.getRawPath(), rPath); + + // Classpath is special cause there is no separate authority + // classpath://a/b/c if (this.getScheme().equals("classpath")) { tPath = merged; } else { @@ -378,7 +385,7 @@ public static String removeDotSegments(String path) { return path; } - StringBuilder out = new StringBuilder(); + var out = new StringBuilder(); int i = 0; while (i < path.length()) { // A: remove prefix "../" or "./" diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java b/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java index 0e177badf8..3edaa597df 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URIFactory.java @@ -73,5 +73,4 @@ public URI createWithoutException(String uri) { throw new IllegalArgumentException(e); } } - -} +} \ No newline at end of file diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URIUtil.java index be07f34461..5d3780a802 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URIUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URIUtil.java @@ -15,7 +15,6 @@ import org.jetbrains.annotations.*; -import java.io.*; import java.net.URI; import java.net.*; import java.util.*; @@ -29,10 +28,6 @@ public class URIUtil { private static final Pattern driveLetterPattern = Pattern.compile("^(\\w)[/:|].*"); - public static String toFileURIString(File f) throws URISyntaxException { - return convertPath2FileURI(f.getAbsolutePath()).toString(); - } - /** * * @param path Filepath like /foo/boo diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java index 3deda0e7b1..26690573f9 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java @@ -37,30 +37,6 @@ public static boolean isHex(char c) { (c >= 'a' && c <= 'f'); } - /** - * Security focused validation only: allowed characters for an IPv6 address text. - * Does not validate IPv6 semantics. Intended for bracket hosts like "[...]" where ':' is expected. - * - * Allowed: HEX, ':', '.', '%', unreserved, sub-delims, '[' and ']'. - * '%' is allowed because zone IDs and percent-encoded sequences may appear (validation of %HH is done elsewhere). - */ - public static void validateIP6Address(String s) { - if (s == null || s.isEmpty()) - throw new IllegalArgumentException("Invalid IPv6 address: empty."); - - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - - if (isHex(c) || c == ':' || c == '.' || c == '%' || c == '[' || c == ']') - continue; - - if (isUnreserved(c) || isSubDelims(c)) - continue; - - throw new IllegalArgumentException("Invalid character in IPv6 address: '" + c + "'"); - } - } - /** * Security focused validation only: host may be a reg-name or IPv4-ish or contain IPv6 literals. * Does not validate correctness of IP addresses. Only enforces allowed characters. diff --git a/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java index 49e58da077..e0d5d54453 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java @@ -16,9 +16,6 @@ import java.net.*; -import static java.lang.Character.*; -import static java.nio.charset.StandardCharsets.*; - public class URLUtil { public static String getAuthority(String uri) { @@ -56,64 +53,4 @@ public static int getPortFromURL(URL loc2) { return loc2.getPort() == -1 ? loc2.getDefaultPort() : loc2.getPort(); } - /** - * Encodes the given value so it can be safely used as a single URI path segment. - * - *

The method performs percent-encoding according to RFC 3986 for - * path segment context. All characters except the unreserved set - * {@code A-Z a-z 0-9 - . _ ~} are UTF-8 encoded and emitted as {@code %HH} - * sequences.

- * - *

This guarantees that the returned string:

- *
    - *
  • cannot introduce additional path separators ({@code /})
  • - *
  • cannot inject query or fragment delimiters ({@code ?, #, &})
  • - *
  • does not rely on {@code +} for spaces (spaces become {@code %20})
  • - *
  • is safe to concatenate into {@code ".../foo/" + pathSeg(value)}
  • - *
- * - *

The input is converted using {@link Object#toString()} and encoded as UTF-8. - * A {@code null} value results in an empty string.

- * - *

Example:

- *
{@code
-     * pathSeg("a/b & c")  -> "a%2Fb%20%26%20c"
-     * pathSeg("ä")        -> "%C3%A4"
-     * pathSeg(123)        -> "123"
-     * }
- * - *

Note: This method is intended for encoding a single - * path segment only. It must not be used for whole URLs, query strings, - * or already structured paths. For those cases, use a URI builder or - * context-specific encoding.

- * - * @param value the value to encode as a path segment; may be {@code null} - * @return a percent-encoded string safe for use as one URI path segment - */ - public static String pathSeg(Object value) { - if (value == null) return ""; - - byte[] bytes = value.toString().getBytes(UTF_8); - var out = new StringBuilder(bytes.length * 3); - - for (byte b : bytes) { - int c = b & 0xff; - - // RFC 3986 unreserved characters - if ((c >= 'A' && c <= 'Z') || - (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || - c == '-' || c == '.' || c == '_' || c == '~') { - - out.append((char) c); - } else { - out.append('%'); - char hex1 = toUpperCase(forDigit((c >> 4) & 0xF, 16)); - char hex2 = toUpperCase(forDigit(c & 0xF, 16)); - out.append(hex1).append(hex2); - } - } - - return out.toString(); - } } diff --git a/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java index 2353913302..5dc8aee21c 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java +++ b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java @@ -15,11 +15,12 @@ private UriIllegalCharacterDetector() { } public static void validateAll(URI uri, Options options) { - validateAll(uri.getScheme(), uri.getAuthority(), uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment(), options); + validateAll(uri.getScheme(), uri.getAuthority(), uri.getUserInfo(), uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment(), options); } public static void validateAll(String scheme, String authority, + String userInfo, String rawPath, String rawQuery, String rawFragment, @@ -41,6 +42,8 @@ public static void validateAll(String scheme, validatePctEncoding(rawQuery, "query"); validatePctEncoding(rawFragment, "fragment"); validatePctEncoding(authority, "authority"); + validateUserInfo(userInfo, options); + // scheme never contains '%' in valid RFC 3986; no need to check percent there. if (options.strictRfc3986) { diff --git a/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java b/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java new file mode 100644 index 0000000000..0c37be421d --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java @@ -0,0 +1,30 @@ +package com.predic8.membrane.core.util.ip; + +import com.predic8.membrane.core.util.*; + +public class IPv6Util { + + /** + * Security focused validation only: allowed characters for an IPv6 address text. + * Does not validate IPv6 semantics. Intended for bracket hosts like "[...]" where ':' is expected. + * + * Allowed: HEX, ':', '.', '%', unreserved, sub-delims, '[' and ']'. + * '%' is allowed because zone IDs and percent-encoded sequences may appear (validation of %HH is done elsewhere). + */ + public static void validateIP6Address(String s) { + if (s == null || s.isEmpty()) + throw new IllegalArgumentException("Invalid IPv6 address: empty."); + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + + if (URIValidationUtil.isHex(c) || c == ':' || c == '.' || c == '%' || c == '[' || c == ']') + continue; + + if (URIValidationUtil.isUnreserved(c) || URIValidationUtil.isSubDelims(c)) + continue; + + throw new IllegalArgumentException("Invalid character in IPv6 address: '" + c + "'"); + } + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java new file mode 100644 index 0000000000..8e57a88296 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -0,0 +1,96 @@ +package com.predic8.membrane.core.util.uri; + +import java.net.*; +import java.util.function.*; + +import static java.lang.Character.*; +import static java.nio.charset.StandardCharsets.*; + +public class EscapingUtil { + + /** + * Specifies the types of escaping that can be performed on strings. + * + * The escaping strategies include: + * + * - {@code NONE}: No escaping is applied. Strings are returned as-is. + * - {@code URL}: Encodes strings for safe inclusion in a URL, replacing spaces and + * other special characters with their percent-encoded counterparts (e.g., SPACE -> %20). + * - {@code SEGMENT}: Encodes strings as safe URI path segments, ensuring they do not introduce + * path separators, query delimiters, or other unsafe characters, as per RFC 3986. + */ + public enum Escaping { + NONE, + URL, + SEGMENT + } + + public static Function getEscapingFunction(Escaping escaping) { + return switch (escaping) { + case NONE -> Function.identity(); + case URL -> s -> URLEncoder.encode(s, UTF_8); + case SEGMENT -> EscapingUtil::pathEncode; + }; + } + + /** + * Encodes the given value so it can be safely used as a single URI path segment. + * + *

The method performs percent-encoding according to RFC 3986 for + * path segment context. All characters except the unreserved set + * {@code A-Z a-z 0-9 - . _ ~} are UTF-8 encoded and emitted as {@code %HH} + * sequences.

+ * + *

This guarantees that the returned string:

+ *
    + *
  • cannot introduce additional path separators ({@code /})
  • + *
  • cannot inject query or fragment delimiters ({@code ?, #, &})
  • + *
  • does not rely on {@code +} for spaces (spaces become {@code %20})
  • + *
  • is safe to concatenate into {@code ".../foo/" + pathSeg(value)}
  • + *
+ * + *

The input is converted using {@link Object#toString()} and encoded as UTF-8. + * A {@code null} value results in an empty string.

+ * + *

Example:

+ *
{@code
+     * pathSeg("a/b & c")  -> "a%2Fb%20%26%20c"
+     * pathSeg("ä")        -> "%C3%A4"
+     * pathSeg(123)        -> "123"
+     * }
+ * + *

Note: This method is intended for encoding a single + * path segment only. It must not be used for whole URLs, query strings, + * or already structured paths. For those cases, use a URI builder or + * context-specific encoding.

+ * + * @param value the value to encode as a path segment; may be {@code null} + * @return a percent-encoded string safe for use as one URI path segment + */ + public static String pathEncode(Object value) { + if (value == null) return ""; + + byte[] bytes = value.toString().getBytes(UTF_8); + var out = new StringBuilder(bytes.length * 3); + + for (byte b : bytes) { + int c = b & 0xff; + + // RFC 3986 unreserved characters + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~') { + + out.append((char) c); + } else { + out.append('%'); + char hex1 = toUpperCase(forDigit((c >> 4) & 0xF, 16)); + char hex2 = toUpperCase(forDigit(c & 0xF, 16)); + out.append(hex1).append(hex2); + } + } + + return out.toString(); + } +} diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java index fb28651d22..c33515f41d 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/DispatchingInterceptorTest.java @@ -18,6 +18,7 @@ import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.proxies.*; import com.predic8.membrane.core.router.*; +import com.predic8.membrane.core.util.*; import org.jetbrains.annotations.*; import org.junit.jupiter.api.*; @@ -154,18 +155,13 @@ private void addRequest(String uri) throws Exception { } @Test - void getAddressFromTargetElement() throws Exception { + void initWithAllowIllegalAndURLExpression() { var api = new APIProxy(); api.setTarget(new Target() {{ setUrl("https://${property.host}:8080"); // Has illegal characters $ { } in base path }}); - api.init(routerAllowIllegal); - dispatcher.init(routerAllowIllegal); - var exc = get("/foo").buildExchange(); - exc.setProxy(api); - - assertEquals("https://${property.host}:8080/foo", dispatcher.getAddressFromTargetElement(exc)); + assertThrows(ConfigurationException.class, ()-> api.init(routerAllowIllegal)); } @Nested diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 820fd1fe6d..2096a7f040 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -19,9 +19,9 @@ import com.predic8.membrane.core.lang.ExchangeExpression.*; import com.predic8.membrane.core.openapi.serviceproxy.*; import com.predic8.membrane.core.proxies.*; -import com.predic8.membrane.core.proxies.Target.*; import com.predic8.membrane.core.router.*; import com.predic8.membrane.core.util.*; +import com.predic8.membrane.core.util.uri.EscapingUtil.*; import org.junit.jupiter.api.*; import java.net.*; @@ -29,7 +29,7 @@ import static com.predic8.membrane.core.http.Header.*; import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; -import static com.predic8.membrane.core.proxies.Target.Escaping.*; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.*; import static org.junit.jupiter.api.Assertions.*; class HTTPClientInterceptorTest { @@ -153,16 +153,9 @@ class injection { void deactivateEvaluationOfURLTemplatesWhenIllegalCharactersAreAllowed() { allowIllegalURICharacters(); var exc = new Request.Builder().method(METHOD_GET).uri("/foo/${555}").buildExchange(); - invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL); - if (!(exc.getProxy() instanceof APIProxy apiProxy)) { - fail(); - return; - } - assertFalse(apiProxy.getTarget().isEvaluateExpressions()); - assertEquals(1, exc.getDestinations().size()); - // The template should not be evaluated, cause illegal characters are allowed! - assertEquals("https://${'hostname'}/foo/${555}", exc.getDestinations().getFirst()); + assertThrows(ConfigurationException.class, ()-> + invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL)); } @Test @@ -185,9 +178,7 @@ void illegalCharacterWithoutTemplate() { void uriTemplateAndIllegalCharacters() throws URISyntaxException { allowIllegalURICharacters(); var exc = get("/foo").buildExchange(); - invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL); - // The template should not be evaluated, cause illegal characters are allowed! - assertEquals("https://${'hostname'}/foo", exc.getDestinations().getFirst()); + assertThrows(ConfigurationException.class, () -> invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL)); } } diff --git a/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java index 742020a1f2..382046e768 100644 --- a/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java +++ b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java @@ -1,14 +1,14 @@ package com.predic8.membrane.core.proxies; -import com.predic8.membrane.core.proxies.Target.*; import org.junit.jupiter.api.*; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.*; +import static org.junit.jupiter.api.Assertions.*; class TargetTest { @Test void defaultEscaping() { - assertEquals(new Target().getEscaping(), Escaping.URL); + assertEquals(new Target().getEscaping(), URL); } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java index 5d742b09b1..8aa93c73cf 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java @@ -19,9 +19,11 @@ import org.junit.jupiter.api.*; import java.io.*; +import java.net.*; import java.nio.file.Path; import static com.predic8.membrane.core.util.FileUtil.*; +import static java.io.File.separator; import static org.junit.jupiter.api.Assertions.*; public class FileUtilTest { @@ -48,4 +50,24 @@ private String getTmpFilename() { String tmpDir = System.getProperty("java.io.tmpdir"); return Path.of(tmpDir, "test.tmp").toString(); } + + @Test + void resolveFile() throws URISyntaxException { + var file = new File("/a/b/c"); + var dir = new File("/a/b/c/"); + assertEquals("file:/a/b/c/d", FileUtil.resolve(file,"d")); + assertEquals("file:/a/b/c/d/", FileUtil.resolve(file,"d/")); + assertEquals("file:/a/b/c/d/", FileUtil.resolve(dir,"d/")); + } + + @Test + void directoryPart() { + assertEquals(null, FileUtil.getDirectoryPart("")); + assertEquals(new File(separator), FileUtil.getDirectoryPart("/")); + assertEquals(new File("\\"), FileUtil.getDirectoryPart("\\")); + assertEquals(new File(separator), FileUtil.getDirectoryPart("/foo")); + assertEquals(new File("/foo/"), FileUtil.getDirectoryPart("/foo/")); + assertEquals(new File("/foo/"), FileUtil.getDirectoryPart("/foo/bar")); + assertEquals(new File("/foo/"), FileUtil.getDirectoryPart("/foo/bar.txt")); + } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index 0cb0dddb88..1c62c98c0f 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -19,11 +19,14 @@ import java.net.*; import static com.predic8.membrane.core.util.URI.*; +import static com.predic8.membrane.core.util.URIFactory.ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY; +import static com.predic8.membrane.core.util.URIFactory.DEFAULT_URI_FACTORY; import static org.junit.jupiter.api.Assertions.*; class URITest { private static URI URI_ALLOW_ILLEGAL; + private static URIFactory FAC = DEFAULT_URI_FACTORY; @BeforeAll static void init() throws URISyntaxException { @@ -128,7 +131,7 @@ void encoding() { @Test void withoutPath() throws URISyntaxException { - URIFactory uf = new URIFactory(true); + var uf = ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY; assertEquals("http://localhost", uf.create("http://localhost").getWithoutPath()); assertEquals("http://localhost:8080", uf.create("http://localhost:8080").getWithoutPath()); assertEquals("http://localhost:8080", uf.create("http://localhost:8080/foo").getWithoutPath()); @@ -196,6 +199,11 @@ void getAuthorityIPv6Custom() throws URISyntaxException { } } + @Test + void userInfo() throws URISyntaxException { + assertEquals("alice:secret", FAC.create("http://alice:secret@localhost").getUserInfo()); + } + @Test void getPathWithQuery() throws URISyntaxException { assertEquals("/", new URIFactory().create("").getPathWithQuery()); diff --git a/core/src/test/java/com/predic8/membrane/core/util/URIUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/URIUtilTest.java index 30c89e6398..048f372a6f 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URIUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URIUtilTest.java @@ -179,8 +179,8 @@ void normalizeSingleDot() { @Test void toFileURIStringTest() throws URISyntaxException { - assertEquals(wl("file:/C:/swig/jig","file:/swig/jig"), toFileURIString(new File("/swig/jig"))); - assertEquals(wl("file:/C:/jag%20sag/runt","file:/jag%20sag/runt"), toFileURIString(new File("/jag sag/runt"))); + assertEquals(wl("file:/C:/swig/jig","file:/swig/jig"), FileUtil.toFileURIString(new File("/swig/jig"))); + assertEquals(wl("file:/C:/jag%20sag/runt","file:/jag%20sag/runt"), FileUtil.toFileURIString(new File("/jag sag/runt"))); } String wl(String windows, String linux) { @@ -194,7 +194,7 @@ void toFileURIStringSpaceTest() throws URISyntaxException { assertEquals(wl( "file:/C:/chip%20clip", "file:/chip%20clip" - ), toFileURIString(new File("/chip clip"))); + ), FileUtil.toFileURIString(new File("/chip clip"))); } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java index c5e689f419..8e4613d471 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URLUtilTest.java @@ -15,6 +15,7 @@ package com.predic8.membrane.core.util; +import com.predic8.membrane.core.util.uri.*; import org.junit.jupiter.api.*; import org.junit.jupiter.params.*; import org.junit.jupiter.params.provider.*; @@ -47,7 +48,6 @@ void testCreateQueryString() { assertEquals("endpoint=http%3A%2F%2Fnode1.clustera&cluster=c1", createQueryString("endpoint", "http://node1.clustera", "cluster", "c1")); - } @Test @@ -154,7 +154,7 @@ static Stream cases() { @MethodSource("cases") @DisplayName("pathSeg encodes as RFC3986 path segment") void encodesExpected(Case c) { - assertEquals(c.expected(), URLUtil.pathSeg(c.in())); + assertEquals(c.expected(), EscapingUtil.pathEncode(c.in())); } record AllowedCase(Object in) { @@ -174,7 +174,7 @@ static Stream allowedCases() { @MethodSource("allowedCases") @DisplayName("pathSeg output contains only unreserved characters or percent-escapes") void outputAllowedCharactersOnly(AllowedCase c) { - String out = URLUtil.pathSeg(c.in()); + String out = EscapingUtil.pathEncode(c.in()); assertTrue(out.matches("[A-Za-z0-9\\-._~%]*"), out); // If '%' appears, it must be followed by two hex digits diff --git a/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java new file mode 100644 index 0000000000..94d4ccfbc4 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java @@ -0,0 +1,117 @@ +package com.predic8.membrane.core.util.uri; + +import com.predic8.membrane.core.util.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.*; +import org.junit.jupiter.params.provider.*; + +import java.net.*; +import java.util.stream.*; + +import static com.predic8.membrane.core.util.URIFactory.*; +import static org.junit.jupiter.api.Assertions.*; + +class URIVsJavaNetURITest { + + record Case(String input) { + } + + static Stream urisThatShouldMatchJavaNetURI() { + return Stream.of( + new Case("http://example.com"), + new Case("http://example.com/"), + new Case("http://example.com/basepath"), + new Case("http://example.com/base/path"), + new Case("http://example.com/base/path?x=1&y=2"), + new Case("http://example.com/base/path?x=1&y=2#frag"), + new Case("http://user:pass@example.com:8080/a/b?x=1#f"), + new Case("https://example.com:443/a%20b?x=%2F#c%20d"), + new Case("http://[2001:db8::1]/p?q=1#f"), + new Case("http://[fe80::1%25eth0]/p?q=1#f") + ); + } + + @ParameterizedTest + @MethodSource("urisThatShouldMatchJavaNetURI") + void shouldMatchJavaNetURIForCommonCases(Case c) throws Exception { + var custom = DEFAULT_URI_FACTORY.create(c.input()); + var j = new java.net.URI(c.input()); + + // These should match for typical hierarchical URIs. + assertEquals(j.getScheme(), custom.getScheme(), "scheme"); + assertEquals(j.getRawAuthority(), custom.getAuthority(), "authority (raw, as in input)"); + assertEquals(j.getRawPath(), custom.getRawPath(), "rawPath"); + assertEquals(j.getRawQuery(), custom.getRawQuery(), "rawQuery"); + assertEquals(j.getRawFragment(), custom.getRawFragment(), "rawFragment"); + + assertEquals(j.getPath(), custom.getPath(), "path (decoded)"); + assertEquals(j.getQuery(), custom.getQuery(), "query (decoded)"); + assertEquals(j.getFragment(), custom.getFragment(), "fragment (decoded)"); + + assertEquals(j.getHost(), custom.getHost(), "host (java host is bracket-free)"); + assertEquals(j.getPort(), custom.getPort(), "port"); + + // getPathWithQuery is Membrane-specific; compare to Java reconstruction. + assertEquals(expectedPathWithQueryFromJava(j), custom.getPathWithQuery(), "pathWithQuery"); + } + + @Test + void resolvesLikeJavaNetURIForHttp() throws Exception { + assertResolveMatchesJava("http://example.com/basepath", "x"); + assertResolveMatchesJava("http://example.com/basepath/", "x"); + assertResolveMatchesJava("http://example.com/base/dir/", "../x"); + assertResolveMatchesJava("http://example.com/base/dir/", "./x"); + assertResolveMatchesJava("http://example.com/base/dir/", "../../x"); + assertResolveMatchesJava("http://example.com/base/dir/file", "../x?y=1#f"); + assertResolveMatchesJava("http://example.com/", "a/b/./c/../d"); + } + + private static void assertResolveMatchesJava(String base, String relative) throws URISyntaxException { + var customBase = DEFAULT_URI_FACTORY.create(base); + var customRel = DEFAULT_URI_FACTORY.create(relative); // relative ref: scheme/authority null is OK for this class + var customResolved = customBase.resolve(customRel).toString(); + + var javaResolved = new java.net.URI(base).resolve(relative).toString(); + assertEquals(javaResolved, customResolved, "resolve(" + base + ", " + relative + ")"); + } + + @Test + void acceptsCurlyBracesInPathWhereJavaNetURIRejects() throws Exception { + String s = "http://example.com/{id}/x"; + + // java.net.URI rejects '{' and '}' in paths by default + assertThrows(URISyntaxException.class, () -> new java.net.URI(s)); + + // custom URI is intended to accept '{' in paths + assertDoesNotThrow(() -> ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY.create(s)); + assertEquals(s, ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY.create(s).toString()); + assertEquals("/{id}/x", ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY.create(s).getRawPath()); + } + + @Test + void knownDifference_URLDecoderTreatsPlusAsSpace() throws Exception { + // java.net.URI decodes percent-escapes but does NOT treat '+' as space. + // This custom URI uses URLDecoder, which DOES treat '+' as space. + String s = "http://example.com/p?q=a+b"; + + var j = new java.net.URI(s); + var custom = DEFAULT_URI_FACTORY.create(s); + + assertEquals("q=a+b", j.getQuery(), "java query keeps '+'"); + assertEquals("q=a b", custom.getQuery(), "custom query turns '+' into space (URLDecoder)"); + } + + private static String expectedPathWithQueryFromJava(java.net.URI j) { + String rawPath = j.getRawPath(); + if (rawPath == null || rawPath.isBlank()) rawPath = "/"; + String rawQuery = j.getRawQuery(); + return rawQuery == null ? rawPath : rawPath + "?" + rawQuery; + } + + private static String stripIpv6BracketsIfNeeded(String host) { + // java.net.URI#getHost returns IPv6 without brackets, while the custom class may keep them. + if (host == null) return null; + if (host.startsWith("[") && host.endsWith("]")) return host.substring(1, host.length() - 1); + return host; + } +} From 2b39f1a102c16c78193b36ebe8e621a2714a3dee Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Wed, 18 Feb 2026 10:39:07 +0100 Subject: [PATCH 12/20] refactor(core): improve code readability and consistency in URI tests and utilities - Adjusted visibility modifiers of constants to `final` for better immutability (`URITest`). - Reordered equality assertion arguments for better readability (`TargetTest`). - Replaced redundant comments with concise `

` tags in JavaDocs for uniformity. - Refactored tests to leverage `assertNull` for null comparisons (`FileUtilTest`). - Removed unused method `stripIpv6BracketsIfNeeded` and redundant imports for cleaner code. - Enhanced URI combination logic and streamlined utility imports (`DispatchingInterceptor`). --- .../interceptor/DispatchingInterceptor.java | 6 ++--- .../predic8/membrane/core/util/FileUtil.java | 2 +- .../membrane/core/util/URIValidationUtil.java | 16 +++++++++++- .../membrane/core/util/ip/IPv6Util.java | 16 +++++++++++- .../membrane/core/util/uri/EscapingUtil.java | 18 +++++++++++-- .../membrane/core/proxies/TargetTest.java | 16 +++++++++++- .../membrane/core/util/FileUtilTest.java | 2 +- .../predic8/membrane/core/util/URITest.java | 2 +- .../core/util/uri/URIVsJavaNetURITest.java | 25 ++++++++++++------- 9 files changed, 83 insertions(+), 20 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java index 814f68bae9..e362b4c9a1 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java @@ -18,7 +18,6 @@ import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.openapi.util.*; import com.predic8.membrane.core.proxies.*; -import com.predic8.membrane.core.util.*; import org.jetbrains.annotations.*; import org.slf4j.*; @@ -29,7 +28,8 @@ import static com.predic8.membrane.core.exchange.Exchange.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.Set.*; import static com.predic8.membrane.core.interceptor.Outcome.*; -import static com.predic8.membrane.core.util.URIFactory.ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY; +import static com.predic8.membrane.core.resolver.ResolverMap.combine; +import static com.predic8.membrane.core.util.URIFactory.*; /** * @description This interceptor adds the destination specified in the target @@ -125,7 +125,7 @@ protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLEx // The URL is from the target in the configuration, maintained by admin var basePath = UriUtil.getPathFromURL(ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY, targetURL); if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { - return router.getResolverMap().combine(router.getConfiguration().getUriFactory(),targetURL,getUri(exc)); + return combine(router.getConfiguration().getUriFactory(),targetURL,getUri(exc)); } } return targetURL; diff --git a/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java b/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java index d2c4f80af5..7c7f3f8ee5 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/FileUtil.java @@ -58,7 +58,7 @@ public static boolean isJson(String location) { /** * Checks if string starts / or \ - * @param filepath + * @param filepath String to check * @return boolean true if path starts with / or \ */ public static boolean startsWithSlash(String filepath) { diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java index 26690573f9..3506734c14 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.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; public class URIValidationUtil { @@ -40,7 +54,7 @@ public static boolean isHex(char c) { /** * Security focused validation only: host may be a reg-name or IPv4-ish or contain IPv6 literals. * Does not validate correctness of IP addresses. Only enforces allowed characters. - * + *

* Allowed: unreserved, sub-delims, '.', '%', ':', '[', ']'. */ public static void validateHost(String s) { diff --git a/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java b/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java index 0c37be421d..901ed57e9a 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java +++ b/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.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.ip; import com.predic8.membrane.core.util.*; @@ -7,7 +21,7 @@ public class IPv6Util { /** * Security focused validation only: allowed characters for an IPv6 address text. * Does not validate IPv6 semantics. Intended for bracket hosts like "[...]" where ':' is expected. - * + *

* Allowed: HEX, ':', '.', '%', unreserved, sub-delims, '[' and ']'. * '%' is allowed because zone IDs and percent-encoded sequences may appear (validation of %HH is done elsewhere). */ diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index 8e57a88296..771abe868c 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.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.uri; import java.net.*; @@ -10,9 +24,9 @@ public class EscapingUtil { /** * Specifies the types of escaping that can be performed on strings. - * + *

* The escaping strategies include: - * + *

* - {@code NONE}: No escaping is applied. Strings are returned as-is. * - {@code URL}: Encodes strings for safe inclusion in a URL, replacing spaces and * other special characters with their percent-encoded counterparts (e.g., SPACE -> %20). diff --git a/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java index 382046e768..0eefe80189 100644 --- a/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java +++ b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.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.proxies; import org.junit.jupiter.api.*; @@ -9,6 +23,6 @@ class TargetTest { @Test void defaultEscaping() { - assertEquals(new Target().getEscaping(), URL); + assertEquals(URL,new Target().getEscaping()); } } \ No newline at end of file diff --git a/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java index 8aa93c73cf..6475d89c41 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java @@ -62,7 +62,7 @@ void resolveFile() throws URISyntaxException { @Test void directoryPart() { - assertEquals(null, FileUtil.getDirectoryPart("")); + assertNull(getDirectoryPart("")); assertEquals(new File(separator), FileUtil.getDirectoryPart("/")); assertEquals(new File("\\"), FileUtil.getDirectoryPart("\\")); assertEquals(new File(separator), FileUtil.getDirectoryPart("/foo")); diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index 1c62c98c0f..2899bf39d2 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -26,7 +26,7 @@ class URITest { private static URI URI_ALLOW_ILLEGAL; - private static URIFactory FAC = DEFAULT_URI_FACTORY; + private static final URIFactory FAC = DEFAULT_URI_FACTORY; @BeforeAll static void init() throws URISyntaxException { diff --git a/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java index 94d4ccfbc4..3cb6cd8f5e 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.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.uri; import com.predic8.membrane.core.util.*; @@ -102,16 +116,9 @@ void knownDifference_URLDecoderTreatsPlusAsSpace() throws Exception { } private static String expectedPathWithQueryFromJava(java.net.URI j) { - String rawPath = j.getRawPath(); + var rawPath = j.getRawPath(); if (rawPath == null || rawPath.isBlank()) rawPath = "/"; - String rawQuery = j.getRawQuery(); + var rawQuery = j.getRawQuery(); return rawQuery == null ? rawPath : rawPath + "?" + rawQuery; } - - private static String stripIpv6BracketsIfNeeded(String host) { - // java.net.URI#getHost returns IPv6 without brackets, while the custom class may keep them. - if (host == null) return null; - if (host.startsWith("[") && host.endsWith("]")) return host.substring(1, host.length() - 1); - return host; - } } From 20a2f91bad9181e729c196e666f6f1a2f46fd170 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Wed, 18 Feb 2026 12:37:49 +0100 Subject: [PATCH 13/20] feat(core): introduce `URIFactory` for enhanced URI creation and add comprehensive RFC 3986 compliance tests - Added `URIFactory` to support flexible URI creation with options for illegal character handling and backslash escaping. - Implemented extensive --- .../annot/generator/YamlDocsGenerator.java | 14 + .../predic8/membrane/core/proxies/Target.java | 5 +- .../membrane/core/resolver/ResolverMap.java | 4 +- .../membrane/core/util/TemplateUtil.java | 13 +- .../com/predic8/membrane/core/util/URI.java | 20 +- .../membrane/core/util/URIValidationUtil.java | 8 +- .../util/UriIllegalCharacterDetector.java | 104 ++- .../membrane/core/util/uri/EscapingUtil.java | 8 +- .../HTTPClientInterceptorTest.java | 15 +- .../ExplodeFalseArrayQueryParameterTest.java | 14 +- .../core/openapi/util/UriUtilTest.java | 4 +- .../membrane/core/util/FileUtilTest.java | 7 +- .../util/URIRFC3986ComplianceParsingTest.java | 811 ++++++++++++++++++ .../core/util/URIRFC3986ComplianceTest.java | 532 ++++++++++++ .../predic8/membrane/core/util/URITest.java | 11 +- .../core/util/uri/URIVsJavaNetURITest.java | 2 +- 16 files changed, 1495 insertions(+), 77 deletions(-) create mode 100644 core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceParsingTest.java create mode 100644 core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceTest.java diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/YamlDocsGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/YamlDocsGenerator.java index 1614cd482b..ca34736360 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/YamlDocsGenerator.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/YamlDocsGenerator.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.annot.generator; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index ca03e00005..de3b95255d 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -73,6 +73,8 @@ public class Target implements XMLSupport { private SSLParser sslParser; protected XmlConfig xmlConfig; + private InterceptorAdapter adapter; + public Target() {} public Target(String host) { @@ -89,6 +91,8 @@ public void init(Router router) { if (!containsTemplateMarker(url)) return; + adapter = new InterceptorAdapter(router, xmlConfig); + if (router.getConfiguration().getUriFactory().isAllowIllegalCharacters()) { log.warn("{}Url templates are disabled for security.{} Disable configuration/uriFactory/allowIllegalCharacters to enable them. Illegal characters in templates may lead to injection attacks.", TerminalColors.BRIGHT_RED(), RESET()); throw new ConfigurationException(""" @@ -114,7 +118,6 @@ public void applyModifications(Exchange exc, Router router) { } private List computeDestinationExpressions(Exchange exc, Router router) { - var adapter = new InterceptorAdapter(router, xmlConfig); return exc.getDestinations().stream().map(url -> evaluateTemplate(exc, router, url, adapter)) .collect(Collectors.toList()); // Collectors.toList() generates mutable List .toList() => immutable } diff --git a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java index 99b38f5787..5ebd16f0d6 100644 --- a/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java +++ b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java @@ -50,7 +50,7 @@ public class ResolverMap implements Cloneable, Resolver { private static final Logger log = LoggerFactory.getLogger(ResolverMap.class.getName()); - int count = 0; + private int count = 0; private String[] schemas; private SchemaResolver[] resolvers; @@ -104,7 +104,7 @@ private static String combineInternal(URIFactory factory,String... locations) { // lfold String[] l = new String[locations.length - 1]; System.arraycopy(locations, 0, l, 0, locations.length - 1); - return combine(combine(l), locations[locations.length - 1]); + return combine(factory,combine(l), locations[locations.length - 1]); } return combineInternal2( factory, locations,locations[1], locations[0]); diff --git a/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java index c430b92505..954348d3d2 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java @@ -16,19 +16,16 @@ public class TemplateUtil { + private TemplateUtil() {} + /** * Checks if the provided string contains the template marker "${" - * Fast implementation. + * HotSpot's String.contains → String.indexOf path uses native SIMD intrinsics + * should be as fast or faster then manual loop implementation * @param s the string to be checked for the presence of a template marker * @return true if the string contains a template marker, false otherwise */ public static boolean containsTemplateMarker(String s) { - if (s == null) return false; - for (int i = 0, len = s.length() - 1; i < len; i++) { - if (s.charAt(i) == '$' && s.charAt(i + 1) == '{') { - return true; - } - } - return false; + return s != null && s.contains("${"); } } diff --git a/core/src/main/java/com/predic8/membrane/core/util/URI.java b/core/src/main/java/com/predic8/membrane/core/util/URI.java index bdefc5a7fd..f755cf3121 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URI.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URI.java @@ -77,8 +77,7 @@ private boolean customInit(String s) { fragment = m.group(9); if (!allowIllegalCharacters) { - var options = new UriIllegalCharacterDetector.Options(); - UriIllegalCharacterDetector.validateAll(this, options); + UriIllegalCharacterDetector.validateAll(this, new UriIllegalCharacterDetector.Options.Builder().build()); } return true; @@ -170,8 +169,8 @@ static int parsePort(String restOfAuthority) { } private static int validatePortDigits(String p) { - if (p.isEmpty()) - throw new IllegalArgumentException("Invalid port: ''."); + if (p == null || p.isEmpty()) + return -1; validateDigits(p); int i = Integer.parseInt(p); @@ -190,10 +189,14 @@ public String getScheme() { * - might return something like "[fe80::1%25eth0]". */ public String getHost() { + if (hostPort == null) + return null; return hostPort.host; } public int getPort() { + if (hostPort == null) + return -1; return hostPort.port; } @@ -275,7 +278,14 @@ public String getPathWithQuery() { * @return */ public String getWithoutPath() { - return getScheme() + "://" + getAuthority(); + String r =""; + if (scheme != null) { + r += scheme + "://"; + } + if (authority != null) { + return r + authority; + } + return r; } /** diff --git a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java index 3506734c14..e703bd99ea 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java @@ -14,9 +14,13 @@ package com.predic8.membrane.core.util; -public class URIValidationUtil { +public final class URIValidationUtil { + + private URIValidationUtil() {} public static void validateDigits(String port) { + if (port == null) + return; for (int i = 0; i < port.length(); i++) { if (!isDigit(port.charAt(i))) throw new IllegalArgumentException("Invalid port: " + port); @@ -64,7 +68,7 @@ public static void validateHost(String s) { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); - if (c == '.' || c == '%' || c == ':' || c == '[' || c == ']') + if (c == '%' || c == ':' || c == '[' || c == ']') continue; if (isUnreserved(c) || isSubDelims(c)) diff --git a/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java index 5dc8aee21c..b8f2007e29 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java +++ b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.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; import java.util.*; @@ -56,31 +70,71 @@ public static void validateAll(String scheme, } public static final class Options { - /** - * If true, no checks are performed. - */ - public boolean skipAllValidation = false; - - /** - * If true, apply RFC 3986 component character rules (plus configured extensions). - * If false, only control/space + percent-encoding checks are applied. - */ - public boolean strictRfc3986 = true; - - /** - * Custom extension used by Membrane: allow '{' and '}' in path. - */ - public boolean allowBracesInPath = false; - - /** - * If true, allow '{' and '}' also in query/fragment (default false). - */ - public boolean allowBracesInQueryAndFragment = false; - - /** - * If true, allow '{' and '}' in user-info too (default false). - */ - public boolean allowBracesInUserInfo = false; + + private boolean skipAllValidation = false; + private boolean strictRfc3986 = true; + private boolean allowBracesInPath = false; + private boolean allowBracesInQueryAndFragment = false; + private boolean allowBracesInUserInfo = false; + + private Options() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private final Options o = new Options(); + + /** + * If true, no validation checks are performed at all. + */ + public Builder skipAllValidation(boolean value) { + o.skipAllValidation = value; + return this; + } + + /** + * If true, enforce RFC 3986 component character rules + * (plus configured extensions). + * If false, only control/space and percent-encoding checks are applied. + */ + public Builder strictRfc3986(boolean value) { + o.strictRfc3986 = value; + return this; + } + + /** + * Membrane extension: allow '{' and '}' inside the path component. + */ + public Builder allowBracesInPath(boolean value) { + o.allowBracesInPath = value; + return this; + } + + /** + * If true, allow '{' and '}' in query and fragment components. + * Default is false. + */ + public Builder allowBracesInQueryAndFragment(boolean value) { + o.allowBracesInQueryAndFragment = value; + return this; + } + + /** + * If true, allow '{' and '}' in the user-info component. + * Default is false. + */ + public Builder allowBracesInUserInfo(boolean value) { + o.allowBracesInUserInfo = value; + return this; + } + + public Options build() { + return o; + } + } } private static void validateScheme(String scheme) { diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index 771abe868c..dfa9db3dd2 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -29,7 +29,7 @@ public class EscapingUtil { *

* - {@code NONE}: No escaping is applied. Strings are returned as-is. * - {@code URL}: Encodes strings for safe inclusion in a URL, replacing spaces and - * other special characters with their percent-encoded counterparts (e.g., SPACE -> %20). + * other special characters with their percent-encoded counterparts (e.g., SPACE -> +). * - {@code SEGMENT}: Encodes strings as safe URI path segments, ensuring they do not introduce * path separators, query delimiters, or other unsafe characters, as per RFC 3986. */ @@ -68,9 +68,9 @@ public static Function getEscapingFunction(Escaping escaping) { * *

Example:

*
{@code
-     * pathSeg("a/b & c")  -> "a%2Fb%20%26%20c"
-     * pathSeg("ä")        -> "%C3%A4"
-     * pathSeg(123)        -> "123"
+     * pathEncode("a/b & c")  -> "a%2Fb%20%26%20c"
+     * pathEncode("ä")        -> "%C3%A4"
+     * pathEncode(123)        -> "123"
      * }
* *

Note: This method is intended for encoding a single diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index 2096a7f040..bfce3c0b04 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -150,12 +150,10 @@ void computeCompletePathURLEncoded() throws Exception { class injection { @Test - void deactivateEvaluationOfURLTemplatesWhenIllegalCharactersAreAllowed() { + void illegalCharactersAndTemplateInTargetURL() throws URISyntaxException { allowIllegalURICharacters(); - var exc = new Request.Builder().method(METHOD_GET).uri("/foo/${555}").buildExchange(); - - assertThrows(ConfigurationException.class, ()-> - invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL)); + var exc = get("/foo").buildExchange(); + assertThrows(ConfigurationException.class, () -> invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL)); } @Test @@ -173,13 +171,6 @@ void illegalCharacterWithoutTemplate() { // The template should not be evaluated, cause illegal characters are allowed! assertEquals("https://localhost/foo/${555}", exc.getDestinations().getFirst()); } - - @Test - void uriTemplateAndIllegalCharacters() throws URISyntaxException { - allowIllegalURICharacters(); - var exc = get("/foo").buildExchange(); - assertThrows(ConfigurationException.class, () -> invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL)); - } } private void allowIllegalURICharacters() { diff --git a/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java index 77fbb5234d..7e4c7dfe1d 100644 --- a/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java +++ b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java @@ -109,13 +109,6 @@ void rawQueryIsUsedToSplitParameters() { assertTrue(msg.contains("'foo,bar'")); } - @Test - void valuesUTF8() { - var err = validator.validate(get().path("/array?const=foo,äöü,baz")); - assertEquals(1, err.size()); - assertTrue(err.get(0).getMessage().contains("Invalid query string")); - } - @Test void valuesAreDecoded() { assertEquals(0, validator.validate(get().path("/array?const=foo,%C3%A4%3D%23,baz")).size()); @@ -130,6 +123,13 @@ void numberArrayWithNullValue() { @Nested class Invalid { + @Test + void rawUTF8InQueryStringIsInvalid() { + var err = validator.validate(get().path("/array?const=foo,äöü,baz")); + assertEquals(1, err.size()); + assertTrue(err.get(0).getMessage().contains("Invalid query string")); + } + @Test void stringNotNumber() { var err = validator.validate(get().path("/array?number=1,foo,3")); diff --git a/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java b/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java index cd4e358a34..155d7aa21d 100644 --- a/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/openapi/util/UriUtilTest.java @@ -83,13 +83,13 @@ public void rewriteStartsWithHttps() throws URISyntaxException { } @Test - public void rewriteWithoutHttp() throws URISyntaxException { + public void rewriteFromHttpToHttp() throws URISyntaxException { assertEquals("http://predic8.de:2000", doRewrite("http://localhost:3000", "http", "predic8.de", 2000)); assertEquals("http://predic8.de", doRewrite("http://localhost:3000", "http", "predic8.de", 80)); } @Test - public void rewriteWithoutHttps() throws URISyntaxException { + public void rewriteFromHttpToHttps() throws URISyntaxException { assertEquals("https://predic8.de:2000", doRewrite("http://localhost:3000", "https", "predic8.de", 2000)); assertEquals("https://predic8.de", doRewrite("http://localhost:3000", "https", "predic8.de", 443)); } diff --git a/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java index 6475d89c41..b51d2e4c97 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java @@ -23,6 +23,7 @@ import java.nio.file.Path; import static com.predic8.membrane.core.util.FileUtil.*; +import static com.predic8.membrane.core.util.OSUtil.wl; import static java.io.File.separator; import static org.junit.jupiter.api.Assertions.*; @@ -55,9 +56,9 @@ private String getTmpFilename() { void resolveFile() throws URISyntaxException { var file = new File("/a/b/c"); var dir = new File("/a/b/c/"); - assertEquals("file:/a/b/c/d", FileUtil.resolve(file,"d")); - assertEquals("file:/a/b/c/d/", FileUtil.resolve(file,"d/")); - assertEquals("file:/a/b/c/d/", FileUtil.resolve(dir,"d/")); + assertEquals(wl("file:/C:/a/b/c/d", "file:/a/b/c/d"), FileUtil.resolve(file, "d")); + assertEquals(wl("file:/C:/a/b/c/d/", "file:/a/b/c/d/"), FileUtil.resolve(file, "d/")); + assertEquals(wl("file:/C:/a/b/c/d/", "file:/a/b/c/d/"), FileUtil.resolve(dir, "d/")); } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceParsingTest.java b/core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceParsingTest.java new file mode 100644 index 0000000000..02c336da94 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceParsingTest.java @@ -0,0 +1,811 @@ +/* Copyright 2025 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; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.*; +import org.junit.jupiter.params.provider.*; + +import java.net.*; +import java.util.stream.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests URI parsing against the syntax rules and examples from RFC 3986. + *

+ * Sections covered: + *

    + *
  • Section 3 - Syntax Components (scheme, authority, path, query, fragment)
  • + *
  • Section 3.2 - Authority (userinfo, host, port)
  • + *
  • Appendix B - Parsing a URI Reference with a Regular Expression
  • + *
  • Section 1.1.2 - Example URIs
  • + *
+ * + * @see RFC 3986 + */ +class URIRFC3986ComplianceParsingTest { + + // ================================================================ + // Appendix B - The regex ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))? + // + // Group 2: scheme, Group 4: authority, Group 5: path, + // Group 7: query, Group 9: fragment + // ================================================================ + + @Nested + @DisplayName("Appendix B - URI Reference Regex Decomposition") + class AppendixBTests { + + @Test + @DisplayName("http://www.ics.uci.edu/pub/ietf/uri/#Related (Appendix B example)") + void appendixBExample() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://www.ics.uci.edu/pub/ietf/uri/#Related", true); + assertEquals("http", u.getScheme()); + assertEquals("www.ics.uci.edu", u.getAuthority()); + assertEquals("/pub/ietf/uri/", u.getRawPath()); + assertNull(u.getRawQuery()); + assertEquals("Related", u.getRawFragment()); + } + + @Test + @DisplayName("Scheme, authority, path, query, and fragment all present") + void allComponentsPresent() throws URISyntaxException { + URI u = new com.predic8.membrane.core.util.URI("http://host/path?query#fragment", true); + assertEquals("http", u.getScheme()); + assertEquals("host", u.getAuthority()); + assertEquals("/path", u.getRawPath()); + assertEquals("query", u.getRawQuery()); + assertEquals("fragment", u.getRawFragment()); + } + + @Test + @DisplayName("Only scheme and path (no authority, no query, no fragment)") + void schemeAndPathOnly() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("mailto:user@example.com", true); + assertEquals("mailto", u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("user@example.com", u.getRawPath()); + assertNull(u.getRawQuery()); + assertNull(u.getRawFragment()); + } + + @Test + @DisplayName("Authority present but empty path") + void authorityEmptyPath() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host", true); + assertEquals("http", u.getScheme()); + assertEquals("host", u.getAuthority()); + assertEquals("", u.getRawPath()); + assertNull(u.getRawQuery()); + assertNull(u.getRawFragment()); + } + + @Test + @DisplayName("No scheme (relative reference with authority)") + void noSchemeWithAuthority() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("//host/path", true); + assertNull(u.getScheme()); + assertEquals("host", u.getAuthority()); + assertEquals("/path", u.getRawPath()); + } + + @Test + @DisplayName("No scheme, no authority (relative path reference)") + void relativePathOnly() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("path/to/resource", true); + assertNull(u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("path/to/resource", u.getRawPath()); + } + + @Test + @DisplayName("Query only (no path, no scheme)") + void queryOnly() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("?query", true); + assertNull(u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("", u.getRawPath()); + assertEquals("query", u.getRawQuery()); + } + + @Test + @DisplayName("Fragment only") + void fragmentOnly() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("#fragment", true); + assertNull(u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("", u.getRawPath()); + assertNull(u.getRawQuery()); + assertEquals("fragment", u.getRawFragment()); + } + + @Test + @DisplayName("Empty string parses as empty path") + void emptyString() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("", true); + assertNull(u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("", u.getRawPath()); + assertNull(u.getRawQuery()); + assertNull(u.getRawFragment()); + } + } + + // ================================================================ + // Section 3 - Syntax Components + // ================================================================ + + @Nested + @DisplayName("Section 3 - Full URI Decomposition: foo://example.com:8042/over/there?name=ferret#nose") + class Section3Example { + + private com.predic8.membrane.core.util.URI u; + + @BeforeEach + void setUp() throws URISyntaxException { + u = new com.predic8.membrane.core.util.URI("foo://example.com:8042/over/there?name=ferret#nose", true); + } + + @Test + void scheme() { + assertEquals("foo", u.getScheme()); + } + + @Test + void authority() { + assertEquals("example.com:8042", u.getAuthority()); + } + + @Test + void host() { + assertEquals("example.com", u.getHost()); + } + + @Test + void port() { + assertEquals(8042, u.getPort()); + } + + @Test + void path() { + assertEquals("/over/there", u.getRawPath()); + } + + @Test + void query() { + assertEquals("name=ferret", u.getRawQuery()); + } + + @Test + void fragment() { + assertEquals("nose", u.getRawFragment()); + } + } + + // ================================================================ + // Section 3.1 - Scheme + // ================================================================ + + @Nested + @DisplayName("Section 3.1 - Scheme") + class SchemeTests { + + @Test + @DisplayName("Simple lowercase scheme") + void lowercaseScheme() throws URISyntaxException { + assertEquals("http", new com.predic8.membrane.core.util.URI("http://host", true).getScheme()); + } + + @Test + @DisplayName("Uppercase scheme (schemes are case-insensitive)") + void uppercaseScheme() throws URISyntaxException { + assertEquals("HTTP", new com.predic8.membrane.core.util.URI("HTTP://host", true).getScheme()); + } + + @Test + @DisplayName("Scheme with digits, plus, period, hyphen") + void schemeWithSpecialChars() throws URISyntaxException { + assertEquals("coap+tcp", new com.predic8.membrane.core.util.URI("coap+tcp://host/path", true).getScheme()); + } + + @Test + @DisplayName("Scheme with period and hyphen") + void schemeWithDotAndHyphen() throws URISyntaxException { + assertEquals("a.b-c", new com.predic8.membrane.core.util.URI("a.b-c://host", true).getScheme()); + } + + @Test + @DisplayName("Single letter scheme") + void singleLetterScheme() throws URISyntaxException { + assertEquals("x", new com.predic8.membrane.core.util.URI("x://host", true).getScheme()); + } + + @Test + @DisplayName("No scheme in relative reference") + void noScheme() throws URISyntaxException { + assertNull(new com.predic8.membrane.core.util.URI("/path", true).getScheme()); + } + } + + // ================================================================ + // Section 3.2 - Authority + // ================================================================ + + @Nested + @DisplayName("Section 3.2 - Authority") + class AuthorityTests { + + @Test + @DisplayName("Authority with host only") + void hostOnly() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://example.com/path", true); + assertEquals("example.com", u.getAuthority()); + assertEquals("example.com", u.getHost()); + assertEquals(-1, u.getPort()); + } + + @Test + @DisplayName("Authority with host and port") + void hostAndPort() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://example.com:8080/path", true); + assertEquals("example.com:8080", u.getAuthority()); + assertEquals("example.com", u.getHost()); + assertEquals(8080, u.getPort()); + } + + @Test + @DisplayName("Authority with userinfo, host, and port") + void userinfoHostPort() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://user:pass@example.com:8080/path", true); + assertEquals("user:pass@example.com:8080", u.getAuthority()); + assertEquals("example.com", u.getHost()); + assertEquals(8080, u.getPort()); + } + + @Test + @DisplayName("No authority (scheme:path form)") + void noAuthority() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("mailto:user@example.com", true); + assertNull(u.getAuthority()); + } + + @Test + @DisplayName("Empty authority (file:///path) - custom parser rejects empty host") + void emptyAuthority() { + // RFC 3986 allows empty authority (e.g. file:///path), but the custom + // parser requires a non-empty host, so this throws. + assertThrows(IllegalArgumentException.class, + () -> new com.predic8.membrane.core.util.URI("file:///etc/hosts", true)); + } + + @Test + @DisplayName("Authority stops at slash") + void authorityStopsAtSlash() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/path", true); + assertEquals("host", u.getAuthority()); + assertEquals("/path", u.getRawPath()); + } + + @Test + @DisplayName("Authority stops at question mark") + void authorityStopsAtQuestion() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host?query", true); + assertEquals("host", u.getAuthority()); + assertEquals("", u.getRawPath()); + assertEquals("query", u.getRawQuery()); + } + + @Test + @DisplayName("Authority stops at hash") + void authorityStopsAtHash() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host#fragment", true); + assertEquals("host", u.getAuthority()); + assertEquals("", u.getRawPath()); + assertEquals("fragment", u.getRawFragment()); + } + } + + // ================================================================ + // Section 3.2.2 - Host (IPv6) + // ================================================================ + + @Nested + @DisplayName("Section 3.2.2 - IPv6 Host Parsing") + class IPv6HostTests { + + @Test + @DisplayName("IPv6 address in brackets") + void ipv6Basic() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[2001:db8::1]/path", true); + assertEquals("[2001:db8::1]", u.getHost()); + assertEquals(-1, u.getPort()); + } + + @Test + @DisplayName("IPv6 with port") + void ipv6WithPort() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[2001:db8::1]:8080/path", true); + assertEquals("[2001:db8::1]", u.getHost()); + assertEquals(8080, u.getPort()); + } + + @Test + @DisplayName("IPv6 loopback") + void ipv6Loopback() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[::1]/path", true); + assertEquals("[::1]", u.getHost()); + } + + @Test + @DisplayName("IPv6 with zone ID (percent-encoded)") + void ipv6WithZoneId() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://[fe80::1%25eth0]:1234/path", true); + assertEquals("[fe80::1%25eth0]", u.getHost()); + assertEquals(1234, u.getPort()); + } + } + + // ================================================================ + // Section 3.2.3 - Port + // ================================================================ + + @Nested + @DisplayName("Section 3.2.3 - Port") + class PortTests { + + @Test + @DisplayName("Default port not specified returns -1") + void noPort() throws URISyntaxException { + assertEquals(-1, new com.predic8.membrane.core.util.URI("http://host/path", true).getPort()); + } + + @Test + @DisplayName("Port 80") + void port80() throws URISyntaxException { + assertEquals(80, new com.predic8.membrane.core.util.URI("http://host:80/path", true).getPort()); + } + + @Test + @DisplayName("Port 443") + void port443() throws URISyntaxException { + assertEquals(443, new com.predic8.membrane.core.util.URI("https://host:443/path", true).getPort()); + } + + @Test + @DisplayName("Port 0 (minimum)") + void portZero() throws URISyntaxException { + assertEquals(0, new com.predic8.membrane.core.util.URI("http://host:0/path", true).getPort()); + } + + @Test + @DisplayName("Port 65535 (maximum)") + void portMax() throws URISyntaxException { + assertEquals(65535, new com.predic8.membrane.core.util.URI("http://host:65535/path", true).getPort()); + } + + @Test + @DisplayName("High port number") + void highPort() throws URISyntaxException { + assertEquals(49152, new com.predic8.membrane.core.util.URI("http://host:49152/path", true).getPort()); + } + } + + // ================================================================ + // Section 3.3 - Path + // ================================================================ + + @Nested + @DisplayName("Section 3.3 - Path") + class PathTests { + + @Test + @DisplayName("path-abempty: empty path with authority") + void pathAbemptyEmpty() throws URISyntaxException { + assertEquals("", new com.predic8.membrane.core.util.URI("http://host", true).getRawPath()); + } + + @Test + @DisplayName("path-abempty: slash path with authority") + void pathAbemptySlash() throws URISyntaxException { + assertEquals("/", new com.predic8.membrane.core.util.URI("http://host/", true).getRawPath()); + } + + @Test + @DisplayName("path-abempty: multi-segment path") + void pathAbemptyMultiSegment() throws URISyntaxException { + assertEquals("/a/b/c", new com.predic8.membrane.core.util.URI("http://host/a/b/c", true).getRawPath()); + } + + @Test + @DisplayName("path-absolute: starts with / but no authority") + void pathAbsolute() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("/path/to/resource", true); + assertNull(u.getAuthority()); + assertEquals("/path/to/resource", u.getRawPath()); + } + + @Test + @DisplayName("path-rootless: no leading slash, no authority") + void pathRootless() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("relative/path", true); + assertNull(u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("relative/path", u.getRawPath()); + } + + @Test + @DisplayName("path-empty: no path at all") + void pathEmpty() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host", true); + assertEquals("", u.getRawPath()); + } + + @Test + @DisplayName("Path with semicolon parameters (RFC 3986 treats as opaque path data)") + void pathWithSemicolon() throws URISyntaxException { + assertEquals("/b/c/d;p", new com.predic8.membrane.core.util.URI("http://a/b/c/d;p?q", true).getRawPath()); + } + + @Test + @DisplayName("Trailing slash in path") + void trailingSlash() throws URISyntaxException { + assertEquals("/path/", new com.predic8.membrane.core.util.URI("http://host/path/", true).getRawPath()); + } + + @Test + @DisplayName("Single slash path") + void singleSlash() throws URISyntaxException { + assertEquals("/", new com.predic8.membrane.core.util.URI("/", true).getRawPath()); + } + } + + // ================================================================ + // Section 3.4 - Query + // ================================================================ + + @Nested + @DisplayName("Section 3.4 - Query") + class QueryTests { + + @Test + @DisplayName("Simple query") + void simpleQuery() throws URISyntaxException { + assertEquals("key=value", new com.predic8.membrane.core.util.URI("http://host/path?key=value", true).getRawQuery()); + } + + @Test + @DisplayName("Multiple query parameters") + void multipleParams() throws URISyntaxException { + assertEquals("a=1&b=2", new com.predic8.membrane.core.util.URI("http://host/path?a=1&b=2", true).getRawQuery()); + } + + @Test + @DisplayName("Query can contain slashes and question marks (per RFC 3986 Section 3.4)") + void queryWithSlashesAndQuestionMarks() throws URISyntaxException { + assertEquals("objectClass?one", + new com.predic8.membrane.core.util.URI("ldap://[2001:db8::7]/c=GB?objectClass?one", true).getRawQuery()); + } + + @Test + @DisplayName("Empty query (just question mark)") + void emptyQuery() throws URISyntaxException { + assertEquals("", new com.predic8.membrane.core.util.URI("http://host/path?", true).getRawQuery()); + } + + @Test + @DisplayName("No query returns null") + void noQuery() throws URISyntaxException { + assertNull(new com.predic8.membrane.core.util.URI("http://host/path", true).getRawQuery()); + } + + @Test + @DisplayName("Query with fragment following") + void queryBeforeFragment() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/path?query#frag", true); + assertEquals("query", u.getRawQuery()); + assertEquals("frag", u.getRawFragment()); + } + } + + // ================================================================ + // Section 3.5 - Fragment + // ================================================================ + + @Nested + @DisplayName("Section 3.5 - Fragment") + class FragmentTests { + + @Test + @DisplayName("Simple fragment") + void simpleFragment() throws URISyntaxException { + assertEquals("section1", new com.predic8.membrane.core.util.URI("http://host/path#section1", true).getRawFragment()); + } + + @Test + @DisplayName("Fragment can contain slashes and question marks") + void fragmentWithSlashesAndQuestionMarks() throws URISyntaxException { + assertEquals("s/./x", new com.predic8.membrane.core.util.URI("http://host/path#s/./x", true).getRawFragment()); + } + + @Test + @DisplayName("Empty fragment (just hash)") + void emptyFragment() throws URISyntaxException { + assertEquals("", new com.predic8.membrane.core.util.URI("http://host/path#", true).getRawFragment()); + } + + @Test + @DisplayName("No fragment returns null") + void noFragment() throws URISyntaxException { + assertNull(new com.predic8.membrane.core.util.URI("http://host/path", true).getRawFragment()); + } + + @Test + @DisplayName("Fragment after query") + void fragmentAfterQuery() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/path?q=1#frag", true); + assertEquals("q=1", u.getRawQuery()); + assertEquals("frag", u.getRawFragment()); + } + + @Test + @DisplayName("Fragment without query") + void fragmentWithoutQuery() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/path#frag", true); + assertNull(u.getRawQuery()); + assertEquals("frag", u.getRawFragment()); + } + } + + // ================================================================ + // Section 1.1.2 - Example URIs + // ================================================================ + + @Nested + @DisplayName("Section 1.1.2 - Example URIs from the RFC") + class Section112Examples { + + @Test + @DisplayName("ftp://ftp.is.co.za/rfc/rfc1808.txt") + void ftpUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("ftp://ftp.is.co.za/rfc/rfc1808.txt", true); + assertEquals("ftp", u.getScheme()); + assertEquals("ftp.is.co.za", u.getAuthority()); + assertEquals("/rfc/rfc1808.txt", u.getRawPath()); + assertNull(u.getRawQuery()); + assertNull(u.getRawFragment()); + } + + @Test + @DisplayName("http://www.ietf.org/rfc/rfc2396.txt") + void httpUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://www.ietf.org/rfc/rfc2396.txt", true); + assertEquals("http", u.getScheme()); + assertEquals("www.ietf.org", u.getAuthority()); + assertEquals("/rfc/rfc2396.txt", u.getRawPath()); + } + + @Test + @DisplayName("ldap://[2001:db8::7]/c=GB?objectClass?one") + void ldapUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("ldap://[2001:db8::7]/c=GB?objectClass?one", true); + assertEquals("ldap", u.getScheme()); + assertEquals("[2001:db8::7]", u.getAuthority()); + assertEquals("[2001:db8::7]", u.getHost()); + assertEquals("/c=GB", u.getRawPath()); + assertEquals("objectClass?one", u.getRawQuery()); + } + + @Test + @DisplayName("mailto:John.Doe@example.com") + void mailtoUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("mailto:John.Doe@example.com", true); + assertEquals("mailto", u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("John.Doe@example.com", u.getRawPath()); + } + + @Test + @DisplayName("news:comp.infosystems.www.servers.unix") + void newsUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("news:comp.infosystems.www.servers.unix", true); + assertEquals("news", u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("comp.infosystems.www.servers.unix", u.getRawPath()); + } + + @Test + @DisplayName("tel:+1-816-555-1212") + void telUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("tel:+1-816-555-1212", true); + assertEquals("tel", u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("+1-816-555-1212", u.getRawPath()); + } + + @Test + @DisplayName("telnet://192.0.2.16:80/") + void telnetUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("telnet://192.0.2.16:80/", true); + assertEquals("telnet", u.getScheme()); + assertEquals("192.0.2.16:80", u.getAuthority()); + assertEquals("192.0.2.16", u.getHost()); + assertEquals(80, u.getPort()); + assertEquals("/", u.getRawPath()); + } + + @Test + @DisplayName("urn:oasis:names:specification:docbook:dtd:xml:4.1.2") + void urnUri() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("urn:oasis:names:specification:docbook:dtd:xml:4.1.2", true); + assertEquals("urn", u.getScheme()); + assertNull(u.getAuthority()); + assertEquals("oasis:names:specification:docbook:dtd:xml:4.1.2", u.getRawPath()); + } + } + + // ================================================================ + // Section 5.4 Base URI - parsing of the base used in resolution + // ================================================================ + + @Nested + @DisplayName("Section 5.4 - Base URI Parsing: http://a/b/c/d;p?q") + class Section54BaseUri { + + private com.predic8.membrane.core.util.URI u; + + @BeforeEach + void setUp() throws URISyntaxException { + u = new com.predic8.membrane.core.util.URI("http://a/b/c/d;p?q", true); + } + + @Test + void scheme() { + assertEquals("http", u.getScheme()); + } + + @Test + void authority() { + assertEquals("a", u.getAuthority()); + } + + @Test + void host() { + assertEquals("a", u.getHost()); + } + + @Test + void port() { + assertEquals(-1, u.getPort()); + } + + @Test + void path() { + assertEquals("/b/c/d;p", u.getRawPath()); + } + + @Test + void query() { + assertEquals("q", u.getRawQuery()); + } + + @Test + void fragment() { + assertNull(u.getRawFragment()); + } + } + + // ================================================================ + // Percent-encoding awareness (Section 2.1) + // ================================================================ + + @Nested + @DisplayName("Section 2.1 - Percent-Encoding in Components") + class PercentEncodingTests { + + @Test + @DisplayName("Percent-encoded path is preserved raw") + void percentEncodedPath() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/a%20b", true); + assertEquals("/a%20b", u.getRawPath()); + assertEquals("/a b", u.getPath()); + } + + @Test + @DisplayName("Percent-encoded query is preserved raw") + void percentEncodedQuery() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/path?key=a%20b", true); + assertEquals("key=a%20b", u.getRawQuery()); + } + + @Test + @DisplayName("Percent-encoded fragment is preserved raw") + void percentEncodedFragment() throws URISyntaxException { + com.predic8.membrane.core.util.URI u = new com.predic8.membrane.core.util.URI("http://host/path#a%20b", true); + assertEquals("a%20b", u.getRawFragment()); + assertEquals("a b", u.getFragment()); + } + } + + // ================================================================ + // Parameterized: Section 1.1.2 examples for scheme and authority + // ================================================================ + + @DisplayName("Section 1.1.2 - Parameterized scheme parsing") + @ParameterizedTest(name = "\"{0}\" -> scheme=\"{1}\"") + @MethodSource("schemeExamples") + void schemeExtraction(String input, String expectedScheme) throws URISyntaxException { + assertEquals(expectedScheme, new com.predic8.membrane.core.util.URI(input, true).getScheme()); + } + + static Stream schemeExamples() { + return Stream.of( + Arguments.of("ftp://ftp.is.co.za/rfc/rfc1808.txt", "ftp"), + Arguments.of("http://www.ietf.org/rfc/rfc2396.txt", "http"), + Arguments.of("ldap://[2001:db8::7]/c=GB?objectClass?one", "ldap"), + Arguments.of("mailto:John.Doe@example.com", "mailto"), + Arguments.of("news:comp.infosystems.www.servers.unix", "news"), + Arguments.of("tel:+1-816-555-1212", "tel"), + Arguments.of("telnet://192.0.2.16:80/", "telnet"), + Arguments.of("urn:oasis:names:specification:docbook:dtd:xml:4.1.2", "urn"), + Arguments.of("https://example.com", "https"), + Arguments.of("foo://example.com:8042/over/there?name=ferret#nose", "foo") + ); + } + + @DisplayName("Section 1.1.2 - Parameterized authority parsing") + @ParameterizedTest(name = "\"{0}\" -> authority=\"{1}\"") + @MethodSource("authorityExamples") + void authorityExtraction(String input, String expectedAuthority) throws URISyntaxException { + assertEquals(expectedAuthority, new com.predic8.membrane.core.util.URI(input, true).getAuthority()); + } + + static Stream authorityExamples() { + return Stream.of( + Arguments.of("ftp://ftp.is.co.za/rfc/rfc1808.txt", "ftp.is.co.za"), + Arguments.of("http://www.ietf.org/rfc/rfc2396.txt", "www.ietf.org"), + Arguments.of("ldap://[2001:db8::7]/c=GB?objectClass?one", "[2001:db8::7]"), + Arguments.of("telnet://192.0.2.16:80/", "192.0.2.16:80"), + Arguments.of("foo://example.com:8042/over/there?name=ferret#nose", "example.com:8042"), + Arguments.of("http://user:pass@host:8080/path", "user:pass@host:8080") + ); + } + + @DisplayName("Section 1.1.2 - Parameterized path parsing") + @ParameterizedTest(name = "\"{0}\" -> path=\"{1}\"") + @MethodSource("pathExamples") + void pathExtraction(String input, String expectedPath) throws URISyntaxException { + assertEquals(expectedPath, new com.predic8.membrane.core.util.URI(input, true).getRawPath()); + } + + static Stream pathExamples() { + return Stream.of( + Arguments.of("ftp://ftp.is.co.za/rfc/rfc1808.txt", "/rfc/rfc1808.txt"), + Arguments.of("http://www.ietf.org/rfc/rfc2396.txt", "/rfc/rfc2396.txt"), + Arguments.of("ldap://[2001:db8::7]/c=GB?objectClass?one", "/c=GB"), + Arguments.of("mailto:John.Doe@example.com", "John.Doe@example.com"), + Arguments.of("news:comp.infosystems.www.servers.unix", "comp.infosystems.www.servers.unix"), + Arguments.of("tel:+1-816-555-1212", "+1-816-555-1212"), + Arguments.of("telnet://192.0.2.16:80/", "/"), + Arguments.of("urn:oasis:names:specification:docbook:dtd:xml:4.1.2", + "oasis:names:specification:docbook:dtd:xml:4.1.2"), + Arguments.of("foo://example.com:8042/over/there?name=ferret#nose", "/over/there"), + Arguments.of("http://host", ""), + Arguments.of("/absolute/path", "/absolute/path"), + Arguments.of("relative/path", "relative/path"), + Arguments.of("", "") + ); + } +} diff --git a/core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceTest.java b/core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceTest.java new file mode 100644 index 0000000000..84dd506aad --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/URIRFC3986ComplianceTest.java @@ -0,0 +1,532 @@ +/* Copyright 2025 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; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.*; +import org.junit.jupiter.params.provider.*; + +import java.net.*; +import java.util.stream.*; + +import static com.predic8.membrane.core.util.URI.removeDotSegments; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests URI resolution against the examples from RFC 3986, Section 5.4. + * + * @see RFC 3986 + */ +class URIRFC3986ComplianceTest { + + /** + * Base URI from RFC 3986, Section 5.4: + *
http://a/b/c/d;p?q
+ */ + private static final String BASE = "http://a/b/c/d;p?q"; + + private com.predic8.membrane.core.util.URI base; + + @BeforeEach + void setUp() throws URISyntaxException { + base = new URI(BASE, true); + } + + // ---------------------------------------------------------------- + // Section 5.4.1 - Normal Examples + // ---------------------------------------------------------------- + + @Nested + @DisplayName("RFC 3986 Section 5.4.1 - Normal Examples") + class NormalExamples { + + @Test + @DisplayName("g:h -> g:h (different scheme)") + void differentScheme() throws URISyntaxException { + assertResolves("g:h", "g:h"); + } + + @Test + @DisplayName("g -> http://a/b/c/g") + void relativePath() throws URISyntaxException { + assertResolves("g", "http://a/b/c/g"); + } + + @Test + @DisplayName("./g -> http://a/b/c/g") + void dotSlashRelative() throws URISyntaxException { + assertResolves("./g", "http://a/b/c/g"); + } + + @Test + @DisplayName("g/ -> http://a/b/c/g/") + void relativeWithTrailingSlash() throws URISyntaxException { + assertResolves("g/", "http://a/b/c/g/"); + } + + @Test + @DisplayName("/g -> http://a/g") + void absolutePath() throws URISyntaxException { + assertResolves("/g", "http://a/g"); + } + + @Test + @DisplayName("//g -> http://g") + void networkPathReference() throws URISyntaxException { + assertResolves("//g", "http://g"); + } + + @Test + @DisplayName("?y -> http://a/b/c/d;p?y") + void queryOnly() throws URISyntaxException { + assertResolves("?y", "http://a/b/c/d;p?y"); + } + + @Test + @DisplayName("g?y -> http://a/b/c/g?y") + void relativeWithQuery() throws URISyntaxException { + assertResolves("g?y", "http://a/b/c/g?y"); + } + + @Test + @DisplayName("#s -> http://a/b/c/d;p?q#s") + void fragmentOnly() throws URISyntaxException { + assertResolves("#s", "http://a/b/c/d;p?q#s"); + } + + @Test + @DisplayName("g#s -> http://a/b/c/g#s") + void relativeWithFragment() throws URISyntaxException { + assertResolves("g#s", "http://a/b/c/g#s"); + } + + @Test + @DisplayName("g?y#s -> http://a/b/c/g?y#s") + void relativeWithQueryAndFragment() throws URISyntaxException { + assertResolves("g?y#s", "http://a/b/c/g?y#s"); + } + + @Test + @DisplayName(";x -> http://a/b/c/;x") + void semicolonRelative() throws URISyntaxException { + assertResolves(";x", "http://a/b/c/;x"); + } + + @Test + @DisplayName("g;x -> http://a/b/c/g;x") + void relativeWithParams() throws URISyntaxException { + assertResolves("g;x", "http://a/b/c/g;x"); + } + + @Test + @DisplayName("g;x?y#s -> http://a/b/c/g;x?y#s") + void relativeWithParamsQueryFragment() throws URISyntaxException { + assertResolves("g;x?y#s", "http://a/b/c/g;x?y#s"); + } + + @Test + @DisplayName("\"\" -> http://a/b/c/d;p?q (empty reference)") + void emptyReference() throws URISyntaxException { + assertResolves("", "http://a/b/c/d;p?q"); + } + + @Test + @DisplayName(". -> http://a/b/c/") + void singleDot() throws URISyntaxException { + assertResolves(".", "http://a/b/c/"); + } + + @Test + @DisplayName("./ -> http://a/b/c/") + void dotSlash() throws URISyntaxException { + assertResolves("./", "http://a/b/c/"); + } + + @Test + @DisplayName(".. -> http://a/b/") + void doubleDot() throws URISyntaxException { + assertResolves("..", "http://a/b/"); + } + + @Test + @DisplayName("../ -> http://a/b/") + void doubleDotSlash() throws URISyntaxException { + assertResolves("../", "http://a/b/"); + } + + @Test + @DisplayName("../g -> http://a/b/g") + void parentRelative() throws URISyntaxException { + assertResolves("../g", "http://a/b/g"); + } + + @Test + @DisplayName("../.. -> http://a/") + void twoLevelsUp() throws URISyntaxException { + assertResolves("../..", "http://a/"); + } + + @Test + @DisplayName("../../ -> http://a/") + void twoLevelsUpSlash() throws URISyntaxException { + assertResolves("../../", "http://a/"); + } + + @Test + @DisplayName("../../g -> http://a/g") + void twoLevelsUpRelative() throws URISyntaxException { + assertResolves("../../g", "http://a/g"); + } + } + + // ---------------------------------------------------------------- + // Section 5.4.2 - Abnormal Examples + // ---------------------------------------------------------------- + + @Nested + @DisplayName("RFC 3986 Section 5.4.2 - Abnormal Examples") + class AbnormalExamples { + + @Test + @DisplayName("../../../g -> http://a/g (above root)") + void threeLevelsUp() throws URISyntaxException { + assertResolves("../../../g", "http://a/g"); + } + + @Test + @DisplayName("../../../../g -> http://a/g (far above root)") + void fourLevelsUp() throws URISyntaxException { + assertResolves("../../../../g", "http://a/g"); + } + + @Test + @DisplayName("/./g -> http://a/g") + void absoluteDotSegment() throws URISyntaxException { + assertResolves("/./g", "http://a/g"); + } + + @Test + @DisplayName("/../g -> http://a/g") + void absoluteDoubleDotSegment() throws URISyntaxException { + assertResolves("/../g", "http://a/g"); + } + + @Test + @DisplayName("g. -> http://a/b/c/g. (not a dot segment)") + void trailingDot() throws URISyntaxException { + assertResolves("g.", "http://a/b/c/g."); + } + + @Test + @DisplayName(".g -> http://a/b/c/.g (not a dot segment)") + void leadingDot() throws URISyntaxException { + assertResolves(".g", "http://a/b/c/.g"); + } + + @Test + @DisplayName("g.. -> http://a/b/c/g.. (not a dot segment)") + void trailingDoubleDot() throws URISyntaxException { + assertResolves("g..", "http://a/b/c/g.."); + } + + @Test + @DisplayName("..g -> http://a/b/c/..g (not a dot segment)") + void leadingDoubleDot() throws URISyntaxException { + assertResolves("..g", "http://a/b/c/..g"); + } + + @Test + @DisplayName("./../g -> http://a/b/g") + void mixedDotSegments() throws URISyntaxException { + assertResolves("./../g", "http://a/b/g"); + } + + @Test + @DisplayName("./g/. -> http://a/b/c/g/") + void trailingDotInPath() throws URISyntaxException { + assertResolves("./g/.", "http://a/b/c/g/"); + } + + @Test + @DisplayName("g/./h -> http://a/b/c/g/h") + void dotInMiddle() throws URISyntaxException { + assertResolves("g/./h", "http://a/b/c/g/h"); + } + + @Test + @DisplayName("g/../h -> http://a/b/c/h") + void doubleDotInMiddle() throws URISyntaxException { + assertResolves("g/../h", "http://a/b/c/h"); + } + + @Test + @DisplayName("g;x=1/./y -> http://a/b/c/g;x=1/y") + void paramsWithDot() throws URISyntaxException { + assertResolves("g;x=1/./y", "http://a/b/c/g;x=1/y"); + } + + @Test + @DisplayName("g;x=1/../y -> http://a/b/c/y") + void paramsWithDoubleDot() throws URISyntaxException { + assertResolves("g;x=1/../y", "http://a/b/c/y"); + } + + @Test + @DisplayName("g?y/./x -> http://a/b/c/g?y/./x (dots in query are literal)") + void dotsInQuery() throws URISyntaxException { + assertResolves("g?y/./x", "http://a/b/c/g?y/./x"); + } + + @Test + @DisplayName("g?y/../x -> http://a/b/c/g?y/../x (dots in query are literal)") + void doubleDotsInQuery() throws URISyntaxException { + assertResolves("g?y/../x", "http://a/b/c/g?y/../x"); + } + + @Test + @DisplayName("g#s/./x -> http://a/b/c/g#s/./x (dots in fragment are literal)") + void dotsInFragment() throws URISyntaxException { + assertResolves("g#s/./x", "http://a/b/c/g#s/./x"); + } + + @Test + @DisplayName("g#s/../x -> http://a/b/c/g#s/../x (dots in fragment are literal)") + void doubleDotsInFragment() throws URISyntaxException { + assertResolves("g#s/../x", "http://a/b/c/g#s/../x"); + } + + @Test + @DisplayName("http:g -> http:g (strict interpretation)") + void sameSchemeStrict() throws URISyntaxException { + // RFC 3986 strict interpretation: "http:g" is a URI with scheme "http" + // and path "g" -> returned as-is + assertResolves("http:g", "http:g"); + } + } + + // ---------------------------------------------------------------- + // Section 5.2.4 - removeDotSegments + // ---------------------------------------------------------------- + + @Nested + @DisplayName("RFC 3986 Section 5.2.4 - removeDotSegments") + class RemoveDotSegmentsTests { + + @Test + @DisplayName("null input returns null") + void nullInput() { + assertNull(removeDotSegments(null)); + } + + @Test + @DisplayName("empty input returns empty") + void emptyInput() { + assertEquals("", removeDotSegments("")); + } + + @Test + @DisplayName("/a/b/c/./../../g -> /a/g (RFC 3986 Section 5.2.4 example)") + void rfcExample() { + assertEquals("/a/g", removeDotSegments("/a/b/c/./../../g")); + } + + @Test + @DisplayName("mid/content=5/../6 -> mid/6 (RFC 3986 Section 5.2.4 example)") + void rfcExampleRelative() { + assertEquals("mid/6", removeDotSegments("mid/content=5/../6")); + } + + @Test + void leadingDotDotSlash() { + assertEquals("a", removeDotSegments("../a")); + } + + @Test + void leadingDotSlash() { + assertEquals("a", removeDotSegments("./a")); + } + + @Test + void midDotSlash() { + assertEquals("a/b", removeDotSegments("a/./b")); + } + + @Test + void midDotDotSlash() { + assertEquals("/b", removeDotSegments("a/../b")); + } + + @Test + void chainedDotDot() { + assertEquals("a/c", removeDotSegments("a/b/../c")); + } + + @Test + void aboveRoot() { + assertEquals("/a", removeDotSegments("/../a")); + } + + @Test + void noDotsUnchanged() { + assertEquals("/a/b/c", removeDotSegments("/a/b/c")); + } + + @Test + void trailingDot() { + assertEquals("/a/", removeDotSegments("/a/.")); + } + + @Test + void trailingDoubleDot() { + assertEquals("/", removeDotSegments("/a/..")); + } + + @Test + void onlyDot() { + assertEquals("", removeDotSegments(".")); + } + + @Test + void onlyDoubleDot() { + assertEquals("", removeDotSegments("..")); + } + + @Test + void deepNormalization() { + assertEquals("/g", removeDotSegments("/a/b/c/../../../g")); + } + } + + // ---------------------------------------------------------------- + // Section 5.3 - Component Recomposition + // ---------------------------------------------------------------- + + @Nested + @DisplayName("RFC 3986 Section 5.3 - Component Recomposition") + class RecompositionTests { + + @Test + @DisplayName("Scheme is included with colon separator") + void schemeIncluded() throws URISyntaxException { + URI b = new URI("http://host", true); + com.predic8.membrane.core.util.URI r = new URI("/path", true); + String result = b.resolve(r).toString(); + assertTrue(result.startsWith("http:")); + } + + @Test + @DisplayName("Authority is prefixed with //") + void authorityPrefixed() throws URISyntaxException { + com.predic8.membrane.core.util.URI b = new URI("http://host", true); + com.predic8.membrane.core.util.URI r = new com.predic8.membrane.core.util.URI("/path", true); + String result = b.resolve(r).toString(); + assertTrue(result.contains("//host")); + } + + @Test + @DisplayName("Query is prefixed with ?") + void queryPrefixed() throws URISyntaxException { + com.predic8.membrane.core.util.URI b = new com.predic8.membrane.core.util.URI("http://host", true); + URI r = new com.predic8.membrane.core.util.URI("/path?key=val", true); + String result = b.resolve(r).toString(); + assertTrue(result.contains("?key=val")); + } + + @Test + @DisplayName("Fragment is prefixed with #") + void fragmentPrefixed() throws URISyntaxException { + com.predic8.membrane.core.util.URI b = new com.predic8.membrane.core.util.URI("http://host", true); + com.predic8.membrane.core.util.URI r = new URI("/path#frag", true); + String result = b.resolve(r).toString(); + assertTrue(result.contains("#frag")); + } + + @Test + @DisplayName("Full recomposition preserves all components") + void fullRecomposition() throws URISyntaxException { + URI b = new URI("http://host", true); + com.predic8.membrane.core.util.URI r = new com.predic8.membrane.core.util.URI("/path?q=1#f", true); + assertEquals("http://host/path?q=1#f", b.resolve(r).toString()); + } + } + + // ---------------------------------------------------------------- + // Parameterized - all normal + abnormal in one go + // ---------------------------------------------------------------- + + @DisplayName("RFC 3986 Section 5.4 - Parameterized") + @ParameterizedTest(name = "resolve(\"{0}\") = \"{1}\"") + @MethodSource("rfc3986Section54Examples") + void rfc3986ResolveExamples(String relative, String expected) throws URISyntaxException { + assertResolves(relative, expected); + } + + static Stream rfc3986Section54Examples() { + return Stream.of( + // Section 5.4.1 - Normal Examples + Arguments.of("g:h", "g:h"), + Arguments.of("g", "http://a/b/c/g"), + Arguments.of("./g", "http://a/b/c/g"), + Arguments.of("g/", "http://a/b/c/g/"), + Arguments.of("/g", "http://a/g"), + Arguments.of("//g", "http://g"), + Arguments.of("?y", "http://a/b/c/d;p?y"), + Arguments.of("g?y", "http://a/b/c/g?y"), + Arguments.of("#s", "http://a/b/c/d;p?q#s"), + Arguments.of("g#s", "http://a/b/c/g#s"), + Arguments.of("g?y#s", "http://a/b/c/g?y#s"), + Arguments.of(";x", "http://a/b/c/;x"), + Arguments.of("g;x", "http://a/b/c/g;x"), + Arguments.of("g;x?y#s", "http://a/b/c/g;x?y#s"), + Arguments.of("", "http://a/b/c/d;p?q"), + Arguments.of(".", "http://a/b/c/"), + Arguments.of("./", "http://a/b/c/"), + Arguments.of("..", "http://a/b/"), + Arguments.of("../", "http://a/b/"), + Arguments.of("../g", "http://a/b/g"), + Arguments.of("../..", "http://a/"), + Arguments.of("../../", "http://a/"), + Arguments.of("../../g", "http://a/g"), + + // Section 5.4.2 - Abnormal Examples + Arguments.of("../../../g", "http://a/g"), + Arguments.of("../../../../g", "http://a/g"), + Arguments.of("/./g", "http://a/g"), + Arguments.of("/../g", "http://a/g"), + Arguments.of("g.", "http://a/b/c/g."), + Arguments.of(".g", "http://a/b/c/.g"), + Arguments.of("g..", "http://a/b/c/g.."), + Arguments.of("..g", "http://a/b/c/..g"), + Arguments.of("./../g", "http://a/b/g"), + Arguments.of("./g/.", "http://a/b/c/g/"), + Arguments.of("g/./h", "http://a/b/c/g/h"), + Arguments.of("g/../h", "http://a/b/c/h"), + Arguments.of("g;x=1/./y", "http://a/b/c/g;x=1/y"), + Arguments.of("g;x=1/../y", "http://a/b/c/y"), + Arguments.of("g?y/./x", "http://a/b/c/g?y/./x"), + Arguments.of("g?y/../x", "http://a/b/c/g?y/../x"), + Arguments.of("g#s/./x", "http://a/b/c/g#s/./x"), + Arguments.of("g#s/../x", "http://a/b/c/g#s/../x"), + Arguments.of("http:g", "http:g") + ); + } + + private void assertResolves(String relative, String expected) throws URISyntaxException { + URI rel = new URI(relative, true); + String actual = base.resolve(rel).toString(); + assertEquals(expected, actual, + "Resolving \"%s\" against base <%s>".formatted(relative, BASE)); + } +} diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index 2899bf39d2..7111a9dc92 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -137,8 +137,6 @@ void withoutPath() throws URISyntaxException { assertEquals("http://localhost:8080", uf.create("http://localhost:8080/foo").getWithoutPath()); assertEquals("http://localhost:8080", uf.create("http://localhost:8080#foo").getWithoutPath()); assertEquals("http://localhost", uf.create("http://localhost/foo").getWithoutPath()); - assertEquals("http://localhost", uf.create("http://localhost/foo").getWithoutPath()); - assertEquals("http://localhost", uf.create("http://localhost/foo").getWithoutPath()); } @Test @@ -354,8 +352,11 @@ void parseHostPortInvalidMultipleColons() { @Test void parseHostPortIpv4EmptyPortOrHost() { assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort(":8080")); // empty host - assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("example.com:")); // empty port - } + + var hp = URI_ALLOW_ILLEGAL.parseHostPort("example.com:"); + assertEquals("example.com", hp.host()); + assertEquals(-1, hp.port()); + } @Test void parseHostPortIpv4PortBoundsAndFormats() { @@ -648,7 +649,7 @@ void resolveRelativeWithPathBackClasspath() throws URISyntaxException { void resolveRelativeBackClasspath() throws URISyntaxException { URI base = new URI("classpath://validation"); URI relative = new com.predic8.membrane.core.util.URI("../validation/ArticleType.xsd"); - // getRessource() can deal with that + // getResource() can deal with that assertEquals("classpath://validation/../validation/ArticleType.xsd", base.resolve(relative).toString()); } } diff --git a/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java index 3cb6cd8f5e..f732ab314e 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java @@ -62,7 +62,7 @@ void shouldMatchJavaNetURIForCommonCases(Case c) throws Exception { assertEquals(j.getQuery(), custom.getQuery(), "query (decoded)"); assertEquals(j.getFragment(), custom.getFragment(), "fragment (decoded)"); - assertEquals(j.getHost(), custom.getHost(), "host (java host is bracket-free)"); + assertEquals(j.getHost(), custom.getHost(), "host"); assertEquals(j.getPort(), custom.getPort(), "port"); // getPathWithQuery is Membrane-specific; compare to Java reconstruction. From 78fbd0e25679b025aff4470aed581f0cd555e707 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Wed, 18 Feb 2026 14:30:19 +0100 Subject: [PATCH 14/20] test(core): extend URI test cases for edge scenarios in IPv4 and IPv6 parsing - Added edge case assertions for parsing IPv6 with empty ports. - Introduced additional checks for IPv4 host and port parsing scenarios. - Renamed test methods to reflect broader case coverage. --- .../predic8/membrane/core/util/URITest.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/core/src/test/java/com/predic8/membrane/core/util/URITest.java b/core/src/test/java/com/predic8/membrane/core/util/URITest.java index 7111a9dc92..15bef07107 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/URITest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/URITest.java @@ -294,10 +294,12 @@ void parseIpv6WithZoneId() { } @Test - void parseIpv6InvalidCases() { + void parseIpv6InvalidAndEdgeCases() { assertThrows(IllegalArgumentException.class, () -> parseIpv6("::1")); assertThrows(IllegalArgumentException.class, () -> parseIpv6("[::1")); - assertThrows(IllegalArgumentException.class, () -> parseIpv6("[::1]:")); + var hostPort = parseIpv6("[::1]:"); + assertEquals("[::1]", hostPort.host()); + assertEquals(-1, hostPort.port()); assertThrows(IllegalArgumentException.class, () -> parseIpv6("[::1]:badport")); } @@ -309,9 +311,11 @@ void parseHostPortIpv4WithoutPort() { } @Test - void parseHostPortIpv4InvalidCases() { + void parseHostPortIpv4InvalidAndEdgeCases() { assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname(":8080")); - assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname("example.com:")); + var hostPort = URI_ALLOW_ILLEGAL.parseIPv4OrHostname("example.com:"); + assertEquals("example.com", hostPort.host()); + assertEquals(-1, hostPort.port()); assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname("example.com:abc")); assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname("host:1:2")); } @@ -382,8 +386,10 @@ void parseHostPortIpv6WithZoneIdNormalization() { } @Test - void parseHostPortIpv6BadPortAndJunk() { - assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]:")); + void parseHostPortIpv6BadPortJunkAndEdge() { + var hostPort = URI_ALLOW_ILLEGAL.parseHostPort("[::1]:"); + assertEquals("[::1]", hostPort.host()); + assertEquals(-1, hostPort.port()); assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]:bad")); assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseHostPort("[::1]x123")); } From cefb21926e887bc622b7a7304a079308b0207c84 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Thu, 19 Feb 2026 11:04:54 +0100 Subject: [PATCH 15/20] feat(core): enhance template marker detection and URL evaluation in `Target` and `CallInterceptor` - Replaced `evaluateExpressions` with `urlIsTemplate` for better semantics and clarity. - Improved `Target` and `CallInterceptor` to skip URL evaluation when no template markers are present. - Introduced stricter validation for configurations involving template markers and illegal characters. - Updated relevant tests to validate changes and added new test cases for URL template evaluation. --- .../interceptor/flow/CallInterceptor.java | 31 ++++++- .../predic8/membrane/core/proxies/Target.java | 42 +++++----- .../HTTPClientInterceptorTest.java | 2 +- .../interceptor/flow/CallInterceptorTest.java | 81 +++++++++++++++---- distribution/examples/configuration/apis.yaml | 1 - 5 files changed, 115 insertions(+), 42 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/flow/CallInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/flow/CallInterceptor.java index 54d6da886a..a955f5e40b 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/flow/CallInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/flow/CallInterceptor.java @@ -20,6 +20,7 @@ import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.interceptor.lang.*; import com.predic8.membrane.core.transport.http.*; +import com.predic8.membrane.core.util.*; import org.slf4j.*; import java.io.*; @@ -32,6 +33,7 @@ import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.interceptor.Outcome.*; import static com.predic8.membrane.core.interceptor.Outcome.ABORT; +import static com.predic8.membrane.core.util.TemplateUtil.*; import static java.util.Collections.*; /** @@ -43,6 +45,11 @@ public class CallInterceptor extends AbstractExchangeExpressionInterceptor { private static final Logger log = LoggerFactory.getLogger(CallInterceptor.class); + /** + * If url contains template marker ${}, if not expression evaluation is skipped + */ + private boolean urlIsTemplate = false; + /** * These headers are filtered out from the response of a called resource * and are not added to the current message. @@ -56,6 +63,21 @@ public class CallInterceptor extends AbstractExchangeExpressionInterceptor { @Override public void init() { super.init(); + + if (router.getConfiguration().getUriFactory().isAllowIllegalCharacters()) { + throw new ConfigurationException(""" + URL Templating and Illegal URL Characters + + Url templating expressions and enablement of illegal characters in URLs are mutually exclusive. Either disable + illegal characters in the configuration (configuration/uriFactory/allowIllegalCharacters) or remove the + templating expression %s from the URL in the call URL. + """.formatted(exchangeExpression.getExpression())); + } + + // If there is no template marker ${ than do not try to evaluate url as expression + if (containsTemplateMarker(exchangeExpression.getExpression())) { + urlIsTemplate = true; + } } @Override @@ -69,7 +91,7 @@ public Outcome handleResponse(Exchange exc) { } private Outcome handleInternal(Exchange exc) { - final String dest = exchangeExpression.evaluate(exc, REQUEST, String.class); + var dest = computeDestinationUrl(exc); log.debug("Calling {}", dest); final Exchange newExc = createNewExchange(dest, getNewRequest(exc)); @@ -107,6 +129,13 @@ private Outcome handleInternal(Exchange exc) { } } + private String computeDestinationUrl(Exchange exc) { + if (urlIsTemplate) { + return exchangeExpression.evaluate(exc, REQUEST, String.class); + } + return exchangeExpression.getExpression(); + } + private ProblemDetails createProblemDetails(String dest) { return internal(router.getConfiguration().isProduction(), "call") .title("Error performing callout.") diff --git a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java index de3b95255d..3e4192d75f 100644 --- a/core/src/main/java/com/predic8/membrane/core/proxies/Target.java +++ b/core/src/main/java/com/predic8/membrane/core/proxies/Target.java @@ -24,7 +24,6 @@ import com.predic8.membrane.core.router.*; import com.predic8.membrane.core.util.*; import com.predic8.membrane.core.util.text.*; -import com.predic8.membrane.core.util.uri.*; import com.predic8.membrane.core.util.uri.EscapingUtil.*; import org.slf4j.*; @@ -64,9 +63,9 @@ public class Target implements XMLSupport { private Function escapingFunction; /** - * If exchangeExpressions should be evaluated. + * If url contains template marker ${}, if not expression evaluation is skipped */ - private boolean evaluateExpressions = false; + private boolean urlIsTemplate = false; private boolean adjustHostHeader = true; @@ -75,7 +74,8 @@ public class Target implements XMLSupport { private InterceptorAdapter adapter; - public Target() {} + public Target() { + } public Target(String host) { setHost(host); @@ -91,21 +91,24 @@ public void init(Router router) { if (!containsTemplateMarker(url)) return; - adapter = new InterceptorAdapter(router, xmlConfig); + adapter = new InterceptorAdapter(router, xmlConfig); if (router.getConfiguration().getUriFactory().isAllowIllegalCharacters()) { log.warn("{}Url templates are disabled for security.{} Disable configuration/uriFactory/allowIllegalCharacters to enable them. Illegal characters in templates may lead to injection attacks.", TerminalColors.BRIGHT_RED(), RESET()); throw new ConfigurationException(""" - URL Templating and Illegal URL Characters - - Url templating expressions and enablement of illegal characters in URLs are mutually exclusive. Either disable - illegal characters in the configuration (configuration/uriFactory/allowIllegalCharacters) or remove the - templating expression %s from the target URL. - """.formatted(url)); - } else { - evaluateExpressions = true; - escapingFunction = getEscapingFunction(escaping); + URL Templating and Illegal URL Characters + + Url templating expressions and enablement of illegal characters in URLs are mutually exclusive. Either disable + illegal characters in the configuration (configuration/uriFactory/allowIllegalCharacters) or remove the + templating expression %s from the target URL. + """.formatted(url)); + } + + // If there is no template marker ${ than do not try to evaluate url as expression + if(containsTemplateMarker(url)) { + urlIsTemplate = true; } + escapingFunction = getEscapingFunction(escaping); } public void applyModifications(Exchange exc, Router router) { @@ -124,14 +127,9 @@ private List computeDestinationExpressions(Exchange exc, Router router) private String evaluateTemplate(Exchange exc, Router router, String url, InterceptorAdapter adapter) { // Only evaluate if the target url contains a template marker ${} - if (!evaluateExpressions) + if (!urlIsTemplate) return url; - // If the url does not contain ${ we do not have to evaluate the expression - if (!containsTemplateMarker(url)) { - return url; - } - // Without caching 1_000_000 => 37s with ConcurrentHashMap as Cache => 34s // Cache is probably not worth the effort and complexity return TemplateExchangeExpression.newInstance(adapter, @@ -145,8 +143,8 @@ public String getHost() { return host; } - public boolean isEvaluateExpressions() { - return evaluateExpressions; + public boolean isUrlIsTemplate() { + return urlIsTemplate; } /** diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java index bfce3c0b04..267615f7b3 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptorTest.java @@ -165,7 +165,7 @@ void illegalCharacterWithoutTemplate() { fail(); return; } - assertFalse(apiProxy.getTarget().isEvaluateExpressions()); + assertFalse(apiProxy.getTarget().isUrlIsTemplate()); assertEquals(1, exc.getDestinations().size()); // The template should not be evaluated, cause illegal characters are allowed! diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/CallInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/CallInterceptorTest.java index 5104549c68..0aa4b9fc09 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/CallInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/CallInterceptorTest.java @@ -13,26 +13,38 @@ limitations under the License. */ package com.predic8.membrane.core.interceptor.flow; -import com.predic8.membrane.core.exchange.Exchange; -import com.predic8.membrane.core.http.Request; -import com.predic8.membrane.core.http.Response; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; +import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.http.*; +import com.predic8.membrane.core.interceptor.*; +import com.predic8.membrane.core.interceptor.templating.*; +import com.predic8.membrane.core.openapi.serviceproxy.*; +import com.predic8.membrane.core.router.*; +import com.predic8.membrane.core.util.*; +import org.junit.jupiter.api.*; -import java.net.URISyntaxException; +import java.io.*; +import java.net.*; import static com.predic8.membrane.core.http.Header.*; -import static com.predic8.membrane.core.interceptor.flow.CallInterceptor.copyHeadersFromResponseToRequest; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static com.predic8.membrane.core.http.Request.*; +import static com.predic8.membrane.core.interceptor.flow.CallInterceptor.*; +import static org.junit.jupiter.api.Assertions.*; class CallInterceptorTest { - static Exchange exc; + private Exchange exc; + private Router router; - @BeforeAll - static void beforeAll() throws URISyntaxException { - exc = Request.get("/foo").buildExchange(); + @BeforeEach + void setup() throws URISyntaxException { + exc = get("/foo").buildExchange(); + exc.setProperty("a", "b"); + router = new DefaultRouter(); + } + + @AfterEach + void teardown() { + router.stop(); } @Test @@ -46,12 +58,47 @@ void filterHeaders() { copyHeadersFromResponseToRequest(exc, exc); // preserve - assertEquals("42",exc.getRequest().getHeader().getFirstValue("X-FOO")); + var header = exc.getRequest().getHeader(); + assertEquals("42", header.getFirstValue("X-FOO")); // take out - assertNull(exc.getRequest().getHeader().getFirstValue(SERVER)); - assertNull(exc.getRequest().getHeader().getFirstValue(TRANSFER_ENCODING)); - assertNull(exc.getRequest().getHeader().getFirstValue(CONTENT_ENCODING)); + assertNull(header.getFirstValue(SERVER)); + assertNull(header.getFirstValue(TRANSFER_ENCODING)); + assertNull(header.getFirstValue(CONTENT_ENCODING)); + } + + @Test + void evaluateUrlTemplate() throws IOException { + extracted("Path: /b"); } + @Test + void urlTemplateAndAllowIllegalCharactersInURL() { + router.getConfiguration().getUriFactory().setAllowIllegalCharacters(true); + assertThrows(ConfigurationException.class, () -> extracted("dummy")); + } + + private void extracted(String expected) throws IOException { + var api = new APIProxy(); + api.setKey(new APIProxyKey(2000)); + api.getFlow().add(new AbstractInterceptor() { + @Override + public Outcome handleRequest(Exchange exc) { + System.out.println(exc); + return super.handleRequest(exc); + } + }); + api.getFlow().add(new TemplateInterceptor() {{ + setSrc("Path: ${path}"); + }}); + api.getFlow().add(new ReturnInterceptor()); + router.add(api); + router.start(); + + var ci = new CallInterceptor(); + ci.setUrl("http://localhost:2000/${property.a}"); + ci.init(router); + ci.handleRequest(exc); + assertEquals(expected, exc.getRequest().getBodyAsStringDecoded()); + } } \ No newline at end of file diff --git a/distribution/examples/configuration/apis.yaml b/distribution/examples/configuration/apis.yaml index 2787cfb0d6..8dce64b295 100644 --- a/distribution/examples/configuration/apis.yaml +++ b/distribution/examples/configuration/apis.yaml @@ -22,7 +22,6 @@ configuration: uriFactory: # Allow non-RFC-compliant characters like {, } in URIs # Attention: This may lead to security vulnerabilities! Use with care! - # - ${expression} in the URI will be evaluated allowIllegalCharacters: true autoEscapeBackslashes: true # Escape backslashes in incoming URIs (\\ -> %5C) --- From 60828deb5637ec7bcd207ae5171fcc49a0dad33d Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Fri, 20 Feb 2026 10:10:51 +0100 Subject: [PATCH 16/20] feat(core): add dynamic escaping to `setBody` and improve expression evaluation - Introduced JSON escaping support for `setBody` based on `contentType`. - Enhanced `ExchangeExpression` to centralize router retrieval logic. - Updated tests to include validation logging for transformation scenarios. - Fixed minor formatting inconsistencies and improved code readability. --- .../interceptor/lang/SetBodyInterceptor.java | 21 ++++++++++++++++--- .../core/lang/ExchangeExpression.java | 14 ++++++++----- .../membrane/core/util/uri/EscapingUtil.java | 17 ++++++++++++++- .../soap/WSDLRewriterTutorialTest.java | 1 + .../RestGetToSoapTutorialTest.java | 1 + .../transformation/40-REST-GET-to-SOAP.yaml | 2 +- 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java index c3ac494480..8214699a49 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java @@ -16,19 +16,26 @@ import com.predic8.membrane.annot.*; import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.interceptor.*; +import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.util.*; +import com.predic8.membrane.core.util.uri.*; +import org.jetbrains.annotations.*; import org.slf4j.*; +import java.util.function.*; + import static com.predic8.membrane.core.exceptions.ProblemDetails.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.interceptor.Outcome.*; import static com.predic8.membrane.core.interceptor.Outcome.ABORT; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.NONE; +import static com.predic8.membrane.core.util.uri.EscapingUtil.getEscapingFunction; import static java.nio.charset.StandardCharsets.*; /** - * @description - * sets the content of the HTTP message body to the specified value. The value + * @description sets the content of the HTTP message body to the specified value. The value * can be a static string, or it can be dynamically generated by an expression. * Different languages such as SpEL, Groovy, XMLPath or JsonPath are supported. * setBody does not support conditional processing or loops. When you need these features, @@ -64,7 +71,7 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { } msg.setBodyContent(result.getBytes(UTF_8)); - if (contentType!=null) { + if (contentType != null) { msg.getHeader().setContentType(contentType); } @@ -84,6 +91,14 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { } } + protected ExchangeExpression getExchangeExpression() { + return TemplateExchangeExpression.newInstance(this, language, expression, router, + getEscapingFunction(contentType).orElseGet(() -> { + log.warn("Turning off escaping for 'setBody'. No escaping found for content type {}. To enable escaping set 'contentType' on setBody.", contentType); + return getEscapingFunction(NONE); + })); + } + /** * Sets the expression to be evaluated for modifying the HTTP message body. * The provided string value can represent a static text or a dynamic expression diff --git a/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java b/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java index b366f37bbb..ac28708997 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java @@ -23,6 +23,7 @@ import com.predic8.membrane.core.lang.spel.*; import com.predic8.membrane.core.lang.xpath.*; import com.predic8.membrane.core.router.*; +import org.jetbrains.annotations.*; /** * Language expression that takes an exchange as input @@ -59,15 +60,18 @@ enum Language {GROOVY, SPEL, XPATH, JSONPATH} * @return */ static ExchangeExpression expression(Interceptor interceptor, Language language, String expression) { - var router = interceptor != null ? interceptor.getRouter() : null; return switch (language) { - case GROOVY -> new GroovyExchangeExpression(expression, router); - case SPEL -> new SpELExchangeExpression(expression,null, router); // parserContext is null on purpose ${} or #{} are not needed here - case XPATH -> new XPathExchangeExpression(interceptor,expression, router); - case JSONPATH -> new JsonpathExchangeExpression(expression, router); + case GROOVY -> new GroovyExchangeExpression(expression, getRouter(interceptor)); + case SPEL -> new SpELExchangeExpression(expression,null, getRouter(interceptor)); // parserContext is null on purpose ${} or #{} are not needed here + case XPATH -> new XPathExchangeExpression(interceptor,expression, getRouter(interceptor)); + case JSONPATH -> new JsonpathExchangeExpression(expression, getRouter(interceptor)); }; } + private static @Nullable Router getRouter(Interceptor interceptor) { + return interceptor != null ? interceptor.getRouter() : null; + } + /** * Allows to pass an Interceptor as an argument where there is no interceptor e.g. Target */ diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index dfa9db3dd2..d24f27fe20 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -14,9 +14,15 @@ package com.predic8.membrane.core.util.uri; +import com.predic8.membrane.core.http.*; +import com.predic8.membrane.core.lang.*; + import java.net.*; +import java.util.*; import java.util.function.*; +import static com.predic8.membrane.core.http.MimeType.isJson; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.JSON; import static java.lang.Character.*; import static java.nio.charset.StandardCharsets.*; @@ -36,7 +42,15 @@ public class EscapingUtil { public enum Escaping { NONE, URL, - SEGMENT + SEGMENT, + JSON + } + + public static Optional> getEscapingFunction(String mimeType) { + if (isJson(mimeType)) { + return Optional.of( getEscapingFunction(JSON)); + } + return Optional.empty(); } public static Function getEscapingFunction(Escaping escaping) { @@ -44,6 +58,7 @@ public static Function getEscapingFunction(Escaping escaping) { case NONE -> Function.identity(); case URL -> s -> URLEncoder.encode(s, UTF_8); case SEGMENT -> EscapingUtil::pathEncode; + case JSON -> CommonBuiltInFunctions::toJSON; }; } diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java index b0e0ac6fb7..6399882a4f 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java @@ -59,6 +59,7 @@ void soapCall() throws IOException { .when() .post("http://localhost:%d/my-service".formatted(getPort())) .then() + .log().ifValidationFails() .body("Envelope.Body.getCityResponse.population", equalTo("34665600")); // @formatter:on } diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java index c425a55fa2..61ca31c546 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java @@ -33,6 +33,7 @@ void restGetIsConvertedToSoapAndBackToJson() { .when() .get("http://localhost:2000/cities/Bielefeld") .then() + .log().ifValidationFails() .statusCode(200) .body("country", equalTo("Germany")) .body("population", greaterThan(0)); diff --git a/distribution/tutorials/transformation/40-REST-GET-to-SOAP.yaml b/distribution/tutorials/transformation/40-REST-GET-to-SOAP.yaml index 6f26efd098..4d6dd8b906 100644 --- a/distribution/tutorials/transformation/40-REST-GET-to-SOAP.yaml +++ b/distribution/tutorials/transformation/40-REST-GET-to-SOAP.yaml @@ -31,7 +31,7 @@ api: contentType: application/json value: | { - "country": "${//country}", + "country": ${//country}, "population": ${//population} } target: From 4c2b7b11e90086fae3b3b26c48592d25296396d1 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Fri, 20 Feb 2026 10:47:21 +0100 Subject: [PATCH 17/20] feat(core): add XML escaping support and enhance `getEscapingFunction` - Introduced XML escaping using `StringEscapeUtils::escapeXml11`. - Updated `getEscapingFunction` to handle XML content type. - Added `XML` escaping type to `Escaping` enum. - Enhanced utility imports and updated dependencies for XML handling. --- .../membrane/core/util/uri/EscapingUtil.java | 15 ++++++++++++--- .../util/xml/NormalizeXMLForJsonUtilTest.java | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index d24f27fe20..260e234e1f 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -16,13 +16,17 @@ import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.lang.*; +import groovy.json.*; +import org.apache.commons.text.StringEscapeUtils; +import org.jetbrains.annotations.*; import java.net.*; import java.util.*; import java.util.function.*; import static com.predic8.membrane.core.http.MimeType.isJson; -import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.JSON; +import static com.predic8.membrane.core.http.MimeType.isXML; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.*; import static java.lang.Character.*; import static java.nio.charset.StandardCharsets.*; @@ -43,13 +47,17 @@ public enum Escaping { NONE, URL, SEGMENT, - JSON + JSON, + XML } public static Optional> getEscapingFunction(String mimeType) { if (isJson(mimeType)) { return Optional.of( getEscapingFunction(JSON)); } + if (isXML(mimeType)) { + return Optional.of( getEscapingFunction(XML)); + } return Optional.empty(); } @@ -58,7 +66,8 @@ public static Function getEscapingFunction(Escaping escaping) { case NONE -> Function.identity(); case URL -> s -> URLEncoder.encode(s, UTF_8); case SEGMENT -> EscapingUtil::pathEncode; - case JSON -> CommonBuiltInFunctions::toJSON; + case JSON -> CommonBuiltInFunctions::toJSON; // Alternative: StringEscapeUtils::escapeJson ? + case XML -> StringEscapeUtils::escapeXml11; }; } diff --git a/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java index 2f5b79dac7..94a503a0ce 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.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.xml; import com.fasterxml.jackson.databind.*; From 458e633ab8d60956da77636f4cc7d0dc547f569d Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Fri, 20 Feb 2026 11:32:51 +0100 Subject: [PATCH 18/20] test(core): add JSON and Groovy null value handling tests in `SetBodyInterceptorTest` - Added tests to verify behavior for null values in JSONPath and Groovy expressions. - Enhanced `SetBodyInterceptorTest` with additional scenarios for content escaping. - Updated imports for improved code readability and consistency. --- .../membrane/core/util/uri/EscapingUtil.java | 2 + .../lang/SetBodyInterceptorTest.java | 43 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index 260e234e1f..40a603ff9e 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -39,6 +39,8 @@ public class EscapingUtil { *

* - {@code NONE}: No escaping is applied. Strings are returned as-is. * - {@code URL}: Encodes strings for safe inclusion in a URL, replacing spaces and + * - {@code JSON}: Escapes strings for safe inclusion in a JSON context. + * - {@code XML}: Escapes strings for safe inclusion in an XML context using XML 1.1 rules. * other special characters with their percent-encoded counterparts (e.g., SPACE -> +). * - {@code SEGMENT}: Encodes strings as safe URI path segments, ensuring they do not introduce * path separators, query delimiters, or other unsafe characters, as per RFC 3986. diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java index 04b043b2cc..88da2a05b5 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java @@ -14,6 +14,7 @@ package com.predic8.membrane.core.interceptor.lang; +import com.predic8.membrane.annot.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.router.*; @@ -21,7 +22,11 @@ import java.net.*; -import static com.predic8.membrane.core.http.Response.notImplemented; +import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON_UTF8; +import static com.predic8.membrane.core.http.Request.*; +import static com.predic8.membrane.core.http.Response.*; +import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; +import static java.nio.charset.StandardCharsets.*; import static org.junit.jupiter.api.Assertions.*; /** @@ -37,7 +42,7 @@ class SetBodyInterceptorTest { void setup() throws URISyntaxException { sbi = new SetBodyInterceptor(); - exc = Request.get("/foo").buildExchange(); + exc = get("/foo").buildExchange(); exc.setResponse(notImplemented().body("bar").build()); } @@ -57,7 +62,39 @@ void evalOfSimpleExpression() { assertEquals("/foo", exc.getRequest().getBodyAsStringDecoded()); } - @Test + @Test + void escapeNull() { + exc.getRequest().setBodyContent(""" + {"a":null} + """.getBytes(UTF_8)); + sbi.setLanguage(JSONPATH); + sbi.setValue("${$.a}"); + sbi.init(new DefaultRouter()); + sbi.handleRequest(exc); + // When inserting a value from JSONPath into a JSON document like: + // { "a": ${.a} } + // Inserting an empty string will break the JSON + // Different from Groovy: See below + // See: https://github.com/membrane/api-gateway/discussions/2812 + assertEquals("", exc.getRequest().getBodyAsStringDecoded()); + } + + @Test + void escapeNulGroovy() { + exc.getRequest().setBodyContent(""" + {"a":null} + """.getBytes(UTF_8)); + sbi.setLanguage(GROOVY); + sbi.setContentType(APPLICATION_JSON_UTF8); + sbi.setValue("${fn.jsonPath('$.a')}"); + sbi.init(new DefaultRouter()); + sbi.handleRequest(exc); + // See also test above + // Here JSON is not broken. But is it right? + assertEquals("\"\"", exc.getRequest().getBodyAsStringDecoded()); + } + + @Test void response() { sbi.setValue("SC: ${statusCode}"); sbi.init(new DefaultRouter()); From b982758ab0f984ba042c0bb6a0094d499b6633e1 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Fri, 20 Feb 2026 12:27:12 +0100 Subject: [PATCH 19/20] test(core): improve null value handling tests for JSONPath and Groovy in `SetBodyInterceptorTest` - Refactored tests to consolidate null value handling for JSONPath and Groovy. - Enhanced `SetBodyInterceptor` to handle `Object` evaluation results, ensuring compatibility and proper type handling. - Updated helper methods to restructure and reuse test setup across scenarios. --- .../interceptor/lang/SetBodyInterceptor.java | 11 +-- .../membrane/core/util/uri/EscapingUtil.java | 6 +- .../lang/SetBodyInterceptorTest.java | 70 ++++++++++--------- .../templating/TemplateInterceptorTest.java | 52 ++++++++++---- 4 files changed, 85 insertions(+), 54 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java index 8214699a49..b74ad55a91 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java @@ -65,16 +65,19 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { var msg = exchange.getMessage(flow); try { // The value is typically set from YAML there we can assume UTF-8 - var result = exchangeExpression.evaluate(exchange, flow, String.class); + var result = exchangeExpression.evaluate(exchange, flow, Object.class); if (result == null) { result = "null"; } - msg.setBodyContent(result.getBytes(UTF_8)); - + if (result instanceof String s) { + msg.setBodyContent(s.getBytes(UTF_8)); + } else { + // Hope that all possibles return types are covered + msg.setBodyContent(result.toString().getBytes(UTF_8)); + } if (contentType != null) { msg.getHeader().setContentType(contentType); } - return CONTINUE; } catch (Exception e) { var root = ExceptionUtil.getRootCause(e); diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index 40a603ff9e..60e829282d 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -39,9 +39,9 @@ public class EscapingUtil { *

* - {@code NONE}: No escaping is applied. Strings are returned as-is. * - {@code URL}: Encodes strings for safe inclusion in a URL, replacing spaces and + * other special characters with their percent-encoded counterparts (e.g., SPACE -> +). * - {@code JSON}: Escapes strings for safe inclusion in a JSON context. * - {@code XML}: Escapes strings for safe inclusion in an XML context using XML 1.1 rules. - * other special characters with their percent-encoded counterparts (e.g., SPACE -> +). * - {@code SEGMENT}: Encodes strings as safe URI path segments, ensuring they do not introduce * path separators, query delimiters, or other unsafe characters, as per RFC 3986. */ @@ -55,10 +55,10 @@ public enum Escaping { public static Optional> getEscapingFunction(String mimeType) { if (isJson(mimeType)) { - return Optional.of( getEscapingFunction(JSON)); + return Optional.of(getEscapingFunction(JSON)); } if (isXML(mimeType)) { - return Optional.of( getEscapingFunction(XML)); + return Optional.of(getEscapingFunction(XML)); } return Optional.empty(); } diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java index 88da2a05b5..bce64efc4c 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java @@ -14,19 +14,17 @@ package com.predic8.membrane.core.interceptor.lang; -import com.predic8.membrane.annot.*; import com.predic8.membrane.core.exchange.*; -import com.predic8.membrane.core.http.*; +import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.router.*; import org.junit.jupiter.api.*; import java.net.*; -import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON_UTF8; +import static com.predic8.membrane.core.http.MimeType.*; import static com.predic8.membrane.core.http.Request.*; import static com.predic8.membrane.core.http.Response.*; import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; -import static java.nio.charset.StandardCharsets.*; import static org.junit.jupiter.api.Assertions.*; /** @@ -62,36 +60,40 @@ void evalOfSimpleExpression() { assertEquals("/foo", exc.getRequest().getBodyAsStringDecoded()); } - @Test - void escapeNull() { - exc.getRequest().setBodyContent(""" - {"a":null} - """.getBytes(UTF_8)); - sbi.setLanguage(JSONPATH); - sbi.setValue("${$.a}"); - sbi.init(new DefaultRouter()); - sbi.handleRequest(exc); - // When inserting a value from JSONPath into a JSON document like: - // { "a": ${.a} } - // Inserting an empty string will break the JSON - // Different from Groovy: See below - // See: https://github.com/membrane/api-gateway/discussions/2812 - assertEquals("", exc.getRequest().getBodyAsStringDecoded()); - } - - @Test - void escapeNulGroovy() { - exc.getRequest().setBodyContent(""" - {"a":null} - """.getBytes(UTF_8)); - sbi.setLanguage(GROOVY); - sbi.setContentType(APPLICATION_JSON_UTF8); - sbi.setValue("${fn.jsonPath('$.a')}"); - sbi.init(new DefaultRouter()); - sbi.handleRequest(exc); - // See also test above - // Here JSON is not broken. But is it right? - assertEquals("\"\"", exc.getRequest().getBodyAsStringDecoded()); + /** + * When inserting a value from JSONPath into a JSON document like: + * { "a": ${.a} } + * and the value is null, the document should be: + * { "a": null } + */ + @Nested + class Null { + + @Test + void escapeNullJsonPath() throws URISyntaxException { + callSetBody(JSONPATH, "${$.a}"); + } + + @Test + void escapeNullGroovy() throws URISyntaxException { + callSetBody(GROOVY, "${fn.jsonPath('$.a')}"); + } + + private void callSetBody(ExchangeExpression.Language language, String expression) throws URISyntaxException { + exc = setJsonSample(); + sbi.setLanguage(language); + sbi.setContentType(APPLICATION_JSON_UTF8); + sbi.setValue(expression); + sbi.init(new DefaultRouter()); + sbi.handleRequest(exc); + assertEquals("null", exc.getRequest().getBodyAsStringDecoded()); + } + + private Exchange setJsonSample() throws URISyntaxException { + return post("/foo").json(""" + {"a":null} + """).buildExchange(); + } } @Test diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java index 24ff43408c..0fcb25dd41 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.databind.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.http.*; -import com.predic8.membrane.core.resolver.*; import com.predic8.membrane.core.router.*; import com.predic8.membrane.core.security.*; import com.predic8.membrane.core.util.*; @@ -29,6 +28,7 @@ import javax.xml.parsers.*; import javax.xml.xpath.*; import java.io.*; +import java.net.*; import java.nio.file.*; import java.util.*; @@ -51,7 +51,7 @@ public class TemplateInterceptorTest { Router router; @BeforeEach - void setUp(){ + void setUp() { router = new DummyTestRouter(); ti = new TemplateInterceptor(); exc = new Exchange(null); @@ -84,7 +84,7 @@ void accessJson() throws Exception { @SuppressWarnings("unchecked") @Test void createJson() throws Exception { - Exchange exchange = Request.put("/foo").contentType(APPLICATION_JSON).buildExchange(); + var exchange = Request.put("/foo").contentType(APPLICATION_JSON).buildExchange(); invokeInterceptor(exchange, """ {"foo":7,"bar":"baz"} @@ -92,15 +92,15 @@ void createJson() throws Exception { assertEquals(APPLICATION_JSON, exchange.getRequest().getHeader().getContentType()); - Map m = om.readValue(exchange.getRequest().getBodyAsStringDecoded(),Map.class); - assertEquals(7,m.get("foo")); - assertEquals("baz",m.get("bar")); + Map m = om.readValue(exchange.getRequest().getBodyAsStringDecoded(), Map.class); + assertEquals(7, m.get("foo")); + assertEquals("baz", m.get("bar")); } @Test void accessBindings() throws Exception { Exchange exchange = post("/foo?a=1&b=2").contentType(TEXT_PLAIN).body("vlinder").buildExchange(); - exchange.setProperty("baz",7); + exchange.setProperty("baz", 7); invokeInterceptor(exchange, """ <% for(h in header.allHeaderFields) { %> @@ -123,7 +123,7 @@ void accessBindings() throws Exception { <%= p.key %> : <%= p.value %> <% } %> """, APPLICATION_JSON); - + String body = exchange.getRequest().getBodyAsStringDecoded(); assertTrue(body.contains("/foo")); assertTrue(body.contains("Flow: \"REQUEST\"")); @@ -201,7 +201,7 @@ void contentTypeTestOther() { @Test void contentTypeTestJson() { setAndHandleRequest("json/template_test.json"); - assertEquals(APPLICATION_JSON,exc.getRequest().getHeader().getContentType()); + assertEquals(APPLICATION_JSON, exc.getRequest().getHeader().getContentType()); } @Test @@ -209,7 +209,7 @@ void contentTypeTestNoXml() { ti.setSrc("normal text"); ti.init(router); ti.handleRequest(exc); - assertEquals(TEXT_PLAIN,exc.getRequest().getHeader().getContentType()); + assertEquals(TEXT_PLAIN, exc.getRequest().getHeader().getContentType()); } @Test @@ -223,7 +223,7 @@ void testPrettify() { ti.setContentType(APPLICATION_JSON); ti.setSrc(inputJson); - ti.setPretty( TRUE); + ti.setPretty(TRUE); ti.init(); assertArrayEquals(expectedPrettyJson.getBytes(UTF_8), ti.prettify(inputJson.getBytes(UTF_8))); } @@ -246,14 +246,40 @@ void builtInFunctions() { exc.setProperty(SECURITY_SCHEMES, List.of(new BasicHttpSecurityScheme().username("alice"))); ti.setContentType(APPLICATION_JSON); ti.setSrc(""" - { "foo": ${user()} } - """); + { "foo": ${user()} } + """); ti.init(router); ti.handleRequest(exc); assertTrue(exc.getRequest().getBodyAsStringDecoded().contains("alice")); } + /** + * When inserting a value from JSONPath into a JSON document like: + * { "a": ${.a} } + * and the value is null, the document should be: + * { "a": null } + */ + @Nested + class Null { + + @Test + void escapeNull() throws URISyntaxException { + exc = setJsonSample(); + ti.setContentType(APPLICATION_JSON_UTF8); + ti.setSrc("${fn.jsonPath('$.a')}"); + ti.init(new DefaultRouter()); + ti.handleRequest(exc); + assertEquals("null", exc.getRequest().getBodyAsStringDecoded()); + } + + private Exchange setJsonSample() throws URISyntaxException { + return post("/foo").json(""" + {"a":null} + """).buildExchange(); + } + } + private void setAndHandleRequest(String location) { ti.setLocation(Paths.get("src/test/resources/" + location).toString()); ti.init(router); From 17f3272b40a9e645cfbe6abe843bec656b16b829 Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Fri, 20 Feb 2026 13:14:46 +0100 Subject: [PATCH 20/20] test(tutorials): log validation errors in `SoapArrayToJsonTutorialTest` - Added `.log().ifValidationFails()` to improve debugging of failing test scenarios. --- .../tutorials/transformation/SoapArrayToJsonTutorialTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java index 159177e79e..ab373b7d62 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java @@ -37,6 +37,7 @@ void soapArrayIsConvertedToJsonArray() throws IOException { .when() .post("http://localhost:2000") .then() + .log().ifValidationFails() .statusCode(200) .contentType("application/json") .body("fruits", hasSize(3))