-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
SpiffeId parser compliant with [official spec](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md)
- Loading branch information
Showing
2 changed files
with
318 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a> | ||
*/ | ||
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 <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a> | ||
*/ | ||
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; | ||
} | ||
} | ||
|
||
} |
196 changes: 196 additions & 0 deletions
196
core/src/test/java/io/grpc/internal/SpiffeUtilTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String[]> 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<String> 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()); | ||
} | ||
} | ||
} |