diff --git a/core/src/main/java/io/grpc/internal/SpiffeUtil.java b/core/src/main/java/io/grpc/internal/SpiffeUtil.java new file mode 100644 index 0000000000..bddce3d035 --- /dev/null +++ b/core/src/main/java/io/grpc/internal/SpiffeUtil.java @@ -0,0 +1,122 @@ +/* + * Copyright 2024 The gRPC 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.grpc.internal; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Splitter; +import java.util.Locale; + +/** + * Helper utility to work with SPIFFE URIs. + * @see Standard + */ +public final class SpiffeUtil { + + private static final String PREFIX = "spiffe://"; + + private SpiffeUtil() {} + + /** + * Parses a URI string, applies validation rules described in SPIFFE standard, and, in case of + * success, returns parsed TrustDomain and Path. + * + * @param uri a String representing a SPIFFE ID + */ + public static SpiffeId parse(String uri) { + doInitialUriValidation(uri); + checkArgument(uri.toLowerCase(Locale.US).startsWith(PREFIX), "Spiffe Id must start with " + + PREFIX); + String domainAndPath = uri.substring(PREFIX.length()); + String trustDomain; + String path; + if (!domainAndPath.contains("/")) { + trustDomain = domainAndPath; + path = ""; + } else { + String[] parts = domainAndPath.split("/", 2); + trustDomain = parts[0]; + path = parts[1]; + checkArgument(!path.isEmpty(), "Path must not include a trailing '/'"); + } + validateTrustDomain(trustDomain); + validatePath(path); + if (!path.isEmpty()) { + path = "/" + path; + } + return new SpiffeId(trustDomain, path); + } + + private static void doInitialUriValidation(String uri) { + checkArgument(checkNotNull(uri, "uri").length() > 0, "Spiffe Id can't be empty"); + checkArgument(uri.length() <= 2048, "Spiffe Id maximum length is 2048 characters"); + checkArgument(!uri.contains("#"), "Spiffe Id must not contain query fragments"); + checkArgument(!uri.contains("?"), "Spiffe Id must not contain query parameters"); + } + + private static void validateTrustDomain(String trustDomain) { + checkArgument(!trustDomain.isEmpty(), "Trust Domain can't be empty"); + checkArgument(trustDomain.length() < 256, "Trust Domain maximum length is 255 characters"); + checkArgument(trustDomain.matches("[a-z0-9._-]+"), + "Trust Domain must contain only letters, numbers, dots, dashes, and underscores" + + " ([a-z0-9.-_])"); + } + + private static void validatePath(String path) { + if (path.isEmpty()) { + return; + } + checkArgument(!path.endsWith("/"), "Path must not include a trailing '/'"); + for (String segment : Splitter.on("/").split(path)) { + validatePathSegment(segment); + } + } + + private static void validatePathSegment(String pathSegment) { + checkArgument(!pathSegment.isEmpty(), "Individual path segments must not be empty"); + checkArgument(!(pathSegment.equals(".") || pathSegment.equals("..")), + "Individual path segments must not be relative path modifiers (i.e. ., ..)"); + checkArgument(pathSegment.matches("[a-zA-Z0-9._-]+"), + "Individual path segments must contain only letters, numbers, dots, dashes, and underscores" + + " ([a-zA-Z0-9.-_])"); + } + + /** + * Represents a SPIFFE ID as defined in the SPIFFE standard. + * @see Standard + */ + public static class SpiffeId { + + private final String trustDomain; + private final String path; + + private SpiffeId(String trustDomain, String path) { + this.trustDomain = trustDomain; + this.path = path; + } + + public String getTrustDomain() { + return trustDomain; + } + + public String getPath() { + return path; + } + } + +} diff --git a/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java b/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java new file mode 100644 index 0000000000..c3a98ce33e --- /dev/null +++ b/core/src/test/java/io/grpc/internal/SpiffeUtilTest.java @@ -0,0 +1,196 @@ +/* + * Copyright 2024 The gRPC 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.grpc.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.util.Arrays; +import java.util.Collection; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + + +@RunWith(Enclosed.class) +public class SpiffeUtilTest { + + @RunWith(Parameterized.class) + public static class ParseSuccessTest { + @Parameter + public String uri; + + @Parameter(1) + public String trustDomain; + + @Parameter(2) + public String path; + + @Test + public void parseSuccessTest() { + SpiffeUtil.SpiffeId spiffeId = SpiffeUtil.parse(uri); + assertEquals(trustDomain, spiffeId.getTrustDomain()); + assertEquals(path, spiffeId.getPath()); + } + + @Parameters(name = "spiffeId={0}") + public static Collection data() { + return Arrays.asList(new String[][] { + {"spiffe://example.com", "example.com", ""}, + {"spiffe://example.com/us", "example.com", "/us"}, + {"spIFfe://qa-staging.final_check.example.com/us", "qa-staging.final_check.example.com", + "/us"}, + {"spiffe://example.com/country/us/state/FL/city/Miami", "example.com", + "/country/us/state/FL/city/Miami"}, + {"SPIFFE://example.com/Czech.Republic/region0.1/city_of-Prague", "example.com", + "/Czech.Republic/region0.1/city_of-Prague"}, + {"spiffe://trust-domain-name/path", "trust-domain-name", "/path"}, + {"spiffe://staging.example.com/payments/mysql", "staging.example.com", "/payments/mysql"}, + {"spiffe://staging.example.com/payments/web-fe", "staging.example.com", + "/payments/web-fe"}, + {"spiffe://k8s-west.example.com/ns/staging/sa/default", "k8s-west.example.com", + "/ns/staging/sa/default"}, + {"spiffe://example.com/9eebccd2-12bf-40a6-b262-65fe0487d453", "example.com", + "/9eebccd2-12bf-40a6-b262-65fe0487d453"}, + {"spiffe://trustdomain/.a..", "trustdomain", "/.a.."}, + {"spiffe://trustdomain/...", "trustdomain", "/..."}, + {"spiffe://trustdomain/abcdefghijklmnopqrstuvwxyz", "trustdomain", + "/abcdefghijklmnopqrstuvwxyz"}, + {"spiffe://trustdomain/abc0123.-_", "trustdomain", "/abc0123.-_"}, + {"spiffe://trustdomain/0123456789", "trustdomain", "/0123456789"}, + {"spiffe://trustdomain0123456789/path", "trustdomain0123456789", "/path"}, + }); + } + } + + @RunWith(Parameterized.class) + public static class ParseFailureTest { + @Parameter + public String uri; + + @Test + public void parseFailureTest() { + assertThrows(IllegalArgumentException.class, () -> SpiffeUtil.parse(uri)); + } + + @Parameters(name = "spiffeId={0}") + public static Collection data() { + return Arrays.asList( + "spiffe:///", + "spiffe://example!com", + "spiffe://exampleя.com/workload-1", + "spiffe://example.com/us/florida/miamiя", + "spiffe:/trustdomain/path", + "spiffe:///path", + "spiffe://trust%20domain/path", + "spiffe://user@trustdomain/path", + "spiffe:// /", + "", + "http://trustdomain/path", + "//trustdomain/path", + "://trustdomain/path", + "piffe://trustdomain/path", + "://", + "://trustdomain", + "spiff", + "spiffe", + "spiffe:////", + "spiffe://trust.domain/../path" + ); + } + } + + public static class ExceptionMessageTest { + + @Test + public void spiffeUriFormatTest() { + NullPointerException npe = assertThrows(NullPointerException.class, () -> + SpiffeUtil.parse(null)); + assertEquals("uri", npe.getMessage()); + + IllegalArgumentException iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("https://example.com")); + assertEquals("Spiffe Id must start with spiffe://", iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com/workload#1")); + assertEquals("Spiffe Id must not contain query fragments", iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com/workload-1?t=1")); + assertEquals("Spiffe Id must not contain query parameters", iae.getMessage()); + } + + @Test + public void spiffeTrustDomainFormatTest() { + IllegalArgumentException iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://")); + assertEquals("Trust Domain can't be empty", iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://eXample.com")); + assertEquals( + "Trust Domain must contain only letters, numbers, dots, dashes, and underscores " + + "([a-z0-9.-_])", + iae.getMessage()); + + StringBuilder longTrustDomain = new StringBuilder("spiffe://pi.eu."); + for (int i = 0; i < 50; i++) { + longTrustDomain.append("pi.eu"); + } + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse(longTrustDomain.toString())); + assertEquals("Trust Domain maximum length is 255 characters", iae.getMessage()); + + StringBuilder longSpiffe = new StringBuilder(String.format("spiffe://mydomain%scom/", "%21")); + for (int i = 0; i < 405; i++) { + longSpiffe.append("qwert"); + } + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse(longSpiffe.toString())); + assertEquals("Spiffe Id maximum length is 2048 characters", iae.getMessage()); + } + + @Test + public void spiffePathFormatTest() { + IllegalArgumentException iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com//")); + assertEquals("Path must not include a trailing '/'", iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com/")); + assertEquals("Path must not include a trailing '/'", iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com/us//miami")); + assertEquals("Individual path segments must not be empty", iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com/us/.")); + assertEquals("Individual path segments must not be relative path modifiers (i.e. ., ..)", + iae.getMessage()); + + iae = assertThrows(IllegalArgumentException.class, () -> + SpiffeUtil.parse("spiffe://example.com/us!")); + assertEquals("Individual path segments must contain only letters, numbers, dots, dashes, and " + + "underscores ([a-zA-Z0-9.-_])", iae.getMessage()); + } + } +} \ No newline at end of file