GitHub Advisoryadvisory · Go
GHSA-gcjh-h69q-9w9g cel-go: JSON Private Fields Exposed via NativeTypes and ParseStructTag
MEDIUM
Official page ↗
- Ecosystem
- Go
- Packages
- github.com/google/cel-go
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
- Weaknesses
- CWE-495
- Reviewed
- 2026-07-24
Affected packages
• Go github.com/google/cel-go: >= 0.22.0, < 0.29.0 → fixed in 0.29.0
Description
The function `ext.NativeTypes(ParseStructTag("json"))` does not honour the `encoding/json` skip directive `json:"-"`. Fields tagged `json:"-"` are registered in the CEL type system under the literal name `"-"` and are readable from any user-submitted CEL expression via `dyn(obj)["-"]`.
Additionally, `newNativeTypes` silently registers every nested struct reachable from the type passed to `NativeTypes`, including types from third-party dependencies the developer never examined.
Root cause
In `fieldNameByTag`, the helper used by `ParseStructTag("json")` to translate Go struct tags into CEL field names.
See at `ext/native.go:146`:
```go
func fieldNameByTag(structTagToParse string) func(field reflect.StructField) string {
return func(field reflect.StructField) string {
tag, found := field.Tag.Lookup(structTagToParse)
if found {
splits := strings.Split(tag, ",")
if len(splits) > 0 {
// We make the assumption that the leftmost entry in the tag is the name.
// This seems to be true for most tags that have the concept of a name/key, such as:
// https://pkg.go.dev/encoding/xml#Marshal
// https://pkg.go.dev/encoding/json#Marshal
// https://pkg.go.dev/go.mongodb.org/mongo-driver/bson#hdr-Structs
// https://pkg.go.dev/go.yaml.in/yaml/v3#Marshal
name := splits[0]
return name
}
}
return field.Name
}
}
```
For a field tagged `json:"-"`, this code splits the tag into `[]string{"-"}` and returns `"-"` as the CEL field name. It never checks whether `"-"` is the JSON skip sentinel.
This contradicts the `encoding/json` rule that the source comment explicitly points readers to:
```text
As a special case, if the field tag is "-", the field is always omitted. Note
that a field with name "-" can still be generated using the tag "-,".
```
The public option also documents JSON-style parsing as the intended behavior.
See at `ext/native.go:190`:
```go
// ParseStructTag configures the struct tag to parse. The 0th item in the tag is used as the name of the CEL field.
// For example:
// If the tag to parse is "cel" and the struct field has tag cel:"foo", the CEL struct field will be "foo".
// If the tag to parse is "json" and the struct field has tag json:"foo,omitempty", the CEL struct field will be "foo".
func ParseStructTag(tag string) NativeTypesOption {
return func(ntp *nativeTypeOptions) error {
ntp.fieldNameHandler = fieldNameByTag(tag)
return nil
}
}
```
A developer using `ParseStructTag("json")` is therefore led to expect `encoding/json` field-name semantics. Instead, `json:"-"` is treated as a real field name.
The bad name is accepted during native type construction. `newNativeType` checks for duplicate field names, but it does not reject or skip empty names or skip sentinels.
See at `ext/native.go:663`:
```go
if fieldNameHandler != nil {
fieldNames := make(map[string]struct{})
for idx := 0; idx < refType.NumField(); idx++ {
field := refType.Field(idx)
fieldName := toFieldName(fieldNameHandler, field)
if _, found := fieldNames[fieldName]; found {
return nil, fmt.Errorf("invalid field name `%s` in struct `%s`: %w", fieldName, refType.Name(), errDuplicatedFieldName)
} else {
fieldNames[fieldName] = struct{}{}
}
}
}
```
Once accepted, the field becomes part of CEL's view of the type. Field enumeration reports it as a normal field name.
See at `ext/native.go:286`:
```go
func (tp *nativeTypeProvider) FindStructFieldNames(typeName string) ([]string, bool) {
if t, found := tp.nativeTypes[typeName]; found {
fieldCount := t.refType.NumField()
fields := make([]string, fieldCount)
for i := 0; i < fieldCount; i++ {
fields[i] = toFieldName(tp.options.fieldNameHandler, t.refType.Field(i))
}
return fields, true
}
if celTypeFields, found := tp.baseProvider.FindStructFieldNames(typeName); found {
return celTypeFields, true
}
return tp.baseProvider.FindStructFieldNames(typeName)
}
```
Field lookup also treats the name as valid and returns the underlying Go field value.
See at `ext/native.go:303`:
```go
func (tp *nativeTypeProvider) FindStructFieldType(typeName, fieldName string) (*types.FieldType, bool) {
t, found := tp.nativeTypes[typeName]
if !found {
return tp.baseProvider.FindStructFieldType(typeName, fieldName)
}
refField, isDefined := t.hasField(fieldName)
if !found || !isDefined {
return nil, false
}
return &types.FieldType{
IsSet: func(obj any) bool {
refVal := reflect.Indirect(reflect.ValueOf(obj))
refField := refVal.FieldByName(refField.Name)
return !refField.IsZero()
},
GetFrom: func(obj any) (any, error) {
refVal := reflect.Indirect(reflect.ValueOf(obj))
refField := refVal.FieldByName(refField.Name)
return getFieldValue(refField), nil
},
}, true
}
```
At runtime, native objects advertise index access.
See at `ext/native.go:37`:
```go
var (
nativeObjTraitMask = traits.FieldTesterType | traits.IndexerType
)
```
Because `traits.IndexerType` is present, a user expression can bypass ordinary field syntax and read the registered `"-"` field with bracket access:
```cel
dyn(req.auth)["-"]
```
The same mistaken name is also used when converting native objects to JSON-like CEL values. `ConvertToNative(jsonStructType)` iterates all Go struct fields, computes the CEL field name, and inserts it into the output map without applying the JSON skip rule.
See at `ext/native.go:501`:
```go
case jsonStructType:
refVal := reflect.Indirect(o.refValue)
refType := refVal.Type()
fields := make(map[string]*structpb.Value, refVal.NumField())
for i := 0; i < refVal.NumField(); i++ {
fieldType := refType.Field(i)
fieldValue := refVal.Field(i)
if !fieldValue.IsValid() || fieldValue.IsZero() {
continue
}
fieldName := toFieldName(o.valType.fieldNameHandler, fieldType)
fieldCELVal := o.NativeToValue(fieldValue.Interface())
fieldJSONVal, err := fieldCELVal.ConvertToNative(jsonValueType)
if err != nil {
return nil, err
}
fields[fieldName] = fieldJSONVal.(*structpb.Value)
}
return &structpb.Struct{Fields: fields}, nil
```
This means a `json:"-"` secret is exposed in two ways: it can be read directly through CEL indexing as `dyn(obj)["-"]`, and it can appear under the key `"-"` in JSON struct conversion output.
The blast radius is widened by `newNativeTypes`, which registers not only the type explicitly passed to `NativeTypes`, but also every nested struct reachable from its fields.
See at `ext/native.go:609`:
```go
func newNativeTypes(fieldNameHandler NativeTypesFieldNameHandler, rawType reflect.Type) ([]*nativeType, error) {
nt, err := newNativeType(fieldNameHandler, rawType)
if err != nil {
return nil, err
}
result := []*nativeType{nt}
var iterateStructMembers func(reflect.Type)
iterateStructMembers = func(t reflect.Type) {
if k := t.Kind(); k == reflect.Pointer || k == reflect.Slice || k == reflect.Array || k == reflect.Map {
iterateStructMembers(t.Elem())
return
}
if t.Kind() != reflect.Struct {
return
}
nt, ntErr := newNativeType(fieldNameHandler, t)
if ntErr != nil {
err = ntErr
return
}
result = append(result, nt)
for idx := 0; idx < t.NumField(); idx++ {
iterateStructMembers(t.Field(idx).Type)
}
}
iterateStructMembers(rawType)
return result, err
}
```
As a result, a developer can register one apparently safe reques…
Related
Weakness · CWE (1)
- CWE-495 Private Data Structure Returned From A Public Method weakness