This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-26280 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4f74e1aa5aa1fdb1d4d746e0876d962a98c859de Author: Claus Ibsen <[email protected]> AuthorDate: Thu Nov 12 17:13:37 2026 +0100 CAMEL-26280: camel-azure-functions - validate the function app and function name in invokeFunction invokeFunction now fails fast when the function app is missing or is not an app name (letters, digits and hyphens), and when the function name has a query, a fragment, a backslash, a percent sign, an empty segment or a dot segment. Function names with several segments, such as products/42 for a function with a custom route, are still accepted. The URL is built with the multi-argument java.net.URI constructor, so the function name is encoded as a path, and the function key header is only added to https URLs on an azurewebsites.net host. getServiceUrl applies the same app name check. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../catalog/docs/azure-functions-component.adoc | 8 ++ .../src/main/docs/azure-functions-component.adoc | 8 ++ .../azure/functions/FunctionsEndpoint.java | 15 +- .../azure/functions/FunctionsProducer.java | 51 ++++++- .../client/FunctionsInvocationClient.java | 13 +- .../FunctionsProducerFunctionUrlTest.java | 159 +++++++++++++++++++++ .../functions/FunctionsProducerInvokeTest.java | 14 +- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 ++ 8 files changed, 267 insertions(+), 12 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-functions-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-functions-component.adoc index 15c03b8e1c8e..fc134b123332 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-functions-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-functions-component.adoc @@ -61,6 +61,14 @@ Where: - `functionApp` is the name of your Azure Function App - `functionName` is the name of the function within the app (required for `invokeFunction` operation) +The `invokeFunction` operation calls `+https://<functionApp>.azurewebsites.net/api/<functionName>+`. The function app, +from the endpoint or the `CamelAzureFunctionsFunctionApp` header, must be an app name: letters, digits and hyphens, not +starting or ending with a hyphen, at most 60 characters. The function name, from the endpoint or the +`CamelAzureFunctionsFunctionName` header, is the path after `/api/`: one or more segments separated by `/`, so a +function with a custom route, such as `products/42`, can be called. Characters that are not allowed in a URL path, such +as a space, are encoded. A function name with `?`, `#`, `\`, `%`, an empty segment, or a `.` or `..` segment is +rejected, and so is a function app that is not an app name: the exchange fails with an `IllegalArgumentException`. + == Usage === Authentication Information diff --git a/components/camel-azure/camel-azure-functions/src/main/docs/azure-functions-component.adoc b/components/camel-azure/camel-azure-functions/src/main/docs/azure-functions-component.adoc index 15c03b8e1c8e..fc134b123332 100644 --- a/components/camel-azure/camel-azure-functions/src/main/docs/azure-functions-component.adoc +++ b/components/camel-azure/camel-azure-functions/src/main/docs/azure-functions-component.adoc @@ -61,6 +61,14 @@ Where: - `functionApp` is the name of your Azure Function App - `functionName` is the name of the function within the app (required for `invokeFunction` operation) +The `invokeFunction` operation calls `+https://<functionApp>.azurewebsites.net/api/<functionName>+`. The function app, +from the endpoint or the `CamelAzureFunctionsFunctionApp` header, must be an app name: letters, digits and hyphens, not +starting or ending with a hyphen, at most 60 characters. The function name, from the endpoint or the +`CamelAzureFunctionsFunctionName` header, is the path after `/api/`: one or more segments separated by `/`, so a +function with a custom route, such as `products/42`, can be called. Characters that are not allowed in a URL path, such +as a space, are encoded. A function name with `?`, `#`, `\`, `%`, an empty segment, or a `.` or `..` segment is +rejected, and so is a function app that is not an app name: the exchange fails with an `IllegalArgumentException`. + == Usage === Authentication Information diff --git a/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsEndpoint.java b/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsEndpoint.java index c24444a6a3ea..129c3db0877d 100644 --- a/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsEndpoint.java +++ b/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsEndpoint.java @@ -18,6 +18,7 @@ package org.apache.camel.component.azure.functions; import java.util.HashMap; import java.util.Map; +import java.util.regex.Pattern; import org.apache.camel.Category; import org.apache.camel.Component; @@ -134,6 +135,8 @@ import org.apache.camel.util.ObjectHelper; + " the tags as a Map, and true for delete, start, stop and restart")) public class FunctionsEndpoint extends DefaultEndpoint implements EndpointServiceLocation { + private static final Pattern FUNCTION_APP_NAME = Pattern.compile("[A-Za-z0-9](?:[A-Za-z0-9-]{0,58}[A-Za-z0-9])?"); + @UriParam private FunctionsConfiguration configuration; @@ -162,12 +165,20 @@ public class FunctionsEndpoint extends DefaultEndpoint implements EndpointServic @Override public String getServiceUrl() { - if (ObjectHelper.isNotEmpty(configuration.getFunctionApp())) { - return String.format("https://%s.azurewebsites.net", configuration.getFunctionApp()); + if (isFunctionAppName(configuration.getFunctionApp())) { + return "https://" + configuration.getFunctionApp() + ".azurewebsites.net"; } return null; } + /** + * Whether the value is an Azure app name: letters, digits and hyphens, not starting or ending with a hyphen, at + * most 60 characters. + */ + static boolean isFunctionAppName(String functionApp) { + return functionApp != null && FUNCTION_APP_NAME.matcher(functionApp).matches(); + } + @Override public String getServiceProtocol() { return "azure-functions"; diff --git a/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsProducer.java b/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsProducer.java index 50447076b979..d666e9e6b8f1 100644 --- a/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsProducer.java +++ b/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/FunctionsProducer.java @@ -16,6 +16,8 @@ */ package org.apache.camel.component.azure.functions; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -157,11 +159,7 @@ public class FunctionsProducer extends DefaultProducer { String httpMethod = configProxy.getHttpMethod(exchange); Map<String, String> headers = configProxy.getHttpHeaders(exchange); - if (ObjectHelper.isEmpty(functionName)) { - throw new IllegalArgumentException("Function name is required for invokeFunction operation"); - } - - String url = String.format("https://%s.azurewebsites.net/api/%s", functionApp, functionName); + String url = functionUri(functionApp, functionName).toASCIIString(); // GET and DELETE send no body Object body = null; @@ -192,6 +190,49 @@ public class FunctionsProducer extends DefaultProducer { message.setBody(response.getBody()); } + /** + * The URI of the function: https://<functionApp>.azurewebsites.net/api/<functionName>. The function app + * must be an app name, and the function name a path relative to /api/ of one or more segments (a function with a + * custom route, such as products/42, has several). Characters that are not allowed in a path are encoded. + */ + static URI functionUri(String functionApp, String functionName) throws URISyntaxException { + if (ObjectHelper.isEmpty(functionApp)) { + throw new IllegalArgumentException("Function app is required for invokeFunction operation"); + } + if (!FunctionsEndpoint.isFunctionAppName(functionApp)) { + throw new IllegalArgumentException( + "Function app must be an app name of letters, digits and hyphens, at most 60 characters, but was: " + + functionApp); + } + if (ObjectHelper.isEmpty(functionName)) { + throw new IllegalArgumentException("Function name is required for invokeFunction operation"); + } + if (!isFunctionPath(functionName)) { + throw new IllegalArgumentException( + "Function name must be a path of segments separated by /, without empty, . or .. segments," + + " and without ?, #, \\ or %, but was: " + functionName); + } + + String host = functionApp + ".azurewebsites.net"; + URI uri = new URI("https", host, "/api/" + functionName, null); + if (!host.equalsIgnoreCase(uri.getHost())) { + throw new IllegalArgumentException("Function app " + functionApp + " does not give the host " + host); + } + return uri; + } + + private static boolean isFunctionPath(String functionName) { + if (functionName.chars().anyMatch(c -> c == '?' || c == '#' || c == '\\' || c == '%')) { + return false; + } + for (String segment : functionName.split("/", -1)) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + return false; + } + } + return true; + } + private static boolean hasContentType(Map<String, String> headers) { // HTTP header names are case-insensitive return headers != null && headers.keySet().stream().anyMatch(Exchange.CONTENT_TYPE::equalsIgnoreCase); diff --git a/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/client/FunctionsInvocationClient.java b/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/client/FunctionsInvocationClient.java index 02c56669e6e7..cdb306929de8 100644 --- a/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/client/FunctionsInvocationClient.java +++ b/components/camel-azure/camel-azure-functions/src/main/java/org/apache/camel/component/azure/functions/client/FunctionsInvocationClient.java @@ -24,6 +24,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; +import java.util.Locale; import java.util.Map; import org.slf4j.Logger; @@ -66,13 +67,14 @@ public class FunctionsInvocationClient { public FunctionInvocationResponse invoke(String url, String method, Object body, Map<String, String> headers) throws IOException, InterruptedException { + URI uri = URI.create(url); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(url)) + .uri(uri) .timeout(Duration.ofMillis(readTimeout)); - // Add function key header if present + // Add function key header if present, for an https URL of a function app String authKey = functionKey != null ? functionKey : hostKey; - if (authKey != null) { + if (authKey != null && isFunctionAppUri(uri)) { requestBuilder.header("x-functions-key", authKey); } @@ -124,6 +126,11 @@ public class FunctionsInvocationClient { response.headers().map()); } + private static boolean isFunctionAppUri(URI uri) { + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost() != null + && uri.getHost().toLowerCase(Locale.ROOT).endsWith(".azurewebsites.net"); + } + private static byte[] toBytes(Object body) { if (body == null) { return null; diff --git a/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerFunctionUrlTest.java b/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerFunctionUrlTest.java new file mode 100644 index 000000000000..7f4ac6bb11a4 --- /dev/null +++ b/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerFunctionUrlTest.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.component.azure.functions; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.apache.camel.Exchange; +import org.apache.camel.component.azure.functions.client.FunctionsClientFactory; +import org.apache.camel.component.azure.functions.client.FunctionsInvocationClient; +import org.apache.camel.component.azure.functions.client.FunctionsInvocationClient.FunctionInvocationResponse; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The URL that invokeFunction builds from the function app and the function name. + */ +class FunctionsProducerFunctionUrlTest extends CamelTestSupport { + + private final List<String> urls = new CopyOnWriteArrayList<>(); + + @Test + void urlIsOnTheHostOfTheFunctionApp() { + Exchange result = send(functionsEndpoint(), null, null); + + assertNull(result.getException()); + assertEquals(List.of("https://myapp.azurewebsites.net/api/myfunction"), urls); + } + + @Test + void functionAppOfHeaderIsUsed() { + Exchange result = send(functionsEndpoint(), "my-other-app2", null); + + assertNull(result.getException()); + assertEquals(List.of("https://my-other-app2.azurewebsites.net/api/myfunction"), urls); + } + + @Test + void routeWithSeveralSegmentsIsAccepted() { + Exchange result = send(functionsEndpoint(), null, "products/42"); + + assertNull(result.getException()); + assertEquals(List.of("https://myapp.azurewebsites.net/api/products/42"), urls); + } + + @Test + void functionNameIsEncoded() { + Exchange result = send(functionsEndpoint(), null, "my function/Grüße"); + + assertNull(result.getException()); + assertEquals(List.of("https://myapp.azurewebsites.net/api/my%20function/Gr%C3%BC%C3%9Fe"), urls); + } + + @Test + void missingFunctionAppIsRejected() { + FunctionsEndpoint endpoint = functionsEndpoint(); + endpoint.getConfiguration().setFunctionApp(null); + + assertRejected(send(endpoint, null, null)); + } + + @ParameterizedTest + @ValueSource(strings = { + "my.app", "myapp/api", "myapp:8080", "user@myapp", "myapp?x=1", "myapp#top", "my app", "-myapp", "myapp-", + "a123456789012345678901234567890123456789012345678901234567890" }) + void functionAppThatIsNotAnAppNameIsRejected(String functionApp) { + assertRejected(send(functionsEndpoint(), functionApp, null)); + } + + @ParameterizedTest + @ValueSource(strings = { "myfunction?x=1", "myfunction#top", "myfunction/?x=1" }) + void functionNameWithQueryOrFragmentIsRejected(String functionName) { + assertRejected(send(functionsEndpoint(), null, functionName)); + } + + @ParameterizedTest + @ValueSource(strings = { ".", "..", "./myfunction", "../myfunction", "products/../myfunction", "products/." }) + void functionNameWithDotSegmentsIsRejected(String functionName) { + assertRejected(send(functionsEndpoint(), null, functionName)); + } + + @ParameterizedTest + @ValueSource(strings = { "/myfunction", "products//42", "products/", "products\\42", "products%2F42" }) + void functionNameThatIsNotARelativePathIsRejected(String functionName) { + assertRejected(send(functionsEndpoint(), null, functionName)); + } + + @Test + void serviceUrlIsOnTheHostOfTheFunctionApp() { + assertEquals("https://myapp.azurewebsites.net", functionsEndpoint().getServiceUrl()); + } + + @Test + void serviceUrlOfFunctionAppThatIsNotAnAppNameIsNull() { + FunctionsEndpoint endpoint = functionsEndpoint(); + endpoint.getConfiguration().setFunctionApp("my.app"); + + assertNull(endpoint.getServiceUrl()); + } + + private Exchange send(FunctionsEndpoint endpoint, String functionApp, String functionName) { + return template.send(endpoint, e -> { + e.getIn().setBody("Hello"); + e.getIn().setHeader(FunctionsConstants.FUNCTION_APP, functionApp); + e.getIn().setHeader(FunctionsConstants.FUNCTION_NAME, functionName); + }); + } + + private void assertRejected(Exchange result) { + assertInstanceOf(IllegalArgumentException.class, result.getException()); + assertTrue(urls.isEmpty(), "the function must not be invoked"); + } + + /** + * The azure-functions endpoint of the test, with an invocation client that records the URL instead of sending. + */ + private FunctionsEndpoint functionsEndpoint() { + FunctionsEndpoint endpoint = context.getEndpoint( + "azure-functions:myapp/myfunction?functionKey=secret", FunctionsEndpoint.class); + endpoint.setClientFactory(new FunctionsClientFactory() { + @Override + public FunctionsInvocationClient createInvocationClient(FunctionsConfiguration configuration) { + return new FunctionsInvocationClient( + configuration.getFunctionKey(), configuration.getHostKey(), + configuration.getConnectionTimeout(), configuration.getReadTimeout()) { + @Override + public FunctionInvocationResponse invoke( + String url, String method, Object body, Map<String, String> headers) { + urls.add(url); + return new FunctionInvocationResponse(200, "{\"result\":\"ok\"}", Map.of()); + } + }; + } + }); + return endpoint; + } +} diff --git a/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerInvokeTest.java b/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerInvokeTest.java index ff03f1030fa6..8ceb012205e4 100644 --- a/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerInvokeTest.java +++ b/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/FunctionsProducerInvokeTest.java @@ -314,6 +314,15 @@ class FunctionsProducerInvokeTest extends CamelTestSupport { assertEquals(500, result.getMessage().getHeader(FunctionsConstants.STATUS_CODE)); } + @Test + void functionKeyIsOnlyAddedForHttpsFunctionAppUrls() throws Exception { + // the fake function app is on a plain http URL of the local host + Exchange result = template.send(functionsEndpoint(""), e -> e.getIn().setBody("Hello")); + + assertNull(result.getException()); + assertNull(nextRequest().functionKey()); + } + /** * The azure-functions endpoint of the test, with an invocation client that sends to the fake function app. */ @@ -351,7 +360,8 @@ class FunctionsProducerInvokeTest extends CamelTestSupport { body = in.readAllBytes(); } requests.add(new RecordedRequest( - http.getRequestMethod(), http.getRequestURI().getPath(), http.getRequestHeaders().get("Content-Type"), body)); + http.getRequestMethod(), http.getRequestURI().getPath(), http.getRequestHeaders().get("Content-Type"), + http.getRequestHeaders().getFirst("x-functions-key"), body)); byte[] answer = responseBody.getBytes(StandardCharsets.UTF_8); http.getResponseHeaders().add("X-Function-Error", "details"); @@ -361,6 +371,6 @@ class FunctionsProducerInvokeTest extends CamelTestSupport { } } - private record RecordedRequest(String method, String path, List<String> contentTypes, byte[] body) { + private record RecordedRequest(String method, String path, List<String> contentTypes, String functionKey, byte[] body) { } } diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 03577d5cf348..008213a9ca31 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -169,6 +169,17 @@ now reject the invocation before the tool route runs. Previously, these argument required-argument check and then be discarded by the reserved-name filter, leaving the route without a required value. Rename these parameters and update their `parameter.*` or `argSchema` declarations. +=== camel-azure-functions - invokeFunction checks the function app and the function name + +The `invokeFunction` operation now checks the function app and the function name, from the endpoint or from the +`CamelAzureFunctionsFunctionApp` and `CamelAzureFunctionsFunctionName` headers, before it calls the function, and +builds the URL with the function name encoded as a path. The exchange fails with an `IllegalArgumentException` when the +function app is missing or is not an app name (letters, digits and hyphens, not starting or ending with a hyphen, at +most 60 characters), or when the function name has a `?`, `#`, `\` or `%`, an empty segment, or a `.` or `..` +segment. A function name of several segments, such as `products/42` for a function with a custom route, is still +accepted. The `x-functions-key` header is only added to an `https` URL on an `azurewebsites.net` host. Valid +configurations are not affected. + === camel-mcp-server - the Origin header is checked on /mcp On Camel Main and Camel JBang, the streamable HTTP MCP endpoint now checks the `Origin` header on every request
