Skip to content

Commit

Permalink
core: SpiffeId parser (#11490)
Browse files Browse the repository at this point in the history
SpiffeId parser compliant with [official spec](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md)
  • Loading branch information
erm-g authored Sep 26, 2024
1 parent 64e3801 commit 1c06937
Show file tree
Hide file tree
Showing 2 changed files with 318 additions and 0 deletions.
122 changes: 122 additions & 0 deletions core/src/main/java/io/grpc/internal/SpiffeUtil.java
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 core/src/test/java/io/grpc/internal/SpiffeUtilTest.java
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());
}
}
}

0 comments on commit 1c06937

Please sign in to comment.