Skip to content

Commit f6dd6bb

Browse files
committed
expand ActiveRecord modelling to cover how to access fields
1 parent eb5f26c commit f6dd6bb

3 files changed

Lines changed: 145 additions & 0 deletions

File tree

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ private import codeql.ruby.AST
22
private import codeql.ruby.Concepts
33
private import codeql.ruby.controlflow.CfgNodes
44
private import codeql.ruby.DataFlow
5+
private import codeql.ruby.dataflow.internal.DataFlowDispatch
56
private import codeql.ruby.ast.internal.Module
7+
private import codeql.ruby.ApiGraphs
8+
private import codeql.ruby.frameworks.StandardLibrary
69

710
private class ActiveRecordBaseAccess extends ConstantReadAccess {
811
ActiveRecordBaseAccess() {
@@ -160,5 +163,138 @@ class ActiveRecordSqlExecutionRange extends SqlExecution::Range {
160163

161164
override DataFlow::Node getSql() { result = this }
162165
}
166+
163167
// TODO: model `ActiveRecord` sanitizers
164168
// https://api.rubyonrails.org/classes/ActiveRecord/Sanitization/ClassMethods.html
169+
// TODO: factor this out
170+
private string constantQualifiedName(ConstantWriteAccess w) {
171+
/* get the qualified name for the parent module, then append w */
172+
exists(ConstantWriteAccess parent | parent = w.getEnclosingModule() |
173+
result = constantQualifiedName(parent) + "::" + w.getName()
174+
)
175+
or
176+
/* base case - there's no parent module */
177+
not exists(ConstantWriteAccess parent | parent = w.getEnclosingModule()) and
178+
result = w.getName()
179+
}
180+
181+
/**
182+
* A node that may evaluate to one or more `ActiveRecordModelClass` instances.
183+
*/
184+
abstract class ActiveRecordModelInstantiation extends DataFlow::Node {
185+
abstract ActiveRecordModelClass getClass();
186+
}
187+
188+
// Names of class methods on ActiveRecord models that may return one or more
189+
// instance of that model. This also includes the `initialize` method.
190+
// See https://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html
191+
private string finderMethodName() {
192+
exists(string baseName |
193+
baseName =
194+
[
195+
"fifth", "find", "find_by", "find_or_initialize_by", "find_or_create_by", "first",
196+
"forty_two", "fourth", "last", "second", "second_to_last", "take", "third", "third_to_last"
197+
] and
198+
(result = baseName or result = baseName + "!")
199+
)
200+
or
201+
result = "new"
202+
}
203+
204+
// Gets the "final" receiver in a chain of method calls.
205+
// For example, in `Foo.bar`, this would give the `Foo` access, and in
206+
// `foo.bar.baz("arg")` it would give the `foo` variable access
207+
private Expr getUltimateReceiver(MethodCall call) {
208+
exists(Expr recv |
209+
recv = call.getReceiver() and
210+
(
211+
result = getUltimateReceiver(recv)
212+
or
213+
not recv instanceof MethodCall and result = recv
214+
)
215+
)
216+
}
217+
218+
// A call to `find`, `where`, etc. that may return active record model object(s)
219+
private class ActiveRecordModelFinderCall extends ActiveRecordModelInstantiation, DataFlow::CallNode {
220+
private MethodCall call;
221+
private ActiveRecordModelClass cls;
222+
private Expr recv;
223+
224+
ActiveRecordModelFinderCall() {
225+
call = this.asExpr().getExpr() and
226+
recv = getUltimateReceiver(call) and
227+
resolveConstant(recv) = constantQualifiedName(cls) and
228+
call.getMethodName() = finderMethodName()
229+
}
230+
231+
final override ActiveRecordModelClass getClass() { result = cls }
232+
233+
string getConstantQualifiedClassName() { result = constantQualifiedName(cls) }
234+
235+
Expr getUltimateReceiver() { result = recv }
236+
}
237+
238+
// A `self` reference that may resolve to an active record model object
239+
private class ActiveRecordModelClassSelfReference extends ActiveRecordModelInstantiation,
240+
DataFlow::LocalSourceNode {
241+
private ActiveRecordModelClass cls;
242+
243+
ActiveRecordModelClassSelfReference() {
244+
exists(Self s |
245+
s.getEnclosingModule() = cls and
246+
s.getEnclosingMethod() = cls.getAMethod() and
247+
s = this.asExpr().getExpr()
248+
)
249+
}
250+
251+
final override ActiveRecordModelClass getClass() { result = cls }
252+
}
253+
254+
// A (locally tracked) active record model object
255+
private DataFlow::Node activeRecordModelInstance() {
256+
result instanceof ActiveRecordModelInstantiation
257+
or
258+
exists(ActiveRecordModelInstantiation inst | inst.(DataFlow::LocalSourceNode).flowsTo(result))
259+
}
260+
261+
// A call whose receiver may be an active record model object
262+
private class ActiveRecordInstanceMethodCall extends DataFlow::CallNode {
263+
ActiveRecordInstanceMethodCall() { this.getReceiver() = activeRecordModelInstance() }
264+
}
265+
266+
private string activeRecordPersistenceInstanceMethodName() {
267+
result =
268+
[
269+
"becomes", "becomes!", "decrement", "decrement!", "delete", "delete!", "destroy", "destroy!",
270+
"destroyed?", "increment", "increment!", "new_record?", "persisted?",
271+
"previously_new_record?", "reload", "save", "save!", "toggle", "toggle!", "touch", "update",
272+
"update!", "update_attribute", "update_column", "update_columns"
273+
]
274+
}
275+
276+
private predicate isCallToBuiltInMethod(MethodCall c) {
277+
c.getMethodName() = activeRecordPersistenceInstanceMethodName() or
278+
c instanceof BasicObjectInstanceMethodCall or
279+
c instanceof ObjectInstanceMethodCall
280+
}
281+
282+
/**
283+
* Returns true if `call` may refer to a method that returns a database value
284+
* if invoked against a `sourceClass` instance.
285+
*/
286+
predicate activeRecordMethodMayAccessField(ActiveRecordModelClass sourceClass, MethodCall call) {
287+
not (
288+
// Methods whose names can be hardcoded
289+
isCallToBuiltInMethod(call)
290+
or
291+
// Methods defined in `sourceClass` that do not return database fields
292+
exists(Method m | m = sourceClass.getMethod(call.getMethodName()) |
293+
forall(DataFlow::Node returned, ActiveRecordInstanceMethodCall c |
294+
exprNodeReturnedFrom(returned, m) and c.flowsTo(returned)
295+
|
296+
not activeRecordMethodMayAccessField(sourceClass, returned.asExpr().getExpr())
297+
)
298+
)
299+
)
300+
}

ql/test/library-tests/frameworks/ActiveRecord.expected

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,10 @@ potentiallyUnsafeSqlExecutingMethodCall
4444
| ActiveRecordInjection.rb:68:5:68:33 | call to delete_by |
4545
| ActiveRecordInjection.rb:75:5:75:29 | call to order |
4646
| ActiveRecordInjection.rb:80:7:80:40 | call to find_by |
47+
activeRecordModelInstantiations
48+
| ActiveRecordInjection.rb:10:5:10:68 | self | ActiveRecordInjection.rb:5:1:17:3 | User |
49+
| ActiveRecordInjection.rb:15:5:15:40 | call to find_by | ActiveRecordInjection.rb:1:1:3:3 | UserGroup |
50+
| ActiveRecordInjection.rb:23:5:23:25 | self | ActiveRecordInjection.rb:19:1:25:3 | Admin |
51+
| ActiveRecordInjection.rb:80:7:80:40 | call to find_by | ActiveRecordInjection.rb:5:1:17:3 | User |
52+
| ActiveRecordInjection.rb:85:5:85:33 | call to find_by | ActiveRecordInjection.rb:5:1:17:3 | User |
53+
| ActiveRecordInjection.rb:88:5:88:34 | call to find | ActiveRecordInjection.rb:5:1:17:3 | User |

ql/test/library-tests/frameworks/ActiveRecord.ql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ query predicate activeRecordModelClassMethodCalls(ActiveRecordModelClassMethodCa
1010
query predicate potentiallyUnsafeSqlExecutingMethodCall(PotentiallyUnsafeSqlExecutingMethodCall call) {
1111
any()
1212
}
13+
14+
query predicate activeRecordModelInstantiations(ActiveRecordModelInstantiation i, ActiveRecordModelClass cls) { i.getClass() = cls }

0 commit comments

Comments
 (0)