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://<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 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);
+ }
+ }
+}