Skip to content

Commit beb0902

Browse files
authored
Merge pull request #6989 from RasmusWL/flask-file-sending-fixup
Python: Small fixup for `flask.send_from_directory`
2 parents f3977ea + 0acf6aa commit beb0902

File tree

8 files changed

+79
-18
lines changed

8 files changed

+79
-18
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
lgtm,codescanning
2+
* Added modeling of the `send_from_directory` and `send_file` functions from the `flask` PyPI package, resulting in additional sinks for the _Uncontrolled data used in path expression_ (`py/path-injection`) query. This addition was originally [submitted as an external contribution by @porcupineyhairs](https://github.com/github/codeql/pull/6330).

python/ql/lib/semmle/python/frameworks/Flask.qll

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ private import semmle.python.Concepts
1111
private import semmle.python.frameworks.Werkzeug
1212
private import semmle.python.ApiGraphs
1313
private import semmle.python.frameworks.internal.InstanceTaintStepsHelper
14+
private import semmle.python.security.dataflow.PathInjectionCustomizations
1415

1516
/**
1617
* Provides models for the `flask` PyPI package.
@@ -525,13 +526,30 @@ module Flask {
525526
*
526527
* See https://flask.palletsprojects.com/en/1.1.x/api/#flask.send_from_directory
527528
*/
528-
class FlaskSendFromDirectory extends FileSystemAccess::Range, DataFlow::CallCfgNode {
529-
FlaskSendFromDirectory() {
529+
private class FlaskSendFromDirectoryCall extends FileSystemAccess::Range, DataFlow::CallCfgNode {
530+
FlaskSendFromDirectoryCall() {
530531
this = API::moduleImport("flask").getMember("send_from_directory").getACall()
531532
}
532533

533534
override DataFlow::Node getAPathArgument() {
534-
result in [this.getArg(_), this.getArgByName(["directory", "filename"])]
535+
result in [
536+
this.getArg(0), this.getArgByName("directory"),
537+
// as described in the docs, the `filename` argument is restrained to be within
538+
// the provided directory, so is not exposed to path-injection. (but is still a
539+
// path-argument).
540+
this.getArg(1), this.getArgByName("filename")
541+
]
542+
}
543+
}
544+
545+
/**
546+
* To exclude `filename` argument to `flask.send_from_directory` as a path-injection sink.
547+
*/
548+
private class FlaskSendFromDirectoryCallFilenameSanitizer extends PathInjection::Sanitizer {
549+
FlaskSendFromDirectoryCallFilenameSanitizer() {
550+
this = any(FlaskSendFromDirectoryCall c).getArg(1)
551+
or
552+
this = any(FlaskSendFromDirectoryCall c).getArgByName("filename")
535553
}
536554
}
537555

@@ -540,8 +558,8 @@ module Flask {
540558
*
541559
* See https://flask.palletsprojects.com/en/1.1.x/api/#flask.send_file
542560
*/
543-
class FlaskSendFile extends FileSystemAccess::Range, DataFlow::CallCfgNode {
544-
FlaskSendFile() { this = API::moduleImport("flask").getMember("send_file").getACall() }
561+
private class FlaskSendFileCall extends FileSystemAccess::Range, DataFlow::CallCfgNode {
562+
FlaskSendFileCall() { this = API::moduleImport("flask").getMember("send_file").getACall() }
545563

546564
override DataFlow::Node getAPathArgument() {
547565
result in [this.getArg(0), this.getArgByName("filename_or_fp")]

python/ql/lib/semmle/python/security/dataflow/PathInjection.qll

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ class PathNotNormalizedConfiguration extends TaintTracking::Configuration {
2626

2727
override predicate isSink(DataFlow::Node sink) { sink instanceof Sink }
2828

29-
override predicate isSanitizer(DataFlow::Node node) { node instanceof Path::PathNormalization }
29+
override predicate isSanitizer(DataFlow::Node node) {
30+
node instanceof Sanitizer
31+
or
32+
node instanceof Path::PathNormalization
33+
}
3034

3135
override predicate isSanitizerGuard(DataFlow::BarrierGuard guard) {
3236
guard instanceof SanitizerGuard
@@ -52,6 +56,8 @@ class FirstNormalizationConfiguration extends TaintTracking::Configuration {
5256

5357
override predicate isSink(DataFlow::Node sink) { sink instanceof Path::PathNormalization }
5458

59+
override predicate isSanitizer(DataFlow::Node node) { node instanceof Sanitizer }
60+
5561
override predicate isSanitizerOut(DataFlow::Node node) { node instanceof Path::PathNormalization }
5662

5763
override predicate isSanitizerGuard(DataFlow::BarrierGuard guard) {
@@ -67,6 +73,8 @@ class NormalizedPathNotCheckedConfiguration extends TaintTracking2::Configuratio
6773

6874
override predicate isSink(DataFlow::Node sink) { sink instanceof Sink }
6975

76+
override predicate isSanitizer(DataFlow::Node node) { node instanceof Sanitizer }
77+
7078
override predicate isSanitizerGuard(DataFlow::BarrierGuard guard) {
7179
guard instanceof Path::SafeAccessCheck
7280
or

python/ql/lib/semmle/python/security/dataflow/PathInjectionCustomizations.qll

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ module PathInjection {
3232
*/
3333
abstract class Sink extends DataFlow::Node { }
3434

35+
/**
36+
* A sanitizer for "path injection" vulnerabilities.
37+
*
38+
* This should only be used for things like calls to library functions that perform their own
39+
* (correct) normalization/escaping of untrusted paths.
40+
*
41+
* Please also see `Path::SafeAccessCheck` and `Path::PathNormalization` Concepts.
42+
*/
43+
abstract class Sanitizer extends DataFlow::Node { }
44+
3545
/**
3646
* A sanitizer guard for "path injection" vulnerabilities.
3747
*/
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from flask import send_from_directory, send_file
2+
3+
send_from_directory("dir", "file") # $ getAPathArgument="dir" getAPathArgument="file"
4+
send_from_directory(directory="dir", filename="file") # $ getAPathArgument="dir" getAPathArgument="file"
5+
6+
send_file("file") # $ getAPathArgument="file"
7+
send_file(filename_or_fp="file") # $ getAPathArgument="file"
Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
1-
from flask import Flask, request, send_from_directory, send_file
1+
from flask import Flask, request
22
app = Flask(__name__)
33

44
@app.route("/save-uploaded-file") # $routeSetup="/save-uploaded-file"
55
def test_taint(): # $requestHandler
66
request.files['key'].save("path") # $ getAPathArgument="path"
7-
8-
9-
@app.route("/path-injection") # $routeSetup="/path-injection"
10-
def test_path(): # $requestHandler
11-
12-
send_from_directory("filepath","file") # $ getAPathArgument="filepath" getAPathArgument="file"
13-
send_file("file") # $ getAPathArgument="file"
14-
15-
send_from_directory(directory="filepath","file") # $ getAPathArgument="filepath" getAPathArgument="file"
16-
send_from_directory(filename="filepath","file") # $ getAPathArgument="filepath" getAPathArgument="file"
17-
send_file(filename_or_fp="file") # $ getAPathArgument="file"

python/ql/test/query-tests/Security/CWE-022-PathInjection/PathInjection.expected

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
edges
2+
| flask_path_injection.py:19:15:19:21 | ControlFlowNode for request | flask_path_injection.py:19:15:19:26 | ControlFlowNode for Attribute |
3+
| flask_path_injection.py:19:15:19:26 | ControlFlowNode for Attribute | flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname |
24
| path_injection.py:12:16:12:22 | ControlFlowNode for request | path_injection.py:12:16:12:27 | ControlFlowNode for Attribute |
35
| path_injection.py:12:16:12:27 | ControlFlowNode for Attribute | path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() |
46
| path_injection.py:19:16:19:22 | ControlFlowNode for request | path_injection.py:19:16:19:27 | ControlFlowNode for Attribute |
@@ -68,6 +70,9 @@ edges
6870
| test_chaining.py:41:9:41:16 | ControlFlowNode for source() | test_chaining.py:42:9:42:19 | ControlFlowNode for normpath() |
6971
| test_chaining.py:44:13:44:23 | ControlFlowNode for normpath() | test_chaining.py:45:14:45:14 | ControlFlowNode for z |
7072
nodes
73+
| flask_path_injection.py:19:15:19:21 | ControlFlowNode for request | semmle.label | ControlFlowNode for request |
74+
| flask_path_injection.py:19:15:19:26 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute |
75+
| flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | semmle.label | ControlFlowNode for dirname |
7176
| path_injection.py:12:16:12:22 | ControlFlowNode for request | semmle.label | ControlFlowNode for request |
7277
| path_injection.py:12:16:12:27 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute |
7378
| path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() |
@@ -153,6 +158,7 @@ nodes
153158
| test_chaining.py:44:13:44:23 | ControlFlowNode for normpath() | semmle.label | ControlFlowNode for normpath() |
154159
| test_chaining.py:45:14:45:14 | ControlFlowNode for z | semmle.label | ControlFlowNode for z |
155160
#select
161+
| flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | flask_path_injection.py:19:15:19:21 | ControlFlowNode for request | flask_path_injection.py:21:32:21:38 | ControlFlowNode for dirname | This path depends on $@. | flask_path_injection.py:19:15:19:21 | ControlFlowNode for request | a user-provided value |
156162
| path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | path_injection.py:12:16:12:22 | ControlFlowNode for request | path_injection.py:13:14:13:47 | ControlFlowNode for Attribute() | This path depends on $@. | path_injection.py:12:16:12:22 | ControlFlowNode for request | a user-provided value |
157163
| path_injection.py:21:14:21:18 | ControlFlowNode for npath | path_injection.py:19:16:19:22 | ControlFlowNode for request | path_injection.py:21:14:21:18 | ControlFlowNode for npath | This path depends on $@. | path_injection.py:19:16:19:22 | ControlFlowNode for request | a user-provided value |
158164
| path_injection.py:31:14:31:18 | ControlFlowNode for npath | path_injection.py:27:16:27:22 | ControlFlowNode for request | path_injection.py:31:14:31:18 | ControlFlowNode for npath | This path depends on $@. | path_injection.py:27:16:27:22 | ControlFlowNode for request | a user-provided value |
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from flask import Flask, request, send_from_directory
2+
app = Flask(__name__)
3+
4+
5+
STATIC_DIR = "/server/static/"
6+
7+
8+
# see https://flask.palletsprojects.com/en/1.1.x/api/#flask.send_from_directory
9+
@app.route("/provide-filename")
10+
def download_file():
11+
filename = request.args.get('filename', '')
12+
# ok since `send_from_directory` ensure this stays within `STATIC_DIR`
13+
return send_from_directory(STATIC_DIR, filename) # OK
14+
15+
16+
# see https://flask.palletsprojects.com/en/1.1.x/api/#flask.send_from_directory
17+
@app.route("/also-provide-dirname")
18+
def download_file():
19+
dirname = request.args.get('dirname', '')
20+
filename = request.args.get('filename', '')
21+
return send_from_directory(dirname, filename) # NOT OK

0 commit comments

Comments
 (0)