Compilation Customizers & Secure Scripting

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14), except the one labelled "not run here". A compilation customizer is a hook that changes how a script or class is compiled without touching its source: it can add imports, apply an AST transformation, forbid constructs, or do any of those only for some files. Customizers are the standard way to build a DSL host (see Domain-Specific Languages in Groovy) and the first layer of defence when a Java application evaluates scripts written by someone else (the embedding APIs are on Integrating Groovy into Java Applications). The scenario is a greenhouse application that lets operators supply small rule scripts. Compile-time AST transformations themselves are covered on Writing AST Transformations & Macros. The same JSR 223 entry point is used from a JMeter test plan on JMeter Scripting with JSR 223 & Groovy.

CompilerConfiguration and the customizer model

Whether Groovy compiles with groovyc or evaluates text through GroovyShell, a org.codehaus.groovy.control.CompilerConfiguration is used under the hood. It holds the source encoding, the classpath, the target bytecode level and the list of compilation customizers. The pattern is always the same: create a configuration, add customizers, then hand the configuration to the host (GroovyShell, GroovyScriptEngine, GroovyClassLoader).

A customizer extends org.codehaus.groovy.control.customizers.CompilationCustomizer. It runs in one specific compilation phase and is called once for every class node being compiled. A script is itself a class, so a customizer sees the script class and every class declared inside the script. Groovy ships the customizers described below, and you can write your own by extending CompilationCustomizer and passing the phase to its constructor.

flowchart LR A[Script text] --> B[GroovyShell] C[CompilerConfiguration] --> B C --> D[ImportCustomizer] C --> E[ASTTransformationCustomizer] C --> F[SecureASTCustomizer] C --> G[SourceAwareCustomizer] D --> H[Compilation phases] E --> H F --> H G --> H H --> I[Loaded class]

Import customizer

ImportCustomizer adds imports to every compiled unit, so the author of a DSL script does not have to write them. It supports all the import forms of the language: class imports (optionally aliased), star imports, static imports (optionally aliased) and static star imports.

import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ImportCustomizer

def icz = new ImportCustomizer()
icz.addImports('java.util.concurrent.atomic.AtomicInteger')
icz.addImport('CHM', 'java.util.concurrent.ConcurrentHashMap')
icz.addStaticImport('java.lang.Math', 'PI')
icz.addStaticStars('java.lang.Math')

def config = new CompilerConfiguration()
config.addCompilationCustomizers(icz)

def shell = new GroovyShell(config)
def result = shell.evaluate '''
    def counter = new AtomicInteger(40)
    def map = new CHM()
    map.put('answer', counter.addAndGet(2))
    [map: map, area: (PI * 2 * 2).round(2), root: sqrt(144)]
'''
println result
assert result.map.answer == 42
[map:[answer:42], area:12.57, root:12.0]

The evaluated text contains no import line at all: AtomicInteger, the alias CHM, PI and sqrt all resolve because the customizer added the imports before the script was compiled. The other methods are addStarImports (a package, as in import java.util.concurrent.*) and addStaticImport with an alias (import static java.lang.Math.PI as pi).

AST transformation customizer

A global AST transformation applies to every class compiled as long as it is on the classpath, which increases compilation time and can change code that should not be touched. ASTTransformationCustomizer applies a transformation selectively, only in the compiler configuration it is added to. It also solves a practical problem: a script cannot be annotated, because its class is implicit, so @Log cannot be written on it. The customizer applies the transformation to the script class (and to every class declared in the script) as if the annotation were there. Annotation parameters are passed as named arguments.

import groovy.util.logging.Log
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer

def config = new CompilerConfiguration()
config.addCompilationCustomizers(new ASTTransformationCustomizer(Log, value: 'LOGGER'))

def script = new GroovyShell(config).parse('''
    LOGGER.info 'logger injected into a plain script'
    LOGGER.class.name
''')
println script.run()
println script.class.declaredFields*.name.findAll { it == 'LOGGER' }
oct 14, 2026 10:32:57 P. M. Script1 run
INFO: logger injected into a plain script
java.util.logging.Logger
[LOGGER]

The first two lines are the java.util.logging console handler writing to standard error (the date format follows the default locale). The customizer behaves like @Log(value='LOGGER'). Because the customizer works with objects rather than AST nodes, only some values convert to annotation parameters: primitives, strings and class literals become constant expressions, while a transformation that takes a closure (such as @ConditionalInterrupt) needs a ClosureExpression, which can be produced with AstBuilder.

The secure AST customizer

SecureASTCustomizer lets the author of a DSL restrict the grammar of the language: which constructs, tokens, constants, imports and method receivers a script may use. It works on an allow list (only what is listed is permitted) or a deny list (what is listed is forbidden), chosen separately for each feature, though an allow list is the usual choice. It can:

  • allow or forbid closures, method definitions and package definitions

  • restrict imports, static imports and star imports

  • restrict the receivers of method calls

  • restrict the AST expression and statement kinds

  • restrict the grammar tokens (operators) and the types of constants

The official documentation is explicit about the nature of the guarantee: the customizer is "secure" in one aspect only, limiting the constructs a DSL permits. It checks the syntax tree at compile time, not behaviour at run time, and it does not replace other security measures. The example is a rule language that only evaluates arithmetic.

import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.customizers.SecureASTCustomizer
import static org.codehaus.groovy.syntax.Types.*

def scz = new SecureASTCustomizer()
scz.with {
    closuresAllowed = false
    methodDefinitionAllowed = false
    allowedImports = []
    allowedStaticImports = []
    allowedStaticStarImports = ['java.lang.Math']
    allowedTokens = [PLUS, MINUS, MULTIPLY, DIVIDE, REMAINDER, POWER].asImmutable()
    allowedConstantTypesClasses = [Integer, Long, Double, BigDecimal, Integer.TYPE, Long.TYPE, Double.TYPE].asImmutable()
    allowedReceiversClasses = [Math, Integer, Long, Double, BigDecimal].asImmutable()
}
def config = new CompilerConfiguration()
config.addCompilationCustomizers(scz)

def run = { String src ->
    try {
        new GroovyShell(config).evaluate(src)
    } catch (MultipleCompilationErrorsException e) {
        'REJECTED: ' + e.errorCollector.getError(0).cause.message.readLines().first()
    }
}
println run('2 + 3 * 4')
println run('Math.sqrt(16) + 1')
println run('"ls".execute()')
println run('[1, 2].each { println it }')
println run('def f() { 1 }')
println run('import java.io.File; 1')
println run('System.exit(0)')
println run('1 < 2')
14
5.0
REJECTED: Method calls not allowed on [java.lang.String]
REJECTED: Method calls not allowed on [java.util.List]
REJECTED: Method definitions are not allowed
REJECTED: Importing [java.io.File] is not allowed
REJECTED: Method calls not allowed on [java.lang.System]
REJECTED: Token ("<" at 1:3:  "<" ) is not allowed

Two points from the output. A rejected script fails at compile time with a MultipleCompilationErrorsException, before any of it runs. And 1 < 2 is rejected because < is not in the token allow list, which shows how fine-grained the grammar control is: the allowed tokens are constants of org.codehaus.groovy.syntax.Types.

Custom statement and expression checkers

When the built-in flags are not enough, SecureASTCustomizer.ExpressionChecker and SecureASTCustomizer.StatementChecker are single-method interfaces (isAuthorized) that receive an AST node and return a boolean. There is, for example, no flag to forbid the direct field access a.@val, but an expression checker does it in one line.

import org.codehaus.groovy.ast.expr.AttributeExpression
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.customizers.SecureASTCustomizer

def scz = new SecureASTCustomizer()
scz.addExpressionCheckers({ expr -> !(expr instanceof AttributeExpression) } as SecureASTCustomizer.ExpressionChecker)
def config = new CompilerConfiguration()
config.addCompilationCustomizers(scz)

def run = { String src ->
    try { new GroovyShell(config).evaluate(src) }
    catch (MultipleCompilationErrorsException e) { 'REJECTED: ' + e.errorCollector.getError(0).cause.message.readLines().first() }
}
println run('class P { int val }; def p = new P(val: 123); p.@val')
println run('class P { int val }; def p = new P(val: 123); p.val')
REJECTED: Expression [AttributeExpression] is not allowed: p.@val
123

What the secure AST customizer does not do

The customizer sees static types written in the source, never run-time types, and that is the root of its limits. The receiver of a call such as s.getProperty(...) is whatever type the AST declares for s; with def that is Object. A deny list that names java.lang.System therefore stops System.getProperty(...) but not the same call made through a variable. An allow list closes the gap, because Object is not on it.

import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.customizers.SecureASTCustomizer

def shellWith = { Closure setup ->
    def scz = new SecureASTCustomizer()
    scz.with(setup)
    def config = new CompilerConfiguration()
    config.addCompilationCustomizers(scz)
    new GroovyShell(config)
}
def run = { GroovyShell sh, String src ->
    try { sh.evaluate(src) }
    catch (MultipleCompilationErrorsException e) {
        'REJECTED: ' + e.errorCollector.getError(0).cause.message.readLines().first()
    }
}

// a deny list is only as good as the types it names
def deny = shellWith { disallowedReceivers = ['java.lang.System'] }
println run(deny, "System.getProperty('java.specification.version')")
println run(deny, "def s = System; s.getProperty('java.specification.version')")

// an allow list closes the gap: Object receivers are rejected
def allow = shellWith { allowedReceiversClasses = [Math, Integer] }
println run(allow, "Math.max(1, 2)")
println run(allow, "def s = System; s.getProperty('java.specification.version')")
REJECTED: Method calls not allowed on [java.lang.System]
26
2
REJECTED: Method calls not allowed on [java.lang.Object]

Read the output in order: the first deny-list script is rejected, the second one (same call through a variable) returns the JDK specification version, 26; the allow-list shell accepts Math.max(1, 2) (result 2) and rejects the indirect call because the receiver is Object. Other limits follow from the same cause: the customizer does not constrain what permitted code does at run time (an allowed method can still be slow, allocate without bound, or read a file), it cannot see code that does not exist yet when the script is compiled, and it cannot stop a script from exhausting the CPU or the heap. It narrows the language; it is not a sandbox. The official documentation says it does not replace other security measures.

Wider coverage in Groovy 6.0

Groovy 6.0 closes several gaps in what the customizer checks. Its restrictions now also apply to constructors, static and instance initializer blocks and field initializers, and to authored code that a transformation relocates into a generated or synthetic member, such as the pre block of @TupleConstructor or the condition of @ConditionalInterrupt. Purely generated code (accessors, delegate forwarders, enum machinery, trait bridges) remains exempt. Import rules also cover the target type of a method pointer or method reference (ProcessBuilder.&new), casts and coercions that construct an instance from a list, map or closure literal, and named-argument subscript construction. Authored code is told apart from generated code by line number: generated code has a line number of zero or less, which is what SecureASTCustomizer.isFromSource tests. The deny-list example above shows the effect on a field initializer and a constructor.

import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.customizers.SecureASTCustomizer

def scz = new SecureASTCustomizer()
scz.disallowedReceivers = ['java.lang.System']
def config = new CompilerConfiguration()
config.addCompilationCustomizers(scz)

def run = { String src ->
    try { new GroovyShell(config).evaluate(src) }
    catch (MultipleCompilationErrorsException e) { 'REJECTED: ' + e.errorCollector.getError(0).cause.message.readLines().first() }
}
println run("class A { def v = System.getProperty('java.specification.version') }; new A().v")
println run("class B { B() { System.getProperty('x') } }; new B()")
REJECTED: Method calls not allowed on [java.lang.System]
REJECTED: Method calls not allowed on [java.lang.System]

The release notes describe the earlier behaviour as restrictions applied to the script body and method bodies, so a script author could not rely on a customizer to police constructors or initializers on older lines. The run above proves only the 6.0 behaviour; it was not repeated on an older release. Groovy 6.0 also lets an embedder disable @ASTTest when compiling source it does not control, covered under Compiling untrusted source: the @ASTTest switch.

Source aware customizer

SourceAwareCustomizer is a filter around another customizer. It takes the customizer to apply as a delegate and applies it only when predicates on the org.codehaus.groovy.control.SourceUnit match. The predicates can look at the base name of the file, its extension, the whole source unit (for example, its number of classes) or each class node.

import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.classgen.GeneratorContext
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.control.customizers.*

def seen = []
def recorder = new CompilationCustomizer(CompilePhase.CONVERSION) {
    void call(SourceUnit source, GeneratorContext context, ClassNode classNode) {
        seen << "${source.name}:${classNode.name}"
    }
}
def sac = new SourceAwareCustomizer(recorder)
sac.baseNameValidator = { it.endsWith('Bean') }

def config = new CompilerConfiguration()
config.addCompilationCustomizers(sac)
def shell = new GroovyShell(config)
shell.evaluate(new GroovyCodeSource('class Order {}; 1', 'OrderBean.groovy', '/x'))
shell.evaluate(new GroovyCodeSource('class Helper {}; 2', 'Util.groovy', '/x'))
println seen
[OrderBean.groovy:OrderBean, OrderBean.groovy:Order]

The recorder is a hand-written customizer (it also shows the shape of one: a phase passed to the constructor and a call method). It is invoked for the two class nodes of OrderBean.groovy (the script class and Order) and not at all for Util.groovy, whose base name does not end with Bean. The other predicates are extensionValidator, sourceUnitValidator and classValidator.

The customizer builder

Writing new for every customizer gets verbose. CompilerCustomizationBuilder.withConfig takes a configuration and a closure written in a small hierarchical DSL and registers the customizers for you: imports { ... } (normal, alias, star, staticMember), ast(Class, params), secureAst { ... }, source(...) { ... }, and inline(phase: ...) { source, context, classNode -> ... } for a customizer written directly in the closure. Several customizers can be declared in one block.

import groovy.transform.TimedInterrupt
import java.util.concurrent.TimeUnit
import org.codehaus.groovy.control.CompilerConfiguration
import static org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder.withConfig

def config = new CompilerConfiguration()
withConfig(config) {
    imports { star 'java.time' }
    ast(TimedInterrupt, value: 1, unit: TimeUnit.SECONDS)
}
println new GroovyShell(config).evaluate('Duration.ofMinutes(90).toHours()')
1

The source-aware form of the builder selects files by extension, extensions, basename, basenames or by validator closures (extensionValidator, basenameValidator, unitValidator). A common use is statically compiling only the files that follow a naming convention.

import groovy.transform.CompileStatic
import org.codehaus.groovy.control.CompilerConfiguration
import static org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder.withConfig

def config = new CompilerConfiguration()
withConfig(config) {
    source(extension: 'sgroovy') {
        ast(CompileStatic)
    }
}
def shell = new GroovyShell(config)
def dynamicBody = 'def s = "abc"; s.noSuchMethod()'

try { shell.evaluate(new GroovyCodeSource(dynamicBody, 'plain.groovy', '/groovy/shell')) }
catch (MissingMethodException e) { println "plain.groovy -> runtime MissingMethodException" }

try { shell.evaluate(new GroovyCodeSource(dynamicBody, 'checked.sgroovy', '/groovy/shell')) }
catch (org.codehaus.groovy.control.MultipleCompilationErrorsException e) {
    println "checked.sgroovy -> " + e.errorCollector.getError(0).cause.message.readLines().first()
}
plain.groovy -> runtime MissingMethodException
checked.sgroovy -> [Static type checking] - Cannot find matching method java.lang.String#noSuchMethod(). Please check if the declared type is correct and if the method exists.

The same script text is dynamic when it comes from a .groovy source and statically checked when its name ends with .sgroovy; the second one fails at compile time instead of at the call. Static checking is explained on Static Type Checking & @CompileStatic.

Customizing groovyc with --configscript

Everything above needs an embedding application to build a CompilerConfiguration. For classes compiled by groovyc (or Ant, Gradle, Maven), the --configscript option (short form -cf) names a Groovy script that receives the configuration before the files are compiled. The script sees the CompilerConfiguration as a variable called configuration, and the builder’s withConfig is already available without an import. The documentation’s example makes @CompileStatic the default for all compiled classes, and recommends keeping the configuration file outside the source directory (for instance src/conf next to src/main).

// config.groovy
withConfig(configuration) {
    ast(groovy.transform.CompileStatic)
}

// Main.groovy
class Main {
    static void main(String[] args) {
        String s = 'abc'
        println s.noSuchMethod()
    }
}

Compiling Main.groovy with that configuration script turns the dynamic call into a compile-time error:

$ groovyc --configscript=config.groovy Main.groovy
org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
Main.groovy: 4: [Static type checking] - Cannot find matching method java.lang.String#noSuchMethod(). Please check if the declared type is correct and if the method exists.
 @ line 4, column 17.
           println s.noSuchMethod()
                   ^

1 error

Without --configscript, the same file compiles and fails only when main runs, with a MissingMethodException. The groovy launcher also accepts --configscript, and the option is how a build can enable a customizer such as SecureASTCustomizer for the classes it compiles. A configuration script can also run arbitrary Groovy, for example to set system properties that would otherwise go on a long command line.

Running untrusted scripts

A customizer limits what a script may say. Running code written by someone you do not trust is a wider problem, and the sensible answer is layers: restrict the language, bound the resources, and isolate the execution so that a mistake in one layer is not a compromise.

flowchart TB S[Untrusted script] --> L1[Layer 1: restrict the grammar with SecureASTCustomizer] L1 --> L2[Layer 2: bound CPU time with TimedInterrupt and ThreadInterrupt] L2 --> L3[Layer 3: separate JVM with heap cap and kill timeout] L3 --> L4[Layer 4: container or OS user with no network and a read only file system]

The Groovy documentation is clear that the customizer alone does not make a script safe, and since Groovy 6.0 the old JVM-level answer is gone (see Security Manager support removed in Groovy 6.0). The layers below are the ones that remain.

Bounding execution time

The interrupt transformations are applied through the customizer (they are the same annotations as in the AST transformations page, applied to code you do not control). @TimedInterrupt makes a script throw java.util.concurrent.TimeoutException once a time budget is exhausted, and @ThreadInterrupt and @ConditionalInterrupt add checks for thread interruption or for an arbitrary condition. The checks are inserted at method entry and at loop iterations (and in closures), so they stop code that keeps executing Groovy bytecode.

import groovy.transform.TimedInterrupt
import java.util.concurrent.TimeUnit
import org.codehaus.groovy.control.CompilerConfiguration
import static org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder.withConfig

def config = new CompilerConfiguration()
withConfig(config) { ast(TimedInterrupt, value: 1, unit: TimeUnit.SECONDS) }
def shell = new GroovyShell(config)

def timed = { String label, String src ->
    def t0 = System.nanoTime()
    def outcome
    try { outcome = shell.evaluate(src) } catch (java.util.concurrent.TimeoutException e) { outcome = 'TimeoutException' }
    println "${label}: ${outcome} after about ${Math.round((System.nanoTime() - t0) / 1e9)} s"
}
timed('busy loop      ', 'while (true) { }')
timed('single blocking', "Thread.sleep(3000); 'finished'")
busy loop      : TimeoutException after about 1 s
single blocking: finished after about 3 s

The busy loop is stopped after about one second. The single blocking call is not: Thread.sleep(3000) is one call into the JDK, so no check runs until it returns, and the script then finishes normally after three seconds. The same applies to a long native or library operation, to a regular expression with catastrophic backtracking, or to a huge allocation. The interrupt transformations bound Groovy-level work, nothing else. A hard limit has to come from outside the JVM that runs the script.

Process isolation

The strongest in-process-free option is a child JVM per script: the parent writes the script text to the child’s standard input, caps its heap, and kills it when a timeout expires. A runaway loop, an out-of-memory error or a call to System.exit then costs the parent one process, not itself. The example uses a one-line child (Child.groovy evaluates its standard input) and a parent that starts it with ProcessBuilder.

// Child.groovy: the untrusted text arrives on stdin
println new GroovyShell().evaluate(System.in.text)

// parent.groovy
def groovyHome = System.getenv('GROOVY_HOME') ?: System.getProperty('groovy.home')
def run = { String code, long timeoutSeconds ->
    def pb = new ProcessBuilder('java', '-Xmx64m', '-cp', "${groovyHome}/lib/*",
            'groovy.ui.GroovyMain', 'Child.groovy').redirectErrorStream(true)
    def proc = pb.start()
    proc.outputStream.withWriter { it << code }
    def finished = proc.waitFor(timeoutSeconds, java.util.concurrent.TimeUnit.SECONDS)
    if (!finished) { proc.destroyForcibly(); return 'killed after timeout' }
    def out = proc.inputStream.text
    if (out.contains('OutOfMemoryError')) return "child JVM failed, exit value ${proc.exitValue()}: OutOfMemoryError"
    out.readLines().findAll { !it.startsWith('WARNING') }.join('\n')
}
println run('6 * 7', 20)
println run('while (true) { }', 3)
println run('def l = []; while (true) { l << new byte[10_000_000] }', 20)
42
killed after timeout
child JVM failed, exit value 1: OutOfMemoryError

The three runs are a normal script, an infinite loop killed by the parent after three seconds, and a memory hog stopped by the 64 MB heap cap. The child also needs its own restrictions: run it as an unprivileged OS user, with no credentials in its environment, in an empty working directory, and combine this layer with the secure AST customizer inside the child.

Container isolation

A container (or any equivalent OS-level sandbox) adds what a JVM cannot: a separate file system view, no network, and CPU and memory limits enforced by the kernel. The usual shape is the same as the child JVM above, with the child started through the container runtime. The command below is the typical form; it was not run here, so treat it as a sketch to adapt (image name, paths and limits are placeholders).

docker run --rm -i --network none --read-only --memory 128m --cpus 1 --pids-limit 64 \
  --user 10001:10001 --cap-drop ALL example/groovy-runner:6.0 < rule.groovy

The flags remove the network, make the root file system read-only, cap memory, CPU and process count, drop the process to a non-root user and remove all Linux capabilities. Combined with a wall-clock timeout in the parent, a script that escapes every Groovy-level restriction is still contained.

Security Manager support removed in Groovy 6.0

Earlier guidance, including the sandboxing section of the first edition of Groovy in Action, relies on the Java Security Manager with a policy file to limit what a script can do (file, network, reflection, exit). The Security Manager was deprecated for removal in JDK 17 (JEP 411), and since JDK 24 it is permanently disabled (JEP 486): it can no longer be enabled, so a policy file has no effect on those JDKs. Groovy 6 removes its own use of AccessController.doPrivileged and the related infrastructure. The 6.0 release notes tell users who relied on it to adopt other mechanisms such as containers and operating-system security; Groovy 5 still includes the support on JDK versions that have a Security Manager. Practically, a Groovy 6 deployment should be designed with the layers above rather than with a policy file.

Compiling untrusted source: the @ASTTest switch

Compiling is not safe either, because some annotations run code at compile time. The closure of @ASTTest executes during compilation, so merely compiling untrusted source would run it. Groovy 6.0 adds a system property for embedders that compile source they do not control; it mirrors groovy.grape.enable, which disables @Grab.

import groovy.transform.ASTTest
@ASTTest(value = { println 'ASTTest closure ran at compile time' })
class T {}
println 'done'
$ groovy t.groovy
ASTTest closure ran at compile time
ASTTest closure ran at compile time
ASTTest closure ran at compile time
ASTTest closure ran at compile time
done

$ JAVA_OPTS="-Dgroovy.asttest.enable=false" groovy t.groovy
done

The closure ran four times in the first run (the repetition was observed, not investigated) and not at all with -Dgroovy.asttest.enable=false. A host that compiles user text should set that property, set groovy.grape.enable=false for the same reason, and run the compilation itself in the isolated process described above.

Groovy 6.0 hardening elsewhere

The 6.0 release notes group a broad set of hardening under "Security & Hardening". The items relevant to this page and to the neighbouring ones are summarized here, each covered in depth where the feature lives.

Area Change in 6.0 Where

XML

Secure-by-default parsing (XXE and DTD protections) across the XML APIs

XML Parsing, GPath & Security

SQL

SqlInjectionChecker at compile time and rejection in groovy-sql at run time

Databases with groovy.sql.Sql

Regular expressions

@SafeRegex timeouts against ReDoS

Regular Expressions

JSON

Nesting-depth limits

JSON in Groovy

Grape

Stricter coordinate validation; grape uninstall deletes only inside the module cache

Grape Dependency Management

GroovyDoc

Writes only inside its destination directory and does not follow symbolic links out of doc-files/

Groovydoc

Groovlets

A failing groovlet no longer returns internals to the client

Groovlets, Servlets & JMX

Embedding

SecureASTCustomizer coverage of constructors, initializers and relocated code; @ASTTest switch

This page

Security advisories

The Groovy project publishes advisories for released versions at https://groovy-lang.org/security.html. Only three are listed on that page; they are worth knowing because two of them are why untrusted data must never reach Java deserialization in an application that has Groovy on its classpath. Each is fixed by upgrading; releases before 2.4.4 were not released under Apache and have no official patches.

Advisory Problem Fixed in

CVE-2015-3253

An application with Groovy on the classpath that uses standard Java serialization between servers or to store data can execute code when a specially crafted serialized object is deserialized (impact: remote execution of untrusted code, denial of service). Affects the unsupported Codehaus versions 1.7.0 to 2.4.3

2.4.4

CVE-2016-6814

Similar to the previous one, through extra wrapping of objects and catching of exceptions. Affects 1.7.0 to 2.4.7

2.4.8

CVE-2020-17521

On Unix-like systems, the temporary directories that Groovy created for Java stubs and for two createTempDir extension methods were created with insecure permissions, with a race condition. High risk only if executable code or sensitive data is stored there. Affects 2.0 to 2.4.20, 2.5.0 to 2.5.13, 3.0.0 to 3.0.6 and 4.0.0-alpha-1

2.4.21, 2.5.14, 3.0.7, 4.0.0-alpha-2

Mitigations given by the project: isolate the code that deserializes objects, upgrade, avoid relying on serialization to communicate remotely, and (for the temporary directory problem) set java.io.tmpdir to a directory exclusively owned by the running user or use the JDK’s Files.createTempDirectory. The page lists no advisory for the Groovy 5 or 6 lines at the time of checking (2026-10-14); the hardening items above are release-note changes rather than advisories.

Where the book is outdated

The first edition of Groovy in Action (Groovy 1.0, 2007) covers sandboxing scripts before compilation customizers existed in their current form, and relies on the Java Security Manager, which Groovy 6.0 no longer supports. Today’s recipe is the one on this page: a CompilerConfiguration with customizers, interrupt transformations, and isolation by process or container. Treat any sandboxing recipe in older material that depends on a policy file as obsolete on Groovy 6.