From 8a5186968686e546c1514082a2db9511bfafad4d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 24 Sep 2026 23:18:47 +0200 Subject: [PATCH 1/3] test: specify that Django's CSRF token and reversed URLs carry no attacker choice --- tests/test_django_safe_values.py | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/test_django_safe_values.py diff --git a/tests/test_django_safe_values.py b/tests/test_django_safe_values.py new file mode 100644 index 0000000..15060dd --- /dev/null +++ b/tests/test_django_safe_values.py @@ -0,0 +1,66 @@ +"""Django values attacker input cannot shape, whatever reaches their arguments. + +``django.middleware.csrf.get_token(request)`` returns the masked CSRF secret, built from +ASCII letters and digits only (``CSRF_ALLOWED_CHARS``): a cookie of another shape is +replaced before it is used. ``django.urls.reverse`` builds a path of the project's URL +configuration and never a scheme-relative one (``escape_leading_slashes``), so attacker +data in its arguments or its query reaches no other host through a redirect. +""" + +from __future__ import annotations + +from coretrace_python import engine +from coretrace_python.source import SourceManager + +DJANGO = ( + "from django.http import HttpRequest, HttpResponse\n" + "from django.middleware import csrf\n" + "from django.middleware.csrf import get_token\n" + "from django.shortcuts import redirect\n" + "from django.urls import reverse, reverse_lazy\n\n" +) + + +def rules(body: str) -> list[tuple[str, int]]: + findings = engine.check(SourceManager().add_source("views.py", DJANGO + body), [engine.BUNDLED_PLUGINS]) + return sorted((f.rule_id, f.span.start_line) for f in findings) + + +def test_the_csrf_token_of_a_request_is_no_injection() -> None: + body = ( + "def token(request: HttpRequest):\n" + " return HttpResponse(csrf.get_token(request))\n\n" + "def other(request: HttpRequest):\n" + " return HttpResponse('' + get_token(request) + '')\n" + ) + + assert rules(body) == [] + + +def test_a_reversed_url_with_attacker_data_is_no_open_redirect() -> None: + body = ( + "def login(request: HttpRequest):\n" + " path = reverse('hc-login-totp', query={'next': request.GET['next']})\n" + " return redirect(path)\n\n" + "def details(request: HttpRequest):\n" + " return redirect(reverse_lazy('details', args=[request.GET['code']]))\n" + ) + + assert rules(body) == [] + + +def test_what_reverse_returns_still_carries_other_kinds() -> None: + # ``reverse`` leaves ``'`` unquoted (an RFC 3986 sub-delimiter): not a redirect, still SQL. + body = ( + "import sqlite3\n\n" + "def page(request: HttpRequest):\n" + " sqlite3.connect('app.db').execute(\"SELECT '\" + reverse('page', args=[request.GET['name']]) + \"'\")\n" + ) + + assert rules(body) == [("sql-injection", 10)] + + +def test_a_redirect_to_raw_attacker_input_stays_an_open_redirect() -> None: + body = "def go(request: HttpRequest):\n return redirect(request.GET['next'] + reverse('home'))\n" + + assert rules(body) == [("open-redirect", 8)] From 6e452d22d05cc135c825bf0517511423b286e4f7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 24 Sep 2026 23:18:47 +0200 Subject: [PATCH 2/3] fix: model Django's CSRF token and reversed URLs as values attackers cannot shape --- CHANGELOG.md | 6 ++++ .../bundled/models/django/django_models.py | 7 ++++ tests/regression/expected/healthchecks.json | 36 ------------------- 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e1a63..54c5152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Precision + +- Django's `csrf.get_token` returns a token of letters and digits whatever the request holds, and `reverse`/`reverse_lazy` build a local path that attacker data in their arguments or query cannot turn into another host: the first is no longer an injection, the second no longer an open redirect. `reverse` still carries other kinds, since it leaves `'` unquoted (#132). + ## 0.6.0 (2026-09-24) ### Reports diff --git a/src/coretrace_python/bundled/models/django/django_models.py b/src/coretrace_python/bundled/models/django/django_models.py index 060b346..b6f60d6 100644 --- a/src/coretrace_python/bundled/models/django/django_models.py +++ b/src/coretrace_python/bundled/models/django/django_models.py @@ -119,6 +119,13 @@ class DjangoModels(ModelPlugin): Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY), Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY), Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML), + # The masked CSRF secret: ASCII letters and digits only, a malformed cookie is + # replaced before it is used. + Sanitizer(_sym("django.middleware.csrf.get_token"), TaintKind.ALL), + # A path of the project's URL configuration, never scheme-relative: attacker data + # in its arguments or query reaches no other host through a redirect. + *(Sanitizer(_sym(f"django.urls.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")), + *(Sanitizer(_sym(f"django.urls.base.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")), Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML), *(AuthorizationGuard(_sym(decorator), label) for decorator, label in _AUTHORIZATION_DECORATORS), # ``urlpatterns = [path('login/', views.log_in)]``: the referenced view is an diff --git a/tests/regression/expected/healthchecks.json b/tests/regression/expected/healthchecks.json index 7ba63f7..c3cf831 100644 --- a/tests/regression/expected/healthchecks.json +++ b/tests/regression/expected/healthchecks.json @@ -72,24 +72,12 @@ "rule": "open-redirect", "function": "_redirect_after_login" }, - { - "file": "hc/accounts/views.py", - "line": 142, - "rule": "open-redirect", - "function": "_check_2fa" - }, { "file": "hc/accounts/views.py", "line": 170, "rule": "open-redirect", "function": "login" }, - { - "file": "hc/accounts/views.py", - "line": 214, - "rule": "xss", - "function": "signup_csrf" - }, { "file": "hc/accounts/views.py", "line": 281, @@ -210,36 +198,12 @@ "rule": "hardcoded-secret", "function": "test_it_handles_port_and_basic_auth" }, - { - "file": "hc/front/views.py", - "line": 553, - "rule": "open-redirect", - "function": "add_check" - }, - { - "file": "hc/front/views.py", - "line": 574, - "rule": "open-redirect", - "function": "update_name" - }, - { - "file": "hc/front/views.py", - "line": 670, - "rule": "open-redirect", - "function": "update_timeout" - }, { "file": "hc/front/views.py", "line": 814, "rule": "xss", "function": "ping_body" }, - { - "file": "hc/front/views.py", - "line": 1115, - "rule": "open-redirect", - "function": "copy" - }, { "file": "hc/integrations/discord/tests/test_add_complete.py", "line": 24, From 0aa9ef5e2b045e00f1b1d6f64ad5bff522a8a275 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 24 Sep 2026 23:45:02 +0200 Subject: [PATCH 3/3] style: list the token and reverse sanitizers after Django's escaping functions --- src/coretrace_python/bundled/models/django/django_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coretrace_python/bundled/models/django/django_models.py b/src/coretrace_python/bundled/models/django/django_models.py index b6f60d6..daf875a 100644 --- a/src/coretrace_python/bundled/models/django/django_models.py +++ b/src/coretrace_python/bundled/models/django/django_models.py @@ -119,6 +119,7 @@ class DjangoModels(ModelPlugin): Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY), Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY), Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML), + Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML), # The masked CSRF secret: ASCII letters and digits only, a malformed cookie is # replaced before it is used. Sanitizer(_sym("django.middleware.csrf.get_token"), TaintKind.ALL), @@ -126,7 +127,6 @@ class DjangoModels(ModelPlugin): # in its arguments or query reaches no other host through a redirect. *(Sanitizer(_sym(f"django.urls.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")), *(Sanitizer(_sym(f"django.urls.base.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")), - Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML), *(AuthorizationGuard(_sym(decorator), label) for decorator, label in _AUTHORIZATION_DECORATORS), # ``urlpatterns = [path('login/', views.log_in)]``: the referenced view is an # entry point wherever it is defined; routers register viewsets.