public class GormQuerySafetyTransformer extends ClassCodeVisitorSupport
ClassCodeVisitorSupport that detects GORM HQL/Cypher query text built from a
GStringExpression that Groovy coerced to a plain String before it
reaches a GORM query method, e.g.:
String query = "from Book where name = ${userInput" // coerced to String right here
Book.executeQuery(query) // -> raw, unescaped text, no binding
}
When a GString is passed directly to a GORM query method, GORM binds
each interpolated value as a query parameter — safe. Once the GString has been coerced
to a String (an explicit String-typed local, a .toString() call, or an
as String/cast coercion), that information is gone: a String carries no trace
of ever having been a GString, so this can only be caught here, before the coercion
erases it — a runtime check at the query boundary is structurally blind to this case.
A GString does not have to be flattened directly at the call site to be unsafe —
aliasing it through one or more intermediate variables still loses the binding the moment it is
assigned to a String-typed variable, however many hops away that happens:
def g = "from Book where name = ${userInput" // g: still a live GString
String q = g // flattened HERE, not at the executeQuery call
Book.executeQuery(q)
}
Query text vs. values. Only an interpolation that can carry runtime data is
a finding. An interpolated expression that is constant text cannot be influenced by
user input, so a GString whose interpolations are all constant text is treated exactly
like a plain String literal. Constant text is: a literal; a static final field
initialised from constant text; a ternary or Elvis expression choosing between constant text; a
switch expression, or any closure called on the spot, whose every result is constant
text; a + concatenation, GString, cast or .toString() of constant text;
and a local variable that only ever held constant text on every path reaching the use. This is what
lets query text be assembled from fixed HQL fragments chosen at runtime, which a GString
passed directly to GORM could not express (GORM would bind the fragment as a parameter value):
String restriction = ""
if (params.title) {
restriction = " and b.title = :title" // constant text, chosen at runtime
queryParams.title = params.title // the value is bound, never interpolated
String query = "from Book b where 1 = 1 ${restriction}" // not a finding
Book.executeQuery(query, queryParams)
}
Compound assignment with += is tracked exactly like x = x + y: a constant
local stays constant when the appended text is constant, a live GString operand is
flattened (Groovy's GString.plus returns String), and an already-flattened one
stays flattened. The same holds for any + concatenation: a flattened or live
GString operand anywhere in it makes the whole result flattened, not merely a
lower-confidence concatenation.
Control flow. Local tracking is flow-sensitive. The branches of an
if/else and the cases of a switch (including fall-through) are each
walked from the state before the statement and merged pessimistically afterwards: a variable
unsafe at the end of any path stays unsafe, and is constant text only if it is constant at the
end of every path. A catch block starts from the merge of every state its try
block passed through, since an exception may leave the block after any statement, and a
finally block is checked against every path into it. A loop body or closure body may
run any number of times, so a body that assigns a local declared outside it is re-walked from
the merged loop-head state until that state is stable before findings are reported: an
assignment late in the body is seen by a use earlier in it, which the next iteration reaches.
break, continue and, in a closure, return carry their state to the
exit or head they jump to, and a path ending in such a jump or in throw contributes
nothing to the state after the statement. A jump or an exception that leaves a try
statement runs its finally block first, so it carries the state that block ends with
when walked from every path into it. Locals are tracked per declaration, so a later local,
parameter or loop variable that reuses a name never inherits the state of an earlier one.
Some assignments can run at a point the walk cannot place: inside a closure or the code of
an anonymous inner class, which may run at any later point; inside another expression, such
as a ternary or Elvis branch, an && or || operand, a method argument or a loop
condition; or through multiple assignment. Where the walk cannot know what a local holds - a
local assigned in any of these ways, anywhere; inside a closure, a local declared outside it;
and inside an anonymous inner class, a local of the method enclosing it - the local holds the
worst that any assignment to it in the method stores. It is constant text only when every
assignment is, and flattened when any assignment flattens a GString into it.
This is a build-breaking error for the local-variable case above, because the detection is precise: every flattening point is visible in the method being compiled. Two related patterns are lower-confidence and instead reported as compile-time warnings, which do not fail the build:
String-typed field initialized from an interpolated
GString, or assigned one via this.field = ..., that later reaches a query
method through this.field. Unlike locals, a field can be reassigned from a
constructor, another method, or a subclass that this check never visits, so it is flagged
rather than failed.+ from a
non-constant value and no GString involved at all, e.g.
"select ... " + userInput. This is a real injection shape, but concatenation is
common enough for benign, non-query purposes that a hard failure would be too blunt an
instrument. Concatenating only constant text, as defined above, is not a finding.A query method is recognised when it is called on a domain class, or on this, written
or implied, in a domain class or in a class nested in one that has no method of that name
itself, since Groovy sends such a call on to the enclosing class.
Both warnings share the same SUPPRESS_WARNINGS_VALUE suppression as the error case.
The suppression applies to the enclosing method or class (for a nested class, also to any class
or method it is declared in, as with a closure), or - more narrowly - to a single
local variable or field declaration: @SuppressWarnings("GormUnsafeQueryString") String
query = ... marks that variable as reviewed for the rest of the method, whatever it is later
assigned, and leaves every other variable checked. A reviewed variable is also treated as
constant text wherever it is interpolated or concatenated, so the annotation may go either on
the assembled query or on the fragment it is built from.
Known limitations (deliberate scope):
String built inside a helper method and
returned to the caller is invisible to this check, and so is constant text returned from
one (it is treated as data).static final fields, ternaries and
+/+=/GString composition only - not collections (for example
fragments gathered in a List and joined), method calls, or non-final fields.return, throw, break or continue.GString initializer or
this.field = ... assignment - it does not follow aliasing chains or
.toString()/cast coercions the way local tracking does.groovy.sql.Sql, or any datastore whose query
methods use names outside CANDIDATE_METHODS.| Modifiers | Name | Description |
|---|---|---|
static java.lang.String |
SUPPRESS_WARNINGS_VALUE |
The @SuppressWarnings value that silences this check (both the error and the two
warnings below) on the enclosing method (or, for calls outside any method, the enclosing
class), or on a single local variable or field declaration. |
| Constructor and description |
|---|
GormQuerySafetyTransformer(SourceUnit sourceUnit) |
| Type Params | Return Type | Name and description |
|---|---|---|
|
protected SourceUnit |
getSourceUnit() |
|
public void |
visitBinaryExpression(BinaryExpression expression) |
|
public void |
visitBreakStatement(BreakStatement statement) |
|
public void |
visitClass(ClassNode node) |
|
public void |
visitClosureExpression(ClosureExpression expression)A closure body may run any number of times after the closure is defined, so it is walked like a loop body, with the state at the point of definition. |
|
public void |
visitClosureExpression(ClosureExpression nested) |
|
protected void |
visitConstructorOrMethod(MethodNode node, boolean isConstructor) |
|
public void |
visitContinueStatement(ContinueStatement statement) |
|
public void |
visitDeclarationExpression(DeclarationExpression expression) |
|
public void |
visitDoWhileLoop(DoWhileStatement statement) |
|
public void |
visitField(FieldNode node) |
|
public void |
visitForLoop(ForStatement statement) |
|
public void |
visitIfElse(IfStatement ifElse)Visits an if/else flow-sensitively: each branch is walked from the state
before the statement, and the states at the end of the branches that can complete normally
are merged pessimistically afterwards (see merge). |
|
public void |
visitMethodCallExpression(MethodCallExpression call) |
|
protected void |
visitObjectInitializerStatements(ClassNode node) |
|
public void |
visitProperty(PropertyNode node) |
|
public void |
visitReturnStatement(ReturnStatement statement) |
|
public void |
visitReturnStatement(ReturnStatement statement) |
|
protected void |
visitStatement(Statement statement)Records the state before every statement walked inside a try or catch
block, for the handlers an exception thrown by that statement would reach. |
|
public void |
visitStaticMethodCallExpression(StaticMethodCallExpression call) |
|
public void |
visitSwitch(SwitchStatement statement)Visits a switch: every case is walked from the state before the statement merged
with the state the preceding case falls through with, and the state afterwards is merged
from every path that leaves the statement - the last case or the default completing
normally (which, with no default, includes no case matching) and every break. |
|
public void |
visitTryCatchFinally(TryCatchStatement statement)Visits a try statement. |
|
public void |
visitWhileLoop(WhileStatement statement) |
The @SuppressWarnings value that silences this check (both the error and the two
warnings below) on the enclosing method (or, for calls outside any method, the enclosing
class), or on a single local variable or field declaration.
A closure body may run any number of times after the closure is defined, so it is walked like a loop body, with the state at the point of definition. Because it may also run at any later point, after the locals it shares with the enclosing code have been reassigned, those locals are judged by every assignment to them inside it (see isJudgedByEveryAssignment).
Visits an if/else flow-sensitively: each branch is walked from the state
before the statement, and the states at the end of the branches that can complete normally
are merged pessimistically afterwards (see merge). Without this, whichever branch
happens to be visited last would silently win, e.g. a variable flattened only in the
if branch would be forgotten if the else branch reassigns it safely.
Records the state before every statement walked inside a try or catch
block, for the handlers an exception thrown by that statement would reach.
Visits a switch: every case is walked from the state before the statement merged
with the state the preceding case falls through with, and the state afterwards is merged
from every path that leaves the statement - the last case or the default completing
normally (which, with no default, includes no case matching) and every break.
Visits a try statement. An exception can leave the try block after any
statement, so each catch block starts from the merge of every state the block
passed through (accumulated by visitStatement). The finally block runs
after any of those states, any state a catch block passed through, or any state a
jump out of the statement leaves with, and is checked against all of them. Only a
try or catch block that completes normally continues past the statement,
so the state afterwards is derived from those paths alone. Every other way out - a jump,
which lands only once the finally block has run, or an exception - leaves with the
state the finally block ends with when walked from every path into it.