Skip to content

Compile-time check for GORM query strings flattened from GString (alternative to #15968)#15971

Open
borinquenkid wants to merge 2 commits into
8.0.xfrom
feat/gorm-query-safety-ast-check
Open

Compile-time check for GORM query strings flattened from GString (alternative to #15968)#15971
borinquenkid wants to merge 2 commits into
8.0.xfrom
feat/gorm-query-safety-ast-check

Conversation

@borinquenkid

Copy link
Copy Markdown
Member

Background

While reviewing #15968 ("Warn once on GString-interpolated GORM HQL queries"), GitHub's Copilot reviewer left a comment on HibernateGormStaticApi.groovy noting that the new runtime warning had no test verifying it actually fires through the real Hibernate static API call path — only through the isolated GormQuerySafetyWarnings helper spec. That comment was the starting point for a deeper investigation (with Claude) into whether the warning mechanism actually closes the gap it claims to.

That investigation found:

  1. The warning does fire correctly when a GString is passed directly to find/findAll/executeQuery — confirmed empirically by adding a log-capturing assertion to the existing HibernateGormStaticApiSpec "injection-safe" test and running it against Warn once on GString-interpolated GORM HQL queries #15968's branch. GORM binds the interpolated value as a real query parameter in this case; it is not exploitable.
  2. Warn once on GString-interpolated GORM HQL queries #15968 only wires the warning into one of three GORM implementations with the identical code path. grails-data-hibernate5's AbstractHibernateGormStaticApi and grails-data-neo4j's Neo4jGormStaticApi have the same instanceof GString / buildNamedParameterQueryFromGString pattern (Neo4j's error message still literally says "HQL injection" even though it's building Cypher) — neither references the new warning helper at all.
  3. The actual dangerous case is structurally invisible to any runtime check. GORM's own safe-binding mechanism only engages when the query argument is still a GString at the point of the call. The extremely common Groovy pattern
    String query = "from Book where name = ${userInput}"   // GString -> String, right here
    Book.executeQuery(query)
    causes Groovy to coerce the GString to a plain String at the assignment, because query is declared String. By the time this reaches GORM's query builder, instanceof GString is false, and the interpolated value is used as raw, unescaped query text — a genuine injection vector. A java.lang.String carries no trace of ever having been a GString, so no amount of runtime checking at the query boundary can distinguish this from a safely hand-written HQL string. The information needed to tell them apart only exists at the source-code level, before compilation erases it.

That third point is the actual finding this PR addresses: a warning that fires on the already-safe case and stays silent on the actually-dangerous case is backwards from what a risk-reduction mechanism should do, regardless of whether it warns or (as considered and rejected) throws at runtime.

What this PR does

Adds a global, compile-time Groovy AST transformation (GormQuerySafetyTransformer / GlobalGormQuerySafetyASTTransformation, in grails-datamapping-core) that detects the flattened-String pattern above and fails the build with a clear, actionable error — before the type information that would let a runtime check catch it is lost.

Because the check is syntactic (method name + argument shape), not tied to a specific datastore's runtime class, one transform covers Hibernate5, Hibernate7, and Neo4j simultaneously, with zero changes to any of those three modules and zero configuration required from application developers — it runs automatically wherever grails-datamapping-core is on the compile classpath, the same way @GrailsCompileStatic and domain-class enhancement already apply themselves invisibly.

Detection algorithm

  • Tracks local variables (declarations and reassignments) that go from a live, interpolated GString to a plain String — via explicit String typing, .toString(), (String) cast, or as String.
  • Flags a call to find / findAll / executeQuery / executeUpdate / findAllWithSql / cypherStatic / findPath / findPathTo whose query argument is one of those flattened variables.
  • Gates on the receiver looking like a GORM domain class (reusing the existing AstUtils.isDomainClass, the same utility DetachedCriteriaTransformer's where{} rewriting already relies on) to avoid false positives on unrelated methods.
  • A direct GString literal argument (Book.executeQuery("... ${x} ...")) is never flagged — that path is already safe.

Corner cases handled (with test coverage)

  • .toString() / (String) cast / as String coercions of an interpolated GString — all flagged, not just plain String-typed declarations.
  • Reassignment, not just declaration (String q; q = "...${x}...") — tracked via visitBinaryExpression, with a safe reassignment clearing prior unsafe tracking (last-write-wins; see limitations below).
  • Collection.find / Collection.findAll (GDK methods present on nearly every Iterable) never collide with the GORM methods of the same name — they take a Closure, and the flagged argument must be a tracked String variable, so the two shapes can't overlap.
  • Non-domain classes that happen to define their own find/findAll(String) methods do not false-positive, because of the isDomainClass receiver gate.
  • Closures capture the flattened state correctly — a variable flattened in an enclosing method and referenced inside a nested closure (.each { ... executeQuery(q) }) still triggers, since closures are walked as part of their enclosing method's traversal.
  • Neo4j's findPathTo(Class type, CharSequence query, Map params) has the query as its second argument, not first — handled as a special case in the argument-index map, with a dedicated test.
  • Suppression: a reviewed, genuinely safe call site can opt out per-call-site with @SuppressWarnings("GormUnsafeQueryString") on the enclosing method — a deliberate, auditable exception rather than a global kill switch.
  • Multiple-assignment declarations (def (a, b) = [...]) don't crash the transform — DeclarationExpression.getVariableExpression() returns null for these and is guarded.

Known, deliberate limitations (documented in the class Javadoc and the upgrade guide)

This is intentionally scoped, not a general SQL-injection solution:

  • Intraprocedural only — a flattened String built inside a helper method and returned is invisible to this check.
  • Last-write-wins reassignment tracking, not full branch-sensitive dataflow.
  • Locals only, not fields, in this first version.
  • Does not catch plain string concatenation with no GString involved at all ("select * ... " + userInput) — arguably the more classic injection shape, and untouched by this or Warn once on GString-interpolated GORM HQL queries #15968's runtime check, since there's no GStringExpression node to detect.
  • Does not cover raw JDBC via groovy.sql.Sql, which is outside GORM's static API entirely.
  • Does not apply to MongoDB — its GORM API has no CharSequence-based raw query method at all (it inherits GormStaticApi.executeQuery(), which just throws unsupported('executeQuery')), so it isn't vulnerable to this specific pattern in the first place.

Verification

  • New GormQuerySafetyTransformerSpec — 12 tests, covering every case above (error cases assert MultipleCompilationErrorsException with the expected message; safe/suppressed/non-domain cases assert clean compilation).
  • Full grails-datamapping-core test suite: pass.
  • Full grails-data-hibernate7-core suite (2988 tests) and grails-data-hibernate5-core suite: zero failures, confirming the global transform introduces no false positives against real GORM/query source in either module.
  • codeStyle (Checkstyle + CodeNarc) clean on touched modules.
  • Doc build (publishGuide) verified to render correctly, including the new cross-reference between the upgrade guide and the security guide.

Why this is opinionated, on purpose

Grails' own stated philosophy is "sensible defaults" — convention over configuration, with escape hatches for the cases that genuinely need them. Not permitting query-string injection by default is exactly that kind of sensible default: an application shouldn't have to opt in to safety, and a framework that discovers it's silently coercing a safely-parameterizable query into raw unescaped text has an obligation to say so loudly, not log it once and move on. That's why this fails the build rather than warning: for a compile-time check, the "pre-prod environment" every responsible team already runs is the build itself — failing to compile has no live-traffic blast radius, unlike a runtime throw would. Teams that hit a false positive on a reviewed-safe call get an explicit, auditable, per-call-site escape hatch (@SuppressWarnings("GormUnsafeQueryString")) rather than a single switch that quietly reopens the whole risk surface.

This is offered as an alternative to the runtime-only, single-module approach in #15968 — happy to discuss reconciling the two (e.g. keeping #15968's docs/warning as a softer signal for the cases this transform's intraprocedural limits can't reach, while this closes the case that matters most).

A GString passed directly to a GORM query method (find/findAll/executeQuery/
executeUpdate, and Neo4j's Cypher equivalents) is safe: GORM binds each
interpolated value as a query parameter. But once that GString is assigned
to a String-typed local, coerced via .toString(), or cast (all ordinary
Groovy patterns), the interpolated value becomes raw, unescaped text with no
trace of ever having been a GString - undetectable at the query-execution
boundary because a String carries no metadata about its origin.

Add a global AST transformation (grails-datamapping-core) that catches this
at compile time instead, across every GORM implementation that shares this
method-naming convention (Hibernate5, Hibernate7, Neo4j) without touching
any of their source. Fails the build by default; a reviewed, safe call site
can opt out per call with @SuppressWarnings("GormUnsafeQueryString").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 11, 2026 02:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a global Groovy AST transformation in grails-datamapping-core that fails compilation when a GORM query String argument is detected to have been flattened from an interpolated GString before reaching a GORM query method (closing an injection vector that runtime checks cannot see). It also documents the behavior and adds a dedicated spec suite to validate the detection and suppression behavior.

Changes:

  • Add GormQuerySafetyTransformer + GlobalGormQuerySafetyASTTransformation as a global compile-time check, ordered via GroovyTransformOrder.
  • Register the global AST transform via META-INF/services so it runs automatically wherever grails-datamapping-core is on the compile classpath.
  • Add upgrade/security guide documentation and a new Spock spec suite covering key positive/negative cases and suppression.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
grails-doc/src/en/guide/upgrading/upgrading80x.adoc Adds an upgrade note describing the new compile-time GORM query safety compilation failure and suppression mechanism.
grails-doc/src/en/guide/security/securingAgainstAttacks.adoc Adds a security guide section explaining the “flattened GString → String” injection risk and the compile-time enforcement.
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformerSpec.groovy Adds new tests verifying compilation failures, safe cases, Neo4j argument indexing, closure traversal, and suppression.
grails-datamapping-core/src/main/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation Registers the new global AST transformation so it runs automatically.
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformer.java Implements the visitor that tracks unsafe flattening and reports compilation errors at call sites.
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/transform/GlobalGormQuerySafetyASTTransformation.java Wires the visitor as a global transform at CANONICALIZATION with an explicit priority.
grails-common/src/main/groovy/org/apache/grails/common/compiler/GroovyTransformOrder.groovy Introduces a new transform order constant to place the query-safety check in the global transform sequence.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +183 to +187
private boolean isUnsafeGStringCoercion(Expression expression, ClassNode declaredType) {
if (isInterpolatedGString(expression)) {
return ClassHelper.STRING_TYPE.equals(declaredType);
}
if (expression instanceof CastExpression) {
@bito-code-review

Copy link
Copy Markdown

The current implementation of isUnsafeGStringCoercion indeed focuses on the immediate point of coercion (assignment or explicit cast/toString). As noted in the class Javadoc, the transformer is designed with deliberate limitations for its first version, specifically that it is intraprocedural and uses last-write-wins reassignment tracking rather than full dataflow analysis.

To address the scenario where a GString is stored in a local variable and later coerced or passed to a query method, the transformer would need to track the origin of variables beyond the immediate assignment expression. Currently, flattenedStringVars only stores the ASTNode of the declaration or assignment where the unsafe coercion was detected. Expanding this to detect cases like String q = g; Book.executeQuery(q) would require tracking variable aliases or propagating the "unsafe" state through variable assignments, which is significantly more complex than the current implementation.

grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformer.java

private boolean isUnsafeGStringCoercion(Expression expression, ClassNode declaredType) {
        if (isInterpolatedGString(expression)) {
            return ClassHelper.STRING_TYPE.equals(declaredType);
        }
        if (expression instanceof CastExpression) {
            CastExpression cast = (CastExpression) expression;
            return ClassHelper.STRING_TYPE.equals(cast.getType()) && isInterpolatedGString(cast.getExpression());
        }
        if (expression instanceof MethodCallExpression) {
            MethodCallExpression call = (MethodCallExpression) expression;
            return "toString".equals(call.getMethodAsString()) && isInterpolatedGString(call.getObjectExpression());
        }
        return false;
    }

…cion

A bito-code-review comment on PR #15971 correctly identified that the
detector only matched the immediate coercion expression, so aliasing a
GString through an intermediate variable before flattening it slipped
through undetected:

  def g = "from Book where name = ${x}"   // g: still a live GString
  String q = g                             // flattened HERE, uncaught
  Book.executeQuery(q)

Replace the single flattenedStringVars set with a two-state model
(LIVE_GSTRING / FLATTENED) that resolves through VariableExpression
references, so the flattening point is found correctly regardless of how
many variable-to-variable hops separate the original GString literal from
the query call. Verified against the exact case from the bot comment, plus
a two-hop chain, .toString() on an alias, and re-aliasing an
already-flattened variable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@borinquenkid

Copy link
Copy Markdown
Member Author

@bito-code-review Good catch, and correct — the detector only matched the immediate coercion expression, so this slipped through:

def g = "from Book where name = ${x}"   // g: still a live GString
String q = g                             // flattened HERE, uncaught
Book.executeQuery(q)

Fixed in 46c4ba2: replaced the single flattenedStringVars set with a two-state model (LIVE_GSTRING / FLATTENED) that resolves through VariableExpression references, so the flattening point is found correctly regardless of how many variable-to-variable hops separate the original GString literal from the query call.

Added regression tests for the exact case above, a two-hop alias chain (def g = ...; def h = g; String q = h), .toString() called on an alias, and re-aliasing an already-flattened variable — all in GormQuerySafetyTransformerSpec. Full grails-datamapping-core, grails-data-hibernate7-core, and grails-data-hibernate5-core suites still pass with zero regressions.

Still out of scope by design (see the class Javadoc / PR description): a flattened String built inside a different method and returned is invisible to this check, since the analysis is intraprocedural.

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.10219% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.6508%. Comparing base (06f5567) to head (46c4ba2).

Files with missing lines Patch % Lines
...rm/query/transform/GormQuerySafetyTransformer.java 77.3438% 14 Missing and 15 partials ⚠️
...nsform/GlobalGormQuerySafetyASTTransformation.java 88.8889% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15971        +/-   ##
==================================================
+ Coverage     49.6039%   49.6508%   +0.0469%     
- Complexity      16956      17007        +51     
==================================================
  Files            1999       2001         +2     
  Lines           93791      93928       +137     
  Branches        16421      16452        +31     
==================================================
+ Hits            46524      46636       +112     
- Misses          40100      40109         +9     
- Partials         7167       7183        +16     
Files with missing lines Coverage Δ
...nsform/GlobalGormQuerySafetyASTTransformation.java 88.8889% <88.8889%> (ø)
...rm/query/transform/GormQuerySafetyTransformer.java 77.3438% <77.3438%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Jul 11, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 46c4ba2
▶️ Tests: 33622 executed
⚪️ Checks: 59/59 completed


Learn more about TestLens at testlens.app.

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants