Files
ziti/controller/model/ca_model.go
Andrew Martinez 9470c1f126 fixes #3952 reject invalid externalIdClaim and stop enrollment panic (#3953)
* fixes #3952 reject invalid externalIdClaim and stop enrollment panic

- moves the error check before the locator assignment in Ca.GetExternalId
  so an unsupported matcher/parser no longer nil-derefs and returns HTTP 500
- guards the claim index against negative and out-of-range values and errors
  when a matched claim resolves to an empty string, so an empty externalId is
  never silently accepted nor mapped to an identity
- adds validateExternalIdClaim, rejecting unsupported locations, unsupported
  matcher/parser combos, missing matcher/parser criteria, and negative indexes
  with HTTP 400 at CA create and update time
- applies the validation to both CA create and update, which previously had
  no externalIdClaim validation on the update path
- tests GetExternalId across the matcher/parser matrix for no-panic and
  correct error/value behavior
- tests CA create and update reject invalid externalIdClaim configurations
- tests that an externalIdClaim resolving to empty fails enrollment cleanly

* address pr review

- treats an externalIdClaim with no location as no claim in GetExternalId so an
  empty stored claim (e.g. the empty bucket older CLIs leave behind) falls back to
  fingerprint enrollment instead of erroring
- validates the merged result on CA update rather than the raw request: a partial
  patch overlays only its supplied subfields onto the stored claim, an empty {}
  object preserves the stored claim, and a full replace validates as-is
- skips externalIdClaim validation on update when no claim is supplied, fixing a
  spurious rejection of updates to CAs that have no claim
- reports the offending location/matcher/parser in the externalIdClaim validation
  error instead of the raw struct
- adds a --clear-external-id-claim flag to update ca and only sends the claim object
  when a claim flag changed, so existing updates preserve the claim and clearing is
  explicit
- tests externalIdClaim patch merge, preserve, and clear behavior, and that an
  unconfigured claim reads as no claim
2026-06-12 13:52:54 -04:00

449 lines
15 KiB
Go

/*
Copyright NetFoundry Inc.
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
https://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 model
import (
"crypto/x509"
"fmt"
"net/url"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/x509-claims/x509claims"
"github.com/openziti/ziti/v2/common/cert"
"github.com/openziti/ziti/v2/common/eid"
"github.com/openziti/ziti/v2/controller/apierror"
"github.com/openziti/ziti/v2/controller/db"
"github.com/openziti/ziti/v2/controller/models"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"github.com/pkg/errors"
"go.etcd.io/bbolt"
)
type Ca struct {
models.BaseEntity
Name string
Fingerprint string
CertPem string
IsVerified bool
VerificationToken string
IsAutoCaEnrollmentEnabled bool
IsOttCaEnrollmentEnabled bool
IsAuthEnabled bool
IdentityRoles []string
IdentityNameFormat string
ExternalIdClaim *ExternalIdClaim
}
type ExternalIdClaim struct {
Location string
Matcher string
MatcherCriteria string
Parser string
ParserCriteria string
Index int64
}
type ExternalIdFieldType string
func (entity *Ca) fillFrom(_ Env, _ *bbolt.Tx, boltCa *db.Ca) error {
entity.FillCommon(boltCa)
entity.Name = boltCa.Name
entity.Fingerprint = boltCa.Fingerprint
entity.CertPem = boltCa.CertPem
entity.IsVerified = boltCa.IsVerified
entity.VerificationToken = boltCa.VerificationToken
entity.IsAutoCaEnrollmentEnabled = boltCa.IsAutoCaEnrollmentEnabled
entity.IsOttCaEnrollmentEnabled = boltCa.IsOttCaEnrollmentEnabled
entity.IsAuthEnabled = boltCa.IsAuthEnabled
entity.IdentityRoles = boltCa.IdentityRoles
entity.IdentityNameFormat = boltCa.IdentityNameFormat
if boltCa.ExternalIdClaim != nil {
entity.ExternalIdClaim = &ExternalIdClaim{}
entity.ExternalIdClaim.Location = boltCa.ExternalIdClaim.Location
entity.ExternalIdClaim.Index = boltCa.ExternalIdClaim.Index
entity.ExternalIdClaim.Matcher = boltCa.ExternalIdClaim.Matcher
entity.ExternalIdClaim.MatcherCriteria = boltCa.ExternalIdClaim.MatcherCriteria
entity.ExternalIdClaim.Parser = boltCa.ExternalIdClaim.Parser
entity.ExternalIdClaim.ParserCriteria = boltCa.ExternalIdClaim.ParserCriteria
}
return nil
}
func (entity *Ca) toBoltEntityForCreate(tx *bbolt.Tx, env Env) (*db.Ca, error) {
if entity.IdentityNameFormat == "" {
entity.IdentityNameFormat = DefaultCaIdentityNameFormat
}
if err := validateExternalIdClaim(entity.ExternalIdClaim); err != nil {
return nil, err
}
var fp string
if entity.CertPem != "" {
blocks, err := cert.PemChain2Blocks(entity.CertPem)
if err != nil {
return nil, errorz.NewFieldError(err.Error(), "certPem", entity.CertPem)
}
if len(blocks) == 0 {
return nil, errorz.NewFieldError("at least one leaf certificate must be supplied", "certPem", entity.CertPem)
}
certs, err := cert.Blocks2Certs(blocks)
if err != nil {
return nil, errorz.NewFieldError(err.Error(), "certPem", entity.CertPem)
}
leaf := certs[0]
if !leaf.IsCA {
//return nil, &response.ApiError{
// Code: response.CertificateIsNotCaCode,
// Message: response.CertificateIsNotCaMessage,
// HttpStatusCode: http.StatusBadRequest,
//}
return nil, errors.New("certificate is not a CA")
}
fp = cert.NewFingerprintGenerator().FromCert(certs[0])
}
if fp == "" {
return nil, fmt.Errorf("invalid certificate, could not parse PEM body")
}
query := fmt.Sprintf(`fingerprint = "%v"`, fp)
queryResults, _, err := env.GetStores().Ca.QueryIds(tx, query)
if err != nil {
return nil, err
}
if len(queryResults) > 0 {
return nil, errorz.NewFieldError(fmt.Sprintf("certificate already used as CA %s", queryResults[0]), "certPem", entity.CertPem)
}
boltEntity := &db.Ca{
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
Name: entity.Name,
CertPem: entity.CertPem,
Fingerprint: fp,
IsVerified: false,
VerificationToken: eid.New(),
IsAuthEnabled: entity.IsAuthEnabled,
IsAutoCaEnrollmentEnabled: entity.IsAutoCaEnrollmentEnabled,
IsOttCaEnrollmentEnabled: entity.IsOttCaEnrollmentEnabled,
IdentityRoles: entity.IdentityRoles,
IdentityNameFormat: entity.IdentityNameFormat,
}
if entity.ExternalIdClaim != nil {
boltEntity.ExternalIdClaim = &db.ExternalIdClaim{
Location: entity.ExternalIdClaim.Location,
Matcher: entity.ExternalIdClaim.Matcher,
MatcherCriteria: entity.ExternalIdClaim.MatcherCriteria,
Parser: entity.ExternalIdClaim.Parser,
ParserCriteria: entity.ExternalIdClaim.ParserCriteria,
Index: entity.ExternalIdClaim.Index,
}
}
return boltEntity, nil
}
func (entity *Ca) toBoltEntityForUpdate(tx *bbolt.Tx, env Env, checker boltz.FieldChecker) (*db.Ca, error) {
if entity.IdentityNameFormat == "" {
entity.IdentityNameFormat = DefaultCaIdentityNameFormat
}
claim, err := entity.effectiveExternalIdClaim(tx, env, checker)
if err != nil {
return nil, err
}
if err := validateExternalIdClaim(claim); err != nil {
return nil, err
}
boltEntity := &db.Ca{
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
Name: entity.Name,
IsAuthEnabled: entity.IsAuthEnabled,
IsAutoCaEnrollmentEnabled: entity.IsAutoCaEnrollmentEnabled,
IsOttCaEnrollmentEnabled: entity.IsOttCaEnrollmentEnabled,
IsVerified: entity.IsVerified,
IdentityRoles: entity.IdentityRoles,
IdentityNameFormat: entity.IdentityNameFormat,
}
if entity.ExternalIdClaim != nil {
boltEntity.ExternalIdClaim = &db.ExternalIdClaim{
Location: entity.ExternalIdClaim.Location,
Matcher: entity.ExternalIdClaim.Matcher,
MatcherCriteria: entity.ExternalIdClaim.MatcherCriteria,
Parser: entity.ExternalIdClaim.Parser,
ParserCriteria: entity.ExternalIdClaim.ParserCriteria,
Index: entity.ExternalIdClaim.Index,
}
}
return boltEntity, nil
}
// caExternalIdClaimFields are the persisted externalIdClaim subfields, used to detect which
// subfields a partial update touches.
var caExternalIdClaimFields = []string{
db.FieldCaExternalIdClaimLocation,
db.FieldCaExternalIdClaimMatcher,
db.FieldCaExternalIdClaimMatcherCriteria,
db.FieldCaExternalIdClaimParser,
db.FieldCaExternalIdClaimParserCriteria,
db.FieldCaExternalIdClaimIndex,
}
// effectiveExternalIdClaim returns the externalIdClaim to validate for an update so validation
// matches what will be stored. A nil checker is a full replace, so the request claim stands as-is.
// For a patch only the subfields named in the checker change: if none touch the claim it is left
// unchanged (nil, validation skipped), otherwise the supplied subfields are overlaid onto the stored
// claim and the merged result is validated. Subfield gating mirrors PersistEntity exactly.
func (entity *Ca) effectiveExternalIdClaim(tx *bbolt.Tx, env Env, checker boltz.FieldChecker) (*ExternalIdClaim, error) {
// No claim supplied: the update either clears the claim or leaves it untouched. Nothing to validate.
if entity.ExternalIdClaim == nil {
return nil, nil
}
// Full replace (nil checker): validate the supplied claim as-is.
if checker == nil {
return entity.ExternalIdClaim, nil
}
// Partial update: if no claim subfield is being set, the stored claim is unchanged (the empty {}
// object older CLIs send on every update). Skip validation rather than treating it as incomplete.
touched := false
for _, field := range caExternalIdClaimFields {
if checker.IsUpdated(field) {
touched = true
break
}
}
if !touched {
return nil, nil
}
// Overlay the supplied subfields onto the stored claim so the merged result is what gets validated.
merged := &ExternalIdClaim{}
if existing, err := env.GetStores().Ca.LoadById(tx, entity.Id); err != nil {
return nil, err
} else if existing != nil && existing.ExternalIdClaim != nil {
merged.Location = existing.ExternalIdClaim.Location
merged.Matcher = existing.ExternalIdClaim.Matcher
merged.MatcherCriteria = existing.ExternalIdClaim.MatcherCriteria
merged.Parser = existing.ExternalIdClaim.Parser
merged.ParserCriteria = existing.ExternalIdClaim.ParserCriteria
merged.Index = existing.ExternalIdClaim.Index
}
if checker.IsUpdated(db.FieldCaExternalIdClaimLocation) {
merged.Location = entity.ExternalIdClaim.Location
}
if checker.IsUpdated(db.FieldCaExternalIdClaimMatcher) {
merged.Matcher = entity.ExternalIdClaim.Matcher
}
if checker.IsUpdated(db.FieldCaExternalIdClaimMatcherCriteria) {
merged.MatcherCriteria = entity.ExternalIdClaim.MatcherCriteria
}
if checker.IsUpdated(db.FieldCaExternalIdClaimParser) {
merged.Parser = entity.ExternalIdClaim.Parser
}
if checker.IsUpdated(db.FieldCaExternalIdClaimParserCriteria) {
merged.ParserCriteria = entity.ExternalIdClaim.ParserCriteria
}
if checker.IsUpdated(db.FieldCaExternalIdClaimIndex) {
merged.Index = entity.ExternalIdClaim.Index
}
return merged, nil
}
// validateExternalIdClaim rejects unsupported or incomplete externalIdClaim configurations
// with a bad request error so they cannot be stored and later fail enrollment/authentication.
// It exercises the same location/matcher/parser rules used to extract the claim, so any
// configuration that would error at extraction time is rejected up front at CA create/update.
func validateExternalIdClaim(claim *ExternalIdClaim) error {
if claim == nil {
return nil
}
if claim.Index < 0 {
return apierror.NewBadRequestFieldError(*errorz.NewFieldError("index must be greater than or equal to zero", "externalIdClaim.index", claim.Index))
}
var err error
switch claim.Location {
case db.ExternalIdClaimLocCommonName, db.ExternalIdClaimLocSanEmail:
_, err = getStringDefinition(claim)
case db.ExternalIdClaimLocSanUri:
_, err = getUriDefinition(claim)
default:
return apierror.NewBadRequestFieldError(*errorz.NewFieldError(fmt.Sprintf("unsupported location [%s]", claim.Location), "externalIdClaim.location", claim.Location))
}
if err != nil {
value := fmt.Sprintf("location=%s matcher=%s parser=%s", claim.Location, claim.Matcher, claim.Parser)
return apierror.NewBadRequestFieldError(*errorz.NewFieldError(err.Error(), "externalIdClaim", value))
}
return nil
}
// GetExternalId will attempt to retrieve a string claim from a x509 Certificate based on
// location, matching, and parsing of various x509 Certificate fields.
func (entity *Ca) GetExternalId(cert *x509.Certificate) (string, error) {
// A claim with no location is unconfigured (e.g. the empty bucket an older CLI's empty {} patch
// leaves behind). Treat it as no claim so enrollment falls back to a certificate fingerprint.
if entity.ExternalIdClaim == nil || entity.ExternalIdClaim.Location == "" {
return "", nil
}
provider := &x509claims.ProviderBasic{
Definitions: []x509claims.Definition{},
}
switch entity.ExternalIdClaim.Location {
case db.ExternalIdClaimLocCommonName:
definition, err := getStringDefinition(entity.ExternalIdClaim)
if err != nil {
return "", err
}
definition.Locator = &x509claims.LocatorCommonName{}
provider.Definitions = append(provider.Definitions, definition)
case db.ExternalIdClaimLocSanUri:
definition, err := getUriDefinition(entity.ExternalIdClaim)
if err != nil {
return "", err
}
definition.Locator = &x509claims.LocatorSanUri{}
provider.Definitions = append(provider.Definitions, definition)
case db.ExternalIdClaimLocSanEmail:
definition, err := getStringDefinition(entity.ExternalIdClaim)
if err != nil {
return "", err
}
definition.Locator = &x509claims.LocatorSanEmail{}
provider.Definitions = append(provider.Definitions, definition)
default:
return "", fmt.Errorf("unsupported location [%s]", entity.ExternalIdClaim.Location)
}
claims := provider.Claims(cert)
if entity.ExternalIdClaim.Index < 0 || entity.ExternalIdClaim.Index >= int64(len(claims)) {
return "", errors.New("no claim found")
}
externalId := claims[entity.ExternalIdClaim.Index]
if externalId == "" {
return "", errors.New("claim resolved to an empty value")
}
return externalId, nil
}
// getUriDefinition returns an x509Claims.DefinitionLMP that will locate, match, and parse url.URL properties.
func getUriDefinition(externalIdClaim *ExternalIdClaim) (*x509claims.DefinitionLMP[*url.URL], error) {
definition := &x509claims.DefinitionLMP[*url.URL]{}
switch externalIdClaim.Matcher {
case db.ExternalIdClaimMatcherAll:
definition.Matcher = &x509claims.MatcherAll[*url.URL]{}
case db.ExternalIdClaimMatcherScheme:
if externalIdClaim.MatcherCriteria == "" {
return nil, fmt.Errorf("invalid criteria [%s] for matcher [%s]", externalIdClaim.MatcherCriteria, externalIdClaim.Matcher)
}
definition.Matcher = &x509claims.MatcherScheme{Scheme: externalIdClaim.MatcherCriteria}
default:
return nil, fmt.Errorf("unsupported matcher [%s] for location [%s]", externalIdClaim.Matcher, externalIdClaim.Location)
}
var err error
definition.Parser, err = getStringParser(externalIdClaim)
if err != nil {
return nil, err
}
return definition, nil
}
// getStringDefinition returns an x509Claims.DefinitionLMP that will locate, match, and parse string properties.
func getStringDefinition(externalIdClaim *ExternalIdClaim) (*x509claims.DefinitionLMP[string], error) {
definition := &x509claims.DefinitionLMP[string]{}
switch externalIdClaim.Matcher {
case db.ExternalIdClaimMatcherAll:
definition.Matcher = &x509claims.MatcherAll[string]{}
case db.ExternalIdClaimMatcherPrefix:
if externalIdClaim.MatcherCriteria == "" {
return nil, fmt.Errorf("invalid criteria [%s] for matcher [%s]", externalIdClaim.MatcherCriteria, externalIdClaim.Matcher)
}
definition.Matcher = &x509claims.MatcherPrefix{Prefix: externalIdClaim.MatcherCriteria}
case db.ExternalIdClaimMatcherSuffix:
if externalIdClaim.MatcherCriteria == "" {
return nil, fmt.Errorf("invalid criteria [%s] for matcher [%s]", externalIdClaim.MatcherCriteria, externalIdClaim.Matcher)
}
definition.Matcher = &x509claims.MatcherSuffix{Suffix: externalIdClaim.MatcherCriteria}
default:
return nil, fmt.Errorf("unsupported matcher [%s] for location [%s]", externalIdClaim.Matcher, externalIdClaim.Location)
}
var err error
definition.Parser, err = getStringParser(externalIdClaim)
if err != nil {
return nil, err
}
return definition, nil
}
// getStringParser returns a x509claims.Parser that parses string values into claims
func getStringParser(externalIdClaim *ExternalIdClaim) (x509claims.Parser, error) {
switch externalIdClaim.Parser {
case db.ExternalIdClaimParserNone:
return &x509claims.ParserNoOp{}, nil
case db.ExternalIdClaimParserSplit:
if externalIdClaim.ParserCriteria == "" {
return nil, fmt.Errorf("invalid criteria [%s] for parser [%s]", externalIdClaim.ParserCriteria, externalIdClaim.Parser)
}
return &x509claims.ParserSplit{Separator: externalIdClaim.ParserCriteria}, nil
}
return nil, fmt.Errorf("unsupported parser [%s] for location [%s]", externalIdClaim.Parser, externalIdClaim.Location)
}