Compile-time check for GORM query strings flattened from GString (alternative to #15968)#15971
Compile-time check for GORM query strings flattened from GString (alternative to #15968)#15971borinquenkid wants to merge 2 commits into
Conversation
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>
There was a problem hiding this comment.
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+GlobalGormQuerySafetyASTTransformationas a global compile-time check, ordered viaGroovyTransformOrder. - Register the global AST transform via
META-INF/servicesso it runs automatically wherevergrails-datamapping-coreis 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.
| private boolean isUnsafeGStringCoercion(Expression expression, ClassNode declaredType) { | ||
| if (isInterpolatedGString(expression)) { | ||
| return ClassHelper.STRING_TYPE.equals(declaredType); | ||
| } | ||
| if (expression instanceof CastExpression) { |
|
The current implementation of To address the scenario where a grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/transform/GormQuerySafetyTransformer.java |
…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>
|
@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 Added regression tests for the exact case above, a two-hop alias chain ( Still out of scope by design (see the class Javadoc / PR description): a flattened |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
✅ All tests passed ✅🏷️ Commit: 46c4ba2 Learn more about TestLens at testlens.app. |
Background
While reviewing #15968 ("Warn once on GString-interpolated GORM HQL queries"), GitHub's Copilot reviewer left a comment on
HibernateGormStaticApi.groovynoting that the new runtime warning had no test verifying it actually fires through the real Hibernate static API call path — only through the isolatedGormQuerySafetyWarningshelper 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:
GStringis passed directly tofind/findAll/executeQuery— confirmed empirically by adding a log-capturing assertion to the existingHibernateGormStaticApiSpec"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.grails-data-hibernate5'sAbstractHibernateGormStaticApiandgrails-data-neo4j'sNeo4jGormStaticApihave the sameinstanceof GString/buildNamedParameterQueryFromGStringpattern (Neo4j's error message still literally says "HQL injection" even though it's building Cypher) — neither references the new warning helper at all.GStringat the point of the call. The extremely common Groovy patternGStringto a plainStringat the assignment, becausequeryis declaredString. By the time this reaches GORM's query builder,instanceof GStringisfalse, and the interpolated value is used as raw, unescaped query text — a genuine injection vector. Ajava.lang.Stringcarries no trace of ever having been aGString, 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, ingrails-datamapping-core) that detects the flattened-Stringpattern 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-coreis on the compile classpath, the same way@GrailsCompileStaticand domain-class enhancement already apply themselves invisibly.Detection algorithm
GStringto a plainString— via explicitStringtyping,.toString(),(String)cast, oras String.find/findAll/executeQuery/executeUpdate/findAllWithSql/cypherStatic/findPath/findPathTowhose query argument is one of those flattened variables.AstUtils.isDomainClass, the same utilityDetachedCriteriaTransformer'swhere{}rewriting already relies on) to avoid false positives on unrelated methods.GStringliteral argument (Book.executeQuery("... ${x} ...")) is never flagged — that path is already safe.Corner cases handled (with test coverage)
.toString()/(String)cast /as Stringcoercions of an interpolatedGString— all flagged, not just plainString-typed declarations.String q; q = "...${x}...") — tracked viavisitBinaryExpression, with a safe reassignment clearing prior unsafe tracking (last-write-wins; see limitations below).Collection.find/Collection.findAll(GDK methods present on nearly everyIterable) never collide with the GORM methods of the same name — they take aClosure, and the flagged argument must be a trackedStringvariable, so the two shapes can't overlap.find/findAll(String)methods do not false-positive, because of theisDomainClassreceiver gate..each { ... executeQuery(q) }) still triggers, since closures are walked as part of their enclosing method's traversal.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.@SuppressWarnings("GormUnsafeQueryString")on the enclosing method — a deliberate, auditable exception rather than a global kill switch.def (a, b) = [...]) don't crash the transform —DeclarationExpression.getVariableExpression()returnsnullfor 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:
Stringbuilt inside a helper method and returned is invisible to this check.GStringinvolved 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 noGStringExpressionnode to detect.groovy.sql.Sql, which is outside GORM's static API entirely.CharSequence-based raw query method at all (it inheritsGormStaticApi.executeQuery(), which just throwsunsupported('executeQuery')), so it isn't vulnerable to this specific pattern in the first place.Verification
GormQuerySafetyTransformerSpec— 12 tests, covering every case above (error cases assertMultipleCompilationErrorsExceptionwith the expected message; safe/suppressed/non-domain cases assert clean compilation).grails-datamapping-coretest suite: pass.grails-data-hibernate7-coresuite (2988 tests) andgrails-data-hibernate5-coresuite: 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.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).