[ 
https://issues.apache.org/jira/browse/GROOVY-12244?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Paul King resolved GROOVY-12244.
--------------------------------
    Fix Version/s: 6.0.0-beta-2
       Resolution: Fixed

> SecureASTCustomizer does not check authored code relocated into a synthetic 
> method
> ----------------------------------------------------------------------------------
>
>                 Key: GROOVY-12244
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12244
>             Project: Groovy
>          Issue Type: Improvement
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>             Fix For: 6.0.0-beta-2
>
>
> h2. Summary up front
> This is about *consistency of treatment*, not about catching more code.
> Someone who configures {{disallowedReceivers = ['java.lang.System']}} holds a 
> single expectation: System calls are rejected in the source they compile. 
> After GROOVY-12238 that expectation holds everywhere - method bodies, 
> constructor bodies, static and instance initializers, field initializers, 
> closures relocated into a generated constructor by 
> {{@TupleConstructor(pre=...)}}, groovy-contracts conditions inlined into loop 
> bodies - with exactly one exception: {{@ConditionalInterrupt}}.
> Nothing visible to the user explains the exception. It exists because that 
> transformation relocates its closure into a *synthetic* method while the 
> others relocate into constructors, ordinary methods or generated classes, and 
> the customizer skips synthetic methods. That is an implementation detail of 
> one transformation leaking into observable behaviour, and no user could be 
> expected to predict it.
> Measured across 1632 real files the change produces *zero false positives*, 
> with the checks firing 78 times throughout. It produced no new catches on 
> that corpus, because Groovy's test tree happens to contain no case combining 
> {{@ConditionalInterrupt}} with a restriction - but the case for the change 
> does not rest on catching more code, it rests on the rule being uniform.
> Depends on GROOVY-12238, which introduces the two helpers it reuses.
> h2. Problem
> {{SecureASTCustomizer}} skips synthetic methods when visiting method bodies:
> {code:java}
> for (MethodNode methodNode : clNode.getMethods()) {
>     if (!methodNode.isSynthetic() && methodNode.getCode() != null) {
>         methodNode.getCode().visit(visitor);
>     }
> }
> {code}
> That is right for compiler-generated members, but a transformation may 
> relocate code the *user wrote* into a synthetic method. 
> {{ConditionalInterruptibleASTTransformation}} does exactly this - it lifts 
> the closure supplied to {{@ConditionalInterrupt}} into a private synthetic 
> method and injects calls to it at every method start and every loop:
> {code:groovy}
> type.addSyntheticMethod(conditionMethod, ACC_PRIVATE, ClassHelper.OBJECT_TYPE,
>         Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, conditionNode.code)
> {code}
> So with {{disallowedReceivers = ['java.lang.System']}} configured, this 
> compiles and runs today:
> {code:groovy}
> import groovy.transform.ConditionalInterrupt
> @ConditionalInterrupt({ System.getProperty('x') != null })
> class A { def m() { 1 } }
> {code}
> while the same call written directly in a method body is correctly rejected.
> This is the same relocation story as GROOVY-12238, one destination further 
> on. There, authored code moved into a generated *constructor* escaped the 
> checks; here it is a synthetic *method*. Dumping the AST at CANONICALIZATION 
> shows the code arrives with its source position intact, exactly as in the 
> constructor case:
> {noformat}
> @ConditionalInterrupt({ System.getProperty('x') != null })
>   method conditionalTransform...$condition  line=-1 synthetic=true codeLine=2
>     stmt[0] line=2   (java.lang.System.getProperty(x) != null)   <- authored, 
> relocated here
> {noformat}
> Set against the other transformations which relocate a user-supplied closure, 
> {{@ConditionalInterrupt}} is the only one left uncovered:
> ||Transformation||Where the authored closure lands||Checked after 
> GROOVY-12238?||
> |{{@TupleConstructor(pre/post)}}, {{@MapConstructor(pre/post)}}|generated 
> constructor|yes|
> |{{@AutoImplement(code)}}|generated method|yes|
> |groovy-contracts {{@Requires}}/{{@Ensures}}/{{@Invariant}}|generated closure 
> class added to the module|yes|
> |groovy-contracts loop {{@Invariant}}/{{@Decreases}}|inlined into the loop 
> body|yes|
> |*{{@ConditionalInterrupt}}*|*synthetic method*|*no*|
> |{{@ASTTest}}|nowhere in the AST - rebuilt from raw source text|no, and 
> unreachable by any AST-level check; it has its own off-switch, 
> {{groovy.asttest.enable}}|
> Each destination was chosen for a good reason - {{@ConditionalInterrupt}} 
> injects at every method start and every loop, so one method called many times 
> is cheaper than inlining the condition dozens of times per class. The point 
> is not that the transformation is wrong, but that its choice is invisible to 
> whoever configured the customizer.
> h2. Change
> About six lines, reusing {{visitAuthoredStatementsOf}} and {{isFromSource}} 
> from GROOVY-12238:
> {code:java}
> for (MethodNode method : clNode.getMethods()) {
>     if (method.isSynthetic() && !"<clinit>".equals(method.getName()) && 
> method.getCode() != null) {
>         visitAuthoredStatementsOf(method.getCode(), visitor);
>     }
> }
> {code}
> The per-statement source-position filter is what makes this safe: a synthetic 
> method's *generated* statements carry no source position and are skipped, 
> while relocated authored statements carry one and are checked. {{<clinit>}} 
> is excluded because GROOVY-12238 already handles it.
> h2. Evidence
> *Exposure is far narrower than "synthetic" suggests.* Nine common constructs 
> were scanned for synthetic methods containing source-positioned statements - 
> trait implementations, {{@Delegate}}, records, enums, plain properties, 
> closures in methods, {{@Immutable}}, {{@Sortable}}, 
> {{@ConditionalInterrupt}}. Only {{@ConditionalInterrupt}} produced one. 
> Generated accessors, delegate forwarders, record components, enum machinery 
> and trait bridges all carry unpositioned statements, so the filter excludes 
> them without needing to enumerate them. Notably, a trait's method body is 
> *not* copied onto the implementing class as a source-positioned synthetic 
> method, so there is no duplicate-checking problem there.
> *Corpus: zero false positives; no new catches, for want of a case to catch.* 
> Every {{.groovy}} file under {{src/test}} (1632 files; 22 using 
> {{@Grab}}/{{@Grapes}} excluded) compiled under a {{SecureASTCustomizer}} with 
> {{disallowedReceivers = ['java.lang.System', 'java.lang.Thread', 
> 'java.lang.Runtime', 'java.lang.ProcessBuilder']}}, on top of GROOVY-12238, 
> with and without this change:
> ||Corpus outcome||Without||With||
> |Compiled|1554|1554|
> |Rejected|78|78|
> Byte-identical. The 78 rejections show the checks were live throughout, so 
> the unchanged verdicts mean the change was exercised and stayed quiet. But no 
> file in the corpus was newly caught either: the three files using 
> {{@ConditionalInterrupt}} have conditions that do not touch the restricted 
> receivers, so the corpus could not demonstrate an upside.
> *Hand-written case:* {{@ConditionalInterrupt}} with a disallowed receiver is 
> permitted without the change and rejected with it. Full test suite passes.
> h2. Is it worth doing
> The case for it is consistency. A user configuring the customizer has no way 
> to know that one transformation relocates into a synthetic method while its 
> neighbours relocate into constructors, generated methods, generated classes 
> or the loop body itself, and that the customizer looks in every one of those 
> but the first. Uniform treatment is what makes the configuration 
> comprehensible; an exception that cannot be predicted from the source is the 
> kind of surprise THREAT_MODEL.md section 3 identifies as worth acting on, 
> whether or not it is common.
> It also generalises. The fix catches *any* transformation that relocates 
> authored code into a synthetic method, including third-party ones that will 
> never be reviewed here, which is the property that a per-transformation fix 
> would not have.
> The case against is that one first-party annotation is known to benefit, and 
> there is no evidence that anyone combines {{@ConditionalInterrupt}} with 
> {{SecureASTCustomizer}} today.
> That objection is worth weighing carefully, because it is an absence of 
> evidence rather than evidence of absence: the corpus is Groovy's own test 
> tree, never written to exercise that combination, so it could not have shown 
> a benefit however useful the change is. But the consistency argument does not 
> depend on the answer. Even at zero current users, the rule "restrictions 
> apply to the code you wrote" either holds uniformly or it does not, and today 
> it does not.
> h2. Why not change the transform instead
> Making the condition method non-synthetic would remove the need for this 
> change, and looks like a one-word fix. It is not:
> * {{isSynthetic()}} is load-bearing in three places in 
> {{ConditionalInterruptibleASTTransformation}} - the injection-eligibility 
> check, the traversal check, and a defensive guard on the condition method 
> itself. Two of them are what stop the condition method being instrumented 
> with its own interrupt check.
> * {{filterMethods}} excludes synthetic methods, so a non-synthetic condition 
> method would count as a *method definition*. Any class using 
> {{@ConditionalInterrupt}} would then be rejected under 
> {{methodDefinitionAllowed = false}} - a new false positive in 
> {{SecureASTCustomizer}}, created by trying to make code visible to 
> {{SecureASTCustomizer}}.
> More broadly, the transform is not doing anything wrong. It relocates 
> authored code into the AST with its source position intact, which is the 
> convention documented in {{ARCHITECTURE.md}} and the user guide; the 
> customizer is simply not looking there. Fixing the consumer covers every 
> transformation at once, whereas changing this transformation fixes one and 
> leaves the next one silently uncovered.
> h2. Scope note
> {{SecureASTCustomizer}} is a best-effort grammar filter, not a security 
> boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This is hardening which 
> removes surprising behaviour; it does not alter that position, and a 
> demonstrated bypass remains by design rather than a vulnerability.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to