diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0f1d4..cf53225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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). - The SSRF sinks of Requests and httpx read the destination only: the URL (the first argument, the second after the method for `request` and `stream`, or `url=`) or the prepared request `send` takes. Attacker data in the body, the JSON, the headers or the query parameters is no longer a server-side request forgery. `Sink` gains `keywords` next to `positions`, and function summaries keep the name of each keyword argument, so a project function forwarding `url=` still reaches the sink; the cache format is bumped (#128). ## 0.6.0 (2026-09-24) diff --git a/src/coretrace_python/bundled/models/django/django_models.py b/src/coretrace_python/bundled/models/django/django_models.py index 060b346..daf875a 100644 --- a/src/coretrace_python/bundled/models/django/django_models.py +++ b/src/coretrace_python/bundled/models/django/django_models.py @@ -120,6 +120,13 @@ class DjangoModels(ModelPlugin): 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), + # 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")), *(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. 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, 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)]