Skip to content

Commit 3dd9392

Browse files
authored
Merge pull request #11869 from alexrford/rails/render_locals_shared
Ruby: Rails - generalize rails flow step for accessing render locals hash in view
2 parents 39e9eaf + 3b10a2d commit 3dd9392

File tree

8 files changed

+224
-138
lines changed

8 files changed

+224
-138
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Data flowing from the `locals` argument of a Rails `render` call is now tracked to uses of that data in an associated view.

ruby/ql/lib/codeql/ruby/dataflow/FlowSummary.qll

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ module SummaryComponent {
3030

3131
predicate withContent = SC::withContent/1;
3232

33+
class SyntheticGlobal = SC::SyntheticGlobal;
34+
3335
/** Gets a summary component that represents a receiver. */
3436
SummaryComponent receiver() { result = argument(any(ParameterPosition pos | pos.isSelf())) }
3537

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

Lines changed: 127 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,43 @@ private import codeql.ruby.frameworks.ActiveStorage
1010
private import codeql.ruby.frameworks.internal.Rails
1111
private import codeql.ruby.ApiGraphs
1212
private import codeql.ruby.security.OpenSSL
13+
private import codeql.ruby.dataflow.FlowSummary
14+
15+
/** Provides utility predicates for extracting information from calls to `render`. */
16+
private module RenderCallUtils {
17+
private Expr getTemplatePathArgument(MethodCall renderCall) {
18+
// TODO: support other ways of specifying paths (e.g. `file`)
19+
result =
20+
[renderCall.getKeywordArgument(["partial", "template", "action"]), renderCall.getArgument(0)]
21+
}
22+
23+
private string getTemplatePathValue(MethodCall renderCall) {
24+
result = getTemplatePathArgument(renderCall).getConstantValue().getStringlikeValue()
25+
}
26+
27+
// everything up to and including the final slash, but ignoring any leading slash
28+
private string getSubPath(MethodCall renderCall) {
29+
result = getTemplatePathValue(renderCall).regexpCapture("^/?(.*/)?(?:[^/]*?)$", 1)
30+
}
31+
32+
// everything after the final slash, or the whole string if there is no slash
33+
private string getBaseName(MethodCall renderCall) {
34+
result = getTemplatePathValue(renderCall).regexpCapture("^/?(?:.*/)?([^/]*?)$", 1)
35+
}
36+
37+
/**
38+
* Gets the template file to be rendered by this render call, if any.
39+
*/
40+
ErbFile getTemplateFile(MethodCall renderCall) {
41+
result.getTemplateName() = getBaseName(renderCall) and
42+
result.getRelativePath().matches("%app/views/" + getSubPath(renderCall) + "%")
43+
}
44+
45+
/**
46+
* Gets the local variables passed as context to the renderer.
47+
*/
48+
HashLiteral getLocals(MethodCall renderCall) { result = renderCall.getKeywordArgument("locals") }
49+
}
1350

1451
/**
1552
* Provides classes for working with Rails.
@@ -38,37 +75,15 @@ module Rails {
3875
* rendered content.
3976
*/
4077
class RenderCall extends MethodCall instanceof RenderCallImpl {
41-
private Expr getTemplatePathArgument() {
42-
// TODO: support other ways of specifying paths (e.g. `file`)
43-
result = [this.getKeywordArgument(["partial", "template", "action"]), this.getArgument(0)]
44-
}
45-
46-
private string getTemplatePathValue() {
47-
result = this.getTemplatePathArgument().getConstantValue().getStringlikeValue()
48-
}
49-
50-
// everything up to and including the final slash, but ignoring any leading slash
51-
private string getSubPath() {
52-
result = this.getTemplatePathValue().regexpCapture("^/?(.*/)?(?:[^/]*?)$", 1)
53-
}
54-
55-
// everything after the final slash, or the whole string if there is no slash
56-
private string getBaseName() {
57-
result = this.getTemplatePathValue().regexpCapture("^/?(?:.*/)?([^/]*?)$", 1)
58-
}
59-
6078
/**
6179
* Gets the template file to be rendered by this call, if any.
6280
*/
63-
ErbFile getTemplateFile() {
64-
result.getTemplateName() = this.getBaseName() and
65-
result.getRelativePath().matches("%app/views/" + this.getSubPath() + "%")
66-
}
81+
ErbFile getTemplateFile() { result = RenderCallUtils::getTemplateFile(this) }
6782

6883
/**
69-
* Get the local variables passed as context to the renderer
84+
* Gets the local variables passed as context to the renderer.
7085
*/
71-
HashLiteral getLocals() { result = this.getKeywordArgument("locals") }
86+
HashLiteral getLocals() { result = RenderCallUtils::getLocals(this) }
7287
// TODO: implicit renders in controller actions
7388
}
7489

@@ -287,5 +302,92 @@ private class CookiesSameSiteProtectionSetting extends Settings::NillableStringl
287302
result = "Unsetting 'SameSite' can disable same-site cookie restrictions in some browsers."
288303
}
289304
}
305+
290306
// TODO: initialization hooks, e.g. before_configuration, after_initialize...
291307
// TODO: initializers
308+
/** A synthetic global to represent the value passed to the `locals` argument of a render call for a specific ERB file. */
309+
private class LocalAssignsHashSyntheticGlobal extends SummaryComponent::SyntheticGlobal {
310+
private ErbFile erbFile;
311+
private string id;
312+
// Note that we can't use an actual `Rails::RenderCall` here due to problems with non-monotonic recursion
313+
private MethodCall renderCall;
314+
315+
LocalAssignsHashSyntheticGlobal() {
316+
this = "LocalAssignsHashSyntheticGlobal+" + id and
317+
id = erbFile.getRelativePath() + "+" + renderCall.getLocation() and
318+
renderCall.getMethodName() = "render" and
319+
RenderCallUtils::getTemplateFile(renderCall) = erbFile
320+
}
321+
322+
/** Gets the `ErbFile` which this locals hash is accessible from. */
323+
ErbFile getErbFile() { result = erbFile }
324+
325+
/** Gets the identifier for this particular locals hash synthetic global. */
326+
string getId() { result = id }
327+
328+
/** Gets a call to render that can write to this hash. */
329+
Rails::RenderCall getARenderCall() { result = renderCall }
330+
}
331+
332+
/** A summary for `render` calls linked to some specific ERB file. */
333+
private class RenderLocalsSummary extends SummarizedCallable {
334+
private LocalAssignsHashSyntheticGlobal glob;
335+
336+
RenderLocalsSummary() { this = "rails_render_locals()" + glob.getId() }
337+
338+
override Rails::RenderCall getACall() { result = glob.getARenderCall() }
339+
340+
override predicate propagatesFlowExt(string input, string output, boolean preservesValue) {
341+
input = "Argument[locals:]" and
342+
output = "SyntheticGlobal[" + glob + "]" and
343+
preservesValue = true
344+
}
345+
}
346+
347+
/** A summary for calls to `local_assigns` in a view to access a `render` call `locals` hash. */
348+
private class AccessLocalsSummary extends SummarizedCallable {
349+
private LocalAssignsHashSyntheticGlobal glob;
350+
351+
AccessLocalsSummary() { this = "rails_local_assigns()" + glob.getId() }
352+
353+
override MethodCall getACall() {
354+
glob.getErbFile() = result.getLocation().getFile() and
355+
result.getMethodName() = "local_assigns"
356+
}
357+
358+
override predicate propagatesFlowExt(string input, string output, boolean preservesValue) {
359+
input = "SyntheticGlobal[" + glob + "]" and
360+
output = "ReturnValue" and
361+
preservesValue = true
362+
}
363+
}
364+
365+
private string getAMethodNameFromErbFile(ErbFile f) {
366+
result = any(MethodCall c | c.getLocation().getFile() = f).getMethodName()
367+
}
368+
369+
private class AccessLocalsKeySummary extends SummarizedCallable {
370+
private LocalAssignsHashSyntheticGlobal glob;
371+
private string methodName;
372+
373+
AccessLocalsKeySummary() {
374+
this = "rails_locals_key()" + glob.getId() + "#" + methodName and
375+
methodName = getAMethodNameFromErbFile(glob.getErbFile()) and
376+
// Limit method calls to those that could plausibly be a key in a `locals` hash argument
377+
// TODO: this could be more precise but for problems using the dataflow library in this context
378+
methodName =
379+
any(HashLiteral l).getAKeyValuePair().getKey().getConstantValue().getStringlikeValue()
380+
}
381+
382+
override MethodCall getACall() {
383+
result.getLocation().getFile() = glob.getErbFile() and
384+
result.getMethodName() = methodName and
385+
result.getReceiver() instanceof SelfVariableReadAccess
386+
}
387+
388+
override predicate propagatesFlowExt(string input, string output, boolean preservesValue) {
389+
input = "SyntheticGlobal[" + glob + "].Element[:" + methodName + "]" and
390+
output = "ReturnValue" and
391+
preservesValue = true
392+
}
393+
}

ruby/ql/lib/codeql/ruby/security/XSS.qll

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -162,46 +162,6 @@ private module Shared {
162162
erb = call.getLocation().getFile()
163163
}
164164

165-
/**
166-
* Holds if some render call passes `value` for `hashKey` in the `locals`
167-
* argument, in ERB file `erb`.
168-
*/
169-
pragma[noinline]
170-
private predicate renderCallLocals(string hashKey, Expr value, ErbFile erb) {
171-
exists(Rails::RenderCall call, Pair kvPair |
172-
call.getLocals().getAKeyValuePair() = kvPair and
173-
kvPair.getValue() = value and
174-
kvPair.getKey().getConstantValue().isStringlikeValue(hashKey) and
175-
call.getTemplateFile() = erb
176-
)
177-
}
178-
179-
pragma[noinline]
180-
private predicate isFlowFromLocals0(
181-
CfgNodes::ExprNodes::ElementReferenceCfgNode refNode, string hashKey, ErbFile erb
182-
) {
183-
exists(DataFlow::Node argNode |
184-
argNode.asExpr() = refNode.getArgument(0) and
185-
refNode.getReceiver().getExpr().(MethodCall).getMethodName() = "local_assigns" and
186-
argNode.getALocalSource().getConstantValue().isStringlikeValue(hashKey) and
187-
erb = refNode.getFile()
188-
)
189-
}
190-
191-
private predicate isFlowFromLocals(DataFlow::Node node1, DataFlow::Node node2) {
192-
exists(string hashKey, ErbFile erb |
193-
// node1 is a `locals` argument to a render call...
194-
renderCallLocals(hashKey, node1.asExpr().getExpr(), erb)
195-
|
196-
// node2 is an element reference against `local_assigns`
197-
isFlowFromLocals0(node2.asExpr(), hashKey, erb)
198-
or
199-
// ...node2 is a "method call" to a "method" with `hashKey` as its name
200-
// TODO: This may be a variable read in reality that we interpret as a method call
201-
isMethodCall(node2.asExpr().getExpr(), hashKey, erb)
202-
)
203-
}
204-
205165
/**
206166
* Holds if `action` contains an assignment of `value` to an instance
207167
* variable named `name`, in ERB file `erb`.
@@ -275,8 +235,6 @@ private module Shared {
275235
* An additional step that is preserves dataflow in the context of XSS.
276236
*/
277237
predicate isAdditionalXssFlowStep(DataFlow::Node node1, DataFlow::Node node2) {
278-
isFlowFromLocals(node1, node2)
279-
or
280238
isFlowFromControllerInstanceVariable(node1, node2)
281239
or
282240
isFlowIntoHelperMethod(node1, node2)

0 commit comments

Comments
 (0)