[
https://issues.apache.org/jira/browse/GROOVY-12244?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12244:
-------------------------------
Description:
h2. Summary up front
This is a small, safe and *speculative* change. Measured across 1632 real files
it produces zero false positives - and also zero benefit, because nothing in
that corpus exercises it. The only demonstrated case is hand-written. It is
offered on that basis rather than as something known to be needed; see "Is it
worth doing" below.
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}
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, and zero benefit.* 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
Arguments for: it is small, it reuses machinery already being added, it
completes the relocation story rather than leaving one destination uncovered,
and it generalises - it catches *any* transformation that relocates authored
code into a synthetic method, including third-party ones that will never be
reviewed here.
Arguments against: exactly one first-party annotation benefits, and no evidence
exists that anyone combines {{@ConditionalInterrupt}} with
{{SecureASTCustomizer}} in practice.
Someone with a view on whether that combination occurs in the wild should
decide this; the measurements above do not settle it.
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.
> 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
>
> h2. Summary up front
> This is a small, safe and *speculative* change. Measured across 1632 real
> files it produces zero false positives - and also zero benefit, because
> nothing in that corpus exercises it. The only demonstrated case is
> hand-written. It is offered on that basis rather than as something known to
> be needed; see "Is it worth doing" below.
> 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}
> 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, and zero benefit.* 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
> Arguments for: it is small, it reuses machinery already being added, it
> completes the relocation story rather than leaving one destination uncovered,
> and it generalises - it catches *any* transformation that relocates authored
> code into a synthetic method, including third-party ones that will never be
> reviewed here.
> Arguments against: exactly one first-party annotation benefits, and no
> evidence exists that anyone combines {{@ConditionalInterrupt}} with
> {{SecureASTCustomizer}} in practice.
> Someone with a view on whether that combination occurs in the wild should
> decide this; the measurements above do not settle it.
> 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)