VDB
KO
MEDIUM

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

Go / github.com/google/cel-go
Introduced in: 0.22.0 Fixed in: 0.29.0
Fix go get github.com/google/cel-go@v0.29.0

References