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

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


The following commit(s) were added to refs/heads/main by this push:
     new aa7a9bc76a66 CAMEL-26280: camel-azure-functions - validate the 
function app and function name in invokeFunction (#29733)
aa7a9bc76a66 is described below

commit aa7a9bc76a6670bc4ef07a03a693c15026a3784f
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 12 21:10:29 2026 +0100

    CAMEL-26280: camel-azure-functions - validate the function app and function 
name in invokeFunction (#29733)
    
    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 ac5eeea831aa..55ab8bca70f5 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