Merge pull request #11954 from hmac/sinatra

Ruby: Model Sinatra
This commit is contained in:
Harry Maclean
2023-03-17 10:46:52 +13:00
committed by GitHub
10 changed files with 609 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
category: minorAnalysis
---
* Accesses of `params` in Sinatra applications are now recognised as HTTP input accesses.
* Data flow is tracked from Sinatra route handlers to ERB files.
* Data flow is tracked between basic Sinatra filters (those without URL patterns) and their corresponding route handlers.

View File

@@ -27,4 +27,5 @@ private import codeql.ruby.frameworks.ActionDispatch
private import codeql.ruby.frameworks.PosixSpawn
private import codeql.ruby.frameworks.StringFormatters
private import codeql.ruby.frameworks.Json
private import codeql.ruby.frameworks.Sinatra
private import codeql.ruby.frameworks.Twirp

View File

@@ -294,6 +294,24 @@ module Ssa {
override Location getLocation() { result = this.getBasicBlock().getLocation() }
}
/**
* An SSA definition inserted at the beginning of a scope to represent a captured `self` variable.
* For example, in
*
* ```rb
* def m(x)
* x.tap do |x|
* foo(x)
* end
* end
* ```
*
* an entry definition for `self` is inserted at the start of the `do` block.
*/
class CapturedSelfDefinition extends CapturedEntryDefinition {
CapturedSelfDefinition() { this.getSourceVariable() instanceof SelfVariable }
}
/**
* An SSA definition inserted at a call that may update the value of a captured
* variable. For example, in

View File

@@ -0,0 +1,300 @@
/** Provides modeling for the Sinatra library. */
private import codeql.ruby.controlflow.CfgNodes::ExprNodes
private import codeql.ruby.DataFlow
private import codeql.ruby.Concepts
private import codeql.ruby.AST
private import codeql.ruby.dataflow.FlowSummary
private import codeql.ruby.dataflow.internal.DataFlowPrivate as DataFlowPrivate
private import codeql.ruby.dataflow.SSA
/** Provides modeling for the Sinatra library. */
module Sinatra {
/**
* A Sinatra application.
*
* ```rb
* class MyApp < Sinatra::Base
* get "/" do
* erb :home
* end
* end
* ```
*/
class App extends DataFlow::ClassNode {
App() { this = DataFlow::getConstant("Sinatra").getConstant("Base").getADescendentModule() }
/**
* Gets a route defined in this application.
*/
Route getARoute() { result.getApp() = this }
}
/**
* A Sinatra route handler. HTTP requests with a matching method and path will
* be handled by the block. For example, the following route will handle `GET`
* requests with path `/`.
*
* ```rb
* get "/" do
* erb :home
* end
* ```
*/
class Route extends DataFlow::CallNode {
private App app;
Route() {
this =
app.getAModuleLevelCall([
"get", "post", "put", "patch", "delete", "options", "link", "unlink"
])
}
/**
* Gets the application that defines this route.
*/
App getApp() { result = app }
/**
* Gets the body of this route.
*/
DataFlow::BlockNode getBody() { result = this.getBlock() }
}
/**
* An access to the parameters of an HTTP request in a Sinatra route handler or callback.
*/
private class Params extends DataFlow::CallNode, Http::Server::RequestInputAccess::Range {
Params() {
this.asExpr().getExpr().getEnclosingCallable() =
[any(Route r).getBody(), any(Filter f).getBody()].asCallableAstNode() and
this.getMethodName() = "params"
}
override string getSourceType() { result = "Sinatra::Base#params" }
override Http::Server::RequestInputKind getKind() {
result = Http::Server::parameterInputKind()
}
}
/**
* A call which renders an ERB template as an HTTP response.
*/
class ErbCall extends DataFlow::CallNode {
private Route route;
ErbCall() {
this.asExpr().getExpr().getEnclosingCallable() = route.getBody().asCallableAstNode() and
this.getMethodName() = "erb"
}
/**
* Gets the template file corresponding to this call.
*/
ErbFile getTemplateFile() { result = getTemplateFile(this.asExpr().getExpr()) }
/**
* Gets the route containing this call.
*/
Route getRoute() { result = route }
}
/**
* Gets the template file referred to by `erbCall`.
* This works on the AST level to avoid non-monotonic reecursion in `ErbLocalsHashSyntheticGlobal`.
*/
private ErbFile getTemplateFile(MethodCall erbCall) {
erbCall.getMethodName() = "erb" and
result.getTemplateName() = erbCall.getArgument(0).getConstantValue().getStringlikeValue() and
result.getRelativePath().matches("%views/%")
}
/**
* Like `Location.toString`, but displays the relative path rather than the full path.
*/
private string locationRelativePathToString(Location loc) {
result =
loc.getFile().getRelativePath() + "@" + loc.getStartLine() + ":" + loc.getStartColumn() + ":" +
loc.getEndLine() + ":" + loc.getEndColumn()
}
/**
* A synthetic global representing the hash of local variables passed to an ERB template.
*/
class ErbLocalsHashSyntheticGlobal extends SummaryComponent::SyntheticGlobal {
private string id;
private MethodCall erbCall;
private ErbFile erbFile;
ErbLocalsHashSyntheticGlobal() {
this = "SinatraErbLocalsHash(" + id + ")" and
id = erbFile.getRelativePath() + "," + locationRelativePathToString(erbCall.getLocation()) and
erbCall.getMethodName() = "erb" and
erbFile = getTemplateFile(erbCall)
}
/**
* Gets the `erb` call associated with this global.
*/
MethodCall getErbCall() { result = erbCall }
/**
* Gets the ERB template that this global contains the locals for.
*/
ErbFile getErbFile() { result = erbFile }
/**
* Gets a unique identifer for this global.
*/
string getId() { result = id }
}
/**
* A summary for `Sinatra::Base#erb`. This models the first half of the flow
* from the `locals` keyword argument to variables in the ERB template. The
* second half is modeled by `ErbLocalsAccessSummary`.
*/
private class ErbLocalsSummary extends SummarizedCallable {
ErbLocalsSummary() { this = "Sinatra::Base#erb" }
override MethodCall getACall() { result = any(ErbCall c).asExpr().getExpr() }
override predicate propagatesFlowExt(string input, string output, boolean preservesValue) {
input = "Argument[locals:]" and
output = "SyntheticGlobal[" + any(ErbLocalsHashSyntheticGlobal global) + "]" and
preservesValue = true
}
}
/**
* A summary for accessing a local variable in an ERB template.
* This is the second half of the modeling of the flow from the `locals`
* keyword argument to variables in the ERB template.
* The first half is modeled by `ErbLocalsSummary`.
*/
private class ErbLocalsAccessSummary extends SummarizedCallable {
private ErbLocalsHashSyntheticGlobal global;
private string local;
ErbLocalsAccessSummary() {
this = "sinatra_erb_locals_access()" + global.getId() + "#" + local and
local = any(MethodCall c | c.getLocation().getFile() = global.getErbFile()).getMethodName() and
local = any(Pair p).getKey().getConstantValue().getStringlikeValue()
}
override MethodCall getACall() {
result.getLocation().getFile() = global.getErbFile() and
result.getMethodName() = local and
result.getReceiver() instanceof SelfVariableReadAccess
}
override predicate propagatesFlowExt(string input, string output, boolean preservesValue) {
input = "SyntheticGlobal[" + global + "].Element[:" + local + "]" and
output = "ReturnValue" and
preservesValue = true
}
}
/**
* A class representing Sinatra filters AKA callbacks.
*
* Filters are run before or after the route handler. They can modify the
* request and response, and share instance variables with the route handler.
*/
class Filter extends DataFlow::CallNode {
private App app;
Filter() { this = app.getAModuleLevelCall(["before", "after"]) }
/** Gets the app which this filter belongs to. */
App getApp() { result = app }
/**
* Gets the pattern which constrains this route, if any. In the example below, the pattern is `/protected/*`.
* Patterns are typically given as strings, and are interpreted by the `mustermann` gem (they are not regular expressions).
* ```rb
* before '/protected/*' do
* authenticate!
* end
* ```
*/
DataFlow::ExprNode getPattern() { result = this.getArgument(0) }
/**
* Holds if this filter has a pattern.
*/
predicate hasPattern() { exists(this.getPattern()) }
/**
* Gets the body of this filter.
*/
DataFlow::BlockNode getBody() { result = this.getBlock() }
}
/**
* A class for Sinatra `before` filters. These run before the route handler.
*/
class BeforeFilter extends Filter {
BeforeFilter() { this.getMethodName() = "before" }
}
/**
* A class for Sinatra `after` filters. These run after the route handler.
*/
class AfterFilter extends Filter {
AfterFilter() { this.getMethodName() = "after" }
}
/**
* A class defining additional jump steps arising from filters.
* This only models flow between filters with no patterns - i.e. those that apply to all routes.
* Filters with patterns are not yet modeled.
*/
class FilterJumpStep extends DataFlowPrivate::AdditionalJumpStep {
/**
* Holds if data can flow from `pred` to `succ` via a callback chain.
*/
override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
exists(BeforeFilter filter, Route route |
// the filter and route belong to the same app
filter.getApp() = route.getApp() and
// the filter applies to all routes
not filter.hasPattern() and
selfPostUpdate(pred, filter.getApp(), filter.getBody().asExpr().getExpr()) and
blockCapturedSelfParameterNode(succ, route.getBody().asExpr().getExpr())
)
}
}
/**
* Holds if `n` is a post-update node for the `self` parameter of `app` in block `b`.
*
* In this example, `n` is the post-update node for `@foo = 1`.
* ```rb
* class MyApp < Sinatra::Base
* before do
* @foo = 1
* end
* end
* ```
*/
private predicate selfPostUpdate(DataFlow::PostUpdateNode n, App app, Block b) {
n.getPreUpdateNode().asExpr().getExpr() =
any(SelfVariableAccess self |
pragma[only_bind_into](b) = self.getEnclosingCallable() and
self.getVariable().getDeclaringScope() = app.getADeclaration()
)
}
/**
* Holds if `n` is a node representing the `self` parameter captured by block `b`.
*/
private predicate blockCapturedSelfParameterNode(DataFlow::Node n, Block b) {
exists(Ssa::CapturedSelfDefinition d |
n.(DataFlowPrivate::SsaDefinitionExtNode).getDefinitionExt() = d and
d.getBasicBlock().getScope() = b
)
}
}

View File

@@ -0,0 +1,25 @@
failures
| views/index.erb:2:10:2:12 | call to foo | Unexpected result: hasTaintFlow= |
edges
| app.rb:75:5:75:8 | [post] self [@foo] : | app.rb:76:32:76:35 | self [@foo] : |
| app.rb:75:12:75:17 | call to params : | app.rb:75:12:75:24 | ...[...] : |
| app.rb:75:12:75:24 | ...[...] : | app.rb:75:5:75:8 | [post] self [@foo] : |
| app.rb:76:32:76:35 | @foo : | views/index.erb:2:10:2:12 | call to foo |
| app.rb:76:32:76:35 | self [@foo] : | app.rb:76:32:76:35 | @foo : |
| app.rb:95:10:95:14 | self [@user] : | app.rb:95:10:95:14 | @user |
| app.rb:103:5:103:9 | [post] self [@user] : | app.rb:95:10:95:14 | self [@user] : |
| app.rb:103:13:103:22 | call to source : | app.rb:103:5:103:9 | [post] self [@user] : |
nodes
| app.rb:75:5:75:8 | [post] self [@foo] : | semmle.label | [post] self [@foo] : |
| app.rb:75:12:75:17 | call to params : | semmle.label | call to params : |
| app.rb:75:12:75:24 | ...[...] : | semmle.label | ...[...] : |
| app.rb:76:32:76:35 | @foo : | semmle.label | @foo : |
| app.rb:76:32:76:35 | self [@foo] : | semmle.label | self [@foo] : |
| app.rb:95:10:95:14 | @user | semmle.label | @user |
| app.rb:95:10:95:14 | self [@user] : | semmle.label | self [@user] : |
| app.rb:103:5:103:9 | [post] self [@user] : | semmle.label | [post] self [@user] : |
| app.rb:103:13:103:22 | call to source : | semmle.label | call to source : |
| views/index.erb:2:10:2:12 | call to foo | semmle.label | call to foo |
subpaths
#select
| views/index.erb:2:10:2:12 | call to foo | app.rb:75:12:75:17 | call to params : | views/index.erb:2:10:2:12 | call to foo | $@ | app.rb:75:12:75:17 | call to params : | call to params : |

View File

@@ -0,0 +1,19 @@
/**
* @kind path-problem
*/
import ruby
import TestUtilities.InlineFlowTest
import PathGraph
import codeql.ruby.frameworks.Sinatra
import codeql.ruby.Concepts
class SinatraConf extends DefaultTaintFlowConf {
override predicate isSource(DataFlow::Node source) {
source instanceof Http::Server::RequestInputAccess::Range
}
}
from DataFlow::PathNode source, DataFlow::PathNode sink, SinatraConf conf
where conf.hasFlowPath(source, sink)
select sink, source, sink, "$@", source, source.toString()

View File

@@ -0,0 +1,93 @@
routes
| app.rb:1:1:114:3 | MyApp | app.rb:2:3:4:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:6:3:8:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:10:3:13:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:15:3:18:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:20:3:22:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:24:3:26:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:28:3:31:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:33:3:35:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:37:3:42:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:44:3:46:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:48:3:50:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:52:3:54:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:56:3:58:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:60:3:62:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:66:3:68:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:70:3:72:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:74:3:77:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:79:3:82:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:89:3:92:5 | call to get |
| app.rb:1:1:114:3 | MyApp | app.rb:94:3:96:5 | call to get |
params
| app.rb:3:14:3:19 | call to params |
| app.rb:12:5:12:10 | call to params |
| app.rb:17:5:17:10 | call to params |
| app.rb:25:15:25:20 | call to params |
| app.rb:39:13:39:18 | call to params |
| app.rb:40:14:40:19 | call to params |
| app.rb:45:38:45:43 | call to params |
| app.rb:75:12:75:17 | call to params |
| app.rb:91:5:91:10 | call to params |
erbCalls
| app.rb:76:5:76:36 | call to erb | views/index.erb:0:0:0:0 | views/index.erb |
erbSyntheticGlobals
| SinatraErbLocalsHash(library-tests/frameworks/sinatra/views/index.erb,library-tests/frameworks/sinatra/app.rb@76:5:76:36) | views/index.erb:0:0:0:0 | views/index.erb |
filters
| app.rb:84:3:87:5 | call to before | before |
| app.rb:98:3:100:5 | call to after | after |
| app.rb:102:3:104:5 | call to before | before |
| app.rb:106:3:108:5 | call to before | before |
| app.rb:111:3:113:5 | call to after | after |
filterPatterns
| app.rb:106:3:108:5 | call to before | app.rb:106:10:106:23 | "/protected/*" |
| app.rb:111:3:113:5 | call to after | app.rb:111:9:111:23 | "/create/:slug" |
additionalFlowSteps
| app.rb:85:5:85:9 | [post] self | app.rb:2:22:4:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:10:21:13:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:15:23:18:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:24:26:26:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:37:16:42:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:44:53:46:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:56:32:58:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:60:48:62:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:74:11:77:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:79:11:82:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:89:16:92:5 | <captured> self |
| app.rb:85:5:85:9 | [post] self | app.rb:94:15:96:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:2:22:4:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:10:21:13:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:15:23:18:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:24:26:26:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:37:16:42:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:44:53:46:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:56:32:58:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:60:48:62:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:74:11:77:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:79:11:82:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:89:16:92:5 | <captured> self |
| app.rb:86:5:86:11 | [post] self | app.rb:94:15:96:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:2:22:4:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:10:21:13:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:15:23:18:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:24:26:26:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:37:16:42:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:44:53:46:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:56:32:58:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:60:48:62:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:74:11:77:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:79:11:82:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:89:16:92:5 | <captured> self |
| app.rb:103:5:103:9 | [post] self | app.rb:94:15:96:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:2:22:4:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:10:21:13:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:15:23:18:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:24:26:26:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:37:16:42:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:44:53:46:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:56:32:58:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:60:48:62:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:74:11:77:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:79:11:82:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:89:16:92:5 | <captured> self |
| app.rb:103:13:103:22 | [post] self | app.rb:94:15:96:5 | <captured> self |

View File

@@ -0,0 +1,30 @@
import ruby
import codeql.ruby.frameworks.Sinatra
import codeql.ruby.Concepts
import codeql.ruby.AST
query predicate routes(Sinatra::App app, Sinatra::Route route) { route = app.getARoute() }
query predicate params(Http::Server::RequestInputAccess params) { any() }
query predicate erbCalls(Sinatra::ErbCall c, ErbFile templateFile) {
templateFile = c.getTemplateFile()
}
query predicate erbSyntheticGlobals(Sinatra::ErbLocalsHashSyntheticGlobal g, ErbFile file) {
file = g.getErbFile()
}
query predicate filters(Sinatra::Filter filter, string kind) {
filter instanceof Sinatra::BeforeFilter and kind = "before"
or
filter instanceof Sinatra::AfterFilter and kind = "after"
}
query predicate filterPatterns(Sinatra::Filter filter, DataFlow::ExprNode pattern) {
pattern = filter.getPattern()
}
query predicate additionalFlowSteps(DataFlow::Node pred, DataFlow::Node succ) {
any(Sinatra::FilterJumpStep s).step(pred, succ)
}

View File

@@ -0,0 +1,115 @@
class MyApp < Sinatra::Base
get '/hello/:name' do
"Hello #{params['name']}!"
end
get '/goodbye/:name' do |n|
"Goodbyte #{n}!"
end
get '/say/*/to/*' do
# matches /say/hello/to/world
params['splat'] # => ["hello", "world"]
end
get '/download/*.*' do
# matches /download/path/to/file.xml
params['splat'] # => ["path/to/file", "xml"]
end
get '/download/*.*' do |path, ext|
[path, ext] # => ["path/to/file", "xml"]
end
get /\/hello\/([\w]+)/ do
"Hello, #{params['captures'].first}!"
end
get %r{/hello/([\w]+)} do |c|
# Matches "GET /meta/hello/world", "GET /hello/world/1234" etc.
"Hello, #{c}!"
end
get '/posts/:format?' do
# matches "GET /posts/" and any extension "GET /posts/json", "GET /posts/xml" etc
end
get '/posts' do
# matches "GET /posts?title=foo&author=bar"
title = params['title']
author = params['author']
# uses title and author variables; query is optional to the /posts route
end
get '/foo', :agent => /Songbird (\d\.\d)[\d\/]*?/ do
"You're using Songbird version #{params['agent'][0]}"
end
get '/foo' do
# Matches non-songbird browsers
end
get '/', :host_name => /^admin\./ do
"Admin Area, Access denied!"
end
get '/', :provides => 'html' do
haml :index
end
get '/', :provides => ['rss', 'atom', 'xml'] do
builder :feed
end
set(:probability) { |value| condition { rand <= value } }
get '/win_a_car', :probability => 0.1 do
"You won!"
end
get '/win_a_car' do
"Sorry, you lost."
end
get '/' do
@foo = params["foo"]
erb :index, locals: {foo: @foo}
end
get '/' do
code = "<%= Time.now %>"
erb code
end
before do
@note = 'Hi!'
request.path_info = '/foo/bar/baz'
end
get '/foo/*' do
@note #=> 'Hi!'
params['splat'] #=> 'bar/baz'
end
get "/home" do
sink @user # $ hasValueFlow=a
end
after do
puts response.status
end
before do
@user = source "a"
end
before '/protected/*' do
authenticate!
end
after '/create/:slug' do |slug|
session[:last_slug] = slug
end
end

View File

@@ -0,0 +1,2 @@
<%= @foo %>
<%= sink foo %>