Skip to content

Commit c9b8e93

Browse files
committed
Ruby: Add a query for CSRF protection not enabled
Specifically in Rails apps, we look for root ActionController classes without a call to `protect_from_forgery`.
1 parent 06cb277 commit c9b8e93

File tree

7 files changed

+144
-33
lines changed

7 files changed

+144
-33
lines changed

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

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,35 @@ private import codeql.ruby.dataflow.internal.DataFlowDispatch
2121
module ActionController {
2222
// TODO: move the rest of this file inside this module.
2323
import codeql.ruby.frameworks.actioncontroller.Filters
24+
25+
/**
26+
* An ActionController class which sits at the top of the class hierarchy.
27+
* In other words, it does not subclass any other class in source code.
28+
*/
29+
class RootController extends ActionControllerClass {
30+
RootController() {
31+
not exists(ActionControllerClass parent | this != parent and this = parent.getADescendent())
32+
}
33+
}
34+
35+
/**
36+
* A call to `protect_from_forgery`.
37+
*/
38+
class ProtectFromForgeryCall extends CsrfProtectionSetting::Range, DataFlow::CallNode {
39+
ProtectFromForgeryCall() {
40+
this = actionControllerInstance().getAMethodCall("protect_from_forgery")
41+
}
42+
43+
private string getWithValueText() {
44+
result = this.getKeywordArgument("with").getConstantValue().getSymbol()
45+
}
46+
47+
// Calls without `with: :exception` can allow for bypassing CSRF protection
48+
// in some scenarios.
49+
override boolean getVerificationSetting() {
50+
if this.getWithValueText() = "exception" then result = true else result = false
51+
}
52+
}
2453
}
2554

2655
/**
@@ -48,18 +77,10 @@ deprecated class CookiesCall = Rails::CookiesCall;
4877
*/
4978
class ActionControllerClass extends DataFlow::ClassNode {
5079
ActionControllerClass() {
51-
this =
52-
[
53-
DataFlow::getConstant("ActionController").getConstant("Base"),
54-
// In Rails applications `ApplicationController` typically extends `ActionController::Base`, but we
55-
// treat it separately in case the `ApplicationController` definition is not in the database.
56-
DataFlow::getConstant("ApplicationController"),
57-
// ActionController::Metal technically doesn't contain all of the
58-
// methods available in Base, such as those for rendering views.
59-
// However we prefer to be over-sensitive in this case in order to find
60-
// more results.
61-
DataFlow::getConstant("ActionController").getConstant("Metal")
62-
].getADescendentModule()
80+
this = DataFlow::getConstant("ApplicationController").getADescendentModule()
81+
or
82+
this = actionControllerBaseClass().getADescendentModule() and
83+
not exists(DataFlow::ModuleNode m | m = actionControllerBaseClass().asModule() | this = m)
6384
}
6485

6586
/**
@@ -83,6 +104,20 @@ class ActionControllerClass extends DataFlow::ClassNode {
83104
}
84105
}
85106

107+
private DataFlow::ConstRef actionControllerBaseClass() {
108+
result =
109+
[
110+
// In Rails applications `ApplicationController` typically extends `ActionController::Base`, but we
111+
// treat it separately in case the `ApplicationController` definition is not in the database.
112+
DataFlow::getConstant("ActionController").getConstant("Base"),
113+
// ActionController::Metal technically doesn't contain all of the
114+
// methods available in Base, such as those for rendering views.
115+
// However we prefer to be over-sensitive in this case in order to find
116+
// more results.
117+
DataFlow::getConstant("ActionController").getConstant("Metal")
118+
]
119+
}
120+
86121
private API::Node actionControllerInstance() {
87122
result = any(ActionControllerClass cls).getSelf().track()
88123
}
@@ -431,27 +466,6 @@ class ActionControllerSkipForgeryProtectionCall extends CsrfProtectionSetting::R
431466
override boolean getVerificationSetting() { result = false }
432467
}
433468

434-
/**
435-
* A call to `protect_from_forgery`.
436-
*/
437-
private class ActionControllerProtectFromForgeryCall extends CsrfProtectionSetting::Range,
438-
DataFlow::CallNode
439-
{
440-
ActionControllerProtectFromForgeryCall() {
441-
this = actionControllerInstance().getAMethodCall("protect_from_forgery")
442-
}
443-
444-
private string getWithValueText() {
445-
result = this.getKeywordArgument("with").getConstantValue().getSymbol()
446-
}
447-
448-
// Calls without `with: :exception` can allow for bypassing CSRF protection
449-
// in some scenarios.
450-
override boolean getVerificationSetting() {
451-
if this.getWithValueText() = "exception" then result = true else result = false
452-
}
453-
}
454-
455469
/**
456470
* A call to `send_file`, which sends the file at the given path to the client.
457471
*/
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
Cross-site request forgery (CSRF) is a type of vulnerability in which an
9+
attacker is able to force a user to carry out an action that the user did
10+
not intend.
11+
</p>
12+
13+
<p>
14+
The attacker tricks an authenticated user into submitting a request to the
15+
web application. Typically this request will result in a state change on
16+
the server, such as changing the user's password. The request can be
17+
initiated when the user visits a site controlled by the attacker. If the
18+
web application relies only on cookies for authentication, or on other
19+
credentials that are automatically included in the request, then this
20+
request will appear as legitimate to the server.
21+
</p>
22+
23+
<p>
24+
A common countermeasure for CSRF is to generate a unique token to be
25+
included in the HTML sent from the server to a user. This token can be
26+
used as a hidden field to be sent back with requests to the server, where
27+
the server can then check that the token is valid and associated with the
28+
relevant user session.
29+
</p>
30+
</overview>
31+
32+
<recommendation>
33+
<p>
34+
In the Rails web framework, CSRF protection is enabled by the adding a call to
35+
the <code>protect_from_forgery</code> method inside an
36+
<code>ActionController</code> class. Typically this is done in the
37+
<code>ApplicationController</code> class, or an equivalent class from which
38+
other controller classes are subclassed.
39+
40+
The default behaviour of this method is to null the session when an invalid
41+
CSRF token is provided. This may not be sufficient to avoid a CSRF
42+
vulnerability - for example if parts of the session are memoized. Calling
43+
<code>protect_from_forgery with: :exception</code> can help to avoid this
44+
by raising an exception on an invalid CSRF token instead.
45+
</p>
46+
</recommendation>
47+
48+
<example>
49+
<p>
50+
The following example shows a case where CSRF protection is enabled with
51+
a secure request handling strategy of <code>:exception</code>.
52+
</p>
53+
54+
<sample src="examples/ProtectFromForgeryGood.rb"/>
55+
56+
</example>
57+
58+
<references>
59+
<li>Wikipedia: <a href="https://en.wikipedia.org/wiki/Cross-site_request_forgery">Cross-site request forgery</a></li>
60+
<li>OWASP: <a href="https://owasp.org/www-community/attacks/csrf">Cross-site request forgery</a></li>
61+
<li>Securing Rails Applications: <a href="https://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf">Cross-Site Request Forgery (CSRF)</a></li>
62+
<li>Veracode: <a href="https://www.veracode.com/blog/managing-appsec/when-rails-protectfromforgery-fails">When Rails' protect_from_forgery Fails</a>.</li>
63+
</references>
64+
65+
</qhelp>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @name CSRF protection not enabled
3+
* @description Not enabling CSRF protection may make the application
4+
* vulnerable to a Cross-Site Request Forgery (CSRF) attack.
5+
* @kind problem
6+
* @problem.severity warning
7+
* @security-severity 8.8
8+
* @precision high
9+
* @id rb/csrf-protection-not-enabled
10+
* @tags security
11+
* external/cwe/cwe-352
12+
*/
13+
14+
import codeql.ruby.AST
15+
import codeql.ruby.Concepts
16+
import codeql.ruby.frameworks.ActionController
17+
18+
from ActionController::RootController c
19+
where
20+
not exists(ActionController::ProtectFromForgeryCall call |
21+
c.getSelf().flowsTo(call.getReceiver())
22+
)
23+
select c, "Potential CSRF vulnerability due to forgery protection not being enabled"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class ApplicationController < ActionController::Base
2+
protect_from_forgery with: :exception
3+
end
4+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
| railsapp/app/controllers/alternative_root_controller.rb:1:1:3:3 | AlternativeRootController | Potential CSRF vulnerability due to forgery protection not being enabled |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
queries/security/cwe-352/CSRFProtectionNotEnabled.ql
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class AlternativeRootController < ActionController::Base
2+
# BAD: no protect_from_forgery call
3+
end

0 commit comments

Comments
 (0)