GHSA-gcjh-h69q-9w9g: cel-go JSON Private Fields Exposed Vulnerability

MODERATE
CWE-495
Published
Advisory Summary

GHSA-gcjh-h69q-9w9g is a MODERATE json private fields exposed vulnerability (CWE-495: Private Data Structure Returned From A Public Method) affecting cel-go libraries in the Go ecosystem. The issue involves json private fields exposed via nativetypes and parsestructtag. Remediation is available by upgrading affected packages to version 0.29.0.

GHSA-gcjh-h69q-9w9g at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-gcjh-h69q-9w9g
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-495: Private Data Structure Returned From A Public Method
Affected Product cel-go
Package Ecosystem Go
Published Date
Last Updated

How to Fix GHSA-gcjh-h69q-9w9g

To remediate this vulnerability, upgrade all affected cel-go dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/google/cel-go fixed: 0.29.0
Upgrade github.com/google/cel-go to 0.29.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/google/cel-go

How to Check if You Are Affected

To determine whether your application is affected by GHSA-gcjh-h69q-9w9g, inspect your project dependencies for cel-go:

inspect dependencies for github.com/google/cel-go

Analyze the terminal output to locate any version of cel-go in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.29.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-495: Private Data Structure Returned From A Public Method, where the product has a method that is declared public, but returns a reference to a private data structure, which could then be modified in unexpected ways. In cel-go, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

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.

GHSA-gcjh-h69q-9w9g Affected cel-go Versions

1 component(s)
Fixed Versions:
0.29.0
Product / Package Ecosystem Affected Versions Fixed Version
github.com/google/cel-go
Go
≥ 0.22.0
< 0.29.0
0.29.0

References

Frequently Asked Questions

What is GHSA-gcjh-h69q-9w9g?
GHSA-gcjh-h69q-9w9g is a security vulnerability in cel-go involving JSON Private Fields Exposed via NativeTypes and ParseStructTag under CWE-495: Private Data Structure Returned From A Public Method.
Which cel-go versions are affected?
Affected versions include releases prior to 0.29.0 across affected packages: github.com/google/cel-go.
What version fixes GHSA-gcjh-h69q-9w9g?
Upgrading to 0.29.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/google/cel-go affected?
Yes, github.com/google/cel-go is affected in releases prior to 0.29.0. Upgrade your dependency to 0.29.0 or later.
What CWE is associated with GHSA-gcjh-h69q-9w9g?
GHSA-gcjh-h69q-9w9g is classified under CWE-495: Private Data Structure Returned From A Public Method (The product has a method that is declared public, but returns a reference to a private data structure, which could then be modified in unexpected ways.).
How can I check Go dependencies for GHSA-gcjh-h69q-9w9g?
Run the dependency tree command: 'inspect dependencies for github.com/google/cel-go'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-gcjh-h69q-9w9g",
  "modified" : "2026-07-24T16:48:56Z",
  "published" : "2026-07-24T16:48:56Z",
  "aliases" : [ ],
  "summary" : "cel-go: JSON Private Fields Exposed via NativeTypes and ParseStructTag",
  "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)[\"-\"]`. \n\nAdditionally, `newNativeTypes` silently registers every nested struct reachable from the type passed to `NativeTypes`, including types from third-party dependencies the developer never examined.\n\n## Root cause\n\nIn `fieldNameByTag`, the helper used by `ParseStructTag(\"json\")` to translate Go struct tags into CEL field names.\n\nSee at `ext/native.go:146`:\n\n```go\nfunc fieldNameByTag(structTagToParse string) func(field reflect.StructField) string {\n    return func(field reflect.StructField) string {\n        tag, found := field.Tag.Lookup(structTagToParse)\n        if found {\n            splits := strings.Split(tag, \",\")\n            if len(splits) > 0 {\n                // We make the assumption that the leftmost entry in the tag is the name.\n                // This seems to be true for most tags that have the concept of a name/key, such as:\n                // https://pkg.go.dev/encoding/xml#Marshal\n                // https://pkg.go.dev/encoding/json#Marshal\n                // https://pkg.go.dev/go.mongodb.org/mongo-driver/bson#hdr-Structs\n                // https://pkg.go.dev/go.yaml.in/yaml/v3#Marshal\n                name := splits[0]\n                return name\n            }\n        }\n\n        return field.Name\n    }\n}\n```\n\nFor 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.\n\nThis contradicts the `encoding/json` rule that the source comment explicitly points readers to:\n\n```text\nAs a special case, if the field tag is \"-\", the field is always omitted. Note\nthat a field with name \"-\" can still be generated using the tag \"-,\".\n```\n\nThe public option also documents JSON-style parsing as the intended behavior.\nSee at `ext/native.go:190`:\n\n```go\n// ParseStructTag configures the struct tag to parse. The 0th item in the tag is used as the name of the CEL field.\n// For example:\n// If the tag to parse is \"cel\" and the struct field has tag cel:\"foo\", the CEL struct field will be \"foo\".\n// If the tag to parse is \"json\" and the struct field has tag json:\"foo,omitempty\", the CEL struct field will be \"foo\".\nfunc ParseStructTag(tag string) NativeTypesOption {\n    return func(ntp *nativeTypeOptions) error {\n        ntp.fieldNameHandler = fieldNameByTag(tag)\n        return nil\n    }\n}\n```\n\nA developer using `ParseStructTag(\"json\")` is therefore led to expect `encoding/json` field-name semantics. Instead, `json:\"-\"` is treated as a real field name.\n\nThe 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.\n\nSee at `ext/native.go:663`:\n\n```go\nif fieldNameHandler != nil {\n    fieldNames := make(map[string]struct{})\n\n    for idx := 0; idx < refType.NumField(); idx++ {\n        field := refType.Field(idx)\n        fieldName := toFieldName(fieldNameHandler, field)\n\n        if _, found := fieldNames[fieldName]; found {\n            return nil, fmt.Errorf(\"invalid field name `%s` in struct `%s`: %w\", fieldName, refType.Name(), errDuplicatedFieldName)\n        } else {\n            fieldNames[fieldName] = struct{}{}\n        }\n    }\n}\n```\n\nOnce accepted, the field becomes part of CEL's view of the type. Field enumeration reports it as a normal field name.\n\nSee at `ext/native.go:286`:\n\n```go\nfunc (tp *nativeTypeProvider) FindStructFieldNames(typeName string) ([]string, bool) {\n    if t, found := tp.nativeTypes[typeName]; found {\n        fieldCount := t.refType.NumField()\n        fields := make([]string, fieldCount)\n        for i := 0; i < fieldCount; i++ {\n            fields[i] = toFieldName(tp.options.fieldNameHandler, t.refType.Field(i))\n        }\n        return fields, true\n    }\n    if celTypeFields, found := tp.baseProvider.FindStructFieldNames(typeName); found {\n        return celTypeFields, true\n    }\n    return tp.baseProvider.FindStructFieldNames(typeName)\n}\n```\n\nField lookup also treats the name as valid and returns the underlying Go field value.\n\nSee at `ext/native.go:303`:\n\n```go\nfunc (tp *nativeTypeProvider) FindStructFieldType(typeName, fieldName string) (*types.FieldType, bool) {\n    t, found := tp.nativeTypes[typeName]\n    if !found {\n        return tp.baseProvider.FindStructFieldType(typeName, fieldName)\n    }\n    refField, isDefined := t.hasField(fieldName)\n    if !found || !isDefined {\n        return nil, false\n    }\n\n    return &types.FieldType{\n        IsSet: func(obj any) bool {\n            refVal := reflect.Indirect(reflect.ValueOf(obj))\n            refField := refVal.FieldByName(refField.Name)\n            return !refField.IsZero()\n        },\n        GetFrom: func(obj any) (any, error) {\n            refVal := reflect.Indirect(reflect.ValueOf(obj))\n            refField := refVal.FieldByName(refField.Name)\n            return getFieldValue(refField), nil\n        },\n    }, true\n}\n```\n\nAt runtime, native objects advertise index access.\nSee at `ext/native.go:37`:\n\n```go\nvar (\n    nativeObjTraitMask = traits.FieldTesterType | traits.IndexerType\n)\n```\n\nBecause `traits.IndexerType` is present, a user expression can bypass ordinary field syntax and read the registered `\"-\"` field with bracket access:\n\n```cel\ndyn(req.auth)[\"-\"]\n```\n\nThe 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.\n\nSee at `ext/native.go:501`:\n\n```go\ncase jsonStructType:\n    refVal := reflect.Indirect(o.refValue)\n    refType := refVal.Type()\n    fields := make(map[string]*structpb.Value, refVal.NumField())\n    for i := 0; i < refVal.NumField(); i++ {\n        fieldType := refType.Field(i)\n        fieldValue := refVal.Field(i)\n        if !fieldValue.IsValid() || fieldValue.IsZero() {\n            continue\n        }\n        fieldName := toFieldName(o.valType.fieldNameHandler, fieldType)\n        fieldCELVal := o.NativeToValue(fieldValue.Interface())\n        fieldJSONVal, err := fieldCELVal.ConvertToNative(jsonValueType)\n        if err != nil {\n            return nil, err\n        }\n        fields[fieldName] = fieldJSONVal.(*structpb.Value)\n    }\n    return &structpb.Struct{Fields: fields}, nil\n```\n\nThis 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.\n\nThe 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.\n\nSee at `ext/native.go:609`:\n\n```go\nfunc newNativeTypes(fieldNameHandler NativeTypesFieldNameHandler, rawType reflect.Type) ([]*nativeType, error) {\n    nt, err := newNativeType(fieldNameHandler, rawType)\n    if err != nil {\n        return nil, err\n    }\n    result := []*nativeType{nt}\n\n    var iterateStructMembers func(reflect.Type)\n    iterateStructMembers = func(t reflect.Type) {\n        if k := t.Kind(); k == reflect.Pointer || k == reflect.Slice || k == reflect.Array || k == reflect.Map {\n            iterateStructMembers(t.Elem())\n            return\n        }\n        if t.Kind() != reflect.Struct {\n            return\n        }\n\n        nt, ntErr := newNativeType(fieldNameHandler, t)\n        if ntErr != nil {\n            err = ntErr\n            return\n        }\n        result = append(result, nt)\n\n        for idx := 0; idx < t.NumField(); idx++ {\n            iterateStructMembers(t.Field(idx).Type)\n        }\n    }\n    iterateStructMembers(rawType)\n\n    return result, err\n}\n```\n\nAs 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.\n\n## Reproduction\n\n```go\npackage main\n\nimport (\n    \"fmt\"\n    \"reflect\"\n\n    \"github.com/google/cel-go/cel\"\n    \"github.com/google/cel-go/ext\"\n)\n\n// Simulates a library type; developer never registers this directly.\ntype AuthCtx struct {\n    UserID string `json:\"userId\"`\n    Secret string `json:\"-\"` // server-internal; never appears in JSON output\n}\n\n// Developer registers only this type.\ntype Req struct{ Auth AuthCtx `json:\"auth\"` }\n\nfunc main() {\n    env, _ := cel.NewEnv(\n        // Only Req is passed; AuthCtx is registered silently by newNativeTypes.\n        ext.NativeTypes(reflect.TypeOf(Req{}), ext.ParseStructTag(\"json\")),\n        cel.Variable(\"req\", cel.ObjectType(\"main.Req\")),\n    )\n    ast, _ := env.Compile(`dyn(req.auth)[\"-\"]`)\n    prg, _ := env.Program(ast)\n    out, _, _ := prg.Eval(map[string]any{\n        \"req\": Req{Auth: AuthCtx{UserID: \"alice\", Secret: \"sk-live-s3cr3t\"}},\n    })\n    fmt.Println(out) // sk-live-s3cr3t\n}\n```\n\n**Expected:** expression compile error or empty result; `json:\"-\"` field should not be\naccessible.  \n**Actual:** `sk-live-s3cr3t`; the server-injected secret is returned verbatim.\n\nThe same field is also included under key `\"-\"` in `ConvertToNative(jsonStructType)`\noutput, and appears in `FindStructFieldNames` enumeration.\n\n### path 1. CEL indexing\n\nTested against the released module `github.com/google/cel-go v0.28.1`\n(latest stable release as of 2026-05-12), using the `go.mod` entry:\n\n```\nrequire github.com/google/cel-go v0.28.1\n```\n\nRunning the PoC above (`go run main.go`) produces:\n\n```\nsk-live-s3cr3t\n```\n\nThe secret value is returned verbatim, with no error at compile time or at runtime.\n\n### Path 2. `ConvertToNative(jsonStructType)`\n\nWhen the `nativeObj` for the `AuthCtx` value is converted to a Protobuf `Struct`\n(the representation used whenever CEL output is serialised to JSON), the\n`json:\"-\"` field appears in the output map under the key `\"-\"`.\n\n```go\npackage main\n\nimport (\n    \"encoding/json\"\n    \"fmt\"\n    \"reflect\"\n\n    \"github.com/google/cel-go/cel\"\n    \"github.com/google/cel-go/ext\"\n\n    structpb \"google.golang.org/protobuf/types/known/structpb\"\n)\n\ntype AuthCtxConv struct {\n    UserID string `json:\"userId\"`\n    Secret string `json:\"-\"` // should never appear in JSON output\n}\n\ntype ReqConv struct{ Auth AuthCtxConv `json:\"auth\"` }\n\nfunc main() {\n    env, _ := cel.NewEnv(\n        ext.NativeTypes(reflect.TypeOf(ReqConv{}), ext.ParseStructTag(\"json\")),\n        cel.Variable(\"req\", cel.ObjectType(\"main.ReqConv\")),\n    )\n\n    ast, _ := env.Compile(`req.auth`)\n    prg, _ := env.Program(ast)\n    out, _, _ := prg.Eval(map[string]any{\n        \"req\": ReqConv{Auth: AuthCtxConv{UserID: \"alice\", Secret: \"sk-live-s3cr3t\"}},\n    })\n\n    jsonStructType := reflect.TypeOf(&structpb.Struct{})\n    raw, _ := out.ConvertToNative(jsonStructType)\n\n    st := raw.(*structpb.Struct)\n    b, _ := json.MarshalIndent(st.AsMap(), \"\", \"  \")\n    fmt.Printf(\"ConvertToNative(jsonStructType) output:\\n%s\\n\", b)\n    fmt.Printf(\"\\nDirect field access via \\\"-\\\" key present: %v\\n\", st.Fields[\"-\"] != nil)\n    if v, ok := st.Fields[\"-\"]; ok {\n        fmt.Printf(\"Value: %s\\n\", v.GetStringValue())\n    }\n}\n```\n\nRunning the PoC above produces:\n\n```\nConvertToNative(jsonStructType) output:\n{\n  \"-\": \"sk-live-s3cr3t\",\n  \"userId\": \"alice\"\n}\n\nDirect field access via \"-\" key present: true\nValue: sk-live-s3cr3t\n```\n\nThe `\"-\"` key is present in the serialised Protobuf struct alongside `userId`.\nAny 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.\n\n## Impact\n\nAny 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.\n\n## Remediation\n\nDo not treat `json:\"-\"` as a CEL field named `\"-\"`. Model it as an explicit skipped field, not as an empty string field name.\n\nUpdate 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.\n\nApply that skip decision consistently anywhere native fields are exposed or resolved:\n\n- duplicate-name validation in `newNativeType`\n- field enumeration in `FindStructFieldNames`\n- field type lookup in `FindStructFieldType`\n- runtime lookup in `fieldByName` / `hasField`\n- object construction in `NewValue`\n- JSON conversion in `ConvertToNative(jsonStructType)`\n\nApply the same omit handling for `xml:\"-\"`, `yaml:\"-\"`, and `bson:\"-\"` where `ParseStructTag` is used.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "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"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/google/cel-go"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.22.0"
      }, {
        "fixed" : "0.29.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.28.1"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/cel-expr/cel-go/security/advisories/GHSA-gcjh-h69q-9w9g"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/cel-expr/cel-go"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-495" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-24T16:48:56Z",
    "nvd_published_at" : null
  }
}