feat: add B905 plugin for Flask send_file() with request-controlled path#1459
Open
Varshith-Kali wants to merge 2 commits into
Open
feat: add B905 plugin for Flask send_file() with request-controlled path#1459Varshith-Kali wants to merge 2 commits into
Varshith-Kali wants to merge 2 commits into
Conversation
Adds a new bandit plugin (B905) that flags Flask's send_file() when the
path argument is request-controlled — either directly
(send_file(request.args.get("path"))) or via a simple local-variable
assignment (path = request.args.get("path"); send_file(path)).
This pattern has been the root cause of multiple path-traversal /
arbitrary-file-read CVEs:
- CVE-2025-6166 (Agent-Zero)
- CVE-2022-31583
- CVE-2022-31549
- CVE-2022-31506
Detection scope (deliberately narrow, AST-level — no interprocedural
dataflow):
1. send_file(...) where any argument expression contains a call to
request.args.get / request.form.get / request.values.get (matched
by suffix so import aliases like flask_request.args.get also work).
2. send_file(name) where `name` is a bare Name whose most recent
assignment in the enclosing function body is a request accessor
call. Uses the _bandit_parent chain set by bandit's node visitor
to find the enclosing FunctionDef, then walks its body for
assignments to the target name.
Severity: MEDIUM (file read, not RCE). Confidence: MEDIUM (consistent
with B201 Flask debug). CWE-22 (Path Traversal).
Files:
- bandit/plugins/flask_send_file.py: new plugin
- examples/flask_send_file.py: 4 bad cases (direct args/form/values +
local-var) + 2 okay cases (hardcoded path, os.path.join with
hardcoded components)
- tests/functional/test_functional.py: test_flask_send_file_with_request_path
expects 4 MEDIUM/MEDIUM issues
- doc/source/plugins/b905_flask_send_file_with_request_path.rst: docs page
- setup.cfg: register flask_send_file_with_request_path entry point
B905 chosen as the next free ID — verified B9xx range is unused by any
existing plugin (B704 is the current max) and no in-flight PR claims
B9xx (searched repo for "B9" in PR bodies). Happy to renumber if
maintainers prefer a different convention.
Tests: 1 new functional test passes. All 80 functional tests pass
(79 existing + 1 new). 163 unit tests pass (3 pre-existing collection
errors from missing optional deps sarif_om/jsii — unrelated to this
change). flake8 + black clean.
Closes PyCQA#1403
Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Varshith Puli <pulivarshit@gmail.com>
Varshith-Kali
requested review from
ericwb,
lukehinds and
sigmavirus24
as code owners
July 19, 2026 18:35
for more information, see https://pre-commit.ci
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds a new bandit plugin (B905) that flags Flask's
send_file()when the path argument is request-controlled — either directly (send_file(request.args.get("path"))) or via a simple local-variable assignment (path = request.args.get("path"); send_file(path)).Closes #1403.
This pattern has been the root cause of multiple path-traversal / arbitrary-file-read CVEs:
Detection scope
Deliberately narrow, AST-level — no interprocedural dataflow:
send_file(...)where any argument expression contains a call torequest.args.get/request.form.get/request.values.get. Matched by suffix so import aliases likeflask_request.args.getalso work.send_file(name)wherenameis a bareNamewhose most recent assignment in the enclosing function body is a request accessor call. Uses the_bandit_parentchain set by bandit's node visitor to find the enclosingFunctionDef, then walks its body for assignments to the target name.Severity: MEDIUM (file read, not RCE — consistent with the issue's framing). Confidence: MEDIUM (consistent with B201 Flask debug). CWE: CWE-22 (Path Traversal).
What's NOT flagged (avoiding false positives)
send_file("/etc/hostname")— hardcoded pathsend_file(os.path.join(base, rel))— both components hardcodedsend_file()called withoutflaskimported (theis_module_imported_like("flask")gate)path = sanitize(user_input); send_file(path)— the sanitised assignment wins)Files
bandit/plugins/flask_send_file.pyexamples/flask_send_file.pytests/functional/test_functional.pytest_flask_send_file_with_request_path— expects 4 MEDIUM/MEDIUM issuesdoc/source/plugins/b905_flask_send_file_with_request_path.rstsetup.cfgflask_send_file_with_request_pathentry pointWhy B905
Verified B9xx range is unused by any existing plugin (B704 is the current max) and no in-flight PR claims B9xx (searched repo for "B9" in PR bodies). Happy to renumber if maintainers prefer a different convention (e.g. B205 to keep it in the Flask family next to B201).
Tests and Documentation
AI assistance disclosure
Drafted with AI assistance (Claude), reviewed and tested by me before submission. I have read every line of the diff, run the full functional + unit test suites locally, and verified the plugin detects all 4 bad cases in the example file while not flagging the 2 safe cases. The detection approach (using the
_bandit_parentchain to find the enclosing FunctionDef for the local-variable case, after discovering thatcontext._context.get("function")is reset bypre_visiton every node) was arrived at by reading bandit'snode_visitor.pysource — the initial approach using the context dict didn't work becausepre_visitclears it per-node.Co-authored-by: Claude noreply@anthropic.com