Skip to content

Commit 3c69ab1

Browse files
committed
Ruby: Restrict rb/csrf-protection-not-enabled
This query only applies to codebases using Ruby on Rails < 5.2, or where there is no call to `csrf_meta_tags` in the base ERb template.
1 parent 5810727 commit 3c69ab1

File tree

2 files changed

+262
-1
lines changed

2 files changed

+262
-1
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
private import codeql.ruby.AST
2+
3+
/**
4+
* Provides classes and predicates for Gemfiles, including version constraint logic.
5+
*/
6+
module Gemfile {
7+
private File getGemfile() { result.getBaseName() = "Gemfile" }
8+
9+
/**
10+
* A call to `gem` inside a gemfile. This defines a dependency. For example:
11+
*
12+
* ```rb
13+
* gem "actionpack", "~> 7.0.0"
14+
* ```
15+
*
16+
* This call defines a dependency on the `actionpack` gem, with version constraint `~> 7.0.0`.
17+
* For detail on version constraints, see the `VersionConstraint` class.
18+
*/
19+
class Gem extends MethodCall {
20+
Gem() { this.getMethodName() = "gem" and this.getFile() = getGemfile() }
21+
22+
string getName() { result = this.getArgument(0).getConstantValue().getStringlikeValue() }
23+
24+
/**
25+
* Gets the `i`th version string for this gem. A single `gem` call may have multiple version constraints, for example:
26+
*
27+
* ```rb
28+
* gem "json", "3.4.0", ">= 3.0"
29+
* ```
30+
*/
31+
string getVersionString(int i) {
32+
result = this.getArgument(i + 1).getConstantValue().getStringlikeValue()
33+
}
34+
35+
/**
36+
* Gets a version constraint defined by this call.
37+
*/
38+
VersionConstraint getAVersionConstraint() { result = this.getVersionString(_) }
39+
}
40+
41+
private newtype TComparator =
42+
TEq() or
43+
TNeq() or
44+
TGt() or
45+
TLt() or
46+
TGeq() or
47+
TLeq() or
48+
TPGeq()
49+
50+
/**
51+
* A comparison operator in a version constraint.
52+
*/
53+
private class Comparator extends TComparator {
54+
string toString() { result = this.toSourceString() }
55+
56+
/**
57+
* The representation of the comparator in source code.
58+
* This is defined separately so that we can change the `toString` implementation without breaking `parseConstraint`.
59+
*/
60+
string toSourceString() {
61+
this = TEq() and result = "="
62+
or
63+
this = TNeq() and result = "!="
64+
or
65+
this = TGt() and result = ">"
66+
or
67+
this = TLt() and result = "<"
68+
or
69+
this = TGeq() and result = ">="
70+
or
71+
this = TLeq() and result = "<="
72+
or
73+
this = TPGeq() and result = "~>"
74+
}
75+
}
76+
77+
bindingset[s]
78+
private predicate parseExactVersion(string s, string version) {
79+
version = s.regexpCapture("\\s*(\\d+\\.\\d+\\.\\d+)\\s*", 1)
80+
}
81+
82+
bindingset[s]
83+
private predicate parseConstraint(string s, Comparator c, string version) {
84+
exists(string pattern | pattern = "(=|!=|>=?|<=?|~>)\\s+(.+)" |
85+
c.toSourceString() = s.regexpCapture(pattern, 1) and version = s.regexpCapture(pattern, 2)
86+
)
87+
}
88+
89+
class VersionConstraint extends string {
90+
Comparator comp;
91+
string versionString;
92+
93+
VersionConstraint() {
94+
this = any(Gem g).getVersionString(_) and
95+
(
96+
parseConstraint(this, comp, versionString)
97+
or
98+
parseExactVersion(this, versionString) and comp = TEq()
99+
)
100+
}
101+
102+
/**
103+
* Gets the string defining the version number used in this constraint.
104+
*/
105+
string getVersionString() { result = versionString }
106+
107+
/**
108+
* Gets the `Version` used in this constraint.
109+
*/
110+
Version getVersion() { result = this.getVersionString() }
111+
112+
/**
113+
* Holds if `other` is a version which is strictly greater than the range described by this version constraint.
114+
*/
115+
bindingset[other]
116+
predicate before(string other) {
117+
comp = TEq() and this.getVersion().before(other)
118+
or
119+
comp = TLt() and
120+
(this.getVersion().before(other) or this.getVersion().equal(other))
121+
or
122+
comp = TLeq() and this.getVersion().before(other)
123+
or
124+
// ~> x.y.z <=> >= x.y.z && < x.(y+1).0
125+
// ~> x.y <=> >= x.y && < (x+1).0
126+
comp = TPGeq() and
127+
exists(int thisMajor, int thisMinor, int otherMajor, int otherMinor |
128+
thisMajor = this.getVersion().getMajor() and
129+
thisMinor = this.getVersion().getMinor() and
130+
exists(string maj, string mi | normalizeSemver(other, _, maj, mi, _) |
131+
otherMajor = maj.toInt() and otherMinor = mi.toInt()
132+
)
133+
|
134+
exists(this.getVersion().getPatch()) and
135+
(
136+
thisMajor < otherMajor
137+
or
138+
thisMajor = otherMajor and
139+
thisMinor < otherMinor
140+
)
141+
or
142+
not exists(this.getVersion().getPatch()) and
143+
thisMajor < otherMajor
144+
)
145+
// if the comparator is > or >=, it has no upper bound and therefore isn't guaranteed to be before any other version.
146+
}
147+
}
148+
149+
/**
150+
* A version number in a version constraint. For example, in the following code
151+
*
152+
* ```rb
153+
* gem "json", ">= 3.4.5"
154+
* ```
155+
*
156+
* The version is `3.4.5`.
157+
*/
158+
private class Version extends string {
159+
string normalized;
160+
161+
Version() {
162+
this = any(Gem c).getAVersionConstraint().getVersionString() and
163+
normalized = normalizeSemver(this)
164+
}
165+
166+
/**
167+
* Holds if this version is strictly before the version defined by `other`.
168+
*/
169+
bindingset[other]
170+
predicate before(string other) { normalized < normalizeSemver(other) }
171+
172+
/**
173+
* Holds if this versino is equal to the version defined by `other`.
174+
*/
175+
bindingset[other]
176+
predicate equal(string other) { normalized = normalizeSemver(other) }
177+
178+
/**
179+
* Holds if this version is strictly after the version defined by `other`.
180+
*/
181+
bindingset[other]
182+
predicate after(string other) { normalized > normalizeSemver(other) }
183+
184+
/**
185+
* Holds if this version defines a patch number.
186+
*/
187+
predicate hasPatch() { exists(getPatch(this)) }
188+
189+
/**
190+
* Gets the major number of this version.
191+
*/
192+
int getMajor() { result = getMajor(normalized).toInt() }
193+
194+
/**
195+
* Gets the minor number of this version, if it exists.
196+
*/
197+
int getMinor() { result = getMinor(normalized).toInt() }
198+
199+
/**
200+
* Gets the patch number of this version, if it exists.
201+
*/
202+
int getPatch() { result = getPatch(normalized).toInt() }
203+
}
204+
205+
/**
206+
* Normalizes a SemVer string such that the lexicographical ordering
207+
* of two normalized strings is consistent with the SemVer ordering.
208+
*
209+
* Pre-release information and build metadata is not supported.
210+
*/
211+
bindingset[orig]
212+
private predicate normalizeSemver(
213+
string orig, string normalized, string major, string minor, string patch
214+
) {
215+
major = getMajor(orig) and
216+
(
217+
minor = getMinor(orig)
218+
or
219+
not exists(getMinor(orig)) and minor = "0"
220+
) and
221+
(
222+
patch = getPatch(orig)
223+
or
224+
not exists(getPatch(orig)) and patch = "0"
225+
) and
226+
normalized = leftPad(major) + "." + leftPad(minor) + "." + leftPad(patch)
227+
}
228+
229+
bindingset[orig]
230+
private string normalizeSemver(string orig) { normalizeSemver(orig, result, _, _, _) }
231+
232+
bindingset[s]
233+
private string getMajor(string s) { result = s.regexpCapture("(\\d+).*", 1) }
234+
235+
bindingset[s]
236+
private string getMinor(string s) { result = s.regexpCapture("(\\d+)\\.(\\d+).*", 2) }
237+
238+
bindingset[s]
239+
private string getPatch(string s) { result = s.regexpCapture("(\\d+)\\.(\\d+)\\.(\\d+).*", 3) }
240+
241+
bindingset[str]
242+
private string leftPad(string str) { result = ("000" + str).suffix(str.length()) }
243+
}

ruby/ql/src/queries/security/cwe-352/CSRFProtectionNotEnabled.ql

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import codeql.ruby.AST
1515
import codeql.ruby.Concepts
1616
import codeql.ruby.frameworks.ActionController
17+
import codeql.ruby.frameworks.Gemfile
1718

1819
/**
1920
* Holds if a call to `protect_from_forgery` is made in the controller class `definedIn`,
@@ -26,6 +27,23 @@ private predicate protectFromForgeryCall(
2627
definedIn.getSelf().flowsTo(call.getReceiver()) and child = definedIn.getADescendent()
2728
}
2829

30+
/**
31+
* Holds if the Gemfile for this application specifies a version of "rails" < 3.0.0.
32+
* Rails versions from 3.0.0 onwards enable CSRF protection by default.
33+
*/
34+
private predicate railsPreVersion3() {
35+
exists(Gemfile::Gem g | g.getName() = "rails" and g.getAVersionConstraint().before("5.2"))
36+
}
37+
2938
from ActionControllerClass c
30-
where not protectFromForgeryCall(_, c, _)
39+
where
40+
not protectFromForgeryCall(_, c, _) and
41+
// Rails versions prior to 3.0.0 require CSRF protection to be explicitly enabled.
42+
// For later versions, there must exist a call to `csrf_meta_tags` in every HTML response.
43+
// We currently just check for a call to this method anywhere in the codebase.
44+
(
45+
railsPreVersion3()
46+
or
47+
not any(MethodCall m).getMethodName() = "csrf_meta_tags"
48+
)
3149
select c, "Potential CSRF vulnerability due to forgery protection not being enabled."

0 commit comments

Comments
 (0)