Compile-time check for GORM query strings flattened from GString (alternative to #15968) - #15971
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% 51.5723% +1.9684%
- Complexity 16956 17865 +909
==================================================
Files 1999 2041 +42
Lines 93791 95815 +2024
Branches 16421 16632 +211
==================================================
+ Hits 46524 49414 +2890
+ Misses 40100 39075 -1025
- Partials 7167 7326 +159
🚀 New features to boost your workflow:
|
davydotcom
left a comment
There was a problem hiding this comment.
This supersedes #15968 and is a fairly elegant compile time safety check. It is important to note there are loopholes it cannot account for and it may be worth considering if this is even the best way to do sql injection protections long term since its hard to determine casting to a String. I approve this and recommend it for merge
…nings Three follow-ups to the compile-time GORM query safety check, building on its existing local-variable/aliasing tracking: 1. Fix a real detection gap: reassignment tracking was last-write-wins across an if/else, so a variable flattened only in one branch could be silently forgotten if the other branch reassigned it safely, depending on which branch happened to be visited last (not which one actually runs). visitIfElse now walks each branch from the same starting state and merges pessimistically - unsafe in either branch stays unsafe. 2. Track String-typed fields assigned an interpolated GString (via a field initializer or this.field = ...) and flag a later this.field read in a query call. Unlike locals, a field can be reassigned from a constructor, another method, or a subclass this check never visits, so this is a compile-time warning, not a build failure. 3. Track query text built via '+' concatenation of a non-constant value with no GString involved (e.g. "select ... " + userInput) and warn - again a warning, not an error, since concatenation is common enough for benign purposes that a hard failure would be too blunt. Concatenating a live GString with more text is a different case: GString.plus returns a plain String, so that's an immediate flattening and is correctly promoted to the existing build-breaking error, not this warning. Verified via 10 new cases in GormQuerySafetyTransformerSpec, the existing suite still green, and a clean compile of grails-data-hibernate5-core and grails-data-hibernate7-core's test sources under the stricter checks - confirming no new false positives against real GORM implementation code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed a follow-up commit ( Three changes:
Both new warnings share the existing Verification:
|
jdaugherty
left a comment
There was a problem hiding this comment.
My largest concern is not having an opt out that can be configured - in the event of an AST related bug. Otherwise, I love this change.
matrei
left a comment
There was a problem hiding this comment.
Nice work @borinquenkid!
I agree with @jdaugherty, there should be a global opt-out to not run the check at all and strings created at the call site should also be flagged for consistency.
|
re-reading my comments, i want to be clear, i'm ok for this on by default, i just want an opt out - mostly in case something goes wrong |
jdaugherty and matrei both asked for a way to disable the compile-time GormUnsafeQueryString check entirely, for the case where it hits a false positive with no per-call-site workaround. Named to describe intent rather than a guarantee, since the check has known gaps (intraprocedural only, doesn't cover raw concatenation) and can't promise full protection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ionSpec Matches the pattern used elsewhere in the test suite instead of a manual cleanup() clearing the system property. Addresses review comment: #15971 (comment)
|
Groovy 6 throws a SqlException rather than just provide a warning in more cases with a system property for opt-out: https://issues.apache.org/jira/browse/GROOVY-12118 Not directly relevant but confirms the direction being taken here will become familiar to Groovy folks. But having said that, the case AI is showing looks weird to me: As a String, would this still be well-formed? For SQL, this would go to: "from Book where name = someInput" but this wouldn't be well-formed unless it was "from Book where name = 'someInput'". I am not sure how HQL handles that. The case that Groovy looks for is: |
findUnsafeArgument only classified the query argument when it was a
VariableExpression, a this.field reference, or a concatenation, so
GString flattening applied directly in the argument slot slipped
through undetected:
Book.executeQuery("from Book where title = ${title}".toString())
Book.executeQuery((String) "from Book where title = ${title}")
Book.executeQuery("from Book where title = ${title}" as String)
Bring the call-site check in line with the assignment tracker, which
already recognized these two shapes for String q = g.toString() /
(String) g. Also corrects @SInCE 8.1 to 8.0 in both transform classes
to match the 8.0.x target branch.
Addresses review feedback from #15971.
✅ All tests passed ✅🏷️ Commit: cb3a575 Learn more about TestLens at testlens.app. |
jdaugherty
left a comment
There was a problem hiding this comment.
All four points from my earlier rounds are addressed at cb3a575, verified locally against the PR head:
- Global opt-out —
protectSqlInjectionAttackssystem property (defaults totrue) short-circuits the global transform, with three tests covering unset/true/falseand the behavior documented inupgrading80x.adoc. @RestoreSystemProperties— adopted inGlobalGormQuerySafetyASTTransformationSpec.- Inline flattening at the call site —
findUnsafeArgumentnow runs a non-variable argument through theCastExpression/.toString()checks, so all three forms I verified as slipping through previously ("...${x}".toString(),(String) "...${x}","...${x}" as String) are flagged, each with its own test. @since— reconciled to8.0in bothGormQuerySafetyTransformerandGlobalGormQuerySafetyASTTransformation.
Verified: GormQuerySafetyTransformerSpec (30) and GlobalGormQuerySafetyASTTransformationSpec (3) all pass, and the full grails-datamapping-core suite is green (289 tests, 0 failures).
The documented gaps remain (interprocedural flattening, fields, plain concatenation, groovy.sql.Sql), but they are called out in both the class Javadoc and the guide, and the kill switch covers the AST-bug scenario that was my main concern. LGTM.
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).