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 3 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% 51.2035% +1.5996%
- Complexity 16956 17698 +742
==================================================
Files 1999 2043 +44
Lines 93791 95761 +1970
Branches 16421 16644 +223
==================================================
+ Hits 46524 49033 +2509
+ Misses 40100 39418 -682
- Partials 7167 7310 +143
🚀 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:
|
✅ All tests passed ✅🏷️ Commit: cceff55 Learn more about TestLens at testlens.app. |
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.
| @@ -1,2 +1,3 @@ | |||
| org.grails.datastore.gorm.query.transform.GlobalDetachedCriteriaASTTransformation | |||
| org.grails.compiler.gorm.GlobalJpaEntityTransform | |||
| org.grails.datastore.gorm.query.transform.GlobalGormQuerySafetyASTTransformation | |||
There was a problem hiding this comment.
Registering this globally makes it a hard compile error, on by default, for every project with grails-datamapping-core on the classpath, with the only escape hatch being per-call-site @SuppressWarnings. Two things worth weighing before that ships enabled-by-default on the 8.0.x line:
- False positives on trusted interpolation. The check flags any interpolated GString flattened to
String, but interpolating a non-user value is common and safe —"from Book where status = ${Status.ACTIVE.name()}", an enum, a numeric id, a config constant. Each of those now breaks the build and forces a suppression annotation despite no injection risk. The check cannot distinguish trusted from untrusted data, so it fails safe code, not just unsafe code. - No global opt-out. On upgrade a team with even one such pattern cannot compile until every site is annotated. Given the approval note about the loopholes this can't close, consider whether the default should be a warning (matching the field/concatenation cases) with an opt-in property to promote it to an error, rather than error-by-default with only per-site suppression.
There was a problem hiding this comment.
Just checking for a system property as an opt out mechanism would suffice for my concern.
| if (isThisFieldReference(argument) && flattenedFields.containsKey(fieldNameOf(argument))) { | ||
| return Finding.FLATTENED_FIELD; | ||
| } | ||
| Origin concatOrigin = classifyConcatenation(argument); |
There was a problem hiding this comment.
Inline flattening at the call site isn't detected. findUnsafeArgument classifies the argument only when it's a VariableExpression, a this.field reference, or a concatenation — a .toString() call or a (String)/as String cast applied directly in the argument slot falls through to classifyConcatenation, which returns NONE:
Book.executeQuery("from Book where t = ${x}".toString()) // not flagged
Book.executeQuery((String) "from Book where t = ${x}") // not flaggedThis is the same irreversible coercion the tracked-variable tests already cover (def q = "...".toString(); find(q)), so the asymmetry both leaves a real vector open and is surprising to a user. The logic already exists — classify(argument, ClassHelper.STRING_TYPE) (or isUnsafeSource) returns FLATTENED for both forms; running the raw argument through it here would close the gap. Please add explicit tests for each inline form as well.
| * methods use names outside {@link #CANDIDATE_METHODS}.</li> | ||
| * </ul> | ||
| * | ||
| * @since 8.1 |
There was a problem hiding this comment.
@since 8.1 (here and in GlobalGormQuerySafetyASTTransformation) doesn't match the target. This PR is against 8.0.x (projectVersion=8.0.0-SNAPSHOT) and the upgrade note is in upgrading80x.adoc, described as something "Grails 8 adds." Either the tag should be 8.0.0, or — if this is genuinely intended for 8.1 — a compile-breaking change probably shouldn't land on the 8.0.x branch. Please reconcile the version.
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 |
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).