Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/coretrace_python/bundled/models/django/django_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 0 additions & 36 deletions tests/regression/expected/healthchecks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/test_django_safe_values.py
Original file line number Diff line number Diff line change
@@ -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('<i>' + get_token(request) + '</i>')\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)]
Loading