From 7b2a377b0171fb2dc86c0f7aebbdd3fdf5b5c7ad Mon Sep 17 00:00:00 2001 From: Blake Li Date: Mon, 12 Oct 2026 18:11:19 +0000 Subject: [PATCH] fix(api-common): validate path template variables against dot segments --- .../google/api/pathtemplate/PathTemplate.java | 40 +++++++-- .../api/pathtemplate/PathTemplateTest.java | 84 +++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java b/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java index d0d6d33f27aa..0731b55645fe 100644 --- a/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java +++ b/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java @@ -36,9 +36,9 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import com.google.common.net.PercentEscaper; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; -import java.net.URLEncoder; import java.util.ArrayList; import java.util.List; import java.util.ListIterator; @@ -881,6 +881,7 @@ private String instantiate(Map values, boolean allowPartial) { continueLast = true; continue; } + boolean hasPathWildcard = hasPathWildcard(iterator); Segment next = iterator.next(); Segment nextNext = iterator.next(); boolean pathEscape = @@ -888,12 +889,14 @@ private String instantiate(Map values, boolean allowPartial) { || nextNext.kind() != SegmentKind.END_BINDING; restore(iterator, iterator.nextIndex() - 2); if (!pathEscape) { + validatePathSegment(var, value, value, hasPathWildcard); result.append(encodeUrl(value)); } else { // For a path wildcard or path of length greater 1, split the value and escape // every sub-segment. boolean first = true; for (String subSeg : SLASH_SPLITTER.split(value)) { + validatePathSegment(var, value, subSeg, hasPathWildcard); if (!first) { result.append('/'); } @@ -1208,13 +1211,38 @@ private static List parseComplexResourceId(String seg) { // Helpers // ======= + private static final PercentEscaper URL_ESCAPER = new PercentEscaper("-_.~", false); + + private static boolean hasPathWildcard(ListIterator iterator) { + int start = iterator.nextIndex(); + boolean found = false; + while (iterator.hasNext()) { + Segment seg = iterator.next(); + if (seg.kind() == SegmentKind.END_BINDING) { + break; + } + if (seg.kind() == SegmentKind.PATH_WILDCARD) { + found = true; + } + } + restore(iterator, start); + return found; + } + + private static void validatePathSegment( + String var, String fullValue, String segment, boolean hasPathWildcard) { + if (".".equals(segment) || "..".equals(segment)) { + if (hasPathWildcard) { + throw new ValidationException( + "Value for %s must not contain segments that are exactly . or ..", var); + } + throw new ValidationException("Invalid value %s for %s.", fullValue, var); + } + } + private String encodeUrl(String text) { if (urlEncoding) { - try { - return URLEncoder.encode(text, "UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new ValidationException("UTF-8 encoding is not supported on this platform"); - } + return URL_ESCAPER.escape(text); } else { // When encoding is disabled, we accept any character except '/' final String INVALID_CHAR = "/"; diff --git a/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java b/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java index db23c9bc8ae5..b3a64990914d 100644 --- a/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java +++ b/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java @@ -642,6 +642,90 @@ void instantiateEscapeUnsafeChar() { .isEqualTo("buckets/f%2Fo%2Fo/objects/b%2Fa%2Fr"); } + @Test + void instantiateRejectsDotAndDoubleDotInSingleWildcard() { + PathTemplate template = + PathTemplate.create("/compute/v1/projects/{project}/regions/{region}/addresses"); + ValidationException dotDotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("project", "my-project", "region", "..")); + Truth.assertThat(dotDotException.getMessage()).isEqualTo("Invalid value .. for region."); + + ValidationException dotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("project", "my-project", "region", ".")); + Truth.assertThat(dotException.getMessage()).isEqualTo("Invalid value . for region."); + } + + @Test + void instantiateRejectsDotAndDoubleDotInMultiSegmentWildcard() { + PathTemplate template = + PathTemplate.create("/v2/{parent=projects/*/locations/*}/content:inspect"); + ValidationException dotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("parent", "projects/my-project/locations/.")); + Truth.assertThat(dotException.getMessage()) + .isEqualTo("Invalid value projects/my-project/locations/. for parent."); + + ValidationException dotDotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("parent", "projects/my-project/locations/..")); + Truth.assertThat(dotDotException.getMessage()) + .isEqualTo("Invalid value projects/my-project/locations/.. for parent."); + } + + @Test + void instantiateRejectsDotAndDoubleDotInPathWildcard() { + PathTemplate template = + PathTemplate.create("/v3/{name=projects/*/monitoredResourceDescriptors/**}"); + for (String invalidName : + new String[] { + "projects/my-project/monitoredResourceDescriptors/instance/my-instance/..", + "projects/my-project/monitoredResourceDescriptors/instance/my-instance/.", + "projects/my-project/monitoredResourceDescriptors/.", + "projects/my-project/monitoredResourceDescriptors/..", + "projects/my-project/monitoredResourceDescriptors/instance/./my-instance/..", + "projects/my-project/monitoredResourceDescriptors/instance/../.." + }) { + ValidationException exception = + Assertions.assertThrows( + ValidationException.class, () -> template.instantiate("name", invalidName)); + Truth.assertThat(exception.getMessage()) + .isEqualTo("Value for name must not contain segments that are exactly . or .."); + } + } + + @Test + void instantiateAllowsSafeDotsAndPercentEncodesReservedChars() { + PathTemplate template = PathTemplate.create("/v1/{x=projects/*/databases/*/documents/**}"); + Truth.assertThat( + template.instantiate("x", "projects/sys-prod-123/databases/default/documents/my.txt")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/my.txt"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/my..file.txt")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/my..file.txt"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/doc?key=val")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/doc%3Fkey%3Dval"); + Truth.assertThat( + template.instantiate("x", "projects/sys-prod-123/databases/default/documents/doc#frag")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/doc%23frag"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/doc with space")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/doc%20with%20space"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/%2e%2e/escape-db")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/%252e%252e/escape-db"); + } + @Test void instantiateNotEscapeForUnboundedWildcard() { PathTemplate template = PathTemplate.create("buckets/*/objects/**");