CVE-2021-27098
Description
SPIRE Server Legacy Node API allows agents to request unauthorized SPIFFE IDs, leading to certificate issuance with incorrect URI SAN values.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
SPIRE Server Legacy Node API allows agents to request unauthorized SPIFFE IDs, leading to certificate issuance with incorrect URI SAN values.
The vulnerability resides in SPIRE Server's Legacy Node API, specifically in the FetchX509SVID RPC. The API fails to properly validate that the requesting agent is authorized to distribute the requested SPIFFE ID, allowing an already-authenticated agent to obtain a certificate for a SPIFFE ID it should not be able to issue [1][3].
An attacker must possess a valid agent certificate and operate within the same trust domain. By crafting a specially crafted request to the FetchX509SVID RPC, they can receive an X.509 certificate with a URI SAN for an unauthorized SPIFFE ID [1][3]. This bypasses intended authorization controls.
The impact is that the attacker can impersonate another workload within the trust domain, potentially gaining unauthorized access to resources protected by SPIFFE authentication [3]. The fix, introduced in the commit referenced, enforces proper authorization checks so that the agent must be explicitly authorized for the requested SPIFFE ID [2].
The issue has been patched in SPIRE versions 0.8.5, 0.9.4, 0.10.2, 0.11.3, and 0.12.1 [1][3]. No workarounds are available, and users are advised to upgrade to the latest patched version.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/spiffe/spireGo | >= 0.8.1, < 0.8.5 | 0.8.5 |
github.com/spiffe/spireGo | >= 0.9.0, < 0.9.4 | 0.9.4 |
github.com/spiffe/spireGo | >= 0.10.0, < 0.10.2 | 0.10.2 |
github.com/spiffe/spireGo | >= 0.11.0, < 0.11.3 | 0.11.3 |
github.com/spiffe/spireGo | >= 0.12.0, < 0.12.1 | 0.12.1 |
Affected products
2- SPIRE/SPIREdescription
Patches
13c5115b57afcMerge pull request from GHSA-q7gm-mjrg-44h9
36 files changed · +854 −73
cmd/spire-server/cli/run/run.go+8 −0 modified@@ -87,6 +87,8 @@ type serverConfig struct { ProfilingFreq int `hcl:"profiling_freq"` ProfilingNames []string `hcl:"profiling_names"` + AllowUnsafeIDs *bool `hcl:"allow_unsafe_ids"` + UnusedKeys []string `hcl:",unusedKeys"` } @@ -471,6 +473,12 @@ func NewServerConfig(c *Config, logOptions []log.Option, allowUnknownConfig bool } } + // This is a terrible hack but is just a short-term band-aid. + if c.Server.AllowUnsafeIDs != nil { + sc.Log.Warn("The insecure allow_unsafe_ids configurable will be deprecated in a future release.") + idutil.SetAllowUnsafeIDs(*c.Server.AllowUnsafeIDs) + } + return sc, nil }
pkg/common/idutil/safety.go+193 −0 added@@ -0,0 +1,193 @@ +package idutil + +import ( + "errors" + "fmt" + "net/url" + "path" + "regexp" + "strings" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/proto/spire/types" +) + +var ( + rePercentEncodedASCII = regexp.MustCompile(`%[0-7][[:xdigit:]]`) + rePercentEncoded = regexp.MustCompile(`%[[:xdigit:]][[:xdigit:]]`) + + allowUnsafeIDsPolicy bool +) + +func allowUnsafeIDs() bool { + return allowUnsafeIDsPolicy +} + +// SetAllowUnsafeIDs effectively removes all safety checks provided by the +// "safety" functions in this source file. It is a switch to allow turning off +// the safety valve for deployments that need time to adjust API usage to +// conform to the restrictions. +func SetAllowUnsafeIDs(allow bool) { + allowUnsafeIDsPolicy = allow +} + +// CheckIDProtoNormalization ensures the the provided ID is properly normalized. +func CheckIDProtoNormalization(in *types.SPIFFEID) error { + if allowUnsafeIDs() { + return nil + } + s, err := IDProtoString(in) + if err != nil { + return err + } + return CheckIDStringNormalization(s) +} + +// CheckIDStringNormalization ensures the the provided ID is properly normalized. +func CheckIDStringNormalization(id string) error { + if allowUnsafeIDs() { + return nil + } + + // Parse the URL. This will unescape the percent-encoded characters. If + // there are invalid percent-encoded characters, this function will fail. + u, err := url.Parse(id) + if err != nil { + return err + } + + return CheckIDURLNormalization(u) +} + +// CheckIDURLNormalization returns if a URL is normalized or not. It relies on +// behavior and fields populated by url.Parse(). DO NOT call it with a URL that +// has not gone through url.Parse(). +func CheckIDURLNormalization(u *url.URL) error { + if allowUnsafeIDs() { + return nil + } + + // Rule out percent-encoded ASCII + if rePercentEncodedASCII.MatchString(u.EscapedPath()) { + return errors.New("path cannot contain percent-encoded ASCII characters") + } + + // At this point, if RawPath is set, then the path contains non-ASCII + // characters, since percent-encoded ASCII was ruled out above. Ensure + // that there is no percent-encoded characters, since that would imply + // a mix-n-match of utf-8 and percent-encoded utf-8, which we want to + // reject since it wouldn't be normal to have this kind of path and it + // likely indicates either 1) a bug, or 2) malicious intent. + if u.RawPath != "" && rePercentEncoded.MatchString(u.RawPath) { + return errors.New("path cannot contain both non-ASCII and percent-encoded characters") + } + + // Check the scheme and host + switch { + case u.Scheme != "spiffe": + return errors.New("scheme must be 'spiffe'") + case strings.ToLower(u.Host) != u.Host: + return errors.New("trust domain name must be lowercase") + } + + // Check the path + switch { + case u.Path == "": + case u.Path[len(u.Path)-1] == '/': + return errors.New("path cannot have a trailing slash") + case u.Path != path.Clean(u.Path): + return errors.New("path cannot contain empty, '.', or '..' segments") + } + + return nil +} + +// IDProtoString constructs a URL string for the given ID protobuf. It does +// not interpret the contents of the trust domain or path with the exception +// of adding a leading slash on the path where necessary. +func IDProtoString(id *types.SPIFFEID) (string, error) { + if id.TrustDomain == "" { + return "", errors.New("trust domain is empty") + } + return "spiffe://" + id.TrustDomain + ensureLeadingSlash(id.Path), nil +} + +// IDProtoFromString parses a SPIFFE ID string into the raw ID proto components. +// It does not attempt to escape/unescape any portion of the ID. +func IDProtoFromString(id string) (*types.SPIFFEID, error) { + trimmed := strings.TrimPrefix(id, "spiffe://") + if trimmed == id { + return nil, errors.New(`scheme must be "spiffe://"`) + } + parts := strings.SplitN(trimmed, "/", 2) + td := parts[0] + if len(td) == 0 { + return nil, errors.New("trust domain is empty") + } + path := "" + if len(parts) > 1 { + path = "/" + parts[1] + } + return &types.SPIFFEID{ + TrustDomain: td, + Path: path, + }, nil +} + +// CheckAgentIDStringNormalization ensures the provided agent ID string is +// properly normalized. It also ensures it is not a server ID. +func CheckAgentIDStringNormalization(agentID string) error { + if allowUnsafeIDs() { + return nil + } + + // Parse the URL. This will unescape the percent-encoded characters. If + // there are invalid percent-encoded characters, this function will fail. + u, err := url.Parse(agentID) + if err != nil { + return err + } + + if err := CheckIDURLNormalization(u); err != nil { + return err + } + + // We want to do more than this but backcompat compels us to not too. We'll + // get more aggressive in the future. + if u.Path == ServerIDPath { + return errors.New("server ID is not allowed for agents") + } + + return nil +} + +// IDFromProto returns SPIFFE ID from the proto representation +func IDFromProto(id *types.SPIFFEID) (spiffeid.ID, error) { + if allowUnsafeIDs() { + return spiffeid.New(id.TrustDomain, id.Path) + } + s, err := IDProtoString(id) + if err != nil { + return spiffeid.ID{}, err + } + return spiffeid.FromString(s) +} + +// FormatPath formats a path string. The function ensures a leading slash is +// present. +func FormatPath(format string, args ...interface{}) string { + return ensureLeadingSlash(fmt.Sprintf(format, args...)) +} + +// JoinPathSegments escapes path segments and joins them together. The +// function also ensures a leading slash is present. +func JoinPathSegments(segments ...string) string { + return ensureLeadingSlash(strings.Join(segments, "/")) +} + +func ensureLeadingSlash(p string) string { + if p != "" && p[0] != '/' { + p = "/" + p + } + return p +}
pkg/common/idutil/safety_test.go+268 −0 added@@ -0,0 +1,268 @@ +package idutil + +import ( + "net/url" + "strings" + "testing" + + "github.com/spiffe/spire/proto/spire/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckIDURLNormalization(t *testing.T) { + assertGood := func(id string) { + u, err := url.Parse(id) + require.NoError(t, err) + assert.NoError(t, CheckIDURLNormalization(u), "%s should have passed", id) + } + assertBad := func(id string, expectedErr string) { + u, err := url.Parse(id) + if err != nil { + assert.EqualError(t, err, expectedErr, "parsing %s should have failed", id) + return + } + assert.EqualError(t, CheckIDURLNormalization(u), expectedErr, "%s should have failed", id) + } + + // Assert the scheme is spiffe + assertBad("sparfe://example.org/workload", + "scheme must be 'spiffe'") + + testCommonCheckIDNormalization(assertGood, assertBad) +} + +func TestCheckIDStringNormalization(t *testing.T) { + assertGood := func(id string) { + assert.NoError(t, CheckIDStringNormalization(id), "%s should have passed", id) + } + assertBad := func(id string, expectedErr string) { + assert.EqualError(t, CheckIDStringNormalization(id), expectedErr, "%s should have failed", id) + } + + // Assert the scheme is spiffe + assertBad("sparfe://example.org/workload", + "scheme must be 'spiffe'") + + // Test the common normalization cases + testCommonCheckIDNormalization(assertGood, assertBad) +} + +func TestCheckIDProtoNormalization(t *testing.T) { + protoFromString := func(t *testing.T, id string) *types.SPIFFEID { + // Rough parsing. We know the shape of the incoming ID's so this is + // ok. We don't want to use url.Parse() here because it will trip + // up some of the tests. + schemeIndex := strings.Index(id, "://") + require.GreaterOrEqual(t, schemeIndex, 0) + rest := id[schemeIndex+3:] + pathIndex := strings.IndexByte(rest, '/') + + out := &types.SPIFFEID{TrustDomain: rest} + if pathIndex >= 0 { + out.TrustDomain = rest[:pathIndex] + out.Path = rest[pathIndex:] + } + return out + } + assertGood := func(id string) { + assert.NoError(t, CheckIDProtoNormalization(protoFromString(t, id)), "%s should have passed", id) + } + assertBad := func(id string, expectedErr string) { + assert.EqualError(t, CheckIDProtoNormalization(protoFromString(t, id)), expectedErr, "%s should have failed", id) + } + + // Since the ID proto doesn't include the scheme, the following + // test is irrelevant but is included here for parity with the other + // Check* tests. + assertGood("sparfe://example.org/workload") + + // Assert that the path is auto-prefixed with "/" when considering + // normalization + assert.NoError(t, CheckIDProtoNormalization(&types.SPIFFEID{ + TrustDomain: "example.org", + Path: "workload", + })) + + // Test the common normalization cases + testCommonCheckIDNormalization(assertGood, assertBad) +} + +func TestCheckAgentIDStringNormalization(t *testing.T) { + assertGood := func(id string) { + assert.NoError(t, CheckAgentIDStringNormalization(id), "%s should have passed", id) + } + assertBad := func(id string, expectedErr string) { + assert.EqualError(t, CheckAgentIDStringNormalization(id), expectedErr, "%s should have failed", id) + } + + // Assert the scheme is spiffe + assertBad("sparfe://example.org/workload", + "scheme must be 'spiffe'") + + // Agent ID cannot be the server ID + assertBad("spiffe://example.org/spire/server", + "server ID is not allowed for agents") + + // Test the common normalization cases + testCommonCheckIDNormalization(assertGood, assertBad) +} + +func testCommonCheckIDNormalization(assertGood func(string), assertBad func(string, string)) { + assertGood("spiffe://example.org") + assertGood("spiffe://example.org/workload") + assertGood("spiffe://example.org/workload/%E4%B8%96%E7%95%8C") + assertGood("sPiFfE://example.org/workload") + assertGood("spiffe://世界/workload") + assertGood("spiffe://example.org/世界") + assertGood("spiffe://%E4%B8%96%E7%95%8C/workload") + + assertBad("spiffe://%45example.org/workload", + `parse "spiffe://%45example.org/workload": invalid URL escape "%45"`) + assertBad("spiffe://example.org/世界/%E4%B8%96%E7%95%8C", + `path cannot contain both non-ASCII and percent-encoded characters`) + assertBad("spiffe://example.org/", + "path cannot have a trailing slash") + assertBad("spiffe://example.org/workload/", + "path cannot have a trailing slash") + assertBad("spiffe://eXaMplE.org/workload", + "trust domain name must be lowercase") + assertBad("spiffe://example.org//workload", + "path cannot contain empty, '.', or '..' segments") + assertBad("spiffe://example.org///workload", + "path cannot contain empty, '.', or '..' segments") + assertBad("spiffe://example.org/./workload", + "path cannot contain empty, '.', or '..' segments") + assertBad("spiffe://example.org/workload/../workload2", + "path cannot contain empty, '.', or '..' segments") + assertBad("spiffe://example.org/workload/%2e%2e/workload2", + "path cannot contain percent-encoded ASCII characters") + assertBad("spiffe://example.org/workload/%252e", + "path cannot contain percent-encoded ASCII characters") + assertBad("spiffe://example.org/workload/%23", + "path cannot contain percent-encoded ASCII characters") + assertBad("spiffe://example.org/workload/%00", + "path cannot contain percent-encoded ASCII characters") + assertBad("spiffe://example.org/%2z", + `parse "spiffe://example.org/%2z": invalid URL escape "%2z"`) + assertBad("spiffe://example.org/workload/"+url.PathEscape("%E4%B8%96%E7%95%8C"), + "path cannot contain percent-encoded ASCII characters") + + // Now test that the function responds favorably if the checks are + // disabled via the flag. + SetAllowUnsafeIDs(true) + defer SetAllowUnsafeIDs(false) + assertGood("spiffe://example.org/") +} + +func TestIDProtoString(t *testing.T) { + assert := assert.New(t) + + id, err := IDProtoString(&types.SPIFFEID{}) + assert.EqualError(err, "trust domain is empty") + assert.Empty(id) + + id, err = IDProtoString(&types.SPIFFEID{TrustDomain: "example.org"}) + assert.NoError(err) + assert.Equal("spiffe://example.org", id) + + id, err = IDProtoString(&types.SPIFFEID{TrustDomain: "example.org", Path: "/"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/", id) + + id, err = IDProtoString(&types.SPIFFEID{TrustDomain: "example.org", Path: "workload"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload", id) + + id, err = IDProtoString(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload", id) + + id, err = IDProtoString(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload/foo"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload/foo", id) +} + +func TestIDProtoFromString(t *testing.T) { + assert := assert.New(t) + + id, err := IDProtoFromString("other://whocares") + assert.EqualError(err, `scheme must be "spiffe://"`) + assert.Nil(id) + + id, err = IDProtoFromString("spiffe://") + assert.EqualError(err, "trust domain is empty") + assert.Nil(id) + + id, err = IDProtoFromString("spiffe://example.org") + assert.NoError(err) + assert.Equal(&types.SPIFFEID{TrustDomain: "example.org"}, id) + + id, err = IDProtoFromString("spiffe://example.org/") + assert.NoError(err) + assert.Equal(&types.SPIFFEID{TrustDomain: "example.org", Path: "/"}, id) + + id, err = IDProtoFromString("spiffe://example.org/workload") + assert.NoError(err) + assert.Equal(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload"}, id) + + id, err = IDProtoFromString("spiffe://example.org/workload/foo") + assert.NoError(err) + assert.Equal(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload/foo"}, id) +} + +func TestIDFromProto(t *testing.T) { + assert := assert.New(t) + + id, err := IDFromProto(&types.SPIFFEID{}) + assert.EqualError(err, "trust domain is empty") + assert.Empty(id) + + id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org"}) + assert.NoError(err) + assert.Equal("spiffe://example.org", id.String()) + + id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/", id.String()) + + id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "workload"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload", id.String()) + + id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload", id.String()) + + id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload/%41%42%43"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload/ABC", id.String()) + + SetAllowUnsafeIDs(true) + defer SetAllowUnsafeIDs(false) + + // When unsafe IDs are allowed, this will not percent encoding properly + // which restores the original behavior of the API. + id, err = IDFromProto(&types.SPIFFEID{TrustDomain: "example.org", Path: "/workload/%41%42%43"}) + assert.NoError(err) + assert.Equal("spiffe://example.org/workload/%2541%2542%2543", id.String()) +} + +func TestJoinPathSegments(t *testing.T) { + assert := assert.New(t) + + assert.Equal("", JoinPathSegments()) + assert.Equal("/foo", JoinPathSegments("foo")) + assert.Equal("/foo", JoinPathSegments("/foo")) + assert.Equal("/foo/世界", JoinPathSegments("foo", "世界")) +} + +func TestFormatPath(t *testing.T) { + assert := assert.New(t) + + assert.Equal("", FormatPath("")) + assert.Equal("/", FormatPath("/")) + assert.Equal("/foo", FormatPath("%s", "foo")) + assert.Equal("/foo", FormatPath("/%s", "foo")) + assert.Equal("/foo//世界", FormatPath("%s//%s", "foo", "世界")) +}
pkg/common/idutil/spiffeid.go+1 −2 modified@@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/url" - "path" "strings" "github.com/spiffe/go-spiffe/v2/spiffeid" @@ -332,7 +331,7 @@ func AgentURI(trustDomain, p string) *url.URL { return &url.URL{ Scheme: "spiffe", Host: trustDomain, - Path: path.Join("spire", "agent", p), + Path: "/spire/agent" + ensureLeadingSlash(p), } }
pkg/common/telemetry/names.go+3 −0 modified@@ -403,6 +403,9 @@ const ( // with other tags to add clarity FederatedBundle = "federated_bundle" + // Ignored tags something as ignored + Ignored = "ignored" + // JoinToken functionality related to a join token; should be used // with other tags to add clarity JoinToken = "join_token"
pkg/common/telemetry/server/server.go+9 −0 added@@ -0,0 +1,9 @@ +package server + +import "github.com/spiffe/spire/pkg/common/telemetry" + +// SetEntryIgnoredGauge emits a gauge with the number of entries that will +// be ignored in the entry cache. +func SetEntryIgnoredGauge(m telemetry.Metrics, ignored int) { + m.SetGauge([]string{telemetry.Entry, telemetry.Ignored}, float32(ignored)) +}
pkg/server/api/agent/v1/service.go+10 −1 modified@@ -13,6 +13,7 @@ import ( "github.com/gofrs/uuid" "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/nodeutil" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/x509util" @@ -254,11 +255,16 @@ func (s *Service) AttestAgent(stream agent.Agent_AttestAgentServer) error { } agentID := attestResp.AgentId + log = log.WithField(telemetry.AgentID, agentID) + + if err := idutil.CheckAgentIDStringNormalization(agentID); err != nil { + return api.MakeErr(log, codes.Internal, "agent ID is malformed", err) + } + agentSpiffeID, err := spiffeid.FromString(agentID) if err != nil { return api.MakeErr(log, codes.Internal, "invalid agent id", err) } - log = log.WithField(telemetry.AgentID, agentID) // fetch the agent/node to check if it was already attested or banned attestedNode, err := s.ds.FetchAttestedNode(ctx, &datastore.FetchAttestedNodeRequest{ @@ -395,6 +401,9 @@ func (s *Service) CreateJoinToken(ctx context.Context, req *agent.CreateJoinToke if err != nil { return nil, api.MakeErr(log, codes.InvalidArgument, "invalid agent ID", err) } + if err := idutil.CheckIDProtoNormalization(req.AgentId); err != nil { + return nil, api.MakeErr(log, codes.InvalidArgument, "agent ID is malformed", err) + } log.WithField(telemetry.SPIFFEID, agentID.String()) }
pkg/server/api/agent/v1/service_test.go+4 −4 modified@@ -445,13 +445,13 @@ func TestBanAgent(t *testing.T) { TrustDomain: "ex ample.org", }, expectCode: codes.InvalidArgument, - expectMsg: `invalid agent ID: spiffeid: unable to parse: parse "spiffe://ex ample.org": invalid character " " in host name`, + expectMsg: `invalid agent ID: spiffeid: unable to parse: parse "spiffe://ex ample.org/spire/agent/agent-1": invalid character " " in host name`, expectLogs: []spiretest.LogEntry{ { Level: logrus.ErrorLevel, Message: "Invalid argument: invalid agent ID", Data: logrus.Fields{ - logrus.ErrorKey: `spiffeid: unable to parse: parse "spiffe://ex ample.org": invalid character " " in host name`, + logrus.ErrorKey: `spiffeid: unable to parse: parse "spiffe://ex ample.org/spire/agent/agent-1": invalid character " " in host name`, }, }, }, @@ -641,12 +641,12 @@ func TestDeleteAgent(t *testing.T) { Level: logrus.ErrorLevel, Message: "Invalid argument: invalid agent ID", Data: logrus.Fields{ - logrus.ErrorKey: "spiffeid: trust domain is empty", + logrus.ErrorKey: "trust domain is empty", }, }, }, code: codes.InvalidArgument, - err: "invalid agent ID: spiffeid: trust domain is empty", + err: "invalid agent ID: trust domain is empty", req: &agentpb.DeleteAgentRequest{ Id: &types.SPIFFEID{ TrustDomain: "",
pkg/server/api/entry.go+7 −0 modified@@ -5,6 +5,7 @@ import ( "fmt" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/protoutil" "github.com/spiffe/spire/pkg/common/x509util" "github.com/spiffe/spire/proto/spire/common" @@ -93,6 +94,9 @@ func ProtoToRegistrationEntryWithMask(td spiffeid.TrustDomain, e *types.Entry, m return nil, fmt.Errorf("invalid parent ID: %v", err) } parentIDString = parentID.String() + if err := idutil.CheckIDProtoNormalization(e.ParentId); err != nil { + return nil, fmt.Errorf("parent ID is malformed: %v", err) + } } var spiffeIDString string @@ -102,6 +106,9 @@ func ProtoToRegistrationEntryWithMask(td spiffeid.TrustDomain, e *types.Entry, m return nil, fmt.Errorf("invalid spiffe ID: %v", err) } spiffeIDString = spiffeID.String() + if err := idutil.CheckIDProtoNormalization(e.SpiffeId); err != nil { + return nil, fmt.Errorf("spiffe ID is malformed: %v", err) + } } var admin bool
pkg/server/api/entry/v1/service_test.go+15 −18 modified@@ -44,8 +44,7 @@ func TestListEntries(t *testing.T) { protoChildID := api.ProtoFromID(childID) protoSecondChildID := api.ProtoFromID(secondChildID) badID := &types.SPIFFEID{ - TrustDomain: "http://example.org", - Path: "/bad", + Path: "/bad", } childRegEntry := &common.RegistrationEntry{ @@ -222,7 +221,7 @@ func TestListEntries(t *testing.T) { }, { name: "bad parent ID filter", - err: "malformed parent ID filter: spiffeid: invalid scheme", + err: "malformed parent ID filter: trust domain is empty", code: codes.InvalidArgument, logMsg: "Invalid argument: malformed parent ID filter", request: &entrypb.ListEntriesRequest{ @@ -233,7 +232,7 @@ func TestListEntries(t *testing.T) { }, { name: "bad SPIFFE ID filter", - err: "malformed SPIFFE ID filter: spiffeid: invalid scheme", + err: "malformed SPIFFE ID filter: trust domain is empty", code: codes.InvalidArgument, logMsg: "Invalid argument: malformed SPIFFE ID filter", request: &entrypb.ListEntriesRequest{ @@ -731,7 +730,7 @@ func TestBatchCreateEntry(t *testing.T) { { Status: &types.Status{ Code: int32(codes.InvalidArgument), - Message: "failed to convert entry: invalid parent ID: spiffeid: trust domain is empty", + Message: "failed to convert entry: invalid parent ID: trust domain is empty", }, }, }, @@ -740,7 +739,7 @@ func TestBatchCreateEntry(t *testing.T) { Level: logrus.ErrorLevel, Message: "Invalid argument: failed to convert entry", Data: logrus.Fields{ - logrus.ErrorKey: "invalid parent ID: spiffeid: trust domain is empty", + logrus.ErrorKey: "invalid parent ID: trust domain is empty", }, }, }, @@ -1322,8 +1321,7 @@ func TestBatchUpdateEntry(t *testing.T) { }, updateEntries: []*types.Entry{ { - // Trust domain will be normalized to lower case - ParentId: &types.SPIFFEID{TrustDomain: "EXAMPLE.org", Path: "/parentUpdated"}, + ParentId: &types.SPIFFEID{TrustDomain: "example.org", Path: "/parentUpdated"}, }, }, expectDsEntries: func(id string) []*types.Entry { @@ -1352,8 +1350,7 @@ func TestBatchUpdateEntry(t *testing.T) { }, updateEntries: []*types.Entry{ { - // Trust domain will be normalized to lower case - SpiffeId: &types.SPIFFEID{TrustDomain: "EXAMPLE.org", Path: "/workloadUpdated"}, + SpiffeId: &types.SPIFFEID{TrustDomain: "example.org", Path: "/workloadUpdated"}, }, }, expectDsEntries: func(id string) []*types.Entry { @@ -1664,7 +1661,7 @@ func TestBatchUpdateEntry(t *testing.T) { expectResults: []*entrypb.BatchUpdateEntryResponse_Result{ { Status: &types.Status{Code: int32(codes.InvalidArgument), - Message: "failed to convert entry: invalid spiffe ID: spiffeid: trust domain is empty"}, + Message: "failed to convert entry: invalid spiffe ID: trust domain is empty"}, }, }, expectLogs: func(m map[string]string) []spiretest.LogEntry { @@ -1674,7 +1671,7 @@ func TestBatchUpdateEntry(t *testing.T) { Message: "Invalid argument: failed to convert entry", Data: logrus.Fields{ telemetry.RegistrationID: m[entry1SpiffeID.Path], - logrus.ErrorKey: "invalid spiffe ID: spiffeid: trust domain is empty", + logrus.ErrorKey: "invalid spiffe ID: trust domain is empty", }, }, } @@ -1694,7 +1691,7 @@ func TestBatchUpdateEntry(t *testing.T) { expectResults: []*entrypb.BatchUpdateEntryResponse_Result{ { Status: &types.Status{Code: int32(codes.InvalidArgument), - Message: "failed to convert entry: invalid parent ID: spiffeid: trust domain is empty"}, + Message: "failed to convert entry: invalid parent ID: trust domain is empty"}, }, }, expectLogs: func(m map[string]string) []spiretest.LogEntry { @@ -1704,7 +1701,7 @@ func TestBatchUpdateEntry(t *testing.T) { Message: "Invalid argument: failed to convert entry", Data: logrus.Fields{ telemetry.RegistrationID: m[entry1SpiffeID.Path], - logrus.ErrorKey: "invalid parent ID: spiffeid: trust domain is empty", + logrus.ErrorKey: "invalid parent ID: trust domain is empty", }, }, } @@ -1724,7 +1721,7 @@ func TestBatchUpdateEntry(t *testing.T) { expectResults: []*entrypb.BatchUpdateEntryResponse_Result{ { Status: &types.Status{Code: int32(codes.InvalidArgument), - Message: "failed to convert entry: invalid parent ID: spiffeid: trust domain is empty"}, + Message: "failed to convert entry: invalid parent ID: trust domain is empty"}, }, }, expectLogs: func(m map[string]string) []spiretest.LogEntry { @@ -1733,7 +1730,7 @@ func TestBatchUpdateEntry(t *testing.T) { Level: logrus.ErrorLevel, Message: "Invalid argument: failed to convert entry", Data: logrus.Fields{ - "error": "invalid parent ID: spiffeid: trust domain is empty", + "error": "invalid parent ID: trust domain is empty", telemetry.RegistrationID: m[entry1SpiffeID.Path], }, }, @@ -1754,7 +1751,7 @@ func TestBatchUpdateEntry(t *testing.T) { expectResults: []*entrypb.BatchUpdateEntryResponse_Result{ { Status: &types.Status{Code: int32(codes.InvalidArgument), - Message: "failed to convert entry: invalid spiffe ID: spiffeid: trust domain is empty"}, + Message: "failed to convert entry: invalid spiffe ID: trust domain is empty"}, }, }, expectLogs: func(m map[string]string) []spiretest.LogEntry { @@ -1763,7 +1760,7 @@ func TestBatchUpdateEntry(t *testing.T) { Level: logrus.ErrorLevel, Message: "Invalid argument: failed to convert entry", Data: logrus.Fields{ - "error": "invalid spiffe ID: spiffeid: trust domain is empty", + "error": "invalid spiffe ID: trust domain is empty", telemetry.RegistrationID: m[entry1SpiffeID.Path], }, },
pkg/server/api/id.go+1 −1 modified@@ -91,5 +91,5 @@ func idFromProto(protoID *types.SPIFFEID) (spiffeid.ID, error) { if protoID == nil { return spiffeid.ID{}, errors.New("request must specify SPIFFE ID") } - return spiffeid.New(protoID.TrustDomain, protoID.Path) + return idutil.IDFromProto(protoID) }
pkg/server/api/id_test.go+1 −1 modified@@ -33,7 +33,7 @@ func TestIDFromProto(t *testing.T) { { name: "missing trust domain", spiffeID: &types.SPIFFEID{Path: "/workload"}, - expectedErr: "spiffeid: trust domain is empty", + expectedErr: "trust domain is empty", }, { name: "wrong trust domain",
pkg/server/api/middleware/caller.go+8 −1 modified@@ -4,6 +4,7 @@ import ( "context" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/server/api/rpccontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -57,7 +58,13 @@ func tcpCallerContextFromPeer(ctx context.Context, p *peer.Peer) (context.Contex return nil, status.Error(codes.Unauthenticated, "client certificate has more than one URI SAN") } - id, err := spiffeid.FromURI(uris[0]) + uri := uris[0] + + if err := idutil.CheckIDURLNormalization(uri); err != nil { + return nil, status.Errorf(codes.Unauthenticated, "client certificate has a malformed URI SAN: %v", err) + } + + id, err := spiffeid.FromURI(uri) if err != nil { return nil, status.Errorf(codes.Unauthenticated, "client certificate has a malformed URI SAN: %v", err) }
pkg/server/api/middleware/caller_test.go+1 −1 modified@@ -162,7 +162,7 @@ func TestCallerContextFromContext(t *testing.T) { name: "mtls peer with malformed URI SAN", peer: mtlsPeerMalformedURISAN, expectCode: codes.Unauthenticated, - expectMsg: "client certificate has a malformed URI SAN: spiffeid: invalid scheme", + expectMsg: "client certificate has a malformed URI SAN: scheme must be 'spiffe'", }, } { tt := tt
pkg/server/api/svid/v1/service.go+9 −0 modified@@ -7,6 +7,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/jwtsvid" "github.com/spiffe/spire/pkg/common/telemetry" "github.com/spiffe/spire/pkg/common/x509util" @@ -87,6 +88,10 @@ func (s *Service) MintX509SVID(ctx context.Context, req *svid.MintX509SVIDReques return nil, api.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is invalid", err) } + if err := idutil.CheckIDURLNormalization(csr.URIs[0]); err != nil { + return nil, api.MakeErr(log, codes.InvalidArgument, "CSR URI SAN is malformed", err) + } + for _, dnsName := range csr.DNSNames { if err := x509util.ValidateDNS(dnsName); err != nil { return nil, api.MakeErr(log, codes.InvalidArgument, "CSR DNS name is not valid", err) @@ -246,6 +251,10 @@ func (s *Service) mintJWTSVID(ctx context.Context, protoID *types.SPIFFEID, audi return nil, api.MakeErr(log, codes.InvalidArgument, "invalid SPIFFE ID", err) } + if err := idutil.CheckIDProtoNormalization(protoID); err != nil { + return nil, api.MakeErr(log, codes.InvalidArgument, "spiffe ID is malformed", err) + } + log = log.WithField(telemetry.SPIFFEID, id.String()) if len(audience) == 0 {
pkg/server/api/svid/v1/service_test.go+2 −2 modified@@ -316,7 +316,7 @@ func TestServiceMintJWTSVID(t *testing.T) { code: codes.InvalidArgument, audience: []string{"AUDIENCE"}, id: spiffeid.ID{}, - err: "invalid SPIFFE ID: spiffeid: trust domain is empty", + err: "invalid SPIFFE ID: trust domain is empty", logMsg: "Invalid argument: invalid SPIFFE ID", }, { @@ -442,7 +442,7 @@ func TestServiceNewJWTSVID(t *testing.T) { code: codes.InvalidArgument, audience: []string{"AUDIENCE"}, entry: invalidEntry, - err: "invalid SPIFFE ID: spiffeid: trust domain is empty", + err: "invalid SPIFFE ID: trust domain is empty", logMsg: "Invalid argument: invalid SPIFFE ID", }, {
pkg/server/cache/entrycache/fullcache_ds.go+21 −0 modified@@ -5,8 +5,10 @@ import ( "time" "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/server/api" "github.com/spiffe/spire/pkg/server/plugin/datastore" + "github.com/spiffe/spire/proto/spire/common" "github.com/spiffe/spire/proto/spire/types" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -49,6 +51,8 @@ func (it *entryIteratorDS) Next(ctx context.Context) bool { return false } + resp.Entries = it.filterEntries(resp.Entries) + it.next = 0 it.entries, err = api.RegistrationEntriesToProto(resp.Entries) if err != nil { @@ -63,6 +67,23 @@ func (it *entryIteratorDS) Next(ctx context.Context) bool { return true } +func (it *entryIteratorDS) filterEntries(in []*common.RegistrationEntry) []*common.RegistrationEntry { + out := make([]*common.RegistrationEntry, 0, len(in)) + for _, entry := range in { + // Filter out entries with invalid SPIFFE IDs. Operators are notified + // that they are ignored on server startup (see + // pkg/server/scanentries.go) + if err := idutil.CheckIDStringNormalization(entry.SpiffeId); err != nil { + continue + } + if err := idutil.CheckIDStringNormalization(entry.ParentId); err != nil { + continue + } + out = append(out, entry) + } + return out +} + func (it *entryIteratorDS) Entry() *types.Entry { return it.entries[it.next-1] }
pkg/server/endpoints/endpoints_test.go+2 −2 modified@@ -47,8 +47,8 @@ import ( var ( testTD = spiffeid.RequireTrustDomainFromString("domain.test") - serverID = testTD.NewID("/server") - agentID = testTD.NewID("/agent") + serverID = testTD.NewID("/spire/server") + agentID = testTD.NewID("/spire/agent/foo") adminID = testTD.NewID("/admin") downstreamID = testTD.NewID("/downstream") rateLimit = RateLimitConfig{Attestation: true}
pkg/server/endpoints/middleware_test.go+4 −4 modified@@ -134,7 +134,7 @@ func TestAgentAuthorizer(t *testing.T) { CertSerialNumber: agentSVID.SerialNumber.String(), }, expectedCode: codes.PermissionDenied, - expectedMsg: `agent "spiffe://domain.test/agent" SVID is expired`, + expectedMsg: `agent "spiffe://domain.test/spire/agent/foo" SVID is expired`, expectedReason: types.PermissionDeniedDetails_AGENT_EXPIRED, expectedLogs: []spiretest.LogEntry{ { @@ -149,7 +149,7 @@ func TestAgentAuthorizer(t *testing.T) { { name: "no attested node", expectedCode: codes.PermissionDenied, - expectedMsg: `agent "spiffe://domain.test/agent" is not attested`, + expectedMsg: `agent "spiffe://domain.test/spire/agent/foo" is not attested`, expectedReason: types.PermissionDeniedDetails_AGENT_NOT_ATTESTED, expectedLogs: []spiretest.LogEntry{ { @@ -167,7 +167,7 @@ func TestAgentAuthorizer(t *testing.T) { SpiffeId: agentID.String(), }, expectedCode: codes.PermissionDenied, - expectedMsg: `agent "spiffe://domain.test/agent" is banned`, + expectedMsg: `agent "spiffe://domain.test/spire/agent/foo" is banned`, expectedReason: types.PermissionDeniedDetails_AGENT_BANNED, expectedLogs: []spiretest.LogEntry{ { @@ -186,7 +186,7 @@ func TestAgentAuthorizer(t *testing.T) { CertSerialNumber: "NEW", }, expectedCode: codes.PermissionDenied, - expectedMsg: fmt.Sprintf(`agent "spiffe://domain.test/agent" expected to have serial number "NEW"; has %q`, agentSVID.SerialNumber.String()), + expectedMsg: fmt.Sprintf(`agent "spiffe://domain.test/spire/agent/foo" expected to have serial number "NEW"; has %q`, agentSVID.SerialNumber.String()), expectedReason: types.PermissionDeniedDetails_AGENT_NOT_ACTIVE, expectedLogs: []spiretest.LogEntry{ {
pkg/server/endpoints/node/handler.go+23 −5 modified@@ -173,6 +173,11 @@ func (h *Handler) Attest(stream node.Node_AttestServer) (err error) { agentID := attestResponse.AgentId log = log.WithField(telemetry.SPIFFEID, agentID) + if err := idutil.CheckAgentIDStringNormalization(agentID); err != nil { + log.WithError(err).Error("Agent ID is malformed") + return status.Errorf(codes.Internal, "agent ID is malformed: %v", err) + } + isBanned, err := h.isBanned(ctx, agentID) switch { case err != nil: @@ -359,6 +364,11 @@ func (h *Handler) FetchX509CASVID(ctx context.Context, req *node.FetchX509CASVID return nil, status.Error(codes.InvalidArgument, err.Error()) } + if err := idutil.CheckIDStringNormalization(csr.SpiffeID); err != nil { + log.WithError(err).Error("CSR SPIFFE ID is malformed") + return nil, status.Errorf(codes.InvalidArgument, "CSR SPIFFE ID is malformed: %v", err) + } + signLog.Debug("Signing downstream CA SVID") svid, err := h.buildCASVID(ctx, ca.X509CASVIDParams{ SpiffeID: h.c.TrustDomain.String(), @@ -1170,14 +1180,22 @@ func tryGetSpiffeIDFromCert(cert *x509.Certificate) string { } func getSpiffeIDFromCert(cert *x509.Certificate) (string, error) { - if len(cert.URIs) == 0 { - return "", errors.New("no URI SANs in certificate") - } - spiffeID, err := idutil.NormalizeSpiffeIDURL(cert.URIs[0], idutil.AllowAny()) + uri, err := getURISANFromCert(cert) if err != nil { return "", err } - return spiffeID.String(), nil + return uri.String(), nil +} + +func getURISANFromCert(cert *x509.Certificate) (*url.URL, error) { + if len(cert.URIs) == 0 { + return nil, errors.New("no URI SANs in certificate") + } + uri := cert.URIs[0] + if err := idutil.CheckIDURLNormalization(uri); err != nil { + return nil, fmt.Errorf("URI SAN %q is malformed: %v", uri, err) + } + return idutil.NormalizeSpiffeIDURL(uri, idutil.AllowAny()) } func makeX509SVID(svid []*x509.Certificate) *node.X509SVID {
pkg/server/endpoints/registration/handler.go+19 −3 modified@@ -749,12 +749,16 @@ func (h *Handler) normalizeSPIFFEIDForMinting(spiffeID string) (string, error) { return "", status.Error(codes.InvalidArgument, "request missing SPIFFE ID") } - spiffeID, err := idutil.NormalizeSpiffeID(spiffeID, idutil.AllowTrustDomainWorkload(h.TrustDomain.Host)) + normalized, err := idutil.NormalizeSpiffeID(spiffeID, idutil.AllowTrustDomainWorkload(h.TrustDomain.Host)) if err != nil { - return "", status.Errorf(codes.InvalidArgument, err.Error()) + return "", status.Error(codes.InvalidArgument, err.Error()) } - return spiffeID, nil + if err := idutil.CheckIDStringNormalization(spiffeID); err != nil { + return "", status.Errorf(codes.InvalidArgument, "spiffe ID is malformed: %v", err) + } + + return normalized, nil } func (h *Handler) isEntryUnique(ctx context.Context, ds datastore.DataStore, entry *common.RegistrationEntry) (*common.RegistrationEntry, bool, error) { @@ -816,6 +820,7 @@ func (h *Handler) createRegistrationEntry(ctx context.Context, requestedEntry *c return createResponse.Entry, false, nil } func (h *Handler) prepareRegistrationEntry(entry *common.RegistrationEntry, forUpdate bool) (*common.RegistrationEntry, error) { + original := entry entry = cloneRegistrationEntry(entry) if forUpdate && entry.EntryId == "" { return nil, errors.New("missing registration entry id") @@ -840,6 +845,14 @@ func (h *Handler) prepareRegistrationEntry(entry *common.RegistrationEntry, forU return nil, err } + if err := idutil.CheckIDStringNormalization(original.ParentId); err != nil { + return nil, fmt.Errorf("parent ID is malformed: %v", err) + } + + if err := idutil.CheckIDStringNormalization(original.SpiffeId); err != nil { + return nil, fmt.Errorf("spiffe ID is malformed: %v", err) + } + // Validate Selectors for _, s := range entry.Selectors { if err := selector.Validate(s); err != nil { @@ -887,6 +900,9 @@ func getSpiffeIDFromCert(cert *x509.Certificate) (string, error) { if len(cert.URIs) == 0 { return "", errors.New("no SPIFFE ID in certificate") } + if err := idutil.CheckIDURLNormalization(cert.URIs[0]); err != nil { + return "", fmt.Errorf("SPIFFE ID is malformed: %v", err) + } spiffeID, err := idutil.NormalizeSpiffeIDURL(cert.URIs[0], idutil.AllowAny()) if err != nil { return "", err
pkg/server/endpoints/registration/handler_test.go+1 −1 modified@@ -1398,7 +1398,7 @@ func (s *HandlerSuite) TestAuthorizeCall() { }, { Peer: makeTLSPeer("whatever://example.org"), - Err: "not a valid SPIFFE ID", + Err: "SPIFFE ID is malformed: scheme must be 'spiffe'", }, { Peer: makeTLSPeer("spiffe://example.org/not-admin"),
pkg/server/scanentries.go+66 −0 added@@ -0,0 +1,66 @@ +package server + +import ( + "context" + + "github.com/sirupsen/logrus" + "github.com/spiffe/spire/pkg/common/idutil" + "github.com/spiffe/spire/pkg/common/telemetry" + serverTelemetry "github.com/spiffe/spire/pkg/common/telemetry/server" + "github.com/spiffe/spire/pkg/server/plugin/datastore" +) + +var ( + // entryScanPageSize defaults to 200 but can be mutated by unit tests to + // easier test page handling + entryScanPageSize int32 = 200 +) + +// scanForBadEntries scans the entry list for entries that we will ignore +// because they contain problematic spiffe IDs. It will never return an +// error since it is advisory only. +func scanForBadEntries(log logrus.FieldLogger, metrics telemetry.Metrics, ds datastore.DataStore) func(ctx context.Context) error { + return func(ctx context.Context) error { + pagination := &datastore.Pagination{PageSize: entryScanPageSize} + ignored := 0 + for { + resp, err := ds.ListRegistrationEntries(ctx, &datastore.ListRegistrationEntriesRequest{ + Pagination: pagination, + }) + if err != nil { + log.WithError(err).Warn("Failed to scan for bad entries") + return nil + } + + for _, entry := range resp.Entries { + if err := idutil.CheckIDStringNormalization(entry.ParentId); err != nil { + log.WithFields(logrus.Fields{ + telemetry.ParentID: entry.ParentId, + telemetry.SPIFFEID: entry.SpiffeId, + telemetry.RegistrationID: entry.EntryId, + logrus.ErrorKey: err, + }).Error("Ignoring entry with invalid parentID; this entry will be automatically deleted by a future release") + ignored++ + continue + } + if err := idutil.CheckIDStringNormalization(entry.SpiffeId); err != nil { + log.WithFields(logrus.Fields{ + telemetry.ParentID: entry.ParentId, + telemetry.SPIFFEID: entry.SpiffeId, + telemetry.RegistrationID: entry.EntryId, + logrus.ErrorKey: err, + }).Error("Ignoring entry with invalid spiffeID; this entry will be automatically deleted by a future release") + ignored++ + continue + } + } + + switch { + case resp.Pagination == nil, resp.Pagination.Token == "": + serverTelemetry.SetEntryIgnoredGauge(metrics, ignored) + return nil + } + pagination.Token = resp.Pagination.Token + } + } +}
pkg/server/scanentries_test.go+134 −0 added@@ -0,0 +1,134 @@ +package server + +import ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/spiffe/spire/pkg/server/plugin/datastore" + "github.com/spiffe/spire/proto/spire/common" + "github.com/spiffe/spire/test/fakes/fakedatastore" + "github.com/spiffe/spire/test/fakes/fakemetrics" + "github.com/spiffe/spire/test/spiretest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + // Use a page size of two for unit tests + entryScanPageSize = 2 +} + +func TestScanForBadEntries(t *testing.T) { + good1 := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/node", + SpiffeId: "spiffe://example.org/workload", + Selectors: []*common.Selector{{Type: "one", Value: "1"}}, + } + good2 := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/node", + SpiffeId: "spiffe://example.org/workload", + Selectors: []*common.Selector{{Type: "two", Value: "2"}}, + } + good3 := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/node", + SpiffeId: "spiffe://example.org/workload", + Selectors: []*common.Selector{{Type: "three", Value: "3"}}, + } + badParentID := &common.RegistrationEntry{ + ParentId: "spiffe://example.org//node", + SpiffeId: "spiffe://example.org/workload", + Selectors: []*common.Selector{{Type: "bad", Value: "parent-id"}}, + } + badSpiffeID := &common.RegistrationEntry{ + ParentId: "spiffe://example.org/node", + SpiffeId: "spiffe://example.org//workload", + Selectors: []*common.Selector{{Type: "bad", Value: "spiffe-id"}}, + } + + for _, tt := range []struct { + name string + entries []*common.RegistrationEntry + expectLogs []spiretest.LogEntry + expectIgnored int + }{ + { + name: "no entries", + expectIgnored: 0, + }, + { + name: "no bad entries", + entries: []*common.RegistrationEntry{good1, good2, good3}, + expectIgnored: 0, + }, + { + name: "bad spiffe id", + entries: []*common.RegistrationEntry{good1, good2, badSpiffeID, good3}, + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "Ignoring entry with invalid spiffeID; this entry will be automatically deleted by a future release", + Data: logrus.Fields{ + "entry_id": "SOME-ID", + "error": "path cannot contain empty, '.', or '..' segments", + "parent_id": "spiffe://example.org/node", + "spiffe_id": "spiffe://example.org//workload", + }, + }, + }, + expectIgnored: 1, + }, + { + name: "bad parent id", + entries: []*common.RegistrationEntry{good1, good2, badParentID, good3}, + expectLogs: []spiretest.LogEntry{ + { + Level: logrus.ErrorLevel, + Message: "Ignoring entry with invalid parentID; this entry will be automatically deleted by a future release", + Data: logrus.Fields{ + "entry_id": "SOME-ID", + "error": "path cannot contain empty, '.', or '..' segments", + "parent_id": "spiffe://example.org//node", + "spiffe_id": "spiffe://example.org/workload", + }, + }, + }, + expectIgnored: 1, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + log, logHook := test.NewNullLogger() + metrics := fakemetrics.New() + ds := fakedatastore.New(t) + + for _, entry := range tt.entries { + _, err := ds.CreateRegistrationEntry(context.Background(), &datastore.CreateRegistrationEntryRequest{ + Entry: entry, + }) + require.NoError(t, err) + } + + require.NoError(t, scanForBadEntries(log, metrics, ds)(context.Background())) + + // Assert the logs are as expected. The Entry IDs need to be + // patched up in the log entries since they are non-deterministic. + logEntries := logHook.AllEntries() + for _, logEntry := range logEntries { + if _, ok := logEntry.Data["entry_id"]; ok { + logEntry.Data["entry_id"] = "SOME-ID" + } + } + spiretest.AssertLogs(t, logEntries, tt.expectLogs) + + assert.Equal(t, []fakemetrics.MetricItem{ + { + Type: fakemetrics.SetGaugeType, + Key: []string{"entry", "ignored"}, + Val: float32(tt.expectIgnored), + }, + }, metrics.AllMetrics()) + }) + } +}
pkg/server/server.go+1 −0 modified@@ -171,6 +171,7 @@ func (s *Server) run(ctx context.Context) (err error) { bundleManager.Run, registrationManager.Run, healthChecks.ListenAndServe, + scanForBadEntries(s.config.Log, metrics, cat.GetDataStore()), ) if err == context.Canceled { err = nil
pkg/server/util/regentryutil/fetch.go+20 −2 modified@@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/util" "github.com/spiffe/spire/pkg/server/cache/entrycache" "github.com/spiffe/spire/pkg/server/plugin/datastore" @@ -108,7 +109,7 @@ func (f *registrationEntryFetcher) childEntries(ctx context.Context, clientID st return nil, err } - return resp.Entries, nil + return f.filterEntries(resp.Entries), nil } // mappedEntries returns all registration entries for which the given ID has @@ -145,7 +146,24 @@ func (f *registrationEntryFetcher) mappedEntries(ctx context.Context, clientID s return nil, err } - return listResp.Entries, nil + return f.filterEntries(listResp.Entries), nil +} + +func (f *registrationEntryFetcher) filterEntries(in []*common.RegistrationEntry) []*common.RegistrationEntry { + out := make([]*common.RegistrationEntry, 0, len(in)) + for _, entry := range in { + // Filter out entries with invalid SPIFFE IDs. Operators are notified + // that they are ignored on server startup (see + // pkg/server/scanentries.go) + if err := idutil.CheckIDStringNormalization(entry.SpiffeId); err != nil { + continue + } + if err := idutil.CheckIDStringNormalization(entry.ParentId); err != nil { + continue + } + out = append(out, entry) + } + return out } type nullCache struct {
pkg/server/util/regentryutil/fetch_test.go+2 −0 modified@@ -48,6 +48,7 @@ func TestFetchRegistrationEntries(t *testing.T) { threeID := "spiffe://example.org/3" fourID := "spiffe://example.org/4" fiveID := "spiffe://example.org/5" + someParentID := "spiffe://example.org/parent" a1 := &common.Selector{Type: "a", Value: "1"} b2 := &common.Selector{Type: "b", Value: "2"} @@ -80,6 +81,7 @@ func TestFetchRegistrationEntries(t *testing.T) { }) fourEntry := createRegistrationEntry(&common.RegistrationEntry{ + ParentId: someParentID, SpiffeId: fourID, Selectors: []*common.Selector{a1, b2}, })
support/k8s/k8s-workload-registrar/config_reconcile.go+3 −3 modified@@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "path" corev1 "k8s.io/api/core/v1" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -17,6 +16,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "github.com/spiffe/spire/pkg/common/idutil" spiretypes "github.com/spiffe/spire/proto/spire/types" "github.com/spiffe/spire/support/k8s/k8s-workload-registrar/mode-reconcile/controllers" "github.com/zeebo/errs" @@ -161,13 +161,13 @@ func (slw SpiffeLogWrapper) Errorf(format string, args ...interface{}) { func ServerID(trustDomain string) *spiretypes.SPIFFEID { return &spiretypes.SPIFFEID{ TrustDomain: trustDomain, - Path: path.Join("/", "spire", "server"), + Path: idutil.JoinPathSegments("spire", "server"), } } func nodeID(trustDomain string, controllerName string, cluster string) *spiretypes.SPIFFEID { return &spiretypes.SPIFFEID{ TrustDomain: trustDomain, - Path: path.Join("/", controllerName, cluster, "node"), + Path: idutil.JoinPathSegments(controllerName, cluster, "node"), } }
support/k8s/k8s-workload-registrar/controller.go+1 −2 modified@@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "path" "github.com/sirupsen/logrus" "github.com/spiffe/spire/pkg/common/idutil" @@ -207,7 +206,7 @@ func (c *Controller) nodeID() *types.SPIFFEID { func (c *Controller) makeID(pathFmt string, pathArgs ...interface{}) *types.SPIFFEID { return &types.SPIFFEID{ TrustDomain: c.c.TrustDomain, - Path: path.Clean("/" + fmt.Sprintf(pathFmt, pathArgs...)), + Path: idutil.FormatPath(pathFmt, pathArgs...), } }
support/k8s/k8s-workload-registrar/controller_test.go+1 −2 modified@@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "path" "sort" "sync" "testing" @@ -581,5 +580,5 @@ func stringFromID(id *types.SPIFFEID) string { if id == nil { return "" } - return fmt.Sprintf("spiffe://%s%s", id.TrustDomain, path.Clean("/"+id.Path)) + return fmt.Sprintf("spiffe://%s%s", id.TrustDomain, id.Path) }
support/k8s/k8s-workload-registrar/mode-crd/api/spiffeid/v1beta1/spiffeid_webhook.go+2 −2 modified@@ -21,7 +21,7 @@ import ( "strings" "github.com/sirupsen/logrus" - "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/pkg/common/x509util" "github.com/spiffe/spire/proto/spire/api/server/entry/v1" "github.com/spiffe/spire/proto/spire/types" @@ -74,7 +74,7 @@ func (s *SpiffeID) ValidateCreate() error { } for _, entry := range resp.Entries { - entrySPIFFEID, err := spiffeid.New(entry.SpiffeId.TrustDomain, entry.SpiffeId.Path) + entrySPIFFEID, err := idutil.IDFromProto(entry.SpiffeId) if err != nil { return fmt.Errorf("entry SPIFFE ID is malformed: %v", err) }
support/k8s/k8s-workload-registrar/mode-crd/controllers/spiffeid_controller_test.go+1 −2 modified@@ -17,7 +17,6 @@ package controllers import ( "fmt" - "path" "testing" entryv1 "github.com/spiffe/spire/proto/spire/api/server/entry/v1" @@ -119,5 +118,5 @@ func (s *SpiffeIDControllerTestSuite) TestCreateSpiffeID() { } func stringFromID(id *spireTypes.SPIFFEID) string { - return fmt.Sprintf("spiffe://%s%s", id.TrustDomain, path.Clean("/"+id.Path)) + return fmt.Sprintf("spiffe://%s%s", id.TrustDomain, id.Path) }
support/k8s/k8s-workload-registrar/mode-crd/controllers/utils.go+2 −3 modified@@ -18,10 +18,9 @@ package controllers import ( "context" "errors" - "fmt" "net/url" - "path" + "github.com/spiffe/spire/pkg/common/idutil" entryv1 "github.com/spiffe/spire/proto/spire/api/server/entry/v1" spiffeidv1beta1 "github.com/spiffe/spire/support/k8s/k8s-workload-registrar/mode-crd/api/spiffeid/v1beta1" "google.golang.org/grpc/codes" @@ -100,7 +99,7 @@ func makeID(trustDomain, pathFmt string, pathArgs ...interface{}) string { id := url.URL{ Scheme: "spiffe", Host: trustDomain, - Path: path.Clean(fmt.Sprintf(pathFmt, pathArgs...)), + Path: idutil.FormatPath(pathFmt, pathArgs...), } return id.String() }
support/k8s/k8s-workload-registrar/mode-reconcile/controllers/node_controller.go+2 −2 modified@@ -18,9 +18,9 @@ package controllers import ( "context" "fmt" - "path" "strings" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/proto/spire/api/server/entry/v1" spiretypes "github.com/spiffe/spire/proto/spire/types" "google.golang.org/protobuf/proto" @@ -58,7 +58,7 @@ func (r *NodeReconciler) shouldProcess(_ ctrl.Request) bool { func (r *NodeReconciler) makeSpiffeID(obj ObjectWithMetadata) *spiretypes.SPIFFEID { return &spiretypes.SPIFFEID{ TrustDomain: r.RootID.TrustDomain, - Path: path.Join(r.RootID.Path, obj.GetName()), + Path: r.RootID.Path + idutil.JoinPathSegments(obj.GetName()), } }
support/k8s/k8s-workload-registrar/mode-reconcile/controllers/node_controller_test.go+2 −2 modified@@ -2,9 +2,9 @@ package controllers import ( "context" - "path" "testing" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/proto/spire/api/server/entry/v1" "github.com/spiffe/spire/test/fakes/fakeentryclient" @@ -64,7 +64,7 @@ func (s *NodeControllerTestSuite) TearDownTest() { func (s *NodeControllerTestSuite) makeNodeID(node string) *spiretypes.SPIFFEID { return &spiretypes.SPIFFEID{ TrustDomain: nodeControllerTestTrustDomain, - Path: path.Join("foo", "node", node), + Path: idutil.JoinPathSegments("foo", "node", node), } }
support/k8s/k8s-workload-registrar/mode-reconcile/controllers/pod_controller.go+7 −7 modified@@ -18,10 +18,10 @@ package controllers import ( "context" "fmt" - "path" "sort" "strings" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/spiffe/spire/proto/spire/api/server/entry/v1" spiretypes "github.com/spiffe/spire/proto/spire/types" @@ -229,23 +229,23 @@ func (r *PodReconciler) makeSpiffeIDForPod(pod *corev1.Pod) *spiretypes.SPIFFEID var spiffeID *spiretypes.SPIFFEID switch r.Mode { case PodReconcilerModeServiceAccount: - spiffeID = r.makeID(path.Join("/ns", pod.Namespace, "sa", pod.Spec.ServiceAccountName)) + spiffeID = r.makeID("ns", pod.Namespace, "sa", pod.Spec.ServiceAccountName) case PodReconcilerModeLabel: if val, ok := pod.GetLabels()[r.Value]; ok { - spiffeID = r.makeID(path.Join("/", val)) + spiffeID = r.makeID(val) } case PodReconcilerModeAnnotation: if val, ok := pod.GetAnnotations()[r.Value]; ok { - spiffeID = r.makeID(path.Join("/", val)) + spiffeID = r.makeID(val) } } return spiffeID } -func (r *PodReconciler) makeID(path string) *spiretypes.SPIFFEID { +func (r *PodReconciler) makeID(segments ...string) *spiretypes.SPIFFEID { return &spiretypes.SPIFFEID{ TrustDomain: r.TrustDomain, - Path: path, + Path: idutil.JoinPathSegments(segments...), } } @@ -256,7 +256,7 @@ func (r *PodReconciler) makeParentIDForPod(pod *corev1.Pod) *spiretypes.SPIFFEID } return &spiretypes.SPIFFEID{ TrustDomain: r.RootID.TrustDomain, - Path: path.Join(r.RootID.Path, nodeName), + Path: r.RootID.Path + idutil.JoinPathSegments(nodeName), } }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-h746-rm5q-8mgqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-27098ghsaADVISORY
- cve.mitre.org/cgi-bin/cvename.cgighsaWEB
- github.com/spiffe/spire/commit/3c5115b57afc20a0a2c2b1b9dd60dd1fd9082e13ghsaWEB
- github.com/spiffe/spire/security/advisories/GHSA-h746-rm5q-8mgqghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.