Embedding Groovy and Script Security
Prerequisites: Building domain-specific languages
Many Java applications let users write Groovy: rules engines, report formulas, plugin systems, Jenkins. Groovy's embedding API is small.
GroovyShell and Binding
def binding = new Binding(order: [total: 120, country: 'DE'], discount: 0)
def shell = new GroovyShell(binding)
def rule = '''
if (order.total > 100) discount = 10
if (order.country == 'DE') discount += 5
"discount: $discount%"
'''
println shell.evaluate(rule)
println binding.discount
discount: 15%
15
The binding passes variables in and receives variables the script
sets. Parse once with shell.parse(text) and call run() many times
when a script is used repeatedly — compiling is the expensive part.
JSR-223 (javax.script)
import javax.script.ScriptEngineManager
def engine = new ScriptEngineManager().getEngineByName('groovy')
engine.put('name', 'JSR-223')
println engine.eval('"Hello from $name, " + (6 * 7)')
Hello from JSR-223, 42
Java code that supports several scripting languages usually goes through
this standard API. For Groovy-only hosts, GroovyShell or
GroovyScriptEngine (which reloads scripts from a folder when they
change) give more control.
Restricting what scripts can do
Never evaluate untrusted input with a plain GroovyShell — a script
can call System.exit, read files, or run processes. A
SecureASTCustomizer limits the language at compile time:
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.customizers.SecureASTCustomizer
def secure = new SecureASTCustomizer()
secure.with {
allowedImports = []
allowedStarImports = []
allowedStaticImports = []
allowedStaticStarImports = []
allowedReceiversClasses = [Object, Integer, BigDecimal, String, Math]
}
def config = new CompilerConfiguration()
config.addCompilationCustomizers(secure)
def shell = new GroovyShell(config)
println shell.evaluate('Math.max(3, 7) * 2')
try {
shell.evaluate('System.exit(1)')
} catch (MultipleCompilationErrorsException e) {
println 'rejected: ' + e.message.readLines().find { it.contains('not allowed') }?.trim()
}
14
rejected: General error during semantic analysis: Method calls not allowed on [java.lang.System]
Stopping runaway scripts
import groovy.transform.TimedInterrupt
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import java.util.concurrent.TimeoutException
def config = new CompilerConfiguration()
config.addCompilationCustomizers(new ASTTransformationCustomizer(value: 1, TimedInterrupt))
def shell = new GroovyShell(config)
try {
shell.evaluate('while (true) { }')
} catch (TimeoutException e) {
println "stopped: ${e.message.takeWhile { it != '.' }}"
}
stopped: Execution timed out after 1 seconds
@TimedInterrupt (or @ThreadInterrupt, @ConditionalInterrupt)
inserts checks into loops and methods so a script can't run forever.
Defense in depth
A compile-time sandbox is a strong first layer but hard to make airtight — dynamic Groovy has many ways to reach a class. For truly untrusted code, also:
- run scripts in a separate process or container with limited permissions, memory, and network access;
- use time limits and resource quotas;
- keep Groovy up to date (Groovy 6 closed several sandbox gaps);
- prefer a restricted DSL with a fixed vocabulary over "run any script". Jenkins, for example, runs pipelines through its own Script Security sandbox and requires administrator approval for risky methods.