borinquenkid opened a new pull request, #15971:
URL: https://github.com/apache/grails-core/pull/15971

   ## 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 #15968's branch. GORM binds the 
interpolated value as a real query parameter in this case; it is not 
exploitable.
   2. **#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
      ```groovy
      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 #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).
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to