From 12ec312508489d7971cfc5b1405f0b72f5afe7a6 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:38:33 +0000 Subject: [PATCH] feat: add Django safe_join as path injection sanitizer django.utils._os.safe_join validates that the resulting path stays within the base directory, preventing path traversal. This eliminates false positives when safe_join is used (e.g., in django/views/static.py). --- .../dataflow/PathInjectionCustomizations.qll | 11 ++++++++++ .../CWE-022-PathInjection/django_safe_join.py | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 python/ql/test/query-tests/Security/CWE-022-PathInjection/django_safe_join.py diff --git a/python/ql/lib/semmle/python/security/dataflow/PathInjectionCustomizations.qll b/python/ql/lib/semmle/python/security/dataflow/PathInjectionCustomizations.qll index 7121faa19ffb..d4a6725750b1 100644 --- a/python/ql/lib/semmle/python/security/dataflow/PathInjectionCustomizations.qll +++ b/python/ql/lib/semmle/python/security/dataflow/PathInjectionCustomizations.qll @@ -9,6 +9,7 @@ private import semmle.python.dataflow.new.DataFlow private import semmle.python.Concepts private import semmle.python.dataflow.new.RemoteFlowSources private import semmle.python.dataflow.new.BarrierGuards +private import semmle.python.ApiGraphs /** * Provides default sources, and sinks for detecting @@ -105,4 +106,14 @@ module PathInjection { class SanitizerFromModel extends Sanitizer { SanitizerFromModel() { ModelOutput::barrierNode(this, "path-injection") } } + + /** + * A call to `django.utils._os.safe_join`, which validates that the resulting path + * stays within the base directory, considered as a sanitizer. + */ + class DjangoSafeJoinSanitizer extends Sanitizer { + DjangoSafeJoinSanitizer() { + this = API::moduleImport("django").getMember("utils").getMember("_os").getMember("safe_join").getACall() + } + } } diff --git a/python/ql/test/query-tests/Security/CWE-022-PathInjection/django_safe_join.py b/python/ql/test/query-tests/Security/CWE-022-PathInjection/django_safe_join.py new file mode 100644 index 000000000000..d9b22dcf3ddd --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-022-PathInjection/django_safe_join.py @@ -0,0 +1,21 @@ +from django.utils._os import safe_join +from flask import Flask, request # $ Source + +app = Flask(__name__) + +MEDIA_ROOT = "/var/www/media" + + +@app.route("/file") +def serve_file(): + filename = request.args.get("filename", "") # user input + # safe_join validates the path stays within MEDIA_ROOT + safe_path = safe_join(MEDIA_ROOT, filename) + open(safe_path) # Safe - no alert expected + + +@app.route("/file_unsafe") +def serve_file_unsafe(): + filename = request.args.get("filename", "") # user input + unsafe_path = MEDIA_ROOT + "/" + filename + open(unsafe_path) # $ Alert