SSRF chains, deserialization, prototype pollution, CSPP, race conditions, and the subtle bugs that get past automated scanners. With Django-specific exploitation and defense.
OWASP Top 10 covers the broad categories. Real-world exploitation often combines several lower-severity bugs into one critical chain. Or exploits subtle behaviors that don't fit neatly into a Top 10 category. This tutorial covers the bugs that get past automated scanners and into bug bounty reports.
Each example includes the attack pattern, a vulnerable Django snippet, and the specific defense.
Server-Side Request Forgery: the app fetches a URL provided by the user. Vulnerable code:
def fetch_image(request):
url = request.POST['url']
response = requests.get(url)
return HttpResponse(response.content, content_type='image/png')
Attacker provides http://127.0.0.1:6379/ and gets Redis output, http://localhost:5432/ and gets PostgreSQL banner, etc. Maps internal services from outside.
The really nasty case. On AWS:
http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name
Returns short-lived AWS credentials for the EC2 instance's IAM role. Attacker now has whatever permissions that role has — often more than you intended.
Same on GCP (http://metadata.google.internal/computeMetadata/v1/...), Azure, and Hetzner Cloud.
Attacker controls a DNS record. First lookup returns a public IP (passes your URL validation), second lookup (when requests actually fetches) returns 169.254.169.254. Defeats simple URL validation.
169.254.169.254 at allrequests with custom session that blocks private IPsimport ipaddress
import socket
import requests
from urllib.parse import urlparse
def is_safe_destination(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
if not parsed.hostname:
return False
# Resolve once
try:
ip = socket.gethostbyname(parsed.hostname)
except socket.gaierror:
return False
ip_obj = ipaddress.ip_address(ip)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
return False
if ip_obj.is_multicast or ip_obj.is_reserved:
return False
# Block AWS/GCP/Azure metadata addresses explicitly
if ip in ('169.254.169.254', 'fd00:ec2::254'):
return False
return True
def fetch_image_safely(request):
url = request.POST['url']
if not is_safe_destination(url):
return HttpResponseBadRequest('URL not allowed')
response = requests.get(url, timeout=5, allow_redirects=False)
# IMPORTANT: don't follow redirects; redirect target could bypass the check
if response.is_redirect:
return HttpResponseBadRequest('Redirects not allowed')
return HttpResponse(response.content)
The helper above validates one address, then hands the hostname to requests, which resolves it again when it connects: that second lookup is the window a rebinding defense has to close. socket.gethostbyname() also returns only one IPv4 address, so IPv6 records are never checked. Treat the in-process check as a first filter and layer behind it:
socket.getaddrinfo(), unwrap IPv4-mapped IPv6 addresses, and reject the hostname if any record is non-public. is_global covers most non-public ranges; multicast needs its own check.def resolve_public_addresses(hostname: str, port: int) -> list[str]:
try:
infos = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
return []
addresses = []
for *_, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0].split('%', 1)[0])
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if not ip.is_global or ip.is_multicast:
return [] # one bad record rejects the whole answer
addresses.append(str(ip))
return addresses
On AWS, also require IMDSv2 and set the metadata response hop limit to 1 so containers cannot reach the service through the node, and keep the instance role least-privilege.
Mock DNS so tests are deterministic and never touch the network:
@pytest.mark.parametrize('answer', [
'127.0.0.1', '10.0.0.12', '169.254.169.254', '::1', '::ffff:10.0.0.12', '203.0.113.10',
])
def test_non_public_answers_are_rejected(answer):
fake = [(socket.AF_INET6 if ':' in answer else socket.AF_INET,
socket.SOCK_STREAM, 6, '', (answer, 443))]
with mock.patch('socket.getaddrinfo', return_value=fake):
assert resolve_public_addresses('files.example.com', 443) == []
Note the last case: 203.0.113.0/24 and the other documentation ranges are not global, so the guard rejects them. For the positive test, use an address from a public allocation you control.
Loading untrusted data into Python objects. The classic vulnerability:
# NEVER do this
import pickle
data = pickle.loads(request.body)
pickle.loads executes arbitrary code from the input. An attacker who can send any bytes gets RCE.
pickle.loads(...) on user input or any external sourceyaml.load(...) (vs safe yaml.safe_load(...)) — same riskmarshal.loads(...)numpy.load(allow_pickle=True)joblib.load(...) on untrusted filessigning.loads(...) uses JSON by default, so it cannot execute code; but if SECRET_KEY leaks, anyone can forge signed values (cookies, tokens, password-reset links), so rotate it immediatelyyaml.safe_load() for YAML, never yaml.load()Few teams write pickle.loads(request.body), but pickle still flows through a typical stack. Django's cache backends pickle stored values, so anything that can write to an unauthenticated Redis or memcached can place bytes the application will later unpickle; a task queue configured with a pickle serializer behaves the same way. Require authentication and network isolation for caches and brokers, pin Celery to JSON (CELERY_ACCEPT_CONTENT = ['json']), and load model artifacts only when their hash matches one recorded at build time. In CI, Bandit flags the common sinks (B301 for pickle, B506 for yaml.load); run it on every pull request and require a linked review for each suppression.
Concurrent requests violate assumptions. The classic example: a coupon code that should only be used once.
def redeem_coupon(request):
code = request.POST['code']
coupon = Coupon.objects.get(code=code)
if coupon.uses_remaining > 0:
coupon.uses_remaining -= 1
coupon.save()
apply_discount(request.user, coupon)
return JsonResponse({'success': True})
return JsonResponse({'success': False, 'error': 'No uses left'}, status=400)
Send 10 simultaneous redemption requests with a coupon that has 1 use left → all 10 see uses_remaining > 0 before any writes the decrement. All 10 get the discount.
from django.db import transaction
def redeem_coupon(request):
code = request.POST['code']
with transaction.atomic():
coupon = Coupon.objects.select_for_update().get(code=code)
if coupon.uses_remaining > 0:
coupon.uses_remaining -= 1
coupon.save()
apply_discount(request.user, coupon)
return JsonResponse({'success': True})
return JsonResponse({'success': False, 'error': 'No uses left'}, status=400)
select_for_update() locks the row for the duration of the transaction. Concurrent transactions queue.
The unifying lesson: any business rule that says "only X of these can happen" needs either DB-level locking or atomic operations.
For a simple counter, move the check into the UPDATE itself:
def redeem_for_user(user, code: str) -> bool:
with transaction.atomic():
updated = (Coupon.objects
.filter(code=code, uses_remaining__gt=0)
.update(uses_remaining=F('uses_remaining') - 1))
if updated == 0:
return False
coupon = Coupon.objects.get(code=code)
Redemption.objects.create(coupon=coupon, user=user)
apply_discount(user, coupon)
return True
Under PostgreSQL's default READ COMMITTED isolation, an UPDATE that waits on another transaction's row lock re-checks its WHERE clause against the committed row, so only as many requests succeed as there were uses. Back it with constraints that hold no matter which code path, admin action or data migration touches the table: models.CheckConstraint(condition=Q(uses_remaining__gte=0), name=...) on Coupon and a UniqueConstraint(fields=['coupon', 'user'], ...) on Redemption. A duplicate then raises IntegrityError, the whole atomic block (including the decrement) rolls back, and the view returns 409.
Django's TestCase wraps each test in a transaction that threads with their own connections cannot see, so concurrency tests need TransactionTestCase, and they must run on PostgreSQL because SQLite's whole-database write lock hides the bug. Start ten threads that each call redeem_for_user() for a one-use coupon, synchronize them with a threading.Barrier, close each thread's connection in a finally block, and assert that exactly one call returned True.
Races are probabilistic, so repeat the test several times in CI.
If your app uses Node.js anywhere (build pipeline, SSR, serverless functions), prototype pollution is a real risk. Attacker manipulates __proto__ of an object, which then affects all objects in the runtime.
// Vulnerable merge function
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// Attacker sends: {"__proto__": {"admin": true}}
// Now every object in the JS runtime has admin=true
Object.create(null) for user-input maps (no prototype chain)Django-only apps without JS runtime aren't affected, but most modern web apps have some JS layer (frontend build, SSR, custom server-side rendering).
The cache stores a manipulated response that other users then receive.
X-Forwarded-Host: evil.com)Vary header)ALLOWED_HOSTS explicit. request.get_host() and build_absolute_uri() validate against it and raise DisallowedHost (a 400, logged on django.security.DisallowedHost).USE_X_FORWARDED_HOST at False unless your proxy always overwrites the header; the same applies to SECURE_PROXY_SSL_HEADER.SITE_URL = 'https://app.example.com', not from the request.@never_cache or @cache_control(private=True), and declare legitimate header dependencies with @vary_on_headers.The classic ?file=../../../../etc/passwd. Mostly handled by frameworks but still appears in specific endpoints:
from pathlib import Path
ALLOWED_DIR = Path('/var/app/uploads/').resolve()
def download(request, filename):
requested = (ALLOWED_DIR / filename).resolve()
# Critical: check requested is INSIDE ALLOWED_DIR after resolution
try:
requested.relative_to(ALLOWED_DIR)
except ValueError:
return HttpResponseForbidden('Outside allowed directory')
if not requested.exists():
raise Http404()
return FileResponse(open(requested, 'rb'))
The .resolve() + .relative_to() pattern handles symlinks and dot-dot sequences correctly (URL-encoded input is already decoded by Django before your view sees it).
The strongest fix removes the user-controlled path entirely: store uploads in a FileField, fetch with get_object_or_404(Document, pk=pk, owner=request.user), and return FileResponse(doc.file.open('rb'), as_attachment=True, filename=doc.original_name). Authorization and path safety now come from the same query, FileSystemStorage refuses paths outside its root with SuspiciousFileOperation, and as_attachment=True stops uploaded HTML or SVG rendering inline on your domain. Keep a parametrized test expecting a 4xx for names with parent-directory segments or absolute paths.
If your app parses XML from users, XXE is the canonical bug:
XML can declare entities. A parser that resolves external entities can be made to read local files or send requests on the server's behalf, and a parser that expands nested entities can be made to exhaust memory. Python's standard xml.etree does not resolve external entities but is still exposed to entity expansion, and other parsers behave differently depending on version and settings. Rather than reasoning about each parser's defaults, parse all untrusted XML with defusedxml.
defusedxml for XML parsingWith the standard library, call defusedxml.ElementTree.fromstring(body, forbid_dtd=True) and catch defusedxml.DefusedXmlException plus ParseError as a 400. With lxml, build the parser explicitly: XMLParser(resolve_entities=False, no_network=True, load_dtd=False). Review indirect XML too: SVG uploads, office documents and SAML responses.
Mentioned in tutorial 6 but worth deep dive: when authorization is missing, the wrong object loaded by ID is the vulnerability.
The variant attackers love: multi-step IDOR chains.
Step 1: GET /api/orders/ → returns orders for current user
Step 2: GET /api/orders/<order_id>/items → returns items for any order if order_id known
Step 3: GET /api/items/<item_id>/full → returns full item details for any item
The middle endpoint might check user ownership; the last might not because it's "just retrieving by ID."
Every endpoint that takes a resource ID must verify the current user has access. Not relative to the previous step — absolutely.
@login_required
def item_detail(request, item_id):
item = get_object_or_404(
OrderItem,
id=item_id,
order__user=request.user # Verify ownership via JOIN
)
return JsonResponse(item.to_dict())
Audit every endpoint individually. Don't trust higher-level controls.
Put the ownership rule on the queryset once, and in Django REST Framework apply it in get_queryset(), which the detail, update and delete actions all use:
class OrderItemQuerySet(models.QuerySet):
def for_user(self, user):
if not user.is_authenticated:
return self.none()
return self.filter(order__user=user)
class OrderItemViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderItemSerializer
def get_queryset(self):
return OrderItem.objects.for_user(self.request.user)
Return 404 rather than 403 so responses do not confirm which IDs exist, restrict writable foreign-key fields in serializers to the user's own objects, and remember that UUIDs slow enumeration but are not authorization. The cheapest regression test creates two users and asserts that the second one gets a 404 for the first one's object.
Vulnerable code:
def login(request):
next_url = request.GET.get('next', '/')
if user_authenticated:
return redirect(next_url)
Attacker sends https://yourdomain.com/login/?next=https://evil.com/. After login, user is redirected to attacker site, which now has high-trust context (came from your domain after auth) to phish further.
from django.utils.http import url_has_allowed_host_and_scheme
def login(request):
next_url = request.GET.get('next', '/')
if not url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure()
):
next_url = '/'
if user_authenticated:
return redirect(next_url)
Always validate redirect targets. Django's built-in helper does this correctly.
The OWASP Top 10 covers the most common risks, but real attackers do not stop there, and a mature security posture has to account for the vulnerabilities beyond the headline list. These advanced issues are often less about a single obvious flaw and more about subtle logic errors, unexpected interactions between features, and edge cases the developers never considered. Moving beyond the Top 10 means thinking like an attacker who probes how your specific application behaves under unusual conditions, not just checking boxes against a generic list. The Top 10 is the floor of web security awareness, not the ceiling.
Some of the most damaging flaws are not technical bugs at all but logic errors — the application does exactly what it was coded to do, but the workflow itself can be abused. Manipulating the order of steps, replaying an action that should happen once, exploiting a discount or limit that was not properly enforced server-side: these bypass technical controls entirely because they live in the business rules. They are invisible to scanners because there is no malformed input, only legitimate requests in an unintended sequence. Defending against them requires understanding how your application could be misused and enforcing every important rule on the server, never trusting the client to follow the intended flow.
Advanced attacks rarely rely on a single flaw; they chain several minor issues into a serious compromise. An information disclosure that leaks an internal detail, combined with a weak access check, combined with a feature that can be abused, can together achieve what none could alone. This is why issues that seem low-severity in isolation matter — an attacker assembles them. Thinking in chains changes how you triage: a small leak is not just a small leak but a potential link in a path to something worse. Defenders who only fix high-severity findings in isolation miss the combinations that turn a handful of minor issues into a breach.
Protecting against advanced vulnerabilities is less about a checklist and more about a mindset of skeptical, adversarial thinking applied throughout development. Question what could go wrong with each feature, assume inputs are hostile, enforce invariants server-side, minimize what each component trusts, and review for how features interact, not just whether each works. This mindset, applied continuously, catches the subtle and chained issues that no automated tool will flag. Beyond the Top 10, security becomes a way of thinking about your own system as an attacker would — and that perspective, built into how you design and review, is the real advanced defense.
Scanners are weak at every class in this tutorial, so review has to do the work. Grep hits are starting points: each one needs a human to trace whether request data can reach it.
| Class | Search for | Acceptable fix |
|---|---|---|
| SSRF | requests., httpx., urlopen(, webhooks, "import from URL" | Allowlist, or resolver check plus egress proxy; no redirects; limits |
| Deserialization | pickle, yaml.load(, joblib.load(, allow_pickle=True | JSON with schema, safe_load(), hash-pinned artifacts |
| Races | .get(), field check, .save() on counters or balances | Conditional update(), select_for_update(), constraints |
| Cache poisoning | Headers echoed into output, public caching of personalized views | Strict ALLOWED_HOSTS, SITE_URL, Vary, private |
| Path traversal | open(, FileResponse(, path joins with request data | Lookup by ID, or resolve() plus relative_to() |
| IDOR | get_object_or_404(Model, pk=...) without an owner filter | for_user() querysets, scoped get_queryset() |
| Open redirect | redirect( with request data | url_has_allowed_host_and_scheme() in one shared helper |
For every feature pull request, also ask which regression test fails if the protection is removed.
Fixes prevent the bug; logging tells you whether someone is looking for it. Have each guard emit a structured warning with a stable event name (ssrf_destination_blocked, xml_rejected) on a myproject.security logger, and route it together with Django's django.security logger to alerting. Alert on repeated blocked destinations from one account, bursts of 404s across many IDs on detail routes, and IntegrityError on one_redemption_per_user above baseline.
In CI, run python manage.py check --deploy against production settings, Bandit, a dependency audit, and a tests/security/ suite grouped by class. Verify each new security test by temporarily removing its fix and watching it fail: a security test that has never failed proves nothing.
Each of these bugs has a pattern: untrusted input → trusted context. The defense is always: validate at the boundary, scope by user/permission, use safe APIs, defense in depth.
Automated scanners find some of these (mostly the classics — SQLi, XSS, basic SSRF). They miss the chains, the multi-step IDORs, the business logic races. Those require manual review and threat modeling.
Tutorial 6 covers how WAFs and runtime defenses help catch what code review misses, and how attackers bypass those defenses.