Skip to content
AgileLogics
6 min read

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.