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/http/Message.java b/core/src/main/java/com/predic8/membrane/core/http/Message.java index a4e99afbf7..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 @@ -81,6 +81,26 @@ 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.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() { 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..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 @@ -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,17 @@ 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 (methodsWithoutBody.contains(newMethod.toUpperCase())) + emptyBody(); + } + public final void writeSTOMP(OutputStream out, boolean retainBody) throws IOException { out.write(getMethod().getBytes(UTF_8)); out.write(10); @@ -257,7 +271,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..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 @@ -26,10 +26,10 @@ 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.*; +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 @@ -44,7 +44,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"; @@ -56,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()); @@ -80,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()) { @@ -106,22 +115,23 @@ private String getForwardingDestination(Exchange exc) throws Exception { } protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLException, URISyntaxException { - AbstractServiceProxy p = (AbstractServiceProxy) exc.getProxy(); + if (!(exc.getProxy() instanceof AbstractServiceProxy asp)) + return null; - if (p.getTargetURL() != null) { - String targetURL = p.getTarget().compileUrl(exc, REQUEST); + if (asp.getTargetURL() != null) { + var targetURL = asp.getTargetURL(); 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, maintained by admin + var basePath = UriUtil.getPathFromURL(ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY, 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 combine(router.getConfiguration().getUriFactory(),targetURL,getUri(exc)); } } return targetURL; } - if (p.getTargetHost() != null) { - return new URL(p.getTargetScheme(), p.getTargetHost(), p.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/interceptor/HTTPClientInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/HTTPClientInterceptor.java index 2c96663857..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 @@ -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 apis.yaml or proxies.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); - try { + applyTargetModifications(exc); 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) @@ -109,8 +90,8 @@ 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()); - log.warn(msg + PROXIES_HINT); + 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") .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, 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/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/interceptor/rewrite/RewriteInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/rewrite/RewriteInterceptor.java index 285f0b093b..0e216bb751 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/lang/CommonBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java index 31a54288c6..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,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.uri.*; 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,21 @@ public static String env(String name) { return getenv(name); } + public static String urlEncode(String s) { + if (s == null) return ""; + 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 + * @return a percent-encoded string safe for use as a single URI path 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 05025dd0c4..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 @@ -16,22 +16,24 @@ 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; + /** + * Plugable encoder to apply various encoding strategies like URL, or path segment encoding. + */ + private final Function encoder; /** * For parsing strings with expressions inside ${} e.g. "foo ${property.bar} baz" @@ -41,16 +43,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 @@ -64,31 +68,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) { - StringBuilder line = new StringBuilder(); - for(Token token : tokens) { + var line = new StringBuilder(); + for (var token : tokens) { try { - line.append(token.eval(exchange, flow, String.class)); + var value = token.eval(exchange, flow, String.class); + if (token instanceof Text) { + line.append(value); + continue; + } + if (value == null) { + line.append("null"); + continue; + } + line.append(encoder.apply(value)); } catch (Exception e) { - throw new ExchangeExpressionException(token.toString(),e); + throw new ExchangeExpressionException(token.toString(), e); } } return line.toString(); } - protected static List parseTokens(Interceptor interceptor, Language language, String expression) { - log.debug("Parsing: {}",expression); + List parseTokens(Interceptor interceptor, Language language) { + log.debug("Parsing: {}", expression); List tokens = new ArrayList<>(); Matcher m = scriptPattern.matcher(expression); @@ -107,7 +120,7 @@ protected static List parseTokens(Interceptor interceptor, Language langu } interface Token { - T eval(Exchange exchange, Flow flow, Class type); + T eval(Exchange exchange, Flow flow, Class type); String getExpression(); } @@ -120,7 +133,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); } @@ -152,7 +165,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/lang/groovy/GroovyBuiltInFunctions.java b/core/src/main/java/com/predic8/membrane/core/lang/groovy/GroovyBuiltInFunctions.java index 1b2b005cb7..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 @@ -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 pathEncode(String segment) { + return CommonBuiltInFunctions.pathEncode(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..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 @@ -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 pathEncode(String segment, SpELExchangeEvaluationContext ignored) { + return CommonBuiltInFunctions.pathEncode(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/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 24e70c3eff..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 @@ -19,10 +19,23 @@ 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.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.EscapingUtil.*; +import org.slf4j.*; +import java.util.*; +import java.util.function.*; +import java.util.stream.*; + +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 com.predic8.membrane.core.util.uri.EscapingUtil.getEscapingFunction; /** * @description

@@ -33,34 +46,33 @@ */ @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; + protected String url; + private Language language = SPEL; + + /** + * Escaping strategy for URL placeholders. + */ + private Escaping escaping = Escaping.URL; + private Function escapingFunction; + + /** + * If url contains template marker ${}, if not expression evaluation is skipped + */ + private boolean urlIsTemplate = false; + 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; - } + private InterceptorAdapter adapter; public Target() { } @@ -74,10 +86,67 @@ 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; + + 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)); + } + + // 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) { + exc.setDestinations(computeDestinationExpressions(exc, router)); + + // Changing the method must be the last step cause it can empty the body! + if (method != null && !method.isEmpty()) { + exc.getRequest().changeMethod(method); + } + } + + private List computeDestinationExpressions(Exchange exc, Router router) { + 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) { + // Only evaluate if the target url contains a template marker ${} + if (!urlIsTemplate) + 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, + language, + url, + router, + escapingFunction).evaluate(exc, REQUEST, String.class); + } + public String getHost() { return host; } + public boolean isUrlIsTemplate() { + return urlIsTemplate; + } + /** * @description Host address of the target. * @example localhost, 192.168.1.1 @@ -150,11 +219,7 @@ public void setMethod(String method) { this.method = method; } - public ExchangeExpression getExchangeExpression() { - return exchangeExpression; - } - - public ExchangeExpression.Language getLanguage() { + public Language getLanguage() { return language; } @@ -164,10 +229,25 @@ public ExchangeExpression.Language getLanguage() { * @example SpEL, groovy, jsonpath, xpath */ @MCAttribute - public void setLanguage(ExchangeExpression.Language language) { + public void setLanguage(Language language) { this.language = language; } + public Escaping getEscaping() { + return escaping; + } + + /** + * @param escaping NONE, URL, SEGMENT + * @description When url contains placeholders ${}, the computed values should be escaped + * to prevent injection attacks. + * @default URL + */ + @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/main/java/com/predic8/membrane/core/resolver/ResolverMap.java b/core/src/main/java/com/predic8/membrane/core/resolver/ResolverMap.java index 99671a6e78..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 @@ -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.*; @@ -33,6 +32,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.*; /** @@ -50,6 +50,34 @@ public class ResolverMap implements Cloneable, Resolver { private static final Logger log = LoggerFactory.getLogger(ResolverMap.class.getName()); + private 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" @@ -58,13 +86,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) { + var 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(); @@ -72,38 +104,41 @@ private static String combineInternal(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]); } - 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 (relativeChild.startsWith("\\") || relativeChild.startsWith("/")) { - return convertPath2FilePathString( new File(relativeChild).getAbsolutePath()); + + // parent is file + if (parent.startsWith("file:/")) { + 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")) - return new URI(parent).resolve(prepare4Uri(relativeChild)).toString(); - if (parent.startsWith("classpath:")) - return new URI(parent).resolve(prepare4Uri(relativeChild)).toString(); - 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); } } + + // parent is absolute path if (parent.startsWith("/")) { try { return pathFromFileURI(convertPath2FileURI(parent).resolve(relativeChild)); @@ -116,11 +151,10 @@ private static String combineInternal(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); } @@ -134,47 +168,7 @@ private static String combineInternal(String... locations) { */ protected static String prepare4Uri(String path) { path = path.replaceAll("\\\\", "/"); - path = path.replaceAll(" ", "%20"); - return path; - } - - 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); + return path.replaceAll(" ", "%20"); } @Override @@ -313,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..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 @@ -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 String to check + * @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/TemplateUtil.java b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.java new file mode 100644 index 0000000000..954348d3d2 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/TemplateUtil.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; + +public class TemplateUtil { + + private TemplateUtil() {} + + /** + * Checks if the provided string contains the template marker "${" + * 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) { + 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 11605dc6dd..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 @@ -14,18 +14,18 @@ package com.predic8.membrane.core.util; -import com.predic8.membrane.core.http.xml.Host; +import com.predic8.membrane.core.util.ip.*; 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; @@ -43,28 +43,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) { @@ -81,6 +75,11 @@ private boolean customInit(String s) { path = m.group(5); query = m.group(7); fragment = m.group(9); + + if (!allowIllegalCharacters) { + UriIllegalCharacterDetector.validateAll(this, new UriIllegalCharacterDetector.Options.Builder().build()); + } + return true; } @@ -96,18 +95,17 @@ private void processAuthority(String rawAuthority) { if (at >= 0) { userInfo = rawAuthority.substring(0, at); } - hostPort = parseHostPort(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); + var hostAndPort = stripUserInfo(rawAuthority); - if (isIPLiteral(hostAndPort)) { + if (isIP6Literal(hostAndPort)) { return parseIpv6(hostAndPort); } @@ -119,7 +117,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(':'); @@ -134,6 +132,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); } @@ -142,17 +142,19 @@ 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."); } + IPv6Util.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("["); } @@ -167,20 +169,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 == null || p.isEmpty()) + return -1; + + 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; } @@ -190,43 +189,34 @@ public String getScheme() { * - might return something like "[fe80::1%25eth0]". */ public String getHost() { - if (uri != null) - return uri.getHost(); + if (hostPort == null) + return null; return hostPort.host; } public int getPort() { - if (uri != null) { - return uri.getPort(); - } + if (hostPort == null) + return -1; 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; } @@ -234,8 +224,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; @@ -249,19 +237,20 @@ 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; } + public String getUserInfo() { + return userInfo; + } + 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; } @@ -269,7 +258,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()); @@ -277,16 +266,186 @@ public String getPathWithQuery() { r.append("/"); } - if (getRawQuery() != null && !getRawQuery().isBlank()) { - r.append('?').append(getRawQuery()); + 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() { + String r =""; + if (scheme != null) { + r += scheme + "://"; + } + if (authority != null) { + return r + authority; + } + return r; + } + + /** + * 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 { + 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); + + // Classpath is special cause there is no separate authority + // classpath://a/b/c + if (this.getScheme().equals("classpath")) { + tPath = merged; + } else { + tPath = removeDotSegments(merged); + } + } + tQuery = rQuery; + } + tScheme = this.getScheme(); + tAuthority = this.getAuthority(); + } + } + tFragment = rFragment; + + // Recompose per RFC 3986, Section 5.3 + var result = new StringBuilder(); + if (tScheme != null) { + result.append(tScheme).append(':'); } - return r.toString(); + 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; + } + 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; + } + + var 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) { + out.setLength(Math.max(out.lastIndexOf("/"), 0)); } @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..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 @@ -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,10 +68,9 @@ 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); } } - -} +} \ 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 new file mode 100644 index 0000000000..e703bd99ea --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/URIValidationUtil.java @@ -0,0 +1,80 @@ +/* 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 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); + } + } + + 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: 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 == ']') + 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/URLUtil.java b/core/src/main/java/com/predic8/membrane/core/util/URLUtil.java index 77c6495dca..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 @@ -18,38 +18,39 @@ 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 getAuthority(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(); + } - public static int getPortFromURL(URL loc2) { - return loc2.getPort() == -1 ? loc2.getDefaultPort() : loc2.getPort(); - } } 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..b8f2007e29 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/UriIllegalCharacterDetector.java @@ -0,0 +1,228 @@ +/* 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.*; + +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.getUserInfo(), uri.getRawPath(), uri.getRawQuery(), uri.getRawFragment(), options); + } + + public static void validateAll(String scheme, + String authority, + String userInfo, + 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"); + validateUserInfo(userInfo, options); + + // 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 { + + 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) { + 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/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..901ed57e9a --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/ip/IPv6Util.java @@ -0,0 +1,44 @@ +/* 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.*; + +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..dfa9db3dd2 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -0,0 +1,110 @@ +/* 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.*; +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 -> +). + * - {@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
+     * pathEncode("a/b & c")  -> "a%2Fb%20%26%20c"
+     * pathEncode("ä")        -> "%C3%A4"
+     * pathEncode(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 307c69c54b..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.*; @@ -26,8 +27,8 @@ 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 com.predic8.membrane.core.util.URIFactory.ALLOW_ILLEGAL_CHARACTERS_URI_FACTORY; import static org.junit.jupiter.api.Assertions.*; class DispatchingInterceptorTest { @@ -36,14 +37,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); } @@ -150,6 +154,16 @@ private void addRequest(String uri) throws Exception { exc.setRequest(get(uri).build()); } + @Test + void initWithAllowIllegalAndURLExpression() { + var api = new APIProxy(); + api.setTarget(new Target() {{ + setUrl("https://${property.host}:8080"); // Has illegal characters $ { } in base path + }}); + + assertThrows(ConfigurationException.class, ()-> api.init(routerAllowIllegal)); + } + @Nested class ErrorHandling { @@ -164,7 +178,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 675402fd78..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 @@ -15,53 +15,189 @@ 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.router.*; +import com.predic8.membrane.core.util.*; +import com.predic8.membrane.core.util.uri.EscapingUtil.*; import org.junit.jupiter.api.*; import java.net.*; 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.util.uri.EscapingUtil.Escaping.*; import static org.junit.jupiter.api.Assertions.*; 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 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()); } + @Test + void computeTargetUrlWithEncodingGroovy() throws Exception { + var exc = get("/foo") + .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", Escaping.URL); + } + + @Test + void computeTargetUrlWithEncodingSpEL() throws Exception { + var exc = get("/foo") + .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", Escaping.URL); + } + + @Test + void computeTargetUrlWithEncodingJsonPath() throws Exception { + var exc = post("/foo") + .json(""" + { + "foo": "% ${}", + "bar": "$&:/)" + } + """) + .buildExchange(); + testExpression(JSONPATH, exc, "http://localhost/foo/${$.foo}: {}${$.bar}", "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29", Escaping.URL); + } + + @Test + void computeTargetUrlWithEncodingXPath() throws Exception { + var exc = post("/foo") + .xml(""" + + % ${} + $&:/) + + """) + .buildExchange(); + testExpression(XPATH, exc, "http://localhost/foo/${//foo}: {}${//bar}", + "http://localhost/foo/%25+%24%7B%7D: {}%24%26%3A%2F%29", Escaping.URL); + } + + @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); + } + + @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); + } + + @Nested + class injection { + + @Test + void illegalCharactersAndTemplateInTargetURL() throws URISyntaxException { + allowIllegalURICharacters(); + var exc = get("/foo").buildExchange(); + assertThrows(ConfigurationException.class, () -> invokeDispatching(SPEL, exc, "https://${'hostname'}", Escaping.URL)); + } + + @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; + } + assertFalse(apiProxy.getTarget().isUrlIsTemplate()); + assertEquals(1, exc.getDestinations().size()); + + // The template should not be evaluated, cause illegal characters are allowed! + assertEquals("https://localhost/foo/${555}", 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); + var di = new DispatchingInterceptor(); + di.init(router); + di.handleRequest(exc); + hci.applyTargetModifications(exc); + } + } \ 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..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 @@ -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.proxies.Target.*; +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/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/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..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 @@ -114,7 +114,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 +126,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/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/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/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java b/core/src/test/java/com/predic8/membrane/core/openapi/oas31/parameters/ExplodeFalseArrayQueryParameterTest.java index 11fc3f5192..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 @@ -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.*; @@ -110,11 +109,6 @@ void rawQueryIsUsedToSplitParameters() { assertTrue(msg.contains("'foo,bar'")); } - @Test - void valuesUTF8() { - assertEquals(0, validator.validate(get().path("/array?const=foo,äöü,baz")).size()); - } - @Test void valuesAreDecoded() { assertEquals(0, validator.validate(get().path("/array?const=foo,%C3%A4%3D%23,baz")).size()); @@ -129,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/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..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,15 +83,15 @@ 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)); + 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 { - assertEquals("https://predic8.de:2000", doRewrite("localhost:3000", "https", "predic8.de", 2000)); - assertEquals("https://predic8.de", doRewrite("localhost:3000", "https", "predic8.de", 443)); + 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)); } @Test 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/TargetTest.java b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java new file mode 100644 index 0000000000..0eefe80189 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/proxies/TargetTest.java @@ -0,0 +1,28 @@ +/* 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.*; + +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.*; +import static org.junit.jupiter.api.Assertions.*; + +class TargetTest { + + @Test + void defaultEscaping() { + assertEquals(URL,new Target().getEscaping()); + } +} \ 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/resolver/ResolverMapCombineTest.java b/core/src/test/java/com/predic8/membrane/core/resolver/ResolverMapCombineTest.java index 79cdfded5e..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,7 +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.OSUtil.*; import static org.junit.jupiter.api.Assertions.*; public class ResolverMapCombineTest { @@ -153,7 +153,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/FileUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/FileUtilTest.java index 5d742b09b1..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 @@ -19,9 +19,12 @@ 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 com.predic8.membrane.core.util.OSUtil.wl; +import static java.io.File.separator; import static org.junit.jupiter.api.Assertions.*; public class FileUtilTest { @@ -48,4 +51,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(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 + void directoryPart() { + assertNull(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/TemplateUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java new file mode 100644 index 0000000000..59118f70e9 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/TemplateUtilTest.java @@ -0,0 +1,34 @@ +/* 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.*; +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")); + assertFalse(containsTemplateMarker("foo$x{bar")); + } +} \ No newline at end of file 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 4fb2fb6e57..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 @@ -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 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 { - @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; + private static final URIFactory FAC = DEFAULT_URI_FACTORY; + + @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); } - @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 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()); + } + + 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()); + } 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 { + 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()); + assertEquals("http://localhost:8080", uf.create("http://localhost:8080#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,53 @@ 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 userInfo() throws URISyntaxException { + assertEquals("alice:secret", FAC.create("http://alice:secret@localhost").getUserInfo()); + } + @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 +250,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 +261,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()); } @@ -320,107 +294,116 @@ 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")); } @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")); + void parseHostPortIpv4InvalidAndEdgeCases() { + assertThrows(IllegalArgumentException.class, () -> URI_ALLOW_ILLEGAL.parseIPv4OrHostname(":8080")); + 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")); } @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 + + var hp = URI_ALLOW_ILLEGAL.parseHostPort("example.com:"); + assertEquals("example.com", hp.host()); + assertEquals(-1, hp.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")); + 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")); } @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 +420,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 +435,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 +448,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 +458,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 +476,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 +485,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 +500,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)); } @@ -546,4 +529,134 @@ 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 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 { + 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 { + 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 { + 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("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 { + 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 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 { + 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 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()); + } + + @Test + @DisplayName("Resolve using URIFactory with allowIllegalCharacters") + void resolveViaURIFactory() throws URISyntaxException { + URIFactory factory = new URIFactory(true); + com.predic8.membrane.core.util.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 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 + void resolveStandardWithQueryOnRelative() throws URISyntaxException { + 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 + void resolveCustomParsedHttps() throws URISyntaxException { + 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"); + // 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/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 1bd1dc3136..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,71 +15,183 @@ 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.*; 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 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 + 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 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()); + } @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(), EscapingUtil.pathEncode(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 = EscapingUtil.pathEncode(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/uri/URIVsJavaNetURITest.java b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java new file mode 100644 index 0000000000..f732ab314e --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/uri/URIVsJavaNetURITest.java @@ -0,0 +1,124 @@ +/* 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.*; +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"); + 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) { + var rawPath = j.getRawPath(); + if (rawPath == null || rawPath.isBlank()) rawPath = "/"; + var rawQuery = j.getRawQuery(); + return rawQuery == null ? rawPath : rawPath + "?" + rawQuery; + } +} diff --git a/distribution/examples/configuration/apis.yaml b/distribution/examples/configuration/apis.yaml index 84f998cfe5..8dce64b295 100644 --- a/distribution/examples/configuration/apis.yaml +++ b/distribution/examples/configuration/apis.yaml @@ -20,7 +20,9 @@ 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! + allowIllegalCharacters: true autoEscapeBackslashes: true # Escape backslashes in incoming URIs (\\ -> %5C) --- 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 153a24bff5..3645591631 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,6 +4,15 @@ - 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. +- 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 + # 7.X PRIO 1: