From 07d0d8f6b83a4aab54c0062b0e34845ed5aaf7c1 Mon Sep 17 00:00:00 2001 From: Javier Aliaga Date: Mon, 29 Jun 2026 16:15:46 +0200 Subject: [PATCH] fix: add DaprInvokeHttpClient.encodePath for URI path encoding newRequestBuilder resolves relativePath raw and now rethrows IllegalArgumentException on illegal chars (e.g. spaces), pointing at encodePath. encodePath percent-encodes each path segment, preserving separators and a leading slash and appending any query unchanged, mirroring DaprHttp/invokeMethod. Signed-off-by: Javier Aliaga --- .../io/dapr/client/DaprInvokeHttpClient.java | 18 +++- sdk/src/main/java/io/dapr/utils/UriUtils.java | 96 +++++++++++++++++++ .../dapr/client/DaprInvokeHttpClientTest.java | 37 +++++++ .../test/java/io/dapr/utils/UriUtilsTest.java | 74 ++++++++++++++ 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 sdk/src/main/java/io/dapr/utils/UriUtils.java create mode 100644 sdk/src/test/java/io/dapr/utils/UriUtilsTest.java diff --git a/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java b/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java index f13edb6cfb..83f9c64b7e 100644 --- a/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java +++ b/sdk/src/main/java/io/dapr/client/DaprInvokeHttpClient.java @@ -13,6 +13,7 @@ package io.dapr.client; +import io.dapr.utils.UriUtils; import io.dapr.utils.Version; import java.io.IOException; @@ -107,17 +108,24 @@ public URI baseUri() { * HTTP read timeout applied. * *

The {@code relativePath} is resolved against {@link #baseUri()} via - * {@link URI#resolve(String)}. Per {@link URI#resolve(String)} semantics, a leading - * slash replaces the entire path, so callers should typically pass a path - * without a leading slash (e.g. {@code "orders/42"}). + * {@link UriUtils#resolve(URI, String)} without modification. Per + * {@link URI#resolve(String)} semantics, a leading slash replaces the entire path, so + * callers should typically pass a path without a leading slash (e.g. + * {@code "orders/42"}). + * + *

The path is not encoded for you. If a segment may contain characters that are + * illegal in a URI path (e.g. spaces), encode it first with + * {@link UriUtils#encodePath(String)}; passing such characters directly throws + * {@link IllegalArgumentException}. * * @param relativePath path appended to the invoke prefix. * @return a request builder ready to be customized and built. + * @throws IllegalArgumentException if {@code relativePath} contains characters that are + * illegal in a URI; encode it first with {@link UriUtils#encodePath(String)}. */ public HttpRequest.Builder newRequestBuilder(String relativePath) { - Objects.requireNonNull(relativePath, "relativePath"); HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(baseUri.resolve(relativePath)) + .uri(UriUtils.resolve(baseUri, relativePath)) .header(Headers.DAPR_USER_AGENT, Version.getSdkVersion()); if (daprApiToken != null && !daprApiToken.isEmpty()) { builder.header(Headers.DAPR_API_TOKEN, daprApiToken); diff --git a/sdk/src/main/java/io/dapr/utils/UriUtils.java b/sdk/src/main/java/io/dapr/utils/UriUtils.java new file mode 100644 index 0000000000..e7f06d9024 --- /dev/null +++ b/sdk/src/main/java/io/dapr/utils/UriUtils.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed 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 io.dapr.utils; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Utilities for building and resolving the URIs used by the Dapr HTTP APIs. + */ +public class UriUtils { + + /** + * Resolves {@code relativePath} against {@code baseUri} via {@link URI#resolve(String)} + * without modifying it. If {@code relativePath} contains characters that are illegal in a + * URI (e.g. spaces), this throws {@link IllegalArgumentException} with guidance to + * {@link #encodePath(String)}. + * + *

The raw {@code relativePath} is never echoed in the thrown exception (it may contain + * sensitive identifiers); only the offending index is surfaced. + * + * @param baseUri the base URI to resolve against. + * @param relativePath the relative path to resolve. + * @return the resolved URI. + * @throws IllegalArgumentException if {@code relativePath} contains characters that are + * illegal in a URI; encode it first with {@link #encodePath(String)}. + */ + public static URI resolve(URI baseUri, String relativePath) { + Objects.requireNonNull(baseUri, "baseUri"); + Objects.requireNonNull(relativePath, "relativePath"); + try { + return baseUri.resolve(relativePath); + } catch (IllegalArgumentException exception) { + // The wrapped URISyntaxException echoes the full relativePath in its message, which may + // contain sensitive identifiers; surface only the offending index, never the input, and + // do not chain the cause. + String position = (exception.getCause() instanceof URISyntaxException syntaxException + && syntaxException.getIndex() >= 0) ? " at index " + syntaxException.getIndex() : ""; + throw new IllegalArgumentException( + "relativePath contains a character that is illegal in a URI" + position + + " (e.g. a space). Encode it first with UriUtils.encodePath(String)."); + } + } + + /** + * Percent-encodes each {@code /}-delimited segment of the path portion of + * {@code relativePath} (preserving the separators) so it can be safely passed to + * {@link #resolve(URI, String)} when a segment contains characters that are illegal in a + * URI path, such as spaces. Mirrors the per-segment encoding the deprecated + * {@code DaprClient.invokeMethod} APIs applied internally. + * + *

The query string (from the first {@code ?} onwards) is appended unchanged and must be + * pre-encoded by the caller. A leading slash is preserved, so {@link URI#resolve(String)} + * still treats it as replacing the entire base path. + * + * @param relativePath the raw, unencoded path to encode. + * @return {@code relativePath} with each path segment percent-encoded. + */ + public static String encodePath(String relativePath) { + Objects.requireNonNull(relativePath, "relativePath"); + int queryStart = relativePath.indexOf('?'); + String path = queryStart < 0 ? relativePath : relativePath.substring(0, queryStart); + String query = queryStart < 0 ? "" : relativePath.substring(queryStart); + + StringBuilder encoded = new StringBuilder(path.length() + 16); + int start = 0; + for (int i = 0; i <= path.length(); i++) { + if (i == path.length() || path.charAt(i) == '/') { + String segment = path.substring(start, i); + if (!segment.isEmpty()) { + encoded.append(URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20")); + } + if (i < path.length()) { + encoded.append('/'); + } + start = i + 1; + } + } + + return encoded.append(query).toString(); + } +} diff --git a/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java b/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java index c3c0514a0e..816048d32f 100644 --- a/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprInvokeHttpClientTest.java @@ -13,6 +13,7 @@ package io.dapr.client; +import io.dapr.utils.UriUtils; import io.dapr.utils.Version; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -81,6 +83,41 @@ public void newRequestBuilder_resolvesRelativePathAgainstBaseUri() { request.uri().toString()); } + @Test + public void newRequestBuilder_acceptsEncodedPath() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + + HttpRequest request = invoker.newRequestBuilder( + UriUtils.encodePath("api/some-resource/Name With Spaces/versions")).GET().build(); + + assertEquals( + "http://localhost:3500/v1.0/invoke/orderprocessor/method/" + + "api/some-resource/Name%20With%20Spaces/versions", + request.uri().toString()); + } + + @Test + public void newRequestBuilder_rejectsIllegalCharactersWithoutLeakingInput() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> invoker.newRequestBuilder("orders/secret-token-abc 123")); + + assertTrue(exception.getMessage().contains("encodePath")); + // The raw input must not leak into the message or via the wrapped cause. + assertFalse(exception.getMessage().contains("secret-token-abc")); + assertNull(exception.getCause()); + } + + @Test + public void newRequestBuilder_leadingSlashStillReplacesEntirePath() { + DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); + + HttpRequest request = invoker.newRequestBuilder("/v1.0/healthz/outbound").GET().build(); + + assertEquals("http://localhost:3500/v1.0/healthz/outbound", request.uri().toString()); + } + @Test public void newRequestBuilder_attachesSdkUserAgentHeader() { DaprInvokeHttpClient invoker = new DaprInvokeHttpClient(httpClient, BASE_URI, null, null); diff --git a/sdk/src/test/java/io/dapr/utils/UriUtilsTest.java b/sdk/src/test/java/io/dapr/utils/UriUtilsTest.java new file mode 100644 index 0000000000..2c150ecadc --- /dev/null +++ b/sdk/src/test/java/io/dapr/utils/UriUtilsTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed 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 io.dapr.utils; + +import org.junit.jupiter.api.Test; + +import java.net.URI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class UriUtilsTest { + + private static final URI BASE_URI = URI.create("http://localhost:3500/v1.0/invoke/orderprocessor/method/"); + + @Test + public void encodePath_encodesSegmentsWithSpacesPreservingSeparators() { + assertEquals("api/some-resource/Name%20With%20Spaces/versions", + UriUtils.encodePath("api/some-resource/Name With Spaces/versions")); + } + + @Test + public void encodePath_encodesIllegalCharsAndAppendsQueryUnchanged() { + assertEquals("orders/a%20b/c%23d?status=open&page=2", + UriUtils.encodePath("orders/a b/c#d?status=open&page=2")); + } + + @Test + public void encodePath_preservesLeadingSlash() { + assertEquals("/v1.0/healthz/outbound", UriUtils.encodePath("/v1.0/healthz/outbound")); + } + + @Test + public void encodePath_rejectsNull() { + assertThrows(NullPointerException.class, () -> UriUtils.encodePath(null)); + } + + @Test + public void resolve_resolvesRelativePathAgainstBaseUri() { + assertEquals(URI.create("http://localhost:3500/v1.0/invoke/orderprocessor/method/orders/42"), + UriUtils.resolve(BASE_URI, "orders/42")); + } + + @Test + public void resolve_rejectsNullArguments() { + assertThrows(NullPointerException.class, () -> UriUtils.resolve(null, "orders/42")); + assertThrows(NullPointerException.class, () -> UriUtils.resolve(BASE_URI, null)); + } + + @Test + public void resolve_rejectsIllegalCharactersWithoutLeakingInput() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> UriUtils.resolve(BASE_URI, "orders/secret-token-abc 123")); + + assertTrue(exception.getMessage().contains("encodePath")); + // The raw input must not leak into the message or via the wrapped cause. + assertFalse(exception.getMessage().contains("secret-token-abc")); + assertNull(exception.getCause()); + } +}