Django Security Bugs AI Coders Write (And BrassCoders Catches)
Four Django security patterns AI coding assistants get wrong: SQL injection via raw(), missing CSRF_COOKIE_SECURE, DEBUG=True in production, and unsafe mark_safe(). Code examples and BrassCoders findings.
AI coding assistants produce plausible-looking Django code. It compiles. It handles the happy path. The problem is Django ships with a serious security layer — CSRF middleware, autoescape in templates, parameterized query wrappers — and that layer only works if you use it correctly. AI-generated Django code often skips the correct usage.
Four patterns show up repeatedly. They’re all subtle enough that a code reviewer scanning for logic errors can miss them. BrassCoders, the bug scanner for AI coders, catches all four with its 12-scanner stack.
Raw SQL with String Formatting
BrassCoders flags Bandit rule B608 when it finds raw SQL constructed via string formatting — %s substitution, f-strings, or .format() calls passed to .raw(), cursor.execute(), or similar Django query escapes. This catches the most common path by which AI-generated Django code introduces SQL injection.
Here’s the pattern:
# views.py — AI-generated search endpoint
def search_users(request):
user_id = request.GET.get("id")
# AI assistant generated this. Looks fine. It's not.
results = MyUser.objects.raw(
f"SELECT * FROM myapp_user WHERE id = {user_id}"
)
return JsonResponse({"users": list(results.values())})
user_id is attacker-controlled. Passing 1 OR 1=1 dumps the table. Passing 1; DROP TABLE myapp_user; -- on a database that allows stacked queries does worse. The Django ORM’s .filter() method would have parameterized this automatically, but .raw() doesn’t — it’s a raw escape hatch, and string formatting into it is injection.
BrassCoders output for this pattern looks like this:
- rule_id: B608
severity: HIGH
confidence: MEDIUM
file: views.py
line: 7
message: >
Possible SQL injection via string-based query construction.
Use parameterized queries or the ORM's filter() instead.
scanner: bandit
The fix is to use .raw() the way Django intends it — with a params list that the database driver handles:
# Correct: parameterized .raw()
results = MyUser.objects.raw(
"SELECT * FROM myapp_user WHERE id = %s",
[user_id]
)
Or, for simple lookups, skip .raw() entirely and use the ORM:
results = MyUser.objects.filter(id=user_id)
AI assistants reach for .raw() with f-strings because the generated code reads naturally and the function signature doesn’t make the danger obvious. BrassCoders doesn’t have to reason about context — B608 fires whenever string formatting flows into a raw SQL call, regardless of how the variable was named or where it came from.
Missing CSRF_COOKIE_SECURE in Production Settings
BrassCoders’s custom AI-pattern scanner flags Django settings.py files where DEBUG = False appears without the HTTPS and CSRF cookie security settings. This pattern catches the gap AI assistants leave when generating production settings: they set DEBUG = False (which looks correct) but omit the CSRF and cookie hardening that production requires.
A typical AI-generated production settings file looks like this:
# settings.py — AI-generated production config
DEBUG = False
ALLOWED_HOSTS = ["yourdomain.com"]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
# ...
}
}
# AI assistant stopped here.
# CSRF_COOKIE_SECURE, SESSION_COOKIE_SECURE, SECURE_SSL_REDIRECT — all absent.
DEBUG = False is necessary for production, but it doesn’t activate HTTPS enforcement or secure cookie transmission. Without CSRF_COOKIE_SECURE = True, Django sends the CSRF cookie over plain HTTP. A network attacker on the same Wi-Fi can steal it. Session hijacking follows.
BrassCoders flags the absent security settings and points you to the three lines that need to exist in any production settings file:
# Required for HTTPS-served Django apps
CSRF_COOKIE_SECURE = True # CSRF cookie only sent over HTTPS
SESSION_COOKIE_SECURE = True # Session cookie only sent over HTTPS
SECURE_SSL_REDIRECT = True # Redirect all HTTP → HTTPS
Django’s own deployment checklist (docs.djangoproject.com/en/stable/howto/deployment/checklist/) lists these as items to audit before going live. BrassCoders automates that audit and puts the result in your YAML output, where Claude Code or Cursor can read it and generate the fix directly.
DEBUG=True Left in Production Settings
BrassCoders catches DEBUG = True in settings files via Bandit’s information exposure rules — a finding that ships with HIGH severity because the consequences of a misconfigured debug flag in production are immediate and broad.
The common path is unremarkable. An AI assistant generates a Django starter project. DEBUG = True is the default — correct for local development, catastrophic in production. The developer deploys without changing it:
# settings.py — starter generated by AI assistant
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-..." # <-- also flagged by BrassCoders
DEBUG = True # <-- flagged: B501, information exposure
ALLOWED_HOSTS = []
With DEBUG = True live in production, Django’s exception handler exposes the full Python stack trace on any 500 error. That stack trace includes local variable values, the full settings module content, and a list of installed apps. An attacker who can trigger an error gets a map of your application internals, your database schema references, and potentially credentials that leaked into local scope.
OWASP lists information exposure via error messages as a distinct attack category (OWASP Top 10, A05:2021 Security Misconfiguration). The Django debug page is a textbook example. BrassCoders flags it before it ships.
The fix is two lines and a deployment habit:
DEBUG = False # Never True in production
# Use environment variable for safety
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
Setting via environment variable prevents the literal True from ever appearing in a deployed settings file. BrassCoders won’t flag the environment-variable form because there’s no information-exposure risk at the source level.
Unsafe mark_safe() on User Content
BrassCoders flags mark_safe() calls where the argument comes from request data, a model field, or any source that could carry user input. This catches the XSS vector that AI assistants introduce when they assume a developer wants to render HTML stored in the database or submitted via a form.
The bad pattern appears in template views that handle user-generated content:
# views.py — AI-generated comment rendering
from django.utils.safestring import mark_safe
from django.shortcuts import render
from .models import Comment
def post_detail(request, post_id):
comments = Comment.objects.filter(post_id=post_id)
# AI assistant added mark_safe to "allow HTML in comments"
rendered_comments = [mark_safe(c.body) for c in comments]
return render(request, "post_detail.html", {
"comments": rendered_comments
})
mark_safe() tells Django’s template engine to skip autoescape for this value. Django’s autoescape is the default defense against XSS in templates — it converts <script>alert(1)</script> into <script> before rendering. Calling mark_safe() on c.body switches that off. An attacker who can write a comment containing a <script> tag now has arbitrary JavaScript execution in every visitor’s browser.
BrassCoders output:
- rule_id: BRASSCODERS-UNSAFE-MARK-SAFE
severity: HIGH
confidence: HIGH
file: views.py
line: 10
message: >
mark_safe() called on a value derived from model data or request input.
Django's autoescape protects against XSS by default — mark_safe() disables it.
Only call mark_safe() on string literals you fully control.
scanner: custom-ai-pattern
The fix depends on what you actually need. For plain text comments, remove mark_safe() entirely and let Django’s autoescape do its job:
# Django autoescapes template variables by default — just pass the string
rendered_comments = [c.body for c in comments]
If you need users to write formatted content, use a library that sanitizes HTML rather than marking the raw input safe:
import bleach
ALLOWED_TAGS = ["b", "i", "em", "strong", "a"]
ALLOWED_ATTRS = {"a": ["href"]}
rendered_comments = [
mark_safe(bleach.clean(c.body, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
for c in comments
]
The core rule: mark_safe() is safe only on string literals you wrote yourself, never on anything that passed through user input, database storage, or external APIs. AI assistants get this wrong because the name sounds like it makes the value safe. It doesn’t. It marks the value as already safe, which is a declaration, not a transformation.
Run the Scan
These four patterns — raw SQL injection, missing HTTPS cookie settings, debug mode exposure, and unsafe HTML marking — all appear in the YAML output of a single brasscoders scan . run. No configuration required.
pip install brasscoders
brasscoders scan .
The output is structured YAML your AI assistant can read directly. Hand it to Claude Code or Cursor and ask for fixes — the findings include file paths, line numbers, and enough context to generate correct remediation without you explaining the Django security model from scratch.
The OSS core is Apache 2.0 licensed and free. If you’re running scans on a larger codebase and want AI-powered deduplication and relevance ranking on top of the raw findings, BrassCoders Paid adds that layer for $12/dev/month.
Frequently Asked Questions
Does BrassCoders work on Django projects without any configuration?
Yes. Run `pip install brasscoders` then `brasscoders scan .` from your Django project root. No configuration required for the OSS core — Bandit, Semgrep, and the custom AI-pattern detectors all activate automatically.
Which BrassCoders scanner catches DEBUG=True in production settings?
Bandit's B501 check flags information exposure patterns. BrassCoders runs Bandit as one of its 12 scanners and surfaces the finding in YAML output with the file path, line number, and severity.
Does BrassCoders scan JavaScript files in a Django project?
Yes. BrassCoders includes a JavaScript/TypeScript scanner as one of its 12 detectors, so frontend code in a Django project gets scanned alongside the Python.
What's the difference between the OSS core and BrassCoders Paid for Django security work?
The OSS core runs all 12 scanners and emits raw YAML findings — that's where SQL injection, CSRF, DEBUG, and XSS findings come from. BrassCoders Paid adds an AI-powered enrichment pass that deduplicates redundant findings and ranks survivors by relevance to your project. For security audit work, the OSS core is the detection layer.
Will BrassCoders catch these issues in a Django app that uses environment variables for settings?
BrassCoders scans source files as they exist on disk. If DEBUG is set via an environment variable (os.environ.get) rather than a hard-coded literal, Bandit won't flag the assignment — but the scan still catches every other pattern: raw() SQL formatting, mark_safe() on untrusted content, and missing CSRF or HTTPS settings.