Skip to content

Commit 5af58d2

Browse files
committed
Ruby: Recognise raw Erb output as XSS sink
1 parent 1520305 commit 5af58d2

File tree

5 files changed

+48
-5
lines changed

5 files changed

+48
-5
lines changed

ruby/ql/lib/codeql/ruby/ast/Erb.qll

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,14 +252,32 @@ class ErbGraphqlDirective extends ErbDirective {
252252
class ErbOutputDirective extends ErbDirective {
253253
private Erb::OutputDirective g;
254254

255-
ErbOutputDirective() { this = TOutputDirective(g) }
255+
ErbOutputDirective() { this = TOutputDirective(g) or this = TRawOutputDirective(g) }
256256

257257
override ErbCode getToken() { toGenerated(result) = g.getChild() }
258258

259+
/**
260+
* Holds if this is a raw Erb output directive.
261+
* ```erb
262+
* <%== foo %>
263+
* ```
264+
*/
265+
predicate isRaw() { this = TRawOutputDirective(g) }
266+
259267
final override string toString() {
260-
result = "<%=" + this.getToken().toString() + "%>"
268+
this.isRaw() and
269+
(
270+
result = "<%==" + this.getToken().toString() + "%>"
271+
or
272+
not exists(this.getToken()) and result = "<%==%>"
273+
)
261274
or
262-
not exists(this.getToken()) and result = "<%=%>"
275+
not this.isRaw() and
276+
(
277+
result = "<%=" + this.getToken().toString() + "%>"
278+
or
279+
not exists(this.getToken()) and result = "<%=%>"
280+
)
263281
}
264282

265283
final override string getAPrimaryQlClass() { result = "ErbOutputDirective" }

ruby/ql/lib/codeql/ruby/ast/internal/Erb.qll

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ private module Cached {
99
TCommentDirective(Erb::CommentDirective g) or
1010
TDirective(Erb::Directive g) or
1111
TGraphqlDirective(Erb::GraphqlDirective g) or
12-
TOutputDirective(Erb::OutputDirective g) or
12+
TOutputDirective(Erb::OutputDirective g) { getOpeningTag(g) != "<%==" } or
13+
TRawOutputDirective(Erb::OutputDirective g) { getOpeningTag(g) = "<%==" } or
1314
TTemplate(Erb::Template g) or
1415
TToken(Erb::Token g) or
1516
TComment(Erb::Comment g) or
1617
TCode(Erb::Code g)
1718

19+
private string getOpeningTag(Erb::OutputDirective g) {
20+
erb_tokeninfo(any(Erb::Token t | erb_ast_node_info(t, g, 0, _)), 0, result)
21+
}
22+
1823
/**
1924
* Gets the underlying TreeSitter entity for a given erb AST node.
2025
*/
@@ -24,6 +29,7 @@ private module Cached {
2429
n = TDirective(result) or
2530
n = TGraphqlDirective(result) or
2631
n = TOutputDirective(result) or
32+
n = TRawOutputDirective(result) or
2733
n = TTemplate(result) or
2834
n = TToken(result) or
2935
n = TComment(result) or
@@ -38,6 +44,7 @@ import Cached
3844

3945
TAstNode fromGenerated(Erb::AstNode n) { n = toGenerated(result) }
4046

41-
class TDirectiveNode = TCommentDirective or TDirective or TGraphqlDirective or TOutputDirective;
47+
class TDirectiveNode =
48+
TCommentDirective or TDirective or TGraphqlDirective or TOutputDirective or TRawOutputDirective;
4249

4350
class TTokenNode = TToken or TComment or TCode;

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ private module Shared {
4848
MethodCall getCall() { result = call }
4949
}
5050

51+
/**
52+
* A value interpolated using a raw erb output directive, which does not perform HTML escaping.
53+
* ```erb
54+
* <%== sink %>
55+
* ```
56+
*/
57+
class ErbRawOutputDirective extends Sink {
58+
ErbRawOutputDirective() {
59+
exists(ErbOutputDirective d | d.isRaw() | this.asExpr().getExpr() = d.getTerminalStmt())
60+
}
61+
}
62+
5163
/**
5264
* An `html_safe` call marking the output as not requiring HTML escaping,
5365
* considered as a flow sink.

ruby/ql/test/query-tests/security/cwe-079/ReflectedXSS.expected

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ edges
2121
| app/controllers/foo/bars_controller.rb:26:53:26:54 | dt | app/views/foo/bars/show.html.erb:17:15:17:27 | call to local_assigns [element :display_text] | provenance | |
2222
| app/controllers/foo/bars_controller.rb:26:53:26:54 | dt | app/views/foo/bars/show.html.erb:35:3:35:14 | call to display_text | provenance | |
2323
| app/controllers/foo/bars_controller.rb:26:53:26:54 | dt | app/views/foo/bars/show.html.erb:43:76:43:87 | call to display_text | provenance | |
24+
| app/controllers/foo/bars_controller.rb:26:53:26:54 | dt | app/views/foo/bars/show.html.erb:82:6:82:17 | call to display_text | provenance | |
2425
| app/controllers/foo/bars_controller.rb:30:5:30:7 | str | app/controllers/foo/bars_controller.rb:31:5:31:7 | str | provenance | |
2526
| app/controllers/foo/bars_controller.rb:30:11:30:16 | call to params | app/controllers/foo/bars_controller.rb:30:11:30:28 | ...[...] | provenance | |
2627
| app/controllers/foo/bars_controller.rb:30:11:30:28 | ...[...] | app/controllers/foo/bars_controller.rb:30:5:30:7 | str | provenance | |
@@ -90,6 +91,7 @@ nodes
9091
| app/views/foo/bars/show.html.erb:73:19:73:34 | ...[...] | semmle.label | ...[...] |
9192
| app/views/foo/bars/show.html.erb:76:28:76:33 | call to params | semmle.label | call to params |
9293
| app/views/foo/bars/show.html.erb:76:28:76:39 | ...[...] | semmle.label | ...[...] |
94+
| app/views/foo/bars/show.html.erb:82:6:82:17 | call to display_text | semmle.label | call to display_text |
9395
subpaths
9496
#select
9597
| app/controllers/foo/bars_controller.rb:24:39:24:59 | ... = ... | app/controllers/foo/bars_controller.rb:24:39:24:44 | call to params | app/controllers/foo/bars_controller.rb:24:39:24:59 | ... = ... | Cross-site scripting vulnerability due to a $@. | app/controllers/foo/bars_controller.rb:24:39:24:44 | call to params | user-provided value |
@@ -109,3 +111,4 @@ subpaths
109111
| app/views/foo/bars/show.html.erb:56:13:56:28 | ...[...] | app/views/foo/bars/show.html.erb:56:13:56:18 | call to params | app/views/foo/bars/show.html.erb:56:13:56:28 | ...[...] | Cross-site scripting vulnerability due to a $@. | app/views/foo/bars/show.html.erb:56:13:56:18 | call to params | user-provided value |
110112
| app/views/foo/bars/show.html.erb:73:19:73:34 | ...[...] | app/views/foo/bars/show.html.erb:73:19:73:24 | call to params | app/views/foo/bars/show.html.erb:73:19:73:34 | ...[...] | Cross-site scripting vulnerability due to a $@. | app/views/foo/bars/show.html.erb:73:19:73:24 | call to params | user-provided value |
111113
| app/views/foo/bars/show.html.erb:76:28:76:39 | ...[...] | app/views/foo/bars/show.html.erb:76:28:76:33 | call to params | app/views/foo/bars/show.html.erb:76:28:76:39 | ...[...] | Cross-site scripting vulnerability due to a $@. | app/views/foo/bars/show.html.erb:76:28:76:33 | call to params | user-provided value |
114+
| app/views/foo/bars/show.html.erb:82:6:82:17 | call to display_text | app/controllers/foo/bars_controller.rb:18:10:18:15 | call to params | app/views/foo/bars/show.html.erb:82:6:82:17 | call to display_text | Cross-site scripting vulnerability due to a $@. | app/controllers/foo/bars_controller.rb:18:10:18:15 | call to params | user-provided value |

ruby/ql/test/query-tests/security/cwe-079/app/views/foo/bars/show.html.erb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,6 @@
7777

7878
<%# GOOD: input is sanitized %>
7979
<%= sanitize(params[:comment]).html_safe %>
80+
81+
<%# BAD: A local rendered raw as a local variable %>
82+
<%== display_text %>

0 commit comments

Comments
 (0)