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