Skip to content

Groovy rules improvements and sandboxing #2818

Open
2 of 3 issues completed
Open

Description

@ebariaux

The initial goal is to provide sandboxing of compilation and evaluation of groovy rules.
But this requires preliminary improvements to the groovy rules system.

Activity

  1. added
    EpicLarge body of work to reach a goal
    on Jun 3, 2026
  2. ebariaux commented on Jun 3, 2026

    @ebariaux
    ContributorAuthor

    Raw recommandations from Codex (5.5 xhigh) to use as guidelines for further defining tasks in this epic.

    I reviewed the Groovy rule path. Short version: sandboxing is currently not implemented. The repo has the sandbox instrumentation dependency and transformer wired in, but no active policy is registered when rules run.

    What Is In Place

    • groovy-sandbox is present via container/build.gradle and versioned as org.craftercms:groovy-sandbox:4.3.2 with Groovy 5.0.6 in gradle.properties.
    • manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java:70 defines GroovyDenyAllFilter, but it is never registered.
    • manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java:92 creates a static GroovyShell with new SandboxTransformer().
    • manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java:322 still has // TODO Implement sandbox.
    • manager/src/main/java/org/openremote/manager/rules/RulesResourceImpl.java:277 restricts Groovy creation/update/delete to super users for realm and asset rules. That is access control, not sandboxing.

    The important subtlety: SandboxTransformer rewrites Groovy calls so they can be intercepted, but without a registered GroovyInterceptor those calls are allowed. The sandbox library’s interceptor is thread-local, so even
    registering it around script.run() would not protect when/then closures later executed by Easy Rules unless rule firing is also wrapped.

    Current Execution Model

    • manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java:324 parses the Groovy script.
    • manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java:327 exposes LOG, rules, assets, users, notifications, historicDatapoints, predictedDatapoints, plus realm/assetId.
    • manager/src/main/java/org/openremote/manager/rules/RulesBuilder.java:75 stores Groovy closures for conditions/actions.
    • manager/src/main/java/org/openremote/manager/rules/RulesEngine.java:565 later executes those closures via Easy Rules.

    So sandboxing needs to cover both deployment-time script execution and runtime rule evaluation/actions.

    Recommended Next Steps

    1. Define the policy first, before implementation:
      Decide whether sandboxed Groovy should be suitable for non-super users. If yes, treat it as hostile code. If no, keep the super-user gate and treat sandboxing as damage reduction.

    2. Replace GroovyDenyAllFilter with a real deny-by-default GroovyInterceptor:
      Allow exact receiver/type/member signatures only. Do not allow broad classes like Object, Class, DefaultGroovyMethods, Logger, File, URL, Runtime, System, Thread, reflection, classloaders, process APIs, JDBC, network clients,
      or metaClass.

    3. Register the interceptor around every sandboxed execution:
      Wrap both:

      • script.run() in compileRulesGroovy
      • engine.fire(...) or the condition/action execution path

      Use try/finally to unregister. Because the CrafterCMS fork uses a thread-local single interceptor, do not assume registration during compilation applies to scheduled rule execution threads.

    4. Add a SecureASTCustomizer compile-time gate:
      Groovy’s own docs recommend allowlists because otherwise future language features are implicitly permitted, and if no allow/disallow list is configured everything is permitted. Use it to restrict imports, static imports,
      packages, annotations, class/method definitions, tokens, statements, and receiver classes. Source: Groovy SecureASTCustomizer docs:
      https://docs.groovy-lang.org/latest/html/api/org/codehaus/groovy/control/customizers/SecureASTCustomizer.html

    5. Disable compile-time escape hatches:
      Pay particular attention to @grab, global/local AST transforms, annotations, script class generation, static initializers, method definitions, and custom classes. Runtime interception does not protect code that executes during
      compilation or AST transformation.

    6. Stop using one static GroovyShell:
      The existing comment already documents classloader breakage with redeployed scripts defining classes. Use a per-deployment GroovyShell/GroovyClassLoader, dispose it on undeploy, and avoid sharing rule-defined classes through
      facts. Prefer Java model classes for shared data.

    7. Harden exposed facades before loosening the REST gate:
      AssetsFacade and UsersFacade contain visible scope restrictions. But manager/src/main/java/org/openremote/manager/rules/facade/HistoricFacade.java:44 and manager/src/main/java/org/openremote/manager/rules/facade/
      PredictedFacade.java:44 do not visibly enforce ruleset scope before querying/updating datapoints. manager/src/main/java/org/openremote/manager/rules/facade/WebhooksFacade.java:47 can build a target from an arbitrary webhook
      URL, which needs SSRF controls.

    8. Add resource isolation:
      A Groovy sandbox cannot reliably stop while(true), huge allocations, sleeps, blocked I/O if allowed, or CPU/memory exhaustion. Java SecurityManager is not a good answer on Java 21; it has been deprecated for removal since Java
      17. Source: OpenJDK JEP 411: https://openjdk.org/jeps/411

    Gotchas To Watch

    • Registering the current deny-all filter would break legitimate rules immediately.
    • Registering only during compilation/deployment leaves runtime closures unsandboxed.
    • Allowing getClass, .class, Class.forName, metaClass, method pointers, or broad Groovy extension methods is usually enough to escape.
    • Allowing Logger directly is risky; expose a tiny rule logger wrapper instead.
    • Allowing Webhooks without URL policy enables internal network access.
    • Rule facts are shared at engine level, so one ruleset can affect other rulesets in the same engine.
    • Loop detection only counts triggered rules; it does not interrupt a single closure that never returns.
    • Existing Groovy examples use imports, packages, streams, Optional, collections, query objects, and closures, so the initial allowlist will need compatibility tests against the current rule corpus.

    I would implement this in phases: first keep Groovy super-user-only and add tests proving obvious escapes are blocked; then harden facades and resource limits; only then consider enabling sandboxed Groovy for non-super users.

    The practical target is: super-user Groovy remains available, but sandboxing narrows the blast radius of mistakes, compromised accounts, copied snippets, or overly broad integrations.

    I’d do it in this order:

    1. Inventory current Groovy rule usage
      Scan real customer rules and test/demo rules for imports, constructors, method calls, static calls, and facade usage. Treat that as the compatibility contract.

    2. Turn that into explicit facades
      For every legitimate pattern, prefer a facade method over allowing raw JVM/Groovy access. Examples: asset lookup/update, fact matching, notifications, alarms, datapoints, webhooks. The sandbox allowlist should mostly contain
      OpenRemote facade APIs, simple value objects, collections, streams, optionals, predicates/closures, and query builders.

    3. Harden facades before relying on them
      AssetsFacade and UsersFacade already encode scope. Datapoint and predicted datapoint facades should also enforce ruleset scope. Webhooks need URL policy/SSRF protection. Returned objects should be immutable or narrow DTOs
      where possible.

    4. Add sandbox tests before enforcing broadly
      Add tests for both “works” and “blocked” cases:
      System.exit, Runtime.exec, new File, URL.openConnection, Class.forName, .class, getClass, classLoader, reflection, metaClass, @grab, static imports, arbitrary constructors, direct field access, thread creation, sleeps/loops
      where possible.

    5. Add compile-time and runtime gates
      Use SecureASTCustomizer to reject dangerous syntax/imports/annotations early. Use groovy-sandbox runtime interception around both script.run() and rule firing, because the closures execute later.

    6. Keep a migration mode
      Initially run sandbox in “report-only” or behind a config flag for super-user Groovy. Log denied signatures and missing facade capabilities. Once customer rule coverage is good, flip to enforcement.

    One thing I would be strict about: do not make the runtime allowlist broad just to keep old scripts working. If a real rule needs something, add a small facade method with scope checks. That gives you an audit trail and avoids
    accidentally allowing the entire JVM through Object, Class, Groovy extension methods, or model internals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EpicLarge body of work to reach a goal

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions