Skip to content

fix: has() on non-container types returns error instead of false#1298

Open
kodareef5 wants to merge 1 commit intogoogle:masterfrom
kodareef5:fix/default-error-on-bad-presence-test
Open

fix: has() on non-container types returns error instead of false#1298
kodareef5 wants to merge 1 commit intogoogle:masterfrom
kodareef5:fix/default-error-on-bad-presence-test

Conversation

@kodareef5
Copy link
Copy Markdown

Summary

has() applied to a non-container type (integer, string, boolean, null) previously returned false silently when EnableErrorOnBadPresenceTest was not set. This caused !has(x.field) to evaluate to true when x had an unexpected type, creating a fail-open condition in policy expressions that use presence guards on dynamically typed inputs.

This change splits the default case in refQualify (interpreter/attributes.go) so that:

  • has() (presenceOnly=true): errors with missingKey on non-container types, preventing silent false returns
  • Optional select ?. (presenceOnly=false): continues to return not-present, preserving optional.none() compatibility

The EnableErrorOnBadPresenceTest option continues to work as before when explicitly set.

Example

env, _ := cel.NewEnv(cel.Variable("request", cel.DynType))
ast, _ := env.Parse(`!has(request.field)`)
prg, _ := env.Program(ast)

// Before: silently returns true (fail-open)
// After: returns error (fail-closed)
out, _, _ := prg.Eval(map[string]any{"request": 42})

Test changes

One test expectation updated: has({'foo': optional.none()}.foo.value) now expects an error ("no such key: value") instead of false, since optional.none() is not a container type and has() should not silently return false on it.

All existing tests pass. Full suite green.

Comment thread cel/cel_test.go
},
{
expr: `has({'foo': optional.none()}.foo.value)`,
out: types.False,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the expected behavior for accesses within an optional value. If the test were for a null return value, then you'd expect a no such key error

@kodareef5 kodareef5 force-pushed the fix/default-error-on-bad-presence-test branch from 755923c to 84f0136 Compare April 5, 2026 11:34
@kodareef5
Copy link
Copy Markdown
Author

Good catch, thank you. You're right — has(optional_none.value) returning false is correct optional semantics, not a type error.

I've updated the fix to narrow the scope. The default case in refQualify now checks whether the value is a *types.Optional before deciding:

  • has() on *types.Optional (e.g. has(optional_none.value)) → returns false (preserves optional semantics, no change)
  • has() on a primitive like types.Int (e.g. has(42.field)) → returns missingKey error (the fix)
  • Optional select ?. on any non-container → returns not-present (preserves optional.none(), no change)

No test expectations changed. Full suite passes.

Comment thread interpreter/attributes.go
default:
if presenceTest && !errorOnBadPresenceTest {
return nil, false, nil
if _, isOpt := celVal.(*types.Optional); isOpt || !presenceOnly {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test case that demonstrates the behavior addressed by this change? The presenceOnly flag is just an optimization to indicate that the return value isn't necessary - only the presence bit.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test cases for has() on int, string, bool, and null via dyn():

{expr: `has(dyn(42).field)`, out: "no such key: field"},
{expr: `has(dyn('hello').field)`, out: "no such key: field"},
{expr: `has(dyn(true).field)`, out: "no such key: field"},
{expr: `has(dyn(null).field)`, out: "no such key: field"},

On the presenceOnly flag -- you're right that it's an optimization. In this context it serves as the only available signal that distinguishes has() (where testOnlyQualifier always passes presenceOnly=true) from ?. optional select (where applyQualifiers passes presenceOnly=false). The ?. path needs to keep returning not-present on non-container types to preserve optional.none() chaining, while has() on a raw primitive like dyn(42).field should error rather than silently returning false.

I initially tried dropping the presenceOnly check and erroring for both paths, but that breaks the ?. tests ({0: dyn(0)}[?0].?invalid expects optional.none(), not an error). Happy to restructure if you'd prefer a different approach, e.g. threading a separate flag through the qualifier chain.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These cases error properly though when EnableErrorOnBadPresenceTest(true) is enabled, so I think that's why I'm not clear on what the issue is that you're trying to address.

has() applied to a non-container, non-optional value (e.g., an integer
or string) previously returned false silently when
errorOnBadPresenceTest was not enabled. This caused !has(x.field) to
evaluate to true when x was an unexpected type, creating a fail-open
condition in policy expressions that use presence guards on dynamic
inputs.

This change narrows the default case in refQualify so that:

- has() on a non-container, non-optional type (presenceOnly=true):
  returns missingKey error, preventing silent false returns
- has() on an optional value (e.g. optional.none()): continues to
  return false, preserving correct optional semantics
- Optional field selection (x.?field, presenceOnly=false): continues
  to return not-present for optional.none() compatibility
- EnableErrorOnBadPresenceTest(true): errors for all cases (unchanged)

No test expectations changed. Full suite green.

Signed-off-by: Koda Reef <koda.reef5@gmail.com>
@kodareef5 kodareef5 force-pushed the fix/default-error-on-bad-presence-test branch from 84f0136 to 9294dcf Compare April 8, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants