Skip to content

AC: resolve a shadowed field name to the most-derived declaration instead of the inherited one #149

Closed

Description

@MoonWorm

Fields are addressed by name throughout ReflectionUtils, and the map backing that lookup was
built by visiting a class's own fields first and then its superclasses — so for a field
shadowing an inherited one of the same name, the inherited declaration overwrote the
subclass one and won. That is backwards from Java, where this.field refers to the most-derived
declaration, and it is the field a serializer emits.

Three consequences, in order of severity. The @AccessControl annotation on a subclass field
was never seen, so the rule silently did not exist — no warning, no error, restricted data
served. Reads returned the inherited field's value, so access control inspected the wrong one.
Writes blanked the inherited field and left the visible one populated:

before:  resolves to Base   reads BASE-SECRET   nulling left CHILD-SECRET visible
after:   resolves to Child  reads CHILD-SECRET  nulling blanked the visible field

Visiting superclasses first fixes all three, so the most-derived declaration wins.

The ordering fix alone is not sufficient, which is worth stating plainly: a
Map<String, Field> holds one field per name, so whichever declaration wins, the other becomes
unreachable. An @AccessControl on the inherited field is still lost when a subclass
shadows it. Preferring the most-derived one is correct — it is the field that reaches the
response — but it cannot make both visible.

So the complement: ReflectionUtils.shadowedFieldNames(Class) reports names declared more than
once in a hierarchy, and access control warns when any declaration of a shadowed name carries
@AccessControl, telling the user to rename one of them. That turns a silent fail-open into a
startup warning, consistent with how the other unenforceable-requirement cases are handled.

Triggering this needs field shadowing in a DTO hierarchy, which is rare and a code smell in its
own right — presumably why it has gone unnoticed. The blast radius of the fix is limited to
shadowed names: findField, both getters, both setters, fetchFieldTypes, fetchFields and
fetchAnnotationForFields behave identically for every other field.

Found while investigating whether ObjectCopier should be folded into ReflectionUtils.
ObjectCopier was never affected — it keeps an ordered list rather than a name-keyed map, and
blanks both declarations, which fails closed.

Activity

  1. added a commit that references this issue on Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions