[
https://issues.apache.org/jira/browse/GROOVY-12238?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12238:
-------------------------------
Description:
h2. Problem
{{SecureASTCustomizer}} visits the script statement block and method bodies
only. Code which lives outside a method body is never handed to the securing
visitor, so none of the configured restrictions apply to it - not
{{disallowedReceivers}}, not the statement or expression allow/deny lists, and
not any registered {{StatementChecker}} or {{ExpressionChecker}}.
Four constructs are affected: constructor bodies, static initializer blocks,
instance (object) initializer blocks, and field initializer expressions (static
and instance).
With {{disallowedReceivers = ['java.lang.System']}} configured, every one of
the following compiles and runs today, while the same call in the script body
is correctly rejected:
{code:groovy}
class A { A() { System.getProperty('x') } } // constructor
class B { static { System.getProperty('x') } } // static initializer
class C { { System.getProperty('x') } } // instance initializer
class D { def f = System.getProperty('x') } // field initializer
class E { static def f = System.getProperty('x') } // static field
initializer
{code}
The existing filters cannot be reused to reach these, for three separate
reasons:
* a static initializer block ends up inside a {{<clinit>}} method, and
{{<clinit>}} is *synthetic*, so it is excluded both by {{filterMethods}} and by
the {{!isSynthetic()}} test in the sibling-class loop;
* instance initializer blocks live in
{{ClassNode.getObjectInitializerStatements()}}, a separate list which is never
read;
* field initializers live in {{FieldNode.getInitialExpression()}}, and the
backing fields of Groovy properties are themselves *synthetic*, so the
synthetic flag cannot be used to filter them either.
h2. Change
Adds {{SecureASTCustomizer.visitConstructorsAndInitializers(ClassNode,
GroovyCodeVisitor)}}, called for every class in the module, which applies the
existing securing visitor to:
* declared constructors (non-synthetic, with a body)
* {{getObjectInitializerStatements()}}
* the statements inside {{<clinit>}}
* {{FieldNode.getInitialExpression()}}
The method is {{protected}} so subclasses can adjust it, consistent with
{{createGroovyCodeVisitor}} and {{filterMethods}}.
h2. Distinguishing generated code
This is the part worth reviewing closely.
Constructors and initializers are not written solely by the author of the
source being secured. The compiler generates constructors for every script
class, and AST transformations add constructors, fields and initializer
statements of their own. Checking those rejects valid programs rather than
restricting the author.
A first cut which visited everything broke 4 of the 81 existing customizer
tests, all on generated code:
{noformat}
ConstructorCallExpressions are not allowed: super (context)
Usage of variables of type [groovy.lang.Binding] is not allowed
Indirect import checks prevents usage of expression (x2)
{noformat}
Those are the script class's generated {{Script()}} and {{Script(Binding)}}
constructors - and they are *not* marked synthetic, so there is no flag
available to exclude them. The discriminator used instead is the source
position: a node is visited only when {{getLineNumber() > 0}}, expressed as an
{{isFromSource(ASTNode)}} helper. Generated nodes normally carry no source
position.
Two caveats a reviewer should weigh:
* This is a heuristic, not a guarantee. There are ~220 {{setSourcePosition}}
calls in the main source; a transformation which copies a source position onto
a generated constructor, field initializer or initializer statement would have
that code checked. Nothing found in testing does so, and {{@Grab}} looks safe
by inspection - it injects via {{addStaticInitializerStatements}} and
{{addObjectInitializerStatements}} using {{stmt()}}/{{callX()}} helpers, which
do not set positions - but {{@Grab}} was not exercised directly. If the
heuristic does misfire, the failure mode is a false rejection of a valid
program, not a silent hole.
* The {{<clinit>}} method's wrapper {{BlockStatement}} is itself synthetic even
when its statements are not, so the filter has to be applied per statement
rather than to the method body. Applying it at the method level leaves static
initializer blocks open.
h2. Testing
{{SecureASTCustomizerTest}} goes from 39 to 47 tests:
||Test||Purpose||
|{{testDisallowedReceiverInScriptBody}}|control; unchanged behaviour|
|{{testDisallowedReceiverInConstructor}}|closed gap|
|{{testDisallowedReceiverInStaticInitializer}}|closed gap|
|{{testDisallowedReceiverInObjectInitializer}}|closed gap|
|{{testDisallowedReceiverInFieldInitializer}}|closed gap|
|{{testDisallowedReceiverInStaticFieldInitializer}}|closed gap|
|{{testGeneratedScriptConstructorsAreNotChecked}}|generated {{super(Binding)}}
stays exempt|
|{{testTransformGeneratedConstructorIsNotChecked}}|{{@TupleConstructor}} output
stays exempt|
Verified:
* the five gap tests fail against unmodified master and pass with the change;
the script-body control passes in both
* the two exemption tests pass in both, by design - they exist to stop a later
simplification from dropping the source-position check, which is the property
most likely to regress silently
* 27 further scenarios probed during development ({{@Singleton}},
{{@Immutable}}, {{@TupleConstructor}}, {{@Canonical}}, {{@Lazy}},
{{@Delegate}}, traits, enums, records, inner classes) under both a
receiver-restriction config and an allow-list config showed results
byte-identical to baseline
* full test suite: 16551 tests, no failures
h2. Compatibility
This is a behavioural change: scripts which compile today under a
{{SecureASTCustomizer}} will be rejected if their constructors or initializers
violate the configured restrictions. That is the intent, but per
[COMPATIBILITY.md|https://github.com/apache/groovy/blob/master/COMPATIBILITY.md]
it is a breaking change and wants a dev@ decision plus a major version.
Targeted at 6.0.
Both {{Limitations}} sections - the user guide and the {{SecureASTCustomizer}}
javadoc - are updated in the same change, since they currently document these
gaps as behaviour.
h2. Out of scope
* *Annotation members* remain unvisited. Higher false-positive risk, and it
does not help the {{@ASTTest}} case that prompted this investigation: that
transformation moves its closure out of the AST into node metadata and
reconstructs it from raw source text, so no AST-level filter can see it at any
phase.
* *Constructors still do not count towards {{methodDefinitionAllowed}}*. Their
bodies are now checked, but declaring a constructor remains permitted. Making
constructors count is a second, separable breaking change.
h2. Note on scope
{{SecureASTCustomizer}} is a best-effort grammar filter, not a security
boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This change is hardening
which removes behaviour that is surprising to a developer following the
documentation; it does not alter that position, and a demonstrated bypass
remains by design rather than a vulnerability.
was:
h2. Problem
{{SecureASTCustomizer}} visits the script statement block and method bodies
only. Code which lives outside a method body is never handed to the securing
visitor, so none of the configured restrictions apply to it - not
{{disallowedReceivers}}, not the statement or expression allow/deny lists, and
not any registered {{StatementChecker}} or {{ExpressionChecker}}.
Four constructs are affected: constructor bodies, static initializer blocks,
instance (object) initializer blocks, and field initializer expressions (static
and instance).
With {{disallowedReceivers = ['java.lang.System']}} configured, every one of
the following compiles and runs today, while the same call in the script body
is correctly rejected:
{code:groovy}
class A { A() { System.getProperty('x') } } // constructor
class B { static { System.getProperty('x') } } // static initializer
class C { { System.getProperty('x') } } // instance initializer
class D { def f = System.getProperty('x') } // field initializer
class E { static def f = System.getProperty('x') } // static field
initializer
{code}
The existing filters cannot be reused to reach these, for three separate
reasons:
* a static initializer block ends up inside a {{<clinit>}} method, and
{{<clinit>}} is *synthetic*, so it is excluded both by {{filterMethods}} and by
the {{!isSynthetic()}} test in the sibling-class loop;
* instance initializer blocks live in
{{ClassNode.getObjectInitializerStatements()}}, a separate list which is never
read;
* field initializers live in {{FieldNode.getInitialExpression()}}, and the
backing fields of Groovy properties are themselves *synthetic*, so the
synthetic flag cannot be used to filter them either.
h2. Change
Adds {{SecureASTCustomizer.visitConstructorsAndInitializers(ClassNode,
GroovyCodeVisitor)}}, called for every class in the module, which applies the
existing securing visitor to:
* declared constructors (non-synthetic, with a body)
* {{getObjectInitializerStatements()}}
* the statements inside {{<clinit>}}
* {{FieldNode.getInitialExpression()}}
The method is {{protected}} so subclasses can adjust it, consistent with
{{createGroovyCodeVisitor}} and {{filterMethods}}.
h2. Distinguishing generated code
This is the part worth reviewing closely.
Constructors and initializers are not written solely by the author of the
source being secured. The compiler generates constructors for every script
class, and AST transformations add constructors, fields and initializer
statements of their own. Checking those rejects valid programs rather than
restricting the author.
A first cut which visited everything broke 4 of the 81 existing customizer
tests, all on generated code:
{noformat}
ConstructorCallExpressions are not allowed: super (context)
Usage of variables of type [groovy.lang.Binding] is not allowed
Indirect import checks prevents usage of expression (x2)
{noformat}
Those are the script class's generated {{Script()}} and {{Script(Binding)}}
constructors - and they are *not* marked synthetic, so there is no flag
available to exclude them. The discriminator used instead is the source
position: a node is visited only when {{getLineNumber() > 0}}, expressed as an
{{isFromSource(ASTNode)}} helper. Generated nodes normally carry no source
position.
Two caveats a reviewer should weigh:
* This is a heuristic, not a guarantee. There are ~220 {{setSourcePosition}}
calls in the main source; a transformation which copies a source position onto
a generated constructor, field initializer or initializer statement would have
that code checked. Nothing found in testing does so, and {{@Grab}} looks safe
by inspection - it injects via {{addStaticInitializerStatements}} and
{{addObjectInitializerStatements}} using {{stmt()}}/{{callX()}} helpers, which
do not set positions - but {{@Grab}} was not exercised directly. If the
heuristic does misfire, the failure mode is a false rejection of a valid
program, not a silent hole.
* The {{<clinit>}} method's wrapper {{BlockStatement}} is itself synthetic even
when its statements are not, so the filter has to be applied per statement
rather than to the method body. Applying it at the method level leaves static
initializer blocks open.
h2. Testing
{{SecureASTCustomizerTest}} goes from 39 to 47 tests:
||Test||Purpose||
|{{testDisallowedReceiverInScriptBody}}|control; unchanged behaviour|
|{{testDisallowedReceiverInConstructor}}|closed gap|
|{{testDisallowedReceiverInStaticInitializer}}|closed gap|
|{{testDisallowedReceiverInObjectInitializer}}|closed gap|
|{{testDisallowedReceiverInFieldInitializer}}|closed gap|
|{{testDisallowedReceiverInStaticFieldInitializer}}|closed gap|
|{{testGeneratedScriptConstructorsAreNotChecked}}|generated {{super(Binding)}}
stays exempt|
|{{testTransformGeneratedConstructorIsNotChecked}}|{{@TupleConstructor}} output
stays exempt|
Verified:
* the five gap tests fail against unmodified master and pass with the change;
the script-body control passes in both
* the two exemption tests pass in both, by design - they exist to stop a later
simplification from dropping the source-position check, which is the property
most likely to regress silently
* 27 further scenarios probed during development ({{@Singleton}},
{{@Immutable}}, {{@TupleConstructor}}, {{@Canonical}}, {{@Lazy}},
{{@Delegate}}, traits, enums, records, inner classes) under both a
receiver-restriction config and an allow-list config showed results
byte-identical to baseline
* full test suite: 16551 tests, no failures
h2. Compatibility
This is a behavioural change: scripts which compile today under a
{{SecureASTCustomizer}} will be rejected if their constructors or initializers
violate the configured restrictions. That is the intent, but per
[COMPATIBILITY.md|https://github.com/apache/groovy/blob/master/COMPATIBILITY.md]
it is a breaking change and wants a dev@ discussion plus a major version.
Targeted at 6.0.
Both {{Limitations}} sections - the user guide and the {{SecureASTCustomizer}}
javadoc - are updated in the same change, since they currently document these
gaps as behaviour.
h2. Out of scope
* *Annotation members* remain unvisited. Higher false-positive risk, and it
does not help the {{@ASTTest}} case that prompted this investigation: that
transformation moves its closure out of the AST into node metadata and
reconstructs it from raw source text, so no AST-level filter can see it at any
phase.
* *Constructors still do not count towards {{methodDefinitionAllowed}}*. Their
bodies are now checked, but declaring a constructor remains permitted. Making
constructors count is a second, separable breaking change.
h2. Note on scope
{{SecureASTCustomizer}} is a best-effort grammar filter, not a security
boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This change is hardening
which removes behaviour that is surprising to a developer following the
documentation; it does not alter that position, and a demonstrated bypass
remains by design rather than a vulnerability.
> Cover additional SecureASTCustomizer cases
> ------------------------------------------
>
> Key: GROOVY-12238
> URL: https://issues.apache.org/jira/browse/GROOVY-12238
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
>
> h2. Problem
> {{SecureASTCustomizer}} visits the script statement block and method bodies
> only. Code which lives outside a method body is never handed to the securing
> visitor, so none of the configured restrictions apply to it - not
> {{disallowedReceivers}}, not the statement or expression allow/deny lists,
> and not any registered {{StatementChecker}} or {{ExpressionChecker}}.
> Four constructs are affected: constructor bodies, static initializer blocks,
> instance (object) initializer blocks, and field initializer expressions
> (static and instance).
> With {{disallowedReceivers = ['java.lang.System']}} configured, every one of
> the following compiles and runs today, while the same call in the script body
> is correctly rejected:
> {code:groovy}
> class A { A() { System.getProperty('x') } } // constructor
> class B { static { System.getProperty('x') } } // static initializer
> class C { { System.getProperty('x') } } // instance
> initializer
> class D { def f = System.getProperty('x') } // field initializer
> class E { static def f = System.getProperty('x') } // static field
> initializer
> {code}
> The existing filters cannot be reused to reach these, for three separate
> reasons:
> * a static initializer block ends up inside a {{<clinit>}} method, and
> {{<clinit>}} is *synthetic*, so it is excluded both by {{filterMethods}} and
> by the {{!isSynthetic()}} test in the sibling-class loop;
> * instance initializer blocks live in
> {{ClassNode.getObjectInitializerStatements()}}, a separate list which is
> never read;
> * field initializers live in {{FieldNode.getInitialExpression()}}, and the
> backing fields of Groovy properties are themselves *synthetic*, so the
> synthetic flag cannot be used to filter them either.
> h2. Change
> Adds {{SecureASTCustomizer.visitConstructorsAndInitializers(ClassNode,
> GroovyCodeVisitor)}}, called for every class in the module, which applies the
> existing securing visitor to:
> * declared constructors (non-synthetic, with a body)
> * {{getObjectInitializerStatements()}}
> * the statements inside {{<clinit>}}
> * {{FieldNode.getInitialExpression()}}
> The method is {{protected}} so subclasses can adjust it, consistent with
> {{createGroovyCodeVisitor}} and {{filterMethods}}.
> h2. Distinguishing generated code
> This is the part worth reviewing closely.
> Constructors and initializers are not written solely by the author of the
> source being secured. The compiler generates constructors for every script
> class, and AST transformations add constructors, fields and initializer
> statements of their own. Checking those rejects valid programs rather than
> restricting the author.
> A first cut which visited everything broke 4 of the 81 existing customizer
> tests, all on generated code:
> {noformat}
> ConstructorCallExpressions are not allowed: super (context)
> Usage of variables of type [groovy.lang.Binding] is not allowed
> Indirect import checks prevents usage of expression (x2)
> {noformat}
> Those are the script class's generated {{Script()}} and {{Script(Binding)}}
> constructors - and they are *not* marked synthetic, so there is no flag
> available to exclude them. The discriminator used instead is the source
> position: a node is visited only when {{getLineNumber() > 0}}, expressed as
> an {{isFromSource(ASTNode)}} helper. Generated nodes normally carry no source
> position.
> Two caveats a reviewer should weigh:
> * This is a heuristic, not a guarantee. There are ~220 {{setSourcePosition}}
> calls in the main source; a transformation which copies a source position
> onto a generated constructor, field initializer or initializer statement
> would have that code checked. Nothing found in testing does so, and {{@Grab}}
> looks safe by inspection - it injects via {{addStaticInitializerStatements}}
> and {{addObjectInitializerStatements}} using {{stmt()}}/{{callX()}} helpers,
> which do not set positions - but {{@Grab}} was not exercised directly. If the
> heuristic does misfire, the failure mode is a false rejection of a valid
> program, not a silent hole.
> * The {{<clinit>}} method's wrapper {{BlockStatement}} is itself synthetic
> even when its statements are not, so the filter has to be applied per
> statement rather than to the method body. Applying it at the method level
> leaves static initializer blocks open.
> h2. Testing
> {{SecureASTCustomizerTest}} goes from 39 to 47 tests:
> ||Test||Purpose||
> |{{testDisallowedReceiverInScriptBody}}|control; unchanged behaviour|
> |{{testDisallowedReceiverInConstructor}}|closed gap|
> |{{testDisallowedReceiverInStaticInitializer}}|closed gap|
> |{{testDisallowedReceiverInObjectInitializer}}|closed gap|
> |{{testDisallowedReceiverInFieldInitializer}}|closed gap|
> |{{testDisallowedReceiverInStaticFieldInitializer}}|closed gap|
> |{{testGeneratedScriptConstructorsAreNotChecked}}|generated
> {{super(Binding)}} stays exempt|
> |{{testTransformGeneratedConstructorIsNotChecked}}|{{@TupleConstructor}}
> output stays exempt|
> Verified:
> * the five gap tests fail against unmodified master and pass with the change;
> the script-body control passes in both
> * the two exemption tests pass in both, by design - they exist to stop a
> later simplification from dropping the source-position check, which is the
> property most likely to regress silently
> * 27 further scenarios probed during development ({{@Singleton}},
> {{@Immutable}}, {{@TupleConstructor}}, {{@Canonical}}, {{@Lazy}},
> {{@Delegate}}, traits, enums, records, inner classes) under both a
> receiver-restriction config and an allow-list config showed results
> byte-identical to baseline
> * full test suite: 16551 tests, no failures
> h2. Compatibility
> This is a behavioural change: scripts which compile today under a
> {{SecureASTCustomizer}} will be rejected if their constructors or
> initializers violate the configured restrictions. That is the intent, but per
> [COMPATIBILITY.md|https://github.com/apache/groovy/blob/master/COMPATIBILITY.md]
> it is a breaking change and wants a dev@ decision plus a major version.
> Targeted at 6.0.
> Both {{Limitations}} sections - the user guide and the
> {{SecureASTCustomizer}} javadoc - are updated in the same change, since they
> currently document these gaps as behaviour.
> h2. Out of scope
> * *Annotation members* remain unvisited. Higher false-positive risk, and it
> does not help the {{@ASTTest}} case that prompted this investigation: that
> transformation moves its closure out of the AST into node metadata and
> reconstructs it from raw source text, so no AST-level filter can see it at
> any phase.
> * *Constructors still do not count towards {{methodDefinitionAllowed}}*.
> Their bodies are now checked, but declaring a constructor remains permitted.
> Making constructors count is a second, separable breaking change.
> h2. Note on scope
> {{SecureASTCustomizer}} is a best-effort grammar filter, not a security
> boundary - see THREAT_MODEL.md sections 3, 9 and 11a. This change is
> hardening which removes behaviour that is surprising to a developer following
> the documentation; 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)