From 7b2a377b0171fb2dc86c0f7aebbdd3fdf5b5c7ad Mon Sep 17 00:00:00 2001 From: Blake Li Date: Mon, 12 Oct 2026 18:11:19 +0000 Subject: [PATCH 1/4] 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/**"); From 64f6f680a3ab284a9bff4f50f1596f1d19384ee5 Mon Sep 17 00:00:00 2001 From: Blake Li Date: Wed, 14 Oct 2026 04:15:13 +0000 Subject: [PATCH 2/4] refactor(api-common): optimize path wildcard check in PathTemplate.instantiate --- .../main/java/com/google/api/pathtemplate/PathTemplate.java | 6 ++++-- .../java/com/google/api/pathtemplate/PathTemplateTest.java | 3 ++- 2 files changed, 6 insertions(+), 3 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 0731b55645fe..211826752a9c 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 @@ -881,7 +881,6 @@ private String instantiate(Map values, boolean allowPartial) { continueLast = true; continue; } - boolean hasPathWildcard = hasPathWildcard(iterator); Segment next = iterator.next(); Segment nextNext = iterator.next(); boolean pathEscape = @@ -889,9 +888,11 @@ private String instantiate(Map values, boolean allowPartial) { || nextNext.kind() != SegmentKind.END_BINDING; restore(iterator, iterator.nextIndex() - 2); if (!pathEscape) { - validatePathSegment(var, value, value, hasPathWildcard); + validatePathSegment(var, value, value, false); result.append(encodeUrl(value)); } else { + boolean hasPathWildcard = + next.kind() == SegmentKind.PATH_WILDCARD || hasPathWildcard(iterator); // For a path wildcard or path of length greater 1, split the value and escape // every sub-segment. boolean first = true; @@ -1223,6 +1224,7 @@ private static boolean hasPathWildcard(ListIterator iterator) { } if (seg.kind() == SegmentKind.PATH_WILDCARD) { found = true; + break; } } restore(iterator, start); 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 b3a64990914d..88472f60693a 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 @@ -689,7 +689,8 @@ void instantiateRejectsDotAndDoubleDotInPathWildcard() { "projects/my-project/monitoredResourceDescriptors/.", "projects/my-project/monitoredResourceDescriptors/..", "projects/my-project/monitoredResourceDescriptors/instance/./my-instance/..", - "projects/my-project/monitoredResourceDescriptors/instance/../.." + "projects/my-project/monitoredResourceDescriptors/instance/../..", + "projects/my-project/monitoredResourceDescriptors/ .. " }) { ValidationException exception = Assertions.assertThrows( From 3618d3e3a91c036ba945fe20884d084b0971927f Mon Sep 17 00:00:00 2001 From: Blake Li Date: Wed, 14 Oct 2026 04:23:07 +0000 Subject: [PATCH 3/4] test(api-common): separate dot and double-dot unit tests in PathTemplateTest --- .../api/pathtemplate/PathTemplateTest.java | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) 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 88472f60693a..e4e09950370e 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 @@ -643,15 +643,9 @@ void instantiateEscapeUnsafeChar() { } @Test - void instantiateRejectsDotAndDoubleDotInSingleWildcard() { + void instantiateRejectsDotInSingleWildcard() { 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, @@ -660,7 +654,18 @@ void instantiateRejectsDotAndDoubleDotInSingleWildcard() { } @Test - void instantiateRejectsDotAndDoubleDotInMultiSegmentWildcard() { + void instantiateRejectsDoubleDotInSingleWildcard() { + 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."); + } + + @Test + void instantiateRejectsDotInMultiSegmentWildcard() { PathTemplate template = PathTemplate.create("/v2/{parent=projects/*/locations/*}/content:inspect"); ValidationException dotException = @@ -669,7 +674,12 @@ void instantiateRejectsDotAndDoubleDotInMultiSegmentWildcard() { () -> template.instantiate("parent", "projects/my-project/locations/.")); Truth.assertThat(dotException.getMessage()) .isEqualTo("Invalid value projects/my-project/locations/. for parent."); + } + @Test + void instantiateRejectsDoubleDotInMultiSegmentWildcard() { + PathTemplate template = + PathTemplate.create("/v2/{parent=projects/*/locations/*}/content:inspect"); ValidationException dotDotException = Assertions.assertThrows( ValidationException.class, @@ -679,14 +689,31 @@ void instantiateRejectsDotAndDoubleDotInMultiSegmentWildcard() { } @Test - void instantiateRejectsDotAndDoubleDotInPathWildcard() { + void instantiateRejectsDotInPathWildcard() { 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/instance/./my-instance", + "projects/my-project/monitoredResourceDescriptors/ . " + }) { + 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 instantiateRejectsDoubleDotInPathWildcard() { + PathTemplate template = + PathTemplate.create("/v3/{name=projects/*/monitoredResourceDescriptors/**}"); + for (String invalidName : + new String[] { + "projects/my-project/monitoredResourceDescriptors/instance/my-instance/..", "projects/my-project/monitoredResourceDescriptors/..", "projects/my-project/monitoredResourceDescriptors/instance/./my-instance/..", "projects/my-project/monitoredResourceDescriptors/instance/../..", From 048dec1f8f3c2fa2070b8f82192deb0559bbecdb Mon Sep 17 00:00:00 2001 From: Blake Li Date: Wed, 14 Oct 2026 19:07:39 +0000 Subject: [PATCH 4/4] refactor(api-common): unify dot-segment validation message and remove hasPathWildcard --- .../google/api/pathtemplate/PathTemplate.java | 33 +++---------------- .../api/pathtemplate/PathTemplateTest.java | 10 +++--- 2 files changed, 11 insertions(+), 32 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 211826752a9c..5adca325d44b 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 @@ -888,16 +888,14 @@ private String instantiate(Map values, boolean allowPartial) { || nextNext.kind() != SegmentKind.END_BINDING; restore(iterator, iterator.nextIndex() - 2); if (!pathEscape) { - validatePathSegment(var, value, value, false); + validatePathSegment(var, value); result.append(encodeUrl(value)); } else { - boolean hasPathWildcard = - next.kind() == SegmentKind.PATH_WILDCARD || hasPathWildcard(iterator); // 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); + validatePathSegment(var, subSeg); if (!first) { result.append('/'); } @@ -1214,31 +1212,10 @@ private static List parseComplexResourceId(String seg) { 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; - break; - } - } - restore(iterator, start); - return found; - } - - private static void validatePathSegment( - String var, String fullValue, String segment, boolean hasPathWildcard) { + private static void validatePathSegment(String var, String segment) { 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); + throw new ValidationException( + "Value for %s must not contain segments that are exactly . or ..", var); } } 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 e4e09950370e..98c6382387f8 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 @@ -650,7 +650,8 @@ void instantiateRejectsDotInSingleWildcard() { Assertions.assertThrows( ValidationException.class, () -> template.instantiate("project", "my-project", "region", ".")); - Truth.assertThat(dotException.getMessage()).isEqualTo("Invalid value . for region."); + Truth.assertThat(dotException.getMessage()) + .isEqualTo("Value for region must not contain segments that are exactly . or .."); } @Test @@ -661,7 +662,8 @@ void instantiateRejectsDoubleDotInSingleWildcard() { Assertions.assertThrows( ValidationException.class, () -> template.instantiate("project", "my-project", "region", "..")); - Truth.assertThat(dotDotException.getMessage()).isEqualTo("Invalid value .. for region."); + Truth.assertThat(dotDotException.getMessage()) + .isEqualTo("Value for region must not contain segments that are exactly . or .."); } @Test @@ -673,7 +675,7 @@ void instantiateRejectsDotInMultiSegmentWildcard() { ValidationException.class, () -> template.instantiate("parent", "projects/my-project/locations/.")); Truth.assertThat(dotException.getMessage()) - .isEqualTo("Invalid value projects/my-project/locations/. for parent."); + .isEqualTo("Value for parent must not contain segments that are exactly . or .."); } @Test @@ -685,7 +687,7 @@ void instantiateRejectsDoubleDotInMultiSegmentWildcard() { ValidationException.class, () -> template.instantiate("parent", "projects/my-project/locations/..")); Truth.assertThat(dotDotException.getMessage()) - .isEqualTo("Invalid value projects/my-project/locations/.. for parent."); + .isEqualTo("Value for parent must not contain segments that are exactly . or .."); } @Test