Skip to main content

django-waf

Self-hosted request filtering, bot management, and WAF middleware for Django.

Provides two-layer defence (nginx + Django middleware) with rate limiting, user-agent anomaly scoring, JS proof-of-work challenges, path-based threat scoring, nginx blocklist generation, and collective threat feed integration, all configurable without a reverse-proxy vendor.

This is bot detection and anti-abuse tooling, not a payload-inspecting firewall: it does not scan request bodies for SQL injection, XSS, or other OWASP Core Rule Set-class attacks. See docs/THREAT-MODEL.md for the full capability matrix, trust boundaries, and the current state of automatic-enforcement safety controls (evidence, confidence, and review path) before relying on this package as a site's only defence layer.

Features

  • Rate limiting: sliding-window per-IP limits (burst, per-minute, per-5-min)
  • UA anomaly scoring: heuristic detection of impossible OS/browser combos, ancient versions, scraper libraries
  • Path-based threat scoring: suspicious path detection for credential probes (.env, wp-config, AWS/SSH config files) adds to the anomaly score
  • HTTP method filtering: block non-standard methods (e.g. HEAD, OPTIONS, PUT, PATCH, DELETE) before rule evaluation
  • JS proof-of-work challenges: hashcash-style SHA-256 challenges for suspicious clients (no CAPTCHAs, no third-party dependencies)
  • Challenge auto-escalation: repeat offenders who exceed the challenge threshold are automatically blocked for a configurable TTL. A client whose HTTP fingerprint classifies as a bot counts towards the threshold even when it solves every challenge, since a datacentre CPU solves the proof-of-work almost for free
  • No-referer challenge trigger: optionally challenge direct-navigation requests lacking a Referer header
  • GeoIP country code population: attach ISO country codes to request log entries using a MaxMind GeoLite2 database
  • Composite rules: block rules combining UA pattern with IP/CIDR
  • In-process rule cache: version-checked in-memory cache avoids Redis round trips on every request; invalidated automatically when rules change
  • Hit count tracking: block rules accumulate hit counts, flushed to the database periodically
  • Configurable anomaly score thresholds: separate thresholds for log, challenge, and block verdicts
  • nginx blocklist export utility: generates map/geo variable declarations (block and throttle kept separate) for C-level filtering at < 0.01 ms latency, with nginx -t validation and automatic rollback to the last-known-good file before any reload is signalled. django-waf generates the variables; you wire the enforcement, see nginx Integration
  • Anomaly detection: auto-creates expiring rules for UA rotation, subnet bursts, challenge farms, unsolved challenges, cloud spray, and scraper 404-ratio patterns
  • Collective threat feed: opt-in sync of anonymised threat intelligence across deployments
  • Staff dashboard: HTMX-powered real-time analytics with anomaly management
  • Form protection: defence-in-depth at the form layer: signed render tokens, honeypots, time-trap, UA-consistency, JS-touch, credential throttle (enumeration-safe), signup velocity, per-submission PoW. Mixin / decorator / template-tag entry points; per-form configuration; optional challenge-replay for false-positive rescue
  • Fail-open design: Redis outage never breaks the site

Requirements

  • Python >= 3.11
  • Django >= 5.2
  • Redis (via django-redis >= 5.4)
  • httpx >= 0.27 (for threat feed sync)
  • Optional: celery >= 5.3 (for scheduled tasks)
  • Optional: maxminddb >= 2.4 (for GeoIP lookups)
  • Optional: djangorestframework >= 3.14 (for the REST API, see REST API (optional))

Installation

pip install django-waf

With optional extras:

pip install django-waf[geoip]    # adds maxminddb for GeoIP support
pip install django-waf[celery]   # adds celery for scheduled tasks
pip install django-waf[api]      # adds djangorestframework for the REST API

Add to INSTALLED_APPS:

INSTALLED_APPS = [
    # ...
    "django_waf",
]

Add the middleware, placing it after SecurityMiddleware and before other middleware so it can block requests early:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django_waf.middleware.WafMiddleware",        # <-- here
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

Include the URL routes for the challenge flow and staff dashboard:

# urls.py
from django.urls import include, path

urlpatterns = [
    path("waf/", include("django_waf.urls")),
    # ...
]

Configure a Redis cache backend (required for rate limiting):

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/0",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        },
    }
}

Run migrations:

python manage.py migrate django_waf

Settings Reference

All settings are namespaced under DJANGO_WAF_* and have sensible defaults.

Core

Setting Default Description
DJANGO_WAF_ENABLED True Master switch: disable to pass all requests through
DJANGO_WAF_EXEMPT_PATHS ["/static/", "/media/", "/health/", "/favicon.ico"] URL prefixes that bypass WAF evaluation entirely
DJANGO_WAF_EXEMPT_HOSTS [] Hostnames that bypass WAF evaluation entirely. Exact match, or a leading-dot entry (.example.com) matching the domain and any subdomain (mirrors Django's ALLOWED_HOSTS). Port is stripped before matching
DJANGO_WAF_TRUST_X_FORWARDED_FOR False Trust X-Forwarded-For header for client IP extraction
DJANGO_WAF_TRUSTED_PROXIES [] CIDR ranges for direct TCP reverse proxies. When REMOTE_ADDR is in a configured range, resolve X-Forwarded-For from right to left, skipping trusted proxy hops
DJANGO_WAF_TRUSTED_UNIX_SOCKET False Explicitly trust an empty REMOTE_ADDR from a unix-socket WSGI peer and use the same right-to-left X-Forwarded-For walk. Enable only when the socket is accessible exclusively to your reverse proxy
ICV_CACHES_ALIAS "default" Fleet-global Django cache alias (ADR-037) the package caches through by default: the threat-feed install id, the nginx log ingest task's offset, and the Redis-unavailable rule-version fallback. Shared across the whole icv fleet, not django-waf-specific
DJANGO_WAF_REDIS_ALIAS The resolved ICV_CACHES_ALIAS (itself "default" unless set) Django cache alias the WAF requires to be backed by Redis, for rate-limit counters and rule-version keys. Follows the resolved ICV_CACHES_ALIAS unless set explicitly. If ICV_CACHES_ALIAS points at a non-Redis cache, set this explicitly or django_waf.E004 fires at check time
DJANGO_WAF_ALLOWED_METHODS None Allowed HTTP methods; requests with other methods receive 405 before rule evaluation. None allows all methods.
DJANGO_WAF_ALLOW_VERIFIED_CRAWLERS True Seed rDNS-verified AllowRule rows for major search crawlers (Googlebot, Bingbot) so a verified crawler is never served the noindex challenge. See Search engine crawlers. Set False to opt out

Rate Limiting

Setting Default Description
DJANGO_WAF_RATE_LIMIT_BURST 10 Max requests per IP per second
DJANGO_WAF_RATE_LIMIT_PER_MINUTE 120 Max requests per IP per minute
DJANGO_WAF_RATE_LIMIT_PER_5MIN 600 Max requests per IP per 5 minutes
DJANGO_WAF_RATE_LIMIT_PATHS {} Per-path rate limits: {path_prefix: (max_requests, window_seconds)}, or, from BR-RATE-004, {path_prefix: (max_requests, window_seconds, methods)} where methods is an iterable of HTTP method strings (e.g. ("POST",)) that scopes the entry to those methods only. The two-item form is unchanged and still limits every method. Checked before the global windows; the longest matching prefix wins, falling through to the next-longest matching prefix if the longest one's method scope excludes the request (see the settings reference spec for the full resolution rule). Malformed entries are refused at boot by django_waf.E009. Cannot cover /waf/verify/ if it is also in DJANGO_WAF_EXEMPT_PATHS, see the dedicated setting below
DJANGO_WAF_VERIFY_RATE_LIMIT_MAX 20 Max POST /waf/verify/ solve attempts per IP within the window below, independent of the settings above (issue #81)
DJANGO_WAF_VERIFY_RATE_LIMIT_WINDOW_SECONDS 300 Window (seconds) for DJANGO_WAF_VERIFY_RATE_LIMIT_MAX. A breach returns 429 with Retry-After, never a block; fails open on Redis errors
DJANGO_WAF_BLOCK_RESPONSE_HANDLER "" Dotted path to a callable handler(request, result) -> HttpResponse that produces the response for a BLOCKED verdict (BR-EVAL-012). Unset (the default) keeps the built-in HttpResponseForbidden("Access denied."), byte for byte. result.retry_after is always None on this path
DJANGO_WAF_THROTTLE_RESPONSE_HANDLER "" Dotted path to a callable handler(request, result) -> HttpResponse that produces the response for a THROTTLED verdict (BR-EVAL-014), mirroring DJANGO_WAF_BLOCK_RESPONSE_HANDLER above. Unset (the default) keeps the built-in 429 with its existing Retry-After logic, byte for byte. Unlike the BLOCKED path, result.retry_after IS populated here

Challenges

Setting Default Description
DJANGO_WAF_CHALLENGE_DIFFICULTY 16 Proof-of-work leading zero bits. Authoritative unless a desktop/mobile override below is explicitly set. Average work is 2 ** bits SHA-256 hashes
DJANGO_WAF_CHALLENGE_DIFFICULTY_DESKTOP None PoW difficulty (bits) for non-mobile User-Agents. None falls back to DJANGO_WAF_CHALLENGE_DIFFICULTY; set explicitly to give desktop clients a different cost
DJANGO_WAF_CHALLENGE_DIFFICULTY_MOBILE None PoW difficulty (bits) for mobile User-Agents. None falls back to DJANGO_WAF_CHALLENGE_DIFFICULTY; set explicitly to give budget devices a lower cost
DJANGO_WAF_CHALLENGE_URL "" Optional literal path to the challenge view. Set this in projects using per-request urlconf routing (django-hosts and similar) where reverse("django_waf:challenge") cannot resolve. Empty = use reverse()
DJANGO_WAF_VERIFY_URL "" Optional literal path to the verify view. Empty = use reverse()
DJANGO_WAF_CHALLENGE_COOKIE_TTL 86400 Seconds a solved-challenge cookie remains valid
DJANGO_WAF_CHALLENGE_NO_REFERER False Challenge requests that have no Referer header
DJANGO_WAF_NO_REFERER_EXEMPT_PATHS ["/", "/search/", "/robots.txt", "/sitemap.xml", "/favicon.ico"] Paths exempt from the no-referer challenge (only evaluated when DJANGO_WAF_CHALLENGE_NO_REFERER is True)
DJANGO_WAF_CHALLENGE_ESCALATION_THRESHOLD 10 Number of challenges before auto-escalating to a block (bot-fingerprinted clients count even when solved)
DJANGO_WAF_ESCALATION_BLOCK_TTL 3600 TTL in seconds for escalation blocks

Search engine crawlers

The proof-of-work challenge carries X-Robots-Tag: noindex and a robots meta tag, and it only clears after JavaScript solves it. A search crawler runs no JavaScript, so left unhandled it is served the noindex interstitial on every path, never sees real content, and your pages fall out of the index. This is a silent SEO failure for any site that puts a public marketing surface behind the WAF.

To prevent it, DJANGO_WAF_ALLOW_VERIFIED_CRAWLERS (default True) seeds AllowRule rows for the major crawlers on migrate. Each row is rDNS-gated (verify_rdns=True): a request claiming to be Googlebot only passes if the client IP's reverse-DNS record resolves under googlebot.com/google.com (Bingbot: search.msn.com). A spoofed Googlebot user-agent from an unverified IP does not match and is scored and challenged like any other bot, so the exemption is not a user-agent bypass. Set the setting to False, or deactivate the seeded rows in the admin, to opt out.

If DJANGO_WAF_TRUST_X_FORWARDED_FOR is True, the rDNS check runs against the X-Forwarded-For client IP, so your reverse proxy must set a trustworthy XFF for crawler traffic; otherwise the check verifies the wrong IP and the rule silently fails to match.

Operators of the Drystane collective feed receive a curated, always-current crawler allowlist (new engines and revised reverse-DNS suffixes) over the same feed that ships block rules; the built-in seed covers Googlebot and Bingbot without any feed.

Anomaly Scoring

Setting Default Description
DJANGO_WAF_SCORE_THRESHOLD_LOG 3.0 Anomaly score at which a request is logged
DJANGO_WAF_SCORE_THRESHOLD_CHALLENGE 5.0 Anomaly score at which a challenge is issued
DJANGO_WAF_SCORE_THRESHOLD_BLOCK 7.0 Anomaly score at which a request is blocked
DJANGO_WAF_ANOMALY_THRESHOLD_DISTINCT_UAS 20 Distinct UAs per IP before triggering a UA-rotation anomaly
DJANGO_WAF_AUTO_RULE_EXPIRY_HOURS 24 Hours before auto-generated rules expire
DJANGO_WAF_SUSPICIOUS_PATH_PATTERNS [r"\.env", r"wp-config\.php", ...] Regex patterns for suspicious paths (credential probes, config files); matched paths add DJANGO_WAF_SUSPICIOUS_PATH_SCORE to the anomaly score
DJANGO_WAF_SUSPICIOUS_PATH_SCORE 3.0 Score added per suspicious path match
DJANGO_WAF_ANOMALY_QUARANTINE_AUTO_RULES False When True, a newly-created auto-generated rule is created inactive and pending review rather than enforcing immediately (BR-ANOM-007). Default False deliberately, not a mirror of the threat-feed equivalent: flipping it would silently stop enforcement on every existing deployment on upgrade
DJANGO_WAF_ANOMALY_OBSERVE_ONLY_DETECTORS [] Detector function names (e.g. ["detect_cloud_spray"]) that always create quarantined rules regardless of the setting above, for building trust in one detector without quarantining all of them (BR-ANOM-008). An unrecognised name is flagged by django_waf.W008 at boot
DJANGO_WAF_SCRAPER_404_MIN_REQUESTS 20 Minimum request count an IP must reach in the window before its 404 ratio is considered (BR-ANOM-014)
DJANGO_WAF_SCRAPER_404_RATIO 0.85 Fraction of an IP's application-reaching requests that must be 404 before it is flagged
DJANGO_WAF_SCRAPER_404_WINDOW_MINUTES 1440 Detection window in minutes for detect_scraper_404_ratio
DJANGO_WAF_SCRAPER_404_ACTION_BLOCK False When True, a qualifying IP is auto-created at action=block instead of the default action=challenge

Auto-generated rules that are created quarantined, or that a detector in DJANGO_WAF_ANOMALY_OBSERVE_ONLY_DETECTORS creates, appear in the dashboard's Anomalies panel with review_status=pending. A superuser confirms (promotes to a permanent, enforcing rule) or rejects (deactivates) them from there; unreviewed quarantined rules that expire are marked expired_unreviewed rather than left pending forever. See docs/THREAT-MODEL.md section 4 for the full approval-queue design.

Logging

Setting Default Description
DJANGO_WAF_LOG_SAMPLE_RATE 0.01 Fraction of allowed requests to log (0.0 to 1.0)
DJANGO_WAF_LOG_RETENTION_DAYS 30 Days to retain RequestLog entries

GeoIP

Setting Default Description
DJANGO_WAF_GEOIP_PATH None Filesystem path to a MaxMind GeoLite2-Country .mmdb database. None disables GeoIP.
DJANGO_WAF_BLOCKED_COUNTRIES [] ISO 3166-1 alpha-2 country codes to block outright (e.g. ["CN", "RU"]). Empty disables country blocking. Requires DJANGO_WAF_GEOIP_PATH; fails open when the lookup is unavailable.

nginx Integration

django-waf is an export utility, not an auto-enforcing package. generate_nginx_blocklist() (run every 5 minutes by the generate_blocklist Celery task, BR-BL-004) writes an nginx configuration file declaring four variables from active BlockRule rows, it does not itself reject, throttle, or otherwise touch any request. You wire the enforcement (the if ($waf_block_ip) { return 403; } style checks, and the limit_req throttle zone) into your own server{}/location{} blocks, once, by hand. This is a deliberate split: which server blocks are public-facing, and what nginx features are already in use there, is a decision the package cannot safely make on an operator's behalf.

Reference wiring is shipped as package data under django_waf/conf/nginx/ (importable path: importlib.resources.files("django_waf") / "conf" / "nginx"), based on the config shape proven in production:

File Purpose
http-include.conf.example The http{}-scope include that pulls in the generated blocklist file, plus the writable-path pattern below
server-include.conf.example The per-server{}/location{} enforcement snippet: block → return 403, throttle → limit_req

Why http{} scope: nginx's map and geo directives are only valid at the http{} configuration level, never inside a server{} or location{} block. Drop http-include.conf.example (or its include line) into your http{}-scope include directory (typically /etc/nginx/conf.d/ on most distributions), never into a server block.

Writable-path pattern: the Django process running generate_blocklist is typically an unprivileged application user, and /etc/nginx/ is root-owned on most distributions. Either grant that user write access to a specific file under /etc/nginx/conf.d/, or point DJANGO_WAF_NGINX_BLOCKLIST_PATH at a location the application user already owns (e.g. /var/lib/django-waf/blocklist.conf) and include that absolute path from a root-owned, one-time-edited file under /etc/nginx/conf.d/. The generated file itself then never needs to live under /etc/nginx/ at all.

Block vs throttle: the generated file declares block and throttle rules to separate variables, $waf_block_ip / $waf_block_ua for action=block, $waf_throttle_ip / $waf_throttle_ua for action=throttle, so a throttle rule never appears in the block variables and vice versa. Wire block to an unconditional return 403; wire throttle to nginx's own limit_req (requires a limit_req_zone at http{} scope). See server-include.conf.example for the full example, including the limit_req_zone declaration.

Validation and rollback: when DJANGO_WAF_NGINX_VALIDATE is True (the default), generate_nginx_blocklist() preserves the previous file as a .last-good copy, activates the new candidate, then runs DJANGO_WAF_NGINX_TEST_COMMAND (nginx -t by default). A validation failure restores the .last-good copy immediately, so a syntactically broken candidate never survives on disk as a reload timebomb waiting for the next restart. If the test command's binary is not on PATH (e.g. the Django process runs somewhere without a local nginx binary), validation is skipped gracefully and logged, the candidate still activates, exactly as if DJANGO_WAF_NGINX_VALIDATE were False.

Setting Default Description
DJANGO_WAF_NGINX_BLOCKLIST_PATH "/etc/nginx/conf.d/django-waf-blocklist.conf" Output path for the generated nginx blocklist
DJANGO_WAF_ACCESS_LOG_PATH "/var/log/nginx/access.log" nginx access log path for parsing
DJANGO_WAF_NGINX_RELOAD_COMMAND ["nginx", "-s", "reload"] Command to reload nginx after blocklist generation
DJANGO_WAF_NGINX_VALIDATE True Run a syntax test against the candidate blocklist before activating it, with automatic rollback on failure
DJANGO_WAF_NGINX_TEST_COMMAND ["nginx", "-t"] Command used for the syntax test above

Collective Threat Feed

Setting Default Description
DJANGO_WAF_FEED_ENABLED True Enable collective threat feed sync
DJANGO_WAF_FEED_URL "https://threats.drystane.com/v1/feed.json" Threat feed JSON endpoint
DJANGO_WAF_FEED_MIN_CONFIDENCE 0.8 Minimum confidence (0.0 to 1.0) to import a feed entry as a rule
DJANGO_WAF_FEED_REPORT False Report local detections back to the feed (opt-in). Setting this to True is the only setting a site needs to start reporting
DJANGO_WAF_FEED_REPORT_URL "https://threats.drystane.com/v1/report" Telemetry reporting endpoint
DJANGO_WAF_FEED_API_KEY "" API key for feed authentication

Form protection (v0.11.0)

The form-protection subsystem is opt-in per form. Defaults are inert until a form opts in via the mixin, decorator, or template tag.

Setting Default Description
DJANGO_WAF_SIGNING_KEY "" Package-wide HMAC secret. Separate from Django's SECRET_KEY so rotation lifecycles are independent. Empty → derives from SECRET_KEY and django_waf.W003 warns at startup
DJANGO_WAF_FORM_PROTECTION_ENABLED True Master kill switch. False makes the mixin/decorator/tag short-circuit to pass without running defences
DJANGO_WAF_FORM_FLAG_THRESHOLD 2.0 Aggregate score crossing this triggers FLAGGED
DJANGO_WAF_FORM_BLOCK_THRESHOLD 5.0 Aggregate score crossing this triggers BLOCKED
DJANGO_WAF_FORM_CHALLENGE_ON_FLAG True Redirect FLAGGED submissions through /waf/challenge/ (then replay the POST). False returns a generic rejection
DJANGO_WAF_FORM_EMIT_PASSED_SIGNAL False Fire form_submission_passed on every PASS. Off by default: busy sites would burn cycles on the hot path; the structured log already records (sampled) passes
DJANGO_WAF_FORM_TOKEN_TTL 3600 Render-token lifetime in seconds; also the Redis marker TTL
DJANGO_WAF_FORM_HONEYPOT_FIELD_NAMES ["url", "website", "homepage", "email_confirm"] Pool of names for the per-form rotating honeypot fields
DJANGO_WAF_FORM_TIME_TRAP_MIN_SECONDS 1.5 Below this → flag; below 0.5 → block (hard floor)
DJANGO_WAF_FORM_TIME_TRAP_MAX_SECONDS 3600 Above this → flag (stale form)
DJANGO_WAF_FORM_CREDENTIAL_THROTTLE_WINDOW 900 Sliding window for credential-failure counters (seconds)
DJANGO_WAF_FORM_CREDENTIAL_THROTTLE_LIMIT 5 Per-account threshold. Observation-only (drives credential_attack_observed signal); never user-visible. The counter increments only when your login view calls waf_record_credential_failure, see Hooking the login flow below
DJANGO_WAF_FORM_CREDENTIAL_IP_LIMIT 20 Per-IP threshold. Drives the user-visible challenge: same behaviour regardless of which accounts were tried (enumeration-safe). Fed by the same waf_record_credential_failure call, see Hooking the login flow below
DJANGO_WAF_FORM_SIGNUP_VELOCITY_WINDOW 86400 Window for completed-signup counter (24h)
DJANGO_WAF_FORM_SIGNUP_VELOCITY_LIMIT 5 Successful signups per IP before next attempt is flagged
DJANGO_WAF_FORM_POW_DIFFICULTY 12 Per-submission PoW difficulty (bits). 12 ≈ 4k SHA-256 hashes ≈ 50ms desktop / ~200ms mobile
DJANGO_WAF_FORM_DEFENCE_WEIGHTS (see code) Per-defence score weights; overridable per-form via FormProtection(defence_weights={...})

Usage: Django Form mixin (recommended for new forms):

from django import forms
from django_waf.forms import FormProtection, ProtectedForm

class ContactForm(ProtectedForm, forms.Form):
    name = forms.CharField()
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)

    waf = FormProtection(
        form_id="contact",
        defences=("render_token", "honeypot", "time_trap", "ua_consistency"),
    )

In the view:

def contact_view(request):
    form = ContactForm(request.POST or None, request=request)
    if request.method == "POST" and form.is_valid():
        # form.waf_result holds the FormEvaluationResult.
        ...

In the template:

<form method="post">
  {% csrf_token %}
  {{ form.waf_fields }}
  {{ form.as_p }}
  <button type="submit">Send</button>
</form>

Handwritten HTML (forms that bypass Django's Form layer):

# views.py
from django_waf.forms import waf_protect_post

@waf_protect_post(form_id="contact-handwritten",
                  defences=("honeypot", "time_trap"))
def contact_view(request):
    if request.method == "POST":
        ...
<!-- contact.html -->
{% load waf_form_tags %}
<form method="post">
  {% csrf_token %}
  {% waf_protect form_id="contact-handwritten" %}
  <input type="email" name="email">
  <button type="submit">Send</button>
</form>

Hooking the login flow (required for the credential_throttle defence): the defence only reads the failure counters at submit time. Neither the mixin nor the decorator increments them, because both run before your authentication check and cannot tell a wrong password from any other validation failure. Your login view must call waf_record_credential_failure after its own credential check fails, unconditionally, whether or not the account exists: an attacker must not be able to tell "wrong password" from "no such account" by observing side effects.

from django_waf.forms import waf_record_credential_failure

# In the login view, after the credential check fails (authenticate()
# returns None, or form.is_valid() is False for an AuthenticationForm):
waf_record_credential_failure(request, form.cleaned_data["username"])

Without that call the per-account counter never increments, so credential_attack_observed never fires, and the per-IP counter never increments, so the challenge never triggers. The helper returns (account_count, ip_count) and fails open, returning (0, 0) on a Redis outage, so it is safe to call from the hot path of a login view.

HTMX compatibility: the form-protection render token persists across HTMX re-renders of the same form (a user fixing a validation error keeps the same token). The Redis marker that backs replay protection is consumed only on a PASS verdict, so submitting twice in succession after a validation error works correctly. Operators must ensure the HTMX target includes {{ form.waf_fields }} / {% waf_protect %} in the swapped fragment.

Authenticated forms: set skip_for_authenticated=True on FormProtection to drop the spam-style defences for logged-in users while keeping render_token for integrity:

waf = FormProtection(
    form_id="team-invite",
    defences=("render_token",),
    skip_for_authenticated=True,
)

Structured Logging

django_waf.logging.WafStructuredFormatter renders each log record as one JSON object per line, ready for a log aggregator (ELK, Loki, CloudWatch Logs, etc.). It always includes timestamp, level, logger, and message; it additionally includes ip, verdict, rule_id, anomaly_score, latency_ms, path, method, and user_agent (truncated to 200 characters) whenever those attributes are present on the log record. Fields that are absent are omitted entirely rather than emitted as null.

Wire it into the django_waf logger via LOGGING:

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "waf_json": {
            "()": "django_waf.logging.WafStructuredFormatter",
        },
    },
    "handlers": {
        "waf_json": {
            "class": "logging.StreamHandler",
            "formatter": "waf_json",
        },
    },
    "loggers": {
        "django_waf": {
            "handlers": ["waf_json"],
            "level": "INFO",
            "propagate": False,
        },
    },
}

Celery Beat Schedule

If using Celery, django_waf.conf.DJANGO_WAF_CELERY_BEAT_SCHEDULE provides a ready-made schedule fragment covering every periodic django-waf task. Merge it into your project's CELERY_BEAT_SCHEDULE rather than hand-transcribing task names and cadences:

from django_waf.conf import DJANGO_WAF_CELERY_BEAT_SCHEDULE

CELERY_BEAT_SCHEDULE = {
    **DJANGO_WAF_CELERY_BEAT_SCHEDULE,
    # ... your project's own periodic tasks
}

The */N minute tasks (generate_blocklist, flush_rule_hit_counts, detect_anomalies, parse_access_log, expire_rules, update_ip_reputation) are expressed as plain second-interval schedules, so they are always present regardless of whether celery is installed.

The wall-clock tasks (prune_request_logs, prune_challenge_tokens, sync_threat_feed, report_threat_telemetry, update_geoip_database) need celery.schedules.crontab to build their schedule. Because django_waf.conf must stay importable even when celery is entirely absent from the environment, the crontab import is guarded: if celery is not installed, these five entries are simply omitted from the dict rather than approximated. Install the celery extra (pip install django-waf[celery]) to get the full schedule:

Task Cadence
generate_blocklist every 5 minutes
flush_rule_hit_counts every 5 minutes
detect_anomalies every 15 minutes
parse_access_log every 10 minutes
expire_rules every 30 minutes
update_ip_reputation every 6 hours
probe_detectors every hour
prune_request_logs daily 04:00
prune_challenge_tokens daily 04:15
sync_threat_feed daily 04:30
report_threat_telemetry daily 05:00
update_geoip_database weekly, Sunday 03:00 UTC

You may of course still hand-write CELERY_BEAT_SCHEDULE entries yourself (or override DJANGO_WAF_CELERY_BEAT_SCHEDULE via the setting of the same name) if you need different cadences.

Management Commands

Command Description
django_waf_generate_blocklist Generate the nginx blocklist file (--dry-run to preview)
django_waf_detect_anomalies Run anomaly detectors and auto-create block rules (--dry-run)
django_waf_prune_logs Delete RequestLog entries older than the retention period (--dry-run)
django_waf_prune_challenges Delete pending/failed ChallengeToken entries older than N hours (--hours, --dry-run)
django_waf_sync_feed Fetch and import rules from the collective threat feed (--dry-run)
django_waf_export_rules Export BlockRule/AllowRule records to JSON (--output, --source, --rule-type)
django_waf_import_rules Import BlockRule/AllowRule records from JSON produced by django_waf_export_rules (--merge, --replace, --dry-run)
django_waf_probe_detectors Run the detector liveness probe against synthetic fixture traffic; exits non-zero if any detector is silent (--exercise-writes)

A dead probe_detectors schedule is silent. The task logs one line every hour: INFO when every detector is alive, WARNING naming any silent detector. This package is stateless and does not persist a last-run timestamp, so a paused Celery worker, a missing Beat entry, or a broken schedule produces no log line at all rather than a failure log. Alert on the absence of the hourly django_waf.detector_probe log line within your expected cadence, not only on its WARNING content: the probe cannot detect that it has stopped running from the inside.

Dashboard

The staff dashboard is available at /waf/dashboard/ for authenticated staff users. It provides:

  • Real-time traffic counters (allowed, blocked, challenged, throttled), with a Today / 7d / 30d time-range selector
  • Top 10 blocked IPs, filterable by the same time-range selector
  • Rule effectiveness: the top 10 active block rules by hit count, plus a list of active rules with zero hits (removal candidates)
  • Auto-detected anomalies awaiting review

Superusers can confirm auto-generated rules (promoting them to permanent) or reject them (deactivating) directly from the anomalies panel.

REST API (optional)

An optional DRF API is available under waf/api/ for integrating django-waf with external tooling (a hosted console, a CLI, a second site's dashboard). It is off by default.

Enable it:

pip install django-waf[api]
DJANGO_WAF_API_ENABLED = True

The routes are mounted automatically by django_waf.urls when the setting is true and djangorestframework is importable, no extra include() needed beyond the existing path("waf/", include("django_waf.urls", namespace="django_waf")). If the setting is true but djangorestframework is not installed, the routes are skipped and a warning is logged at import time; every endpoint also returns 503 Service Unavailable while the setting is false.

Endpoints (all under waf/api/, registered with a DRF DefaultRouter):

Endpoint Viewset Access
block-rules/ BlockRuleViewSet, full CRUD IsWafAdmin
allow-rules/ AllowRuleViewSet, full CRUD IsWafAdmin
request-logs/ RequestLogViewSet, read-only, ?verdict=, ?ip_address=, ?from_ts= (ISO 8601) filters IsAdminUser
ip-reputation/ IPReputationViewSet, read-only, ?min_threat_score= filter IsAdminUser

Permission model:

  • IsWafAdmin, grants access to superusers, and to staff users holding the django_waf.change_blockrule permission. Used on the two writable endpoints (block-rules/, allow-rules/).
  • IsAdminUser (DRF's built-in), grants access to is_staff users. Used on the two read-only audit endpoints (request-logs/, ip-reputation/), which carry no write actions to restrict further.

hit_count, last_hit_at, and source on BlockRuleViewSet are read-only: they are maintained by the rule engine and the threat-feed sync task, not by API clients.

Architecture

Client → nginx (C-level blocklist, < 0.01 ms)
       → Django WafMiddleware (dynamic analysis, < 0.5 ms)
       → Application views

The middleware evaluates requests in this order:

  1. Exempt paths/hosts bypass: static assets, health endpoints, and exempt hosts skip all evaluation
  2. HTTP method filtering: disallowed methods receive 405 immediately
  3. Master switch check: DJANGO_WAF_ENABLED = False passes all requests through
  4. Staff/superuser bypass: authenticated staff skip rule evaluation
  5. Valid challenge cookie check: previously-solved challenges are honoured
  6. Allow rules → Block rules → Rate limits: explicit rule matching
  7. No-referer challenge: optionally challenge requests with no Referer header
  8. Path scoring (always) + UA scoring (after 10 requests): anomaly score accumulates from suspicious paths and UA heuristics; score thresholds determine the verdict (log / challenge / block)
  9. Challenge escalation: IPs exceeding the challenge threshold are auto-blocked for DJANGO_WAF_ESCALATION_BLOCK_TTL seconds. A solved challenge resets the count unless the client's fingerprint classifies as a bot, in which case it still counts
  10. Verdict dispatch: response rendered (allow / block / challenge / throttle), sampled logging written, and WAF signal emitted

Development

# Run tests
pytest

# Run tests with coverage
pytest --cov=src --cov-report=term-missing

# Lint
ruff check src/ tests/
ruff format src/ tests/

# Type check. PYTHONPATH=. is required, not optional: the django-stubs
# plugin resolves `django_settings_module = "tests.settings"` as a dotted
# path from the repo root, and without it mypy exits 2 having checked
# nothing at all, which reads like a pass when scripted.
PYTHONPATH=. mypy src/

Every one of these runs in CI (.github/workflows/ci.yml): ruff check and ruff format --check in the lint job, mypy in the typecheck job, and the suite across the Python/Django matrix plus dedicated legs for real Redis and real PostgreSQL. A branch green on pytest alone is not verified.

Type information for consumers

The package ships a PEP 561 py.typed marker, so a project that type-checks its own code sees the real annotations on everything it imports from django_waf rather than Any.

Licence

MIT

Metadata

Release files for django-waf 2.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-waf 2.10.0
File Size Uploaded
django_waf-2.10.0.tar.gz 504.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-waf 2.10.0
File Interpreter ABI Platform
django_waf-2.10.0-py3-none-any.whl Python 3 none any Details

Total release size: 830.1 kB

Release files / django_waf-2.10.0.tar.gz

Download URL django_waf-2.10.0.tar.gz
Size 504.7 kB
Tags Source
SHA-256 checksum
How to use checksums
e6ed61da6cb8fdb767d6b51bf71739be0516788b5471c325a9cb19e41a32e38b
BLAKE2b-256 checksum
How to use checksums
e81145d6a57b07250a34c6964e8b6cec7a46d4f87e20faf9130d7aee3f0b322d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release files / django_waf-2.10.0-py3-none-any.whl

Download URL django_waf-2.10.0-py3-none-any.whl
Size 325.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7381d717cabaaf02723359d5180fe03df1ac27e8e4c94e2a3780e99c158efaff
BLAKE2b-256 checksum
How to use checksums
5fd9fc325b9adff84f812769ed7bcf3c7f35b32a967eab6a8dd10add438f8f18
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release history Release notifications | RSS feed

2.11.0

2 release files

This release

2.10.0 This release

2 release files

2.9.0

2 release files

2.8.0

2 release files

2.7.0

2 release files

2.6.0

2 release files

2.5.0

2 release files

2.4.0

2 release files

2.3.0

2 release files

2.2.0

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.10.0

2 release files

1.9.1

2 release files

1.9.0

2 release files

1.8.1

2 release files

1.8.0

2 release files

1.7.0

2 release files

1.6.0

2 release files

1.5.2

2 release files

1.5.1

2 release files

1.5.0

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.12.0

2 release files

0.11.2

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.6

2 release files

0.10.5

2 release files

0.10.4

2 release files

0.10.2

2 release files

0.10.1

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page