Skip to content

Commit 8bc4193

Browse files
committed
Ruby: minor overhaul of ActiveRecord model
Old version had scalability issues when adding taking more interprocedural flow and inheritance into account.
1 parent bb3b973 commit 8bc4193

File tree

3 files changed

+247
-127
lines changed

3 files changed

+247
-127
lines changed

ruby/ql/lib/codeql/ruby/frameworks/ActiveRecord.qll

Lines changed: 142 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,8 @@ private predicate isBuiltInMethodForActiveRecordModelInstance(string methodName)
3030
methodName = objectInstanceMethodName()
3131
}
3232

33-
private API::Node activeRecordClassApiNode() {
33+
private API::Node activeRecordBaseClass() {
3434
result =
35-
// class Foo < ActiveRecord::Base
36-
// class Bar < Foo
3735
[
3836
API::getTopLevelMember("ActiveRecord").getMember("Base"),
3937
// In Rails applications `ApplicationRecord` typically extends `ActiveRecord::Base`, but we
@@ -42,6 +40,46 @@ private API::Node activeRecordClassApiNode() {
4240
]
4341
}
4442

43+
/**
44+
* Gets an object with methods from the ActiveRecord query interface.
45+
*/
46+
private API::Node activeRecordQueryBuilder() {
47+
result = activeRecordBaseClass()
48+
or
49+
result = activeRecordBaseClass().getInstance()
50+
or
51+
// Assume any method call might return an ActiveRecord::Relation
52+
// These are dynamically generated
53+
result = activeRecordQueryBuilderMethodAccess(_).getReturn()
54+
}
55+
56+
/** Gets a call targeting the ActiveRecord query interface. */
57+
private API::MethodAccessNode activeRecordQueryBuilderMethodAccess(string name) {
58+
result = activeRecordQueryBuilder().getMethod(name) and
59+
// Due to the heuristic tracking of query builder objects, add a restriction for methods with a known call target
60+
not isUnlikelyExternalCall(result)
61+
}
62+
63+
/** Gets a call targeting the ActiveRecord query interface. */
64+
private DataFlow::CallNode activeRecordQueryBuilderCall(string name) {
65+
result = activeRecordQueryBuilderMethodAccess(name).asCall()
66+
}
67+
68+
/**
69+
* Holds if `call` is unlikely to call into an external library, since it has a possible
70+
* call target in its enclosing module.
71+
*/
72+
private predicate isUnlikelyExternalCall(API::MethodAccessNode node) {
73+
exists(DataFlow::ModuleNode mod, DataFlow::CallNode call | call = node.asCall() |
74+
call.getATarget() = [mod.getAnOwnSingletonMethod(), mod.getAnOwnInstanceMethod()] and
75+
call.getEnclosingMethod() = [mod.getAnOwnSingletonMethod(), mod.getAnOwnInstanceMethod()]
76+
)
77+
}
78+
79+
private API::Node activeRecordConnectionInstance() {
80+
result = activeRecordBaseClass().getReturn("connection")
81+
}
82+
4583
/**
4684
* A `ClassDeclaration` for a class that inherits from `ActiveRecord::Base`. For example,
4785
*
@@ -55,20 +93,19 @@ private API::Node activeRecordClassApiNode() {
5593
* ```
5694
*/
5795
class ActiveRecordModelClass extends ClassDeclaration {
96+
private DataFlow::ClassNode cls;
97+
5898
ActiveRecordModelClass() {
59-
this.getSuperclassExpr() =
60-
activeRecordClassApiNode().getASubclass().getAValueReachableFromSource().asExpr().getExpr()
99+
cls = activeRecordBaseClass().getADescendentModule() and this = cls.getADeclaration()
61100
}
62101

63102
// Gets the class declaration for this class and all of its super classes
64-
private ModuleBase getAllClassDeclarations() {
65-
result = this.getModule().getSuperClass*().getADeclaration()
66-
}
103+
private ModuleBase getAllClassDeclarations() { result = cls.getAnAncestor().getADeclaration() }
67104

68105
/**
69106
* Gets methods defined in this class that may access a field from the database.
70107
*/
71-
Method getAPotentialFieldAccessMethod() {
108+
deprecated Method getAPotentialFieldAccessMethod() {
72109
// It's a method on this class or one of its super classes
73110
result = this.getAllClassDeclarations().getAMethod() and
74111
// There is a value that can be returned by this method which may include field data
@@ -90,58 +127,84 @@ class ActiveRecordModelClass extends ClassDeclaration {
90127
)
91128
)
92129
}
130+
131+
/** Gets the class as a `DataFlow::ClasNode`. */
132+
DataFlow::ClassNode getClassNode() { result = cls }
93133
}
94134

95-
/** A class method call whose receiver is an `ActiveRecordModelClass`. */
96-
class ActiveRecordModelClassMethodCall extends MethodCall {
97-
private ActiveRecordModelClass recvCls;
135+
/**
136+
* Gets a potential reference to an ActiveRecord class object.
137+
*/
138+
deprecated private API::Node getAnActiveRecordModelClassRef() {
139+
result = any(ActiveRecordModelClass cls).getClassNode().trackModule()
140+
or
141+
// For methods with an unknown call target, assume this might be a database field, thus returning another ActiveRecord object.
142+
// In this case we do not know which class it belongs to, which is why this predicate can't associate the reference with a specific class.
143+
result = getAnUnknownActiveRecordModelClassCall().getReturn()
144+
}
98145

146+
/**
147+
* Gets a call performed on an ActiveRecord class object, without a known call target in the codebase.
148+
*/
149+
deprecated private API::MethodAccessNode getAnUnknownActiveRecordModelClassCall() {
150+
result = getAnActiveRecordModelClassRef().getMethod(_) and
151+
result.asCall().asExpr().getExpr() instanceof UnknownMethodCall
152+
}
153+
154+
/**
155+
* DEPRECATED. Use `ActiveRecordModelClass.getClassNode().trackModule().getMethod()` instead.
156+
*
157+
* A class method call whose receiver is an `ActiveRecordModelClass`.
158+
*/
159+
deprecated class ActiveRecordModelClassMethodCall extends MethodCall {
99160
ActiveRecordModelClassMethodCall() {
100-
// e.g. Foo.where(...)
101-
recvCls.getModule() = this.getReceiver().(ConstantReadAccess).getModule()
102-
or
103-
// e.g. Foo.joins(:bars).where(...)
104-
recvCls = this.getReceiver().(ActiveRecordModelClassMethodCall).getReceiverClass()
105-
or
106-
// e.g. self.where(...) within an ActiveRecordModelClass
107-
this.getReceiver() instanceof SelfVariableAccess and
108-
this.getEnclosingModule() = recvCls
161+
this = getAnUnknownActiveRecordModelClassCall().asCall().asExpr().getExpr()
109162
}
110163

111-
/** The `ActiveRecordModelClass` of the receiver of this method. */
112-
ActiveRecordModelClass getReceiverClass() { result = recvCls }
164+
/** Gets the `ActiveRecordModelClass` of the receiver of this method, if it can be determined. */
165+
ActiveRecordModelClass getReceiverClass() {
166+
this = result.getClassNode().trackModule().getMethod(_).asCall().asExpr().getExpr()
167+
}
113168
}
114169

115-
private Expr sqlFragmentArgument(MethodCall call) {
116-
exists(string methodName |
117-
methodName = call.getMethodName() and
118-
(
119-
methodName =
120-
[
121-
"delete_all", "delete_by", "destroy_all", "destroy_by", "exists?", "find_by", "find_by!",
122-
"find_or_create_by", "find_or_create_by!", "find_or_initialize_by", "find_by_sql", "from",
123-
"group", "having", "joins", "lock", "not", "order", "reorder", "pluck", "where",
124-
"rewhere", "select", "reselect", "update_all"
125-
] and
126-
result = call.getArgument(0)
127-
or
128-
methodName = "calculate" and result = call.getArgument(1)
129-
or
130-
methodName in ["average", "count", "maximum", "minimum", "sum", "count_by_sql"] and
131-
result = call.getArgument(0)
132-
or
133-
// This format was supported until Rails 2.3.8
134-
methodName = ["all", "find", "first", "last"] and
135-
result = call.getKeywordArgument("conditions")
136-
or
137-
methodName = "reload" and
138-
result = call.getKeywordArgument("lock")
139-
or
140-
// Calls to `annotate` can be used to add block comments to SQL queries. These are potentially vulnerable to
141-
// SQLi if user supplied input is passed in as an argument.
142-
methodName = "annotate" and
143-
result = call.getArgument(_)
144-
)
170+
private predicate sqlFragmentArgumentInner(DataFlow::CallNode call, DataFlow::Node sink) {
171+
call =
172+
activeRecordQueryBuilderCall([
173+
"delete_all", "delete_by", "destroy_all", "destroy_by", "exists?", "find_by", "find_by!",
174+
"find_or_create_by", "find_or_create_by!", "find_or_initialize_by", "find_by_sql", "from",
175+
"group", "having", "joins", "lock", "not", "order", "reorder", "pluck", "where", "rewhere",
176+
"select", "reselect", "update_all"
177+
]) and
178+
sink = call.getArgument(0)
179+
or
180+
call = activeRecordQueryBuilderCall("calculate") and
181+
sink = call.getArgument(1)
182+
or
183+
call =
184+
activeRecordQueryBuilderCall(["average", "count", "maximum", "minimum", "sum", "count_by_sql"]) and
185+
sink = call.getArgument(0)
186+
or
187+
// This format was supported until Rails 2.3.8
188+
call = activeRecordQueryBuilderCall(["all", "find", "first", "last"]) and
189+
sink = call.getKeywordArgument("conditions")
190+
or
191+
call = activeRecordQueryBuilderCall("reload") and
192+
sink = call.getKeywordArgument("lock")
193+
or
194+
// Calls to `annotate` can be used to add block comments to SQL queries. These are potentially vulnerable to
195+
// SQLi if user supplied input is passed in as an argument.
196+
call = activeRecordQueryBuilderCall("annotate") and
197+
sink = call.getArgument(_)
198+
or
199+
call = activeRecordConnectionInstance().getAMethodCall("execute") and
200+
sink = call.getArgument(0)
201+
}
202+
203+
private predicate sqlFragmentArgument(DataFlow::CallNode call, DataFlow::Node sink) {
204+
exists(DataFlow::Node arg |
205+
sqlFragmentArgumentInner(call, arg) and
206+
sink = [arg, arg.(DataFlow::ArrayLiteralNode).getElement(0)] and
207+
unsafeSqlExpr(sink.asExpr().getExpr())
145208
)
146209
}
147210

@@ -162,6 +225,8 @@ private predicate unsafeSqlExpr(Expr sqlFragmentExpr) {
162225
}
163226

164227
/**
228+
* DEPRECATED. Use the `SqlExecution` concept or `ActiveRecordSqlExecutionRange`.
229+
*
165230
* A method call that may result in executing unintended user-controlled SQL
166231
* queries if the `getSqlFragmentSinkArgument()` expression is tainted by
167232
* unsanitized user-controlled input. For example, supposing that `User` is an
@@ -175,55 +240,32 @@ private predicate unsafeSqlExpr(Expr sqlFragmentExpr) {
175240
* as `"') OR 1=1 --"` could result in the application looking up all users
176241
* rather than just one with a matching name.
177242
*/
178-
class PotentiallyUnsafeSqlExecutingMethodCall extends ActiveRecordModelClassMethodCall {
179-
// The SQL fragment argument itself
180-
private Expr sqlFragmentExpr;
243+
deprecated class PotentiallyUnsafeSqlExecutingMethodCall extends ActiveRecordModelClassMethodCall {
244+
private DataFlow::CallNode call;
181245

182246
PotentiallyUnsafeSqlExecutingMethodCall() {
183-
exists(Expr arg |
184-
arg = sqlFragmentArgument(this) and
185-
unsafeSqlExpr(sqlFragmentExpr) and
186-
(
187-
sqlFragmentExpr = arg
188-
or
189-
sqlFragmentExpr = arg.(ArrayLiteral).getElement(0)
190-
) and
191-
// Check that method has not been overridden
192-
not exists(SingletonMethod m |
193-
m.getName() = this.getMethodName() and
194-
m.getOuterScope() = this.getReceiverClass()
195-
)
196-
)
247+
call.asExpr().getExpr() = this and sqlFragmentArgument(call, _)
197248
}
198249

199250
/**
200251
* Gets the SQL fragment argument of this method call.
201252
*/
202-
Expr getSqlFragmentSinkArgument() { result = sqlFragmentExpr }
253+
Expr getSqlFragmentSinkArgument() {
254+
exists(DataFlow::Node sink |
255+
sqlFragmentArgument(call, sink) and result = sink.asExpr().getExpr()
256+
)
257+
}
203258
}
204259

205260
/**
206-
* An `SqlExecution::Range` for an argument to a
207-
* `PotentiallyUnsafeSqlExecutingMethodCall` that may be vulnerable to being
208-
* controlled by user input.
261+
* A SQL execution arising from a call to the ActiveRecord library.
209262
*/
210263
class ActiveRecordSqlExecutionRange extends SqlExecution::Range {
211-
ActiveRecordSqlExecutionRange() {
212-
exists(PotentiallyUnsafeSqlExecutingMethodCall mc |
213-
this.asExpr().getNode() = mc.getSqlFragmentSinkArgument()
214-
)
215-
or
216-
this = activeRecordConnectionInstance().getAMethodCall("execute").getArgument(0) and
217-
unsafeSqlExpr(this.asExpr().getExpr())
218-
}
264+
ActiveRecordSqlExecutionRange() { sqlFragmentArgument(_, this) }
219265

220266
override DataFlow::Node getSql() { result = this }
221267
}
222268

223-
private API::Node activeRecordConnectionInstance() {
224-
result = activeRecordClassApiNode().getReturn("connection")
225-
}
226-
227269
// TODO: model `ActiveRecord` sanitizers
228270
// https://api.rubyonrails.org/classes/ActiveRecord/Sanitization/ClassMethods.html
229271
/**
@@ -241,15 +283,8 @@ abstract class ActiveRecordModelInstantiation extends OrmInstantiation::Range,
241283
override predicate methodCallMayAccessField(string methodName) {
242284
// The method is not a built-in, and...
243285
not isBuiltInMethodForActiveRecordModelInstance(methodName) and
244-
(
245-
// ...There is no matching method definition in the class, or...
246-
not exists(this.getClass().getMethod(methodName))
247-
or
248-
// ...the called method can access a field.
249-
exists(Method m | m = this.getClass().getAPotentialFieldAccessMethod() |
250-
m.getName() = methodName
251-
)
252-
)
286+
// ...There is no matching method definition in the class
287+
not exists(this.getClass().getMethod(methodName))
253288
}
254289
}
255290

@@ -317,21 +352,10 @@ private class ActiveRecordModelFinderCall extends ActiveRecordModelInstantiation
317352
}
318353

319354
// A `self` reference that may resolve to an active record model object
320-
private class ActiveRecordModelClassSelfReference extends ActiveRecordModelInstantiation,
321-
DataFlow::SelfParameterNode
322-
{
355+
private class ActiveRecordModelClassSelfReference extends ActiveRecordModelInstantiation {
323356
private ActiveRecordModelClass cls;
324357

325-
ActiveRecordModelClassSelfReference() {
326-
exists(MethodBase m |
327-
m = this.getCallable() and
328-
m.getEnclosingModule() = cls and
329-
m = cls.getAMethod()
330-
) and
331-
// In a singleton method, `self` refers to the class itself rather than an
332-
// instance of that class
333-
not this.getSelfVariable().getDeclaringScope() instanceof SingletonMethod
334-
}
358+
ActiveRecordModelClassSelfReference() { this = cls.getClassNode().getAnOwnInstanceSelf() }
335359

336360
final override ActiveRecordModelClass getClass() { result = cls }
337361
}
@@ -342,7 +366,7 @@ private class ActiveRecordModelClassSelfReference extends ActiveRecordModelInsta
342366
class ActiveRecordInstance extends DataFlow::Node {
343367
private ActiveRecordModelInstantiation instantiation;
344368

345-
ActiveRecordInstance() { this = instantiation or instantiation.flowsTo(this) }
369+
ActiveRecordInstance() { this = instantiation.track().getAValueReachableFromSource() }
346370

347371
/** Gets the `ActiveRecordModelClass` that this is an instance of. */
348372
ActiveRecordModelClass getClass() { result = instantiation.getClass() }
@@ -380,12 +404,12 @@ private module Persistence {
380404
/** A call to e.g. `User.create(name: "foo")` */
381405
private class CreateLikeCall extends DataFlow::CallNode, PersistentWriteAccess::Range {
382406
CreateLikeCall() {
383-
exists(this.asExpr().getExpr().(ActiveRecordModelClassMethodCall).getReceiverClass()) and
384-
this.getMethodName() =
385-
[
386-
"create", "create!", "create_or_find_by", "create_or_find_by!", "find_or_create_by",
387-
"find_or_create_by!", "insert", "insert!"
388-
]
407+
this =
408+
activeRecordBaseClass()
409+
.getAMethodCall([
410+
"create", "create!", "create_or_find_by", "create_or_find_by!", "find_or_create_by",
411+
"find_or_create_by!", "insert", "insert!"
412+
])
389413
}
390414

391415
override DataFlow::Node getValue() {
@@ -402,8 +426,7 @@ private module Persistence {
402426
/** A call to e.g. `User.update(1, name: "foo")` */
403427
private class UpdateLikeClassMethodCall extends DataFlow::CallNode, PersistentWriteAccess::Range {
404428
UpdateLikeClassMethodCall() {
405-
exists(this.asExpr().getExpr().(ActiveRecordModelClassMethodCall).getReceiverClass()) and
406-
this.getMethodName() = ["update", "update!", "upsert"]
429+
this = activeRecordBaseClass().getAMethodCall(["update", "update!", "upsert"])
407430
}
408431

409432
override DataFlow::Node getValue() {
@@ -448,10 +471,7 @@ private module Persistence {
448471
* ```
449472
*/
450473
private class TouchAllCall extends DataFlow::CallNode, PersistentWriteAccess::Range {
451-
TouchAllCall() {
452-
exists(this.asExpr().getExpr().(ActiveRecordModelClassMethodCall).getReceiverClass()) and
453-
this.getMethodName() = "touch_all"
454-
}
474+
TouchAllCall() { this = activeRecordQueryBuilderCall("touch_all") }
455475

456476
override DataFlow::Node getValue() { result = this.getKeywordArgument("time") }
457477
}
@@ -461,8 +481,7 @@ private module Persistence {
461481
private ExprNodes::ArrayLiteralCfgNode arr;
462482

463483
InsertAllLikeCall() {
464-
exists(this.asExpr().getExpr().(ActiveRecordModelClassMethodCall).getReceiverClass()) and
465-
this.getMethodName() = ["insert_all", "insert_all!", "upsert_all"] and
484+
this = activeRecordBaseClass().getAMethodCall(["insert_all", "insert_all!", "upsert_all"]) and
466485
arr = this.getArgument(0).asExpr()
467486
}
468487

0 commit comments

Comments
 (0)