← Back to Blog

Why Your AI Wrote ALLOWED_HOSTS = ['*'] — and How That One Line Hands Over Your Django App

It passes every test, the site loads, the errors stop — and it quietly turns off one of the few server-side checks Django ships on by default.

XploitScan Team··8 min read

When people picture a Django security hole, they imagine SQL injection or a leaked SECRET_KEY. The one I actually find most often in AI-generated Django apps is a single line near the top of settings.py:

ALLOWED_HOSTS = ['*']

It passes every test. The site loads. DisallowedHost errors stop appearing in the logs. And it quietly turns off one of the few server-side checks Django ships on by default — the one standing between you and Host-header attacks. This post is the post-mortem on that line: why the assistant wrote it, what it actually breaks, and the env-driven settings that fix it for good.

What ALLOWED_HOSTS is actually for

When a request hits Django, the framework reads the Host header (or X-Forwarded-Host behind a proxy) and checks it against ALLOWED_HOSTS. If the header doesn't match, Django returns a 400 and refuses to serve the request. That's the whole mechanism.

The reason it exists is that Django uses the Host header to build absolute URLs — request.get_host() and request.build_absolute_uri() both trust it. Password-reset emails, cache keys, and any “here's a link back to the site” feature lean on that value. If an attacker controls the Host header, they control those URLs.

ALLOWED_HOSTS = ['*'] says: accept any Host header, from anyone. You've handed the attacker the steering wheel for every URL your app generates.

Why AI tools generate it

The trigger is almost always a setup error. A developer (or an assistant writing the first deploy) runs the app with DEBUG = False, hits the site, and gets this:

Invalid HTTP_HOST header: 'app.example.com'. You may need to add
'app.example.com' to ALLOWED_HOSTS.

Ask an assistant to “fix the ALLOWED_HOSTS error” and the fastest answer that makes the message disappear is the wildcard. It's in thousands of Stack Overflow replies and tutorial gists, almost always with a comment like # TODO: lock this down. The model has seen that pattern a thousand times. It writes ['*'], the error clears, the deploy goes green.

# settings.py — generated to "just make it work in production"
import os

DEBUG = False
ALLOWED_HOSTS = ['*']   # accepts any Host header from anyone

Nothing in the local experience pushes back. The app runs. The feature works. The check is off. This is the same failure mode behind every AI-generated security bug: the assistant made the happy path work, and the security property was never part of the happy path.

The quieter cousin is ALLOWED_HOSTS = ['.example.com']. The leading dot is a subdomain wildcard — it matches example.com and every subdomain. That's reasonable if you actually own every subdomain. It's a problem the moment any subdomain is attacker-controllable: a user-content host like customer.example.com, a stale DNS record pointing at someone else's S3 bucket, or a SaaS subdomain a tenant controls. The dot quietly extends trust to hosts you don't own.

The attack, concretely

Disabling Host validation isn't an abstract “best practice” violation. It enables three real attacks, all of which start with the attacker sending a request to your server with a Host header pointing at theirs.

Password-reset poisoning. This is the one that bites SaaS apps. Django's password-reset flow (and most hand-rolled ones) build the reset link from the request host:

reset_link = request.build_absolute_uri(
    reverse('password_reset_confirm', args=[uid, token])
)
send_mail(..., f"Reset your password: {reset_link}")

The attacker submits the victim's email to your reset form, but sends the request with Host: attacker.com. Django builds the link as https://attacker.com/reset/... — with the victim's real, valid token in it — and emails it to the victim. The victim clicks the link in a legit-looking email from you. The token lands on the attacker's server. Account taken over, no phishing page required.

Web cache poisoning. If a CDN or reverse proxy caches pages and the cache key (or the page content) includes the host, an attacker can prime the cache with a poisoned response — a <script src> pointing at their domain, a wrong canonical URL, a broken login form — and serve it to every subsequent visitor.

SSRF-flavored routing. Webhook callbacks, “verify your domain,” and server-to-server calls that echo request.get_host() can be steered at internal addresses or attacker endpoints. The blast radius depends on what consumes the host downstream, but a wildcard guarantees the input is fully attacker-controlled.

In all three, ALLOWED_HOSTS = ['*'] is the enabling condition. Pin the list and the request never gets past the 400.

How to find it in your own app

List every ALLOWED_HOSTS assignment in your settings files and read each one:

grep -rn "ALLOWED_HOSTS" \
  $(find . -name 'settings*.py' -o -name 'config*.py')

For each hit, ask one question: is every host in this list one I actually own and control? A bare '*' and a leading-dot entry ('.example.com') for a domain you don't fully control both fail that test. If the answer is “no” or “I'm not sure,” it needs to change.

While you're in settings.py, check the line that usually rides along with the wildcard: DEBUG = True in production. The two travel together because both are tutorial defaults that “just work,” and both leak in production.

The fix: an explicit, env-driven list

The rule is simple: list the exact hosts you own, and load them from the environment so prod and dev configs differ. Never a wildcard, never a leading dot for a domain you don't fully control.

# settings.py — explicit, env-driven
import os

DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'

# Comma-separated in the environment:
#   DJANGO_ALLOWED_HOSTS=app.example.com,example.com
ALLOWED_HOSTS = [
    h.strip()
    for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',')
    if h.strip()
]

# Fail loud in prod instead of silently accepting everything.
if not DEBUG and not ALLOWED_HOSTS:
    raise RuntimeError('DJANGO_ALLOWED_HOSTS must be set in production')

Then set the variable per environment:

# production
DJANGO_ALLOWED_HOSTS=app.example.com,example.com

# local development
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1

A few details that matter:

  • No wildcard, ever, in production. If you genuinely run on dynamic hosts (preview deploys, per-tenant subdomains you own end to end), enumerate them or validate against a pattern you control in code — don't hand Django '*'.
  • The raise is doing real work. It converts “misconfigured, silently insecure” into “won't boot.” That's the behavior you want: a missing config should stop the deploy, not disable a security check.
  • Behind a proxy or load balancer, also set USE_X_FORWARDED_HOST deliberately and make sure the proxy strips client-supplied X-Forwarded-Host. A pinned ALLOWED_HOSTS doesn't help if the proxy forwards a spoofed forwarding header verbatim.

The mental model to keep: ALLOWED_HOSTS is an allowlist, and '*' is the empty allowlist wearing a disguise. Every entry is a statement that you own that host and trust URLs built from it. A wildcard says you trust every host on the internet. You don't.

How XploitScan catches this

I build XploitScan, a SAST-style scanner with rules written specifically for the mistakes AI coding tools make — Cursor, Lovable, Bolt, Replit, Claude Code. The Django wildcard is one of its Python configuration rules. It scans settings.py / config.py-style files, flags any ALLOWED_HOSTS list containing '*' as a high-severity Configuration finding, maps it to OWASP A05:2021 (Security Misconfiguration) and CWE-20, and hands you the exact env-driven replacement as a copy-paste fix. It skips commented-out lines, so it isn't noisy.

It's one of 210 rules (30 free, 180 Pro). On a held-out third-party benchmark — OWASP NodeGoat, Juice Shop, DVNA, lodash, with the hint comments stripped — XploitScan caught 15 of 15 issues, against 9 for Bearer and 8 for Semgrep. On our own labeled set it runs at 100% precision (zero false positives) with about 98.7% recall across 230+ fixtures, regenerated on every commit at xploitscan.com/benchmark. I publish the numbers because a scanner that flags Host-header bugs should be honest about its own.

The more durable takeaway is the habit, not the tool: when an assistant adds ALLOWED_HOSTS = ['*'] to make an error go away, that's the moment to stop and write the real host list. The error message is telling you exactly which hosts to add. Add those. Not all of them.

Scan your Django settings for free

npx xploitscan scan . runs locally — your code never leaves your machine — or paste it into the web scanner, which runs in memory and stores nothing. No signup.

Scan Your Code — Free
Why Your AI Wrote ALLOWED_HOSTS = ['*'] — and How That One Line Hands Over Your Django App