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://&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 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

Reply via email to