Skip to content

Commit bb38c4d

Browse files
authored
Merge pull request #6978 from github/nickrolfe/regex_injection
Ruby: add regex injection query
2 parents 83d204d + 1a90b38 commit bb38c4d

File tree

11 files changed

+306
-0
lines changed

11 files changed

+306
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
lgtm,codescanning
2+
* A new query (`rb/regexp-injection`) has been added. The query finds regular expressions constructed from user input, which could allow an attacker to perform a Regular Expression Denial of Service (ReDoS) attack.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ private import codeql.ruby.AST
22
private import codeql.ruby.Concepts
33
private import codeql.ruby.DataFlow
44
private import codeql.ruby.ApiGraphs
5+
private import codeql.ruby.dataflow.FlowSummary
56

67
/**
78
* The `Kernel` module is included by the `Object` class, so its methods are available
@@ -333,3 +334,18 @@ class ModuleEvalCallCodeExecution extends CodeExecution::Range, DataFlow::CallNo
333334

334335
override DataFlow::Node getCode() { result = this.getArgument(0) }
335336
}
337+
338+
/** Flow summary for `Regexp.escape` and its alias, `Regexp.quote`. */
339+
class RegexpEscapeSummary extends SummarizedCallable {
340+
RegexpEscapeSummary() { this = "Regexp.escape" }
341+
342+
override MethodCall getACall() {
343+
result = API::getTopLevelMember("Regexp").getAMethodCall(["escape", "quote"]).asExpr().getExpr()
344+
}
345+
346+
override predicate propagatesFlowExt(string input, string output, boolean preservesValue) {
347+
input = "Argument[0]" and
348+
output = "ReturnValue" and
349+
preservesValue = false
350+
}
351+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Provides default sources, sinks and sanitizers for reasoning about regexp
3+
* injection vulnerabilities, as well as extension points for adding your own.
4+
*/
5+
6+
private import ruby
7+
private import codeql.ruby.DataFlow
8+
private import codeql.ruby.Concepts
9+
private import codeql.ruby.Frameworks
10+
private import codeql.ruby.dataflow.RemoteFlowSources
11+
private import codeql.ruby.dataflow.BarrierGuards
12+
private import codeql.ruby.ApiGraphs
13+
14+
/**
15+
* Provides default sources, sinks and sanitizers for detecting
16+
* regexp injection vulnerabilities, as well as extension points for
17+
* adding your own.
18+
*/
19+
module RegExpInjection {
20+
/**
21+
* A data flow source for regexp injection vulnerabilities.
22+
*/
23+
abstract class Source extends DataFlow::Node { }
24+
25+
/**
26+
* A data flow sink for regexp injection vulnerabilities.
27+
*/
28+
abstract class Sink extends DataFlow::Node { }
29+
30+
/**
31+
* A sanitizer guard for regexp injection vulnerabilities.
32+
*/
33+
abstract class SanitizerGuard extends DataFlow::BarrierGuard { }
34+
35+
/**
36+
* A data flow sanitized for regexp injection vulnerabilities.
37+
*/
38+
abstract class Sanitizer extends DataFlow::Node { }
39+
40+
/**
41+
* A source of remote user input, considered as a flow source.
42+
*/
43+
class RemoteFlowSourceAsSource extends Source, RemoteFlowSource { }
44+
45+
/** A regexp literal, considered as a flow sink. */
46+
class RegExpLiteralAsSink extends Sink {
47+
RegExpLiteralAsSink() { this.asExpr().getExpr() instanceof RegExpLiteral }
48+
}
49+
50+
/**
51+
* The first argument of a call to `Regexp.new` or `Regexp.compile`,
52+
* considered as a flow sink.
53+
*/
54+
class ConstructedRegExpAsSink extends Sink {
55+
ConstructedRegExpAsSink() {
56+
exists(API::Node regexp, DataFlow::CallNode callNode |
57+
regexp = API::getTopLevelMember("Regexp") and
58+
(callNode = regexp.getAnInstantiation() or callNode = regexp.getAMethodCall("compile")) and
59+
this = callNode.getArgument(0)
60+
)
61+
}
62+
}
63+
64+
/**
65+
* A comparison with a constant string, considered as a sanitizer-guard.
66+
*/
67+
class StringConstCompareAsSanitizerGuard extends SanitizerGuard, StringConstCompare { }
68+
69+
/**
70+
* An inclusion check against an array of constant strings, considered as a
71+
* sanitizer-guard.
72+
*/
73+
class StringConstArrayInclusionCallAsSanitizerGuard extends SanitizerGuard,
74+
StringConstArrayInclusionCall { }
75+
76+
/**
77+
* A call to `Regexp.escape` (or its alias, `Regexp.quote`), considered as a
78+
* sanitizer.
79+
*/
80+
class RegexpEscapeSanitization extends Sanitizer {
81+
RegexpEscapeSanitization() {
82+
this = API::getTopLevelMember("Regexp").getAMethodCall(["escape", "quote"])
83+
}
84+
}
85+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Provides a taint-tracking configuration for detecting regexp injection vulnerabilities.
3+
*
4+
* Note, for performance reasons: only import this file if `Configuration` is needed,
5+
* otherwise `RegExpInjectionCustomizations` should be imported instead.
6+
*/
7+
8+
import codeql.ruby.DataFlow::DataFlow::PathGraph
9+
import codeql.ruby.DataFlow
10+
import codeql.ruby.TaintTracking
11+
import RegExpInjectionCustomizations
12+
import codeql.ruby.dataflow.BarrierGuards
13+
14+
/**
15+
* A taint-tracking configuration for detecting regexp injection vulnerabilities.
16+
*/
17+
class Configuration extends TaintTracking::Configuration {
18+
Configuration() { this = "RegExpInjection" }
19+
20+
override predicate isSource(DataFlow::Node source) { source instanceof RegExpInjection::Source }
21+
22+
override predicate isSink(DataFlow::Node sink) { sink instanceof RegExpInjection::Sink }
23+
24+
override predicate isSanitizerGuard(DataFlow::BarrierGuard guard) {
25+
guard instanceof RegExpInjection::SanitizerGuard
26+
}
27+
28+
override predicate isSanitizer(DataFlow::Node node) { node instanceof RegExpInjection::Sanitizer }
29+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
Constructing a regular expression with unsanitized user input is dangerous,
9+
since a malicious user may be able to modify the meaning of the expression. In
10+
particular, such a user may be able to provide a regular expression fragment
11+
that takes exponential time in the worst case, and use that to perform a Denial
12+
of Service attack.
13+
</p>
14+
</overview>
15+
16+
<recommendation>
17+
<p>
18+
Before embedding user input into a regular expression, use a sanitization
19+
function such as <code>Regexp.escape</code> to escape meta-characters that have
20+
special meaning.
21+
</p>
22+
</recommendation>
23+
24+
<example>
25+
<p>
26+
The following examples construct regular expressions from an HTTP request
27+
parameter without sanitizing it first:
28+
</p>
29+
<sample src="examples/regexp_injection_bad.rb" />
30+
<p>
31+
Instead, the request parameter should be sanitized first. This ensures that the
32+
user cannot insert characters that have special meanings in regular expressions.
33+
</p>
34+
<sample src="examples/regexp_injection_good.rb" />
35+
</example>
36+
37+
<references>
38+
<li>
39+
OWASP:
40+
<a href="https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS">Regular expression Denial of Service - ReDoS</a>.
41+
</li>
42+
<li>
43+
Wikipedia: <a href="https://en.wikipedia.org/wiki/ReDoS">ReDoS</a>.
44+
</li>
45+
<li>
46+
Ruby: <a href="https://ruby-doc.org/core-3.0.2/Regexp.html#method-c-escape">Regexp.escape</a>.
47+
</li>
48+
</references>
49+
</qhelp>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @name Regular expression injection
3+
* @description User input should not be used in regular expressions without
4+
* first being escaped. Otherwise, a malicious user may be able to
5+
* inject an expression that could require exponential time on
6+
* certain inputs.
7+
* @kind path-problem
8+
* @problem.severity error
9+
* @security-severity 7.5
10+
* @precision high
11+
* @id rb/regexp-injection
12+
* @tags security
13+
* external/cwe/cwe-1333
14+
* external/cwe/cwe-730
15+
* external/cwe/cwe-400
16+
*/
17+
18+
import ruby
19+
import DataFlow::PathGraph
20+
import codeql.ruby.DataFlow
21+
import codeql.ruby.security.performance.RegExpInjectionQuery
22+
23+
from Configuration cfg, DataFlow::PathNode source, DataFlow::PathNode sink
24+
where cfg.hasFlowPath(source, sink)
25+
select sink.getNode(), source, sink, "This regular expression is constructed from a $@.",
26+
source.getNode(), "user-provided value"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class UsersController < ActionController::Base
2+
def first_example
3+
# BAD: Unsanitized user input is used to construct a regular expression
4+
regex = /#{ params[:key] }/
5+
end
6+
7+
def second_example
8+
# BAD: Unsanitized user input is used to construct a regular expression
9+
regex = Regexp.new(params[:key])
10+
end
11+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class UsersController < ActionController::Base
2+
def example
3+
# GOOD: User input is sanitized before constructing the regular expression
4+
regex = Regexp.new(Regex.escape(params[:key]))
5+
end
6+
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
edges
2+
| RegExpInjection.rb:4:12:4:17 | call to params : | RegExpInjection.rb:5:13:5:21 | /#{...}/ |
3+
| RegExpInjection.rb:10:12:10:17 | call to params : | RegExpInjection.rb:11:13:11:27 | /foo#{...}bar/ |
4+
| RegExpInjection.rb:16:12:16:17 | call to params : | RegExpInjection.rb:17:24:17:27 | name |
5+
| RegExpInjection.rb:22:12:22:17 | call to params : | RegExpInjection.rb:23:24:23:33 | ... + ... |
6+
| RegExpInjection.rb:54:12:54:17 | call to params : | RegExpInjection.rb:55:28:55:37 | ... + ... |
7+
nodes
8+
| RegExpInjection.rb:4:12:4:17 | call to params : | semmle.label | call to params : |
9+
| RegExpInjection.rb:5:13:5:21 | /#{...}/ | semmle.label | /#{...}/ |
10+
| RegExpInjection.rb:10:12:10:17 | call to params : | semmle.label | call to params : |
11+
| RegExpInjection.rb:11:13:11:27 | /foo#{...}bar/ | semmle.label | /foo#{...}bar/ |
12+
| RegExpInjection.rb:16:12:16:17 | call to params : | semmle.label | call to params : |
13+
| RegExpInjection.rb:17:24:17:27 | name | semmle.label | name |
14+
| RegExpInjection.rb:22:12:22:17 | call to params : | semmle.label | call to params : |
15+
| RegExpInjection.rb:23:24:23:33 | ... + ... | semmle.label | ... + ... |
16+
| RegExpInjection.rb:54:12:54:17 | call to params : | semmle.label | call to params : |
17+
| RegExpInjection.rb:55:28:55:37 | ... + ... | semmle.label | ... + ... |
18+
subpaths
19+
#select
20+
| RegExpInjection.rb:5:13:5:21 | /#{...}/ | RegExpInjection.rb:4:12:4:17 | call to params : | RegExpInjection.rb:5:13:5:21 | /#{...}/ | This regular expression is constructed from a $@. | RegExpInjection.rb:4:12:4:17 | call to params | user-provided value |
21+
| RegExpInjection.rb:11:13:11:27 | /foo#{...}bar/ | RegExpInjection.rb:10:12:10:17 | call to params : | RegExpInjection.rb:11:13:11:27 | /foo#{...}bar/ | This regular expression is constructed from a $@. | RegExpInjection.rb:10:12:10:17 | call to params | user-provided value |
22+
| RegExpInjection.rb:17:24:17:27 | name | RegExpInjection.rb:16:12:16:17 | call to params : | RegExpInjection.rb:17:24:17:27 | name | This regular expression is constructed from a $@. | RegExpInjection.rb:16:12:16:17 | call to params | user-provided value |
23+
| RegExpInjection.rb:23:24:23:33 | ... + ... | RegExpInjection.rb:22:12:22:17 | call to params : | RegExpInjection.rb:23:24:23:33 | ... + ... | This regular expression is constructed from a $@. | RegExpInjection.rb:22:12:22:17 | call to params | user-provided value |
24+
| RegExpInjection.rb:55:28:55:37 | ... + ... | RegExpInjection.rb:54:12:54:17 | call to params : | RegExpInjection.rb:55:28:55:37 | ... + ... | This regular expression is constructed from a $@. | RegExpInjection.rb:54:12:54:17 | call to params | user-provided value |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
queries/security/cwe-1333/RegExpInjection.ql
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
class FooController < ActionController::Base
2+
# BAD
3+
def route0
4+
name = params[:name]
5+
regex = /#{name}/
6+
end
7+
8+
# BAD
9+
def route1
10+
name = params[:name]
11+
regex = /foo#{name}bar/
12+
end
13+
14+
# BAD
15+
def route2
16+
name = params[:name]
17+
regex = Regexp.new(name)
18+
end
19+
20+
# BAD
21+
def route3
22+
name = params[:name]
23+
regex = Regexp.new("@" + name)
24+
end
25+
26+
# GOOD - string is compared against a constant string
27+
def route4
28+
name = params[:name]
29+
regex = Regexp.new("@" + name) if name == "foo"
30+
end
31+
32+
# GOOD - string is compared against a constant string array
33+
def route5
34+
name = params[:name]
35+
if ["John", "Paul", "George", "Ringo"].include?(name)
36+
regex = /@#{name}/
37+
end
38+
end
39+
40+
# GOOD - string is explicitly escaped
41+
def route6
42+
name = params[:name]
43+
regex = Regexp.new(Regexp.escape(name))
44+
end
45+
46+
# GOOD - string is explicitly escaped
47+
def route7
48+
name = params[:name]
49+
regex = Regexp.new(Regexp.quote(name))
50+
end
51+
52+
# BAD
53+
def route8
54+
name = params[:name]
55+
regex = Regexp.compile("@" + name)
56+
end
57+
end

0 commit comments

Comments
 (0)