This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 64d95a8d93cf [backport camel-4.22.x] CAMEL-26280: 
camel-azure-functions - validate the function app and function name in 
invokeFunction (#29762)
64d95a8d93cf is described below

commit 64d95a8d93cf36949ca3ce882881578ea024fb91
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 13 08:01:34 2026 +0100

    [backport camel-4.22.x] CAMEL-26280: camel-azure-functions - validate the 
function app and function name in invokeFunction (#29762)
    
    Backport of #29733 onto camel-4.22.x. invokeFunction fails fast when the
    function app is not an app name, or the function name has a query, fragment,
    backslash, percent sign, empty or dot segment. The URL is built with the
    multi-argument URI constructor, and the x-functions-key header is only 
added to
    https URLs on an azurewebsites.net host.
    
    Adapted: camel-4.22.x lacks CAMEL-26247 and CAMEL-26525, so only the URL
    building of invokeFunction is replaced, and the function key test is a new
    FunctionsInvocationClientTest. The upgrade-guide note is on main (#29764).
    
    Co-Authored-By: Claude Opus 5.5 <[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 +++++++++++++++++++++
 .../client/FunctionsInvocationClientTest.java      |  83 +++++++++++
 7 files changed, 327 insertions(+), 10 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 0e86abedfa99..ac3ffd24cca4 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 0e86abedfa99..ac3ffd24cca4 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 0f4305da578b..13eccb2f82b9 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;
@@ -43,6 +44,8 @@ import org.apache.camel.util.ObjectHelper;
              headersClass = FunctionsConstants.class)
 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;
 
@@ -71,12 +74,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 5e8401b08c16..11721dda7958 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;
@@ -154,11 +156,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();
 
         Object body = exchange.getIn().getBody();
 
@@ -172,6 +170,49 @@ public class FunctionsProducer extends DefaultProducer {
         message.setHeader(FunctionsConstants.RESPONSE_HEADERS, 
response.getHeaders());
     }
 
+    /**
+     * The URI of the function: 
https://&lt;functionApp&gt;.azurewebsites.net/api/&lt;functionName&gt;. 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 void listFunctionApps(Exchange exchange) {
         ensureManagementClient();
 
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 979122cbdc85..ed0090d65910 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;
@@ -65,13 +66,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);
         }
 
@@ -121,6 +123,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 String convertBodyToString(Object body) {
         if (body instanceof String string) {
             return string;
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/client/FunctionsInvocationClientTest.java
 
b/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/client/FunctionsInvocationClientTest.java
new file mode 100644
index 000000000000..ac155ae07052
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-functions/src/test/java/org/apache/camel/component/azure/functions/client/FunctionsInvocationClientTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.client;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import 
org.apache.camel.component.azure.functions.client.FunctionsInvocationClient.FunctionInvocationResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * The invocation client against a JDK HttpServer that stands in for the 
function app.
+ */
+class FunctionsInvocationClientTest {
+
+    private final List<String> functionKeys = new CopyOnWriteArrayList<>();
+    private HttpServer server;
+
+    @BeforeEach
+    void startFunctionApp() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/", this::handle);
+        server.start();
+    }
+
+    @AfterEach
+    void stopFunctionApp() {
+        server.stop(0);
+    }
+
+    @Test
+    void functionKeyIsOnlyAddedForHttpsFunctionAppUrls() throws Exception {
+        // the fake function app is on a plain http URL of the local host
+        FunctionsInvocationClient client = new 
FunctionsInvocationClient("secret", null, 10000, 10000);
+        String url = "http://127.0.0.1:"; + server.getAddress().getPort() + 
"/api/myfunction";
+
+        FunctionInvocationResponse response = client.invoke(url, "POST", 
"Hello", Map.of());
+
+        assertEquals(200, response.getStatusCode());
+        assertEquals(1, functionKeys.size());
+        assertNull(functionKeys.get(0));
+    }
+
+    private void handle(HttpExchange http) throws IOException {
+        try (InputStream in = http.getRequestBody()) {
+            in.readAllBytes();
+        }
+        functionKeys.add(http.getRequestHeaders().getFirst("x-functions-key"));
+
+        byte[] answer = "{\"result\":\"ok\"}".getBytes(StandardCharsets.UTF_8);
+        http.sendResponseHeaders(200, answer.length);
+        try (OutputStream out = http.getResponseBody()) {
+            out.write(answer);
+        }
+    }
+}

Reply via email to