diff --git a/python/ql/lib/change-notes/2024-08-30-bottle.md b/python/ql/lib/change-notes/2024-08-30-bottle.md new file mode 100644 index 000000000000..b3a07f41f599 --- /dev/null +++ b/python/ql/lib/change-notes/2024-08-30-bottle.md @@ -0,0 +1,4 @@ +--- +category: majorAnalysis +--- +* Added modeling of the `bottle` framework, leading to new remote flow sources and header writes \ No newline at end of file diff --git a/python/ql/lib/semmle/python/Frameworks.qll b/python/ql/lib/semmle/python/Frameworks.qll index 74d4dedd00c6..da35994b955d 100644 --- a/python/ql/lib/semmle/python/Frameworks.qll +++ b/python/ql/lib/semmle/python/Frameworks.qll @@ -15,6 +15,7 @@ private import semmle.python.frameworks.Anyio private import semmle.python.frameworks.Asyncpg private import semmle.python.frameworks.Baize private import semmle.python.frameworks.BSon +private import semmle.python.frameworks.Bottle private import semmle.python.frameworks.CassandraDriver private import semmle.python.frameworks.Cherrypy private import semmle.python.frameworks.ClickhouseDriver diff --git a/python/ql/lib/semmle/python/frameworks/Bottle.qll b/python/ql/lib/semmle/python/frameworks/Bottle.qll new file mode 100644 index 000000000000..ce2a41dbaf4e --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/Bottle.qll @@ -0,0 +1,175 @@ +/** + * Provides classes modeling security-relevant aspects of the `bottle` PyPI package. + * See https://bottlepy.org/docs/dev/. + */ + +private import python +private import semmle.python.Concepts +private import semmle.python.ApiGraphs +private import semmle.python.dataflow.new.RemoteFlowSources +private import semmle.python.frameworks.internal.InstanceTaintStepsHelper +private import semmle.python.frameworks.Stdlib + +/** + * INTERNAL: Do not use. + * + * Provides models for the `bottle` PyPI package. + * See https://bottlepy.org/docs/dev/. + */ +module Bottle { + /** Gets a reference to the `bottle` module. */ + API::Node bottle() { result = API::moduleImport("bottle") } + + /** Provides models for the `bottle` module. */ + module BottleModule { + /** + * Provides models for Bottle applications. + */ + module App { + /** Gets a reference to a Bottle application (an instance of `bottle.Bottle`) */ + API::Node app() { result = bottle().getMember(["Bottle", "app"]).getReturn() } + } + + /** Provides models for functions that are possible "views" */ + module View { + /** + * A Bottle view callable, that handles incoming requests. + */ + class ViewCallable extends Function { + ViewCallable() { this = any(BottleRouteSetup rs).getARequestHandler() } + } + + /** Get methods that reprsent a route in Bottle */ + string routeMethods() { result = ["route", "get", "post", "put", "delete", "patch"] } + + private class BottleRouteSetup extends Http::Server::RouteSetup::Range, DataFlow::CallCfgNode { + BottleRouteSetup() { + this = + [ + App::app().getMember(routeMethods()).getACall(), + bottle().getMember(routeMethods()).getACall() + ] + } + + override DataFlow::Node getUrlPatternArg() { + result in [this.getArg(0), this.getArgByName("route")] + } + + override string getFramework() { result = "Bottle" } + + override Parameter getARoutedParameter() { none() } + + override Function getARequestHandler() { result.getADecorator().getAFlowNode() = node } + } + } + + /** Provides models for the `bottle.response` module */ + module Response { + /** Gets a reference to the `bottle.response` module or instantiation of Bottle Response class. */ + API::Node response() { + result = [bottle().getMember("response"), bottle().getMember("Response").getReturn()] + } + + /** A response returned by a view callable. */ + class BottleReturnResponse extends Http::Server::HttpResponse::Range { + BottleReturnResponse() { + this.asCfgNode() = any(View::ViewCallable vc).getAReturnValueFlowNode() + } + + override DataFlow::Node getBody() { result = this } + + override DataFlow::Node getMimetypeOrContentTypeArg() { none() } + + override string getMimetypeDefault() { result = "text/html" } + } + + /** + * A call to the `bottle.BaseResponse.set_header` or `bottle.BaseResponse.add_header` method. + * + * See https://bottlepy.org/docs/dev/api.html#bottle.BaseResponse.set_header + */ + class BottleResponseHandlerSetHeaderCall extends Http::Server::ResponseHeaderWrite::Range, + DataFlow::MethodCallNode + { + BottleResponseHandlerSetHeaderCall() { + this = response().getMember(["set_header", "add_header"]).getACall() + } + + override DataFlow::Node getNameArg() { + result in [this.getArg(0), this.getArgByName("name")] + } + + override DataFlow::Node getValueArg() { + result in [this.getArg(1), this.getArgByName("value")] + } + + override predicate nameAllowsNewline() { none() } + + override predicate valueAllowsNewline() { none() } + } + } + + /** Provides models for the `bottle.request` module */ + module Request { + /** Gets a reference to the `bottle.request` module. */ + API::Node request() { result = bottle().getMember("request") } + + private class Request extends RemoteFlowSource::Range { + Request() { this = request().asSource() } + + override string getSourceType() { result = "bottle.request" } + } + + /** + * Taint propagation for `bottle.request`. + * + * See https://bottlepy.org/docs/dev/api.html#bottle.request + */ + private class InstanceTaintSteps extends InstanceTaintStepsHelper { + InstanceTaintSteps() { this = "bottle.request" } + + override DataFlow::Node getInstance() { result = request().getAValueReachableFromSource() } + + override string getAttributeName() { + result in [ + "headers", "query", "forms", "params", "json", "url", "body", "fullpath", + "query_string" + ] + } + + override string getMethodName() { none() } + + override string getAsyncMethodName() { none() } + } + } + + /** Provides models for the `bottle.headers` module */ + module Headers { + /** Gets a reference to the `bottle.headers` module. */ + API::Node headers() { result = bottle().getMember("response").getMember("headers") } + + /** A dict-like write to a response header. */ + class HeaderWriteSubscript extends Http::Server::ResponseHeaderWrite::Range, DataFlow::Node { + DataFlow::Node name; + DataFlow::Node value; + + HeaderWriteSubscript() { + exists(SubscriptNode subscript | + this.asCfgNode() = subscript and + value.asCfgNode() = subscript.(DefinitionNode).getValue() and + name.asCfgNode() = subscript.getIndex() and + subscript.getObject() = headers().asSource().asCfgNode() + ) + } + + override DataFlow::Node getNameArg() { result = name } + + override DataFlow::Node getValueArg() { result = value } + + override predicate nameAllowsNewline() { none() } + + override predicate valueAllowsNewline() { none() } + } + } + } +} diff --git a/python/ql/test/library-tests/frameworks/bottle/ConceptsTest.expected b/python/ql/test/library-tests/frameworks/bottle/ConceptsTest.expected new file mode 100644 index 000000000000..a74f2c23cda2 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/ConceptsTest.expected @@ -0,0 +1,2 @@ +testFailures +failures \ No newline at end of file diff --git a/python/ql/test/library-tests/frameworks/bottle/ConceptsTest.ql b/python/ql/test/library-tests/frameworks/bottle/ConceptsTest.ql new file mode 100644 index 000000000000..b557a0bccb69 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/ConceptsTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.ConceptsTest diff --git a/python/ql/test/library-tests/frameworks/bottle/InlineTaintTest.expected b/python/ql/test/library-tests/frameworks/bottle/InlineTaintTest.expected new file mode 100644 index 000000000000..a1a783553972 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/InlineTaintTest.expected @@ -0,0 +1,4 @@ +argumentToEnsureNotTaintedNotMarkedAsSpurious +untaintedArgumentToEnsureTaintedNotMarkedAsMissing +testFailures +failures \ No newline at end of file diff --git a/python/ql/test/library-tests/frameworks/bottle/InlineTaintTest.ql b/python/ql/test/library-tests/frameworks/bottle/InlineTaintTest.ql new file mode 100644 index 000000000000..8524da5fe7db --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/InlineTaintTest.ql @@ -0,0 +1,2 @@ +import experimental.meta.InlineTaintTest +import MakeInlineTaintTest diff --git a/python/ql/test/library-tests/frameworks/bottle/basic_test.py b/python/ql/test/library-tests/frameworks/bottle/basic_test.py new file mode 100644 index 000000000000..03bc15a027b2 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/basic_test.py @@ -0,0 +1,11 @@ +# Source: https://bottlepy.org/docs/dev/tutorial.html#the-application-object +from bottle import Bottle, run + +app = Bottle() + +@app.route('/hello') # $ routeSetup="/hello" +def hello(): # $ requestHandler + return "Hello World!" # $ HttpResponse responseBody="Hello World!" mimetype=text/html + +if __name__ == '__main__': + app.run(host='localhost', port=8080) \ No newline at end of file diff --git a/python/ql/test/library-tests/frameworks/bottle/header_test.py b/python/ql/test/library-tests/frameworks/bottle/header_test.py new file mode 100644 index 000000000000..ab7b9dc856a3 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/header_test.py @@ -0,0 +1,11 @@ +import bottle +from bottle import Bottle, response, request + +app = Bottle() +@app.route('/test', method=['OPTIONS', 'GET']) # $ routeSetup="/test" +def test1(): # $ requestHandler + response.headers['Content-type'] = 'application/json' # $ headerWriteName='Content-type' headerWriteValue='application/json' + response.set_header('Content-type', 'application/json') # $ headerWriteName='Content-type' headerWriteValue='application/json' + return '[1]' # $ HttpResponse responseBody='[1]' mimetype=text/html + +app.run() \ No newline at end of file diff --git a/python/ql/test/library-tests/frameworks/bottle/taint_test.py b/python/ql/test/library-tests/frameworks/bottle/taint_test.py new file mode 100644 index 000000000000..3e092178ae4e --- /dev/null +++ b/python/ql/test/library-tests/frameworks/bottle/taint_test.py @@ -0,0 +1,21 @@ +import bottle +from bottle import response, request + + +app = bottle.app() +@app.route('/test', method=['OPTIONS', 'GET']) # $ routeSetup="/test" +def test1(): # $ requestHandler + + ensure_tainted( + request.headers, # $ tainted + request.headers, # $ tainted + request.forms, # $ tainted + request.params, # $ tainted + request.url, # $ tainted + request.body, # $ tainted + request.fullpath, # $ tainted + request.query_string # $ tainted + ) + return '[1]' # $ HttpResponse mimetype=text/html responseBody='[1]' + +app.run() \ No newline at end of file