Fix marshalling of non-public classes and Serializable beans - #16296
Fix marshalling of non-public classes and Serializable beans#16296sbglasius wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16296 +/- ##
==================================================
+ Coverage 54.8238% 54.9291% +0.1053%
- Complexity 20521 20567 +46
==================================================
Files 2104 2104
Lines 101102 101114 +12
Branches 17932 17934 +2
==================================================
+ Hits 55428 55541 +113
+ Misses 37787 37656 -131
- Partials 7887 7917 +30
🚀 New features to boost your workflow:
|
| * Hands out instances of Groovy classes that are not public, but whose read methods are. This is the | ||
| * shape produced by an anonymous implementation of a public interface inside a Grails controller. | ||
| */ | ||
| @CompileStatic |
There was a problem hiding this comment.
Question: Any need to test a Groovy bean that isn't @CompileStatic? Probably not, but I thought I'd ask the question anyway.
There was a problem hiding this comment.
It doesn't make a difference if it's @CompileStatic or not. Reflection wise the class is very similar looking.
There was a problem hiding this comment.
A DynamicGroovyPersonFactory was added, just to be on the safe side.
| if (readMethod.getAnnotation(PersistenceMethod.class) != null) continue; | ||
| if (readMethod.getAnnotation(ControllerMethod.class) != null) continue; | ||
| Object value = readMethod.invoke(o, (Object[]) null); | ||
| Method invokableMethod = ClassUtils.getInterfaceMethodIfPossible(readMethod, clazz); |
There was a problem hiding this comment.
Doesn't this make it permanently accessible and not just for this code?
There was a problem hiding this comment.
Yes, it does, but only for non-interface classes. The full answer goes like this:
== beans.PkgWithInterface (class public: false)
raw read method canAccess from this package: false
getInterfaceMethodIfPossible -> beans.Public (same object as raw: false)
resolved canAccess BEFORE makeAccessible: true <- already accessible
resolved canAccess AFTER makeAccessible: true
the raw class-declared method still canAccess: false <- untouched
== beans.PkgNoInterface (class public: false)
getInterfaceMethodIfPossible -> beans.PkgNoInterface (same object as raw: true)
resolved canAccess BEFORE makeAccessible: false
resolved canAccess AFTER makeAccessible: true <- flag flipped
a later BeanUtils+resolve sees it accessible: true (same object: true)
a FRESH getDeclaredMethod copy canAccess: false <- not globally opened
- Interface case (the reported bug — anonymous
UserDetails):getInterfaceMethodIfPossiblehands back a different Method, the interface's, which is public-on-public.ReflectionUtils.makeAccessibleshort-circuits and nothing is mutated. The class's own read method stays inaccessible. - No-interface case (package-private class, public getter, nothing to resolve to): yes,
setAccessible(true)fires and it persists. Two bounds on how far:- The flag lives on the Method instance, not on class metadata.
BeanUtils.getPropertyDescriptorsis backed by the staticCachedIntrospectionResultscache and returns the same instance every call (confirmed above), so anything else in the JVM that asks Spring for that class's descriptors gets an already-invokable method, for the lifetime of that cache. - A fresh
getDeclaredMethod/getMethodcopy is still false. The member is not globally opened, and this grants nothing a caller couldn't get itself — any classpath code in the unnamed module cansetAccessiblea public method of a non-public class. It's also the pattern Spring itself uses on cached members (AutowiredAnnotationBeanPostProcessor,AbstractNestablePropertyAccessor).
- The flag lives on the Method instance, not on class metadata.
If you'd rather not mutate shared cached state at all, the clean alternative is to work on our own copy — clazz.getDeclaredMethod(name) returns a new instance per call (also confirmed above), so setAccessible on that leaks nowhere. Cost is one extra reflective lookup per property per marshal unless we cache it ourselves.
There was a problem hiding this comment.
I'm honestly not sure in this case. I'm hoping we can discuss in the weekly - instead of fixing this, why not force people to make those inner classes public?
There was a problem hiding this comment.
@jdaugherty I don't feel we reached an agreement on the weekly?
And with regards to a case, where access is widened, is that even a problem. Every developer can do so them self, using the exact same techniques.
We could also easily go for the "not mutated" case.
IMO Grails does not have to become the framework where everything has to be according to standards.
There was a problem hiding this comment.
I don't see much downside to going the "not mutated" case but I acknowledge the concern may be performance, as you put it: "Cost is one extra reflective lookup per property per marshal." I think this is an acceptable cost, but it's easy for me to say that as I don't have visibility into that potential cost for other people. I will accept that low cost. I'm a thumbs up on the "not mutated" case suggestion.
|
@sbglasius is this a result of Groovy now honoring the modifiers where previously it would treat package private / protected as public? |
Yes, it is because Groovy 4 stamped ACC_PUBLIC on anonymous inner classes, Groovy 5 keeps them package-private. It would actually show in Groovy 4, if a class was marked |
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
c3446ef to
98fb828
Compare
|
I question if we should instead just force people to update their code to make those inner classes public. @sbglasius I think you said the plugin itself didn't do this, can't we fix that instead? Otherwise, we're encouraging bad habbits as newer versions of groovy are released. (I am ultimately ok with this change, but I'm playing devil's advocate here to ensure we're not introducing something that should just be a note in the upgrade guide). I'm also curious what @matrei thinks on this. |
Marshalling `someObject as JSON` (or `as XML`) failed for two common shapes of object. A class that is not public — anonymous, local or package-private — cannot have its read methods invoked reflectively from another package, even though the methods themselves are public. All four bean marshallers called `readMethod.invoke(...)` bare, so handing `as JSON` an anonymous implementation of a public interface (the usual shape of a Spring Security `UserDetails`) blew up with an `IllegalAccessException`. The read method is now resolved to the interface method where one exists and made accessible otherwise. The public field loops of both `GroovyBeanMarshaller`s failed the same way and are now made accessible too. `GenericJavaBeanMarshaller` evaluated `field.canAccess(o)` before the static check, and `canAccess` throws `IllegalArgumentException` for a static member when the object is non-null. Any bean declaring `private static final long serialVersionUID` — nearly every Serializable bean — therefore failed. The modifier checks now run first so they short-circuit. Groovy compiles the variables captured by an anonymous class into public synthetic `Reference` fields. Now that the field loops can read them, they were emitted as duplicate keys with empty-object values, so synthetic fields are skipped in all four marshallers. Fixes #16294 Fixes #16295
The existing fixtures were all `@CompileStatic`. Dynamic and static Groovy compile an anonymous class to the same shape — a package-private class with public synthetic `Reference` fields for the captured variables — so this covers the same reflection path rather than a new one, and guards against the two compilation modes diverging. Raised in review of #16296.
98fb828 to
cc6f5c8
Compare
✅ All tests passed ✅🏷️ Commit: cc6f5c8 Learn more about TestLens at testlens.app/docs. |
I was under the impression that more than just inner classes are in play, such as anonymous classes? |
|
@jdaugherty What @bkoehm is a good point. It's not just non-public classes that's the issue, also anonymous classes. Does that change your mind? |
Fixes #16294
Fixes #16295
someObject as JSON(andas XML) failed for two very common shapes of object. Both are fixed here, since fixing the first one only exposes the second.#16294 —
IllegalAccessExceptionon a non-public classA class that is not public — anonymous, local or package-private — cannot have its read methods invoked reflectively from another package, even though the methods themselves carry the
publicmodifier. All four bean marshallers calledreadMethod.invoke(...)with no accessibility handling, so handingas JSONan anonymous implementation of a public interface (the usual shape of a Spring SecurityUserDetails) failed with:The read method is now resolved to the interface method where one exists, and made accessible otherwise:
Resolving to the interface is not sufficient on its own — a package-private class with a public getter and no interface has nowhere to resolve to — so
makeAccessiblecarries that case.The public field loops of both
GroovyBeanMarshallers failed identically onfield.get(o)and are now made accessible too.#16295 —
IllegalArgumentExceptionon anySerializablebeanGenericJavaBeanMarshallerevaluatedfield.canAccess(o)before the static check, and per its javadoccanAccessthrows for a static member when the object is non-null. Any bean declaringprivate static final long serialVersionUID— nearly everySerializablebean — therefore failed:The modifier checks now run first so they short-circuit before
canAccess. This is a regression in the 8.x line from9e60b8a4de, which mechanically swapped the non-throwingisAccessible()forcanAccess(o).One change beyond the two issues
Groovy compiles the variables captured by an anonymous class into
ACC_PUBLIC | ACC_SYNTHETICgroovy.lang.Referencefields. Once the field loops could actually read them, they were emitted as duplicate keys with empty-object values ({"name":{},"age":{}}) — the new tests caught exactly that. Synthetic fields are compiler artifacts and never part of a bean's state, so they are now skipped in all four marshallers.Files changed
grails-converters/.../marshaller/json/GroovyBeanMarshaller.javagrails-converters/.../marshaller/json/GenericJavaBeanMarshaller.javagrails-converters/.../marshaller/xml/GroovyBeanMarshaller.javagrails-converters/.../marshaller/xml/GenericJavaBeanMarshaller.javaTests
New fixtures under
org.grails.web.converters.beans, deliberately in a different package from the marshallers — in the same package the JVM access check passes and neither bug reproduces. They cover a public interface with both abstract anddefaultread methods (mirroringUserDetails), Groovy and Java anonymous / package-private / no-interface implementations, an anonymous class with a declared public field, and aSerializablebean carryingprivate static final long serialVersionUIDalongside a public constant and a public instance field.Two new specs of 9 features each, driven through the public
new JSON(x)/new XML(x)API:json.NonPublicClassMarshallingSpecandxml.NonPublicClassMarshallingSpec. Every non-public case first asserts!Modifier.isPublic(person.getClass().modifiers), so the tests fail loudly rather than silently passing if a future compiler stops producing a non-public class.With the production change reverted, 18 of 18 fail with the two reported exceptions verbatim; with it, 18/18 pass. Also green locally: full
:grails-converters:test,:grails-rest-transforms:test,:grails-test-suite-web:test,:grails-test-suite-uber:test,:grails-web-common:testand:grails-converters:codeStyle.No documentation change: this restores the documented behaviour of
as JSON/as XMLand adds no public API.