Skip to content

Decoy routes

Decoy routes are the honeypot's trap surface: database rows that tell HoneyMiddleware which request paths to intercept and which fake response to serve. Because they live in the database, you can add, edit, and disable traps from the admin at runtime — no deploy, no restart.

How matching works

HoneyMiddleware runs early in the middleware stack (directly after SecurityMiddleware), before URL resolution. On every request it matches request.path against the active routes:

  1. Exact matches first — non-regex routes are compared literally against the path.
  2. Then regex routes — patterns are pre-compiled and matched with re.search.
  3. Within each pass, routes are ordered by priority (highest first), then by path_pattern.

First match wins. A matched request is captured and answered with a fake response; it never reaches your URLconf. Non-matching requests pass through untouched.

Match is on the path only

Matching uses request.path — the query string is stripped. So /admin/?id=1' OR 1=1 matches a route for /admin/. The query string is stored on the captured event (it often carries the payload the TTP classifier needs).

DecoyRoute fields

Field Type What it does
path_pattern string (500) The path to match — literal (/wp-admin/) or a regex (/\.env$) when is_regex is on
is_regex bool, default off Treat path_pattern as a regular expression (re.search semantics)
decoy_type choice Which fake response family to serve: admin, env, wpAdmin, api, custom
response_template string (100) Label for the response variant (informational; rendering is driven by decoy_type)
is_active bool, default on Inactive routes are ignored entirely — a kill switch per trap
description string (200) Operator note shown in the admin
priority int, default 0 Higher priority is matched first within its pass (exact or regex)
created_at datetime Auto-set

An invalid regex doesn't break anything: the route is skipped with a warning in the logs.

Built-in decoy types

Every decoy type returns a convincing response — never a 404 — with realistic Server: nginx/1.24.0 and X-Powered-By: PHP/8.1.2 headers, so the attacker believes they found something and keeps probing (revealing more about their toolkit with every request).

decoy_type Response
admin A pixel-faithful Django admin login page
env A plausible .env file (text/plain) full of fake database and mail credentials
wpAdmin A WordPress login form — a terminal page, not a redirect, so broad regex routes can't self-loop
api A JSON 500 error with a fake Python stack trace, mimicking a debug-mode API
custom Falls back to the admin login page

Seeded defaults

python manage.py seed_decoy_routes creates six canonical traps (idempotently — re-running updates them rather than duplicating):

Path pattern Type Priority
/\.env$ (regex) env 100
/wp-admin/ wpAdmin 90
/admin/ admin 80
/phpMyAdmin/ custom 75
/phpinfo.php custom 70
/api/debug/ api 60

A handful of well-known paths (/.env, /wp-admin/, /wp-login.php, /administrator/, /api/debug/) also have explicit fallback views mounted at the site root, so those probes are caught even with no DecoyRoute rows in the database. Middleware wins when both exist — a path matched by a DecoyRoute never reaches the view, so nothing is captured twice.

Adding custom routes

In the admin, add a Decoy route row. For a literal path, leave is_regex off:

path_pattern:  /backup.sql
decoy_type:    env
priority:      50

For a family of paths, use a regex:

path_pattern:  ^/(old|backup|staging)/wp-login\.php$
is_regex:      on
decoy_type:    wpAdmin
priority:      40

Regex tips:

  • Matching uses re.search, so anchor with ^/$ when you mean the whole path — /\.env$ catches /.env and /app/.env, while ^/\.env$ catches only the first.
  • Give broad regexes a lower priority than your exact routes so the more specific trap wins.
  • Make sure a regex can't match your real application or admin paths — a decoy match wins before your URLconf ever runs.

Cache behaviour and invalidation

Route matching never queries the database per request. Active routes are loaded once into an in-process, module-level cache (regexes pre-compiled) and reused for up to 60 seconds (CACHE_TTL).

Saving or deleting a DecoyRoute fires a signal that invalidates the cache immediately, so admin edits apply on the next request in that process. Other processes (each Daphne/Celery worker holds its own cache) pick the change up when their TTL expires — worst case, about a minute.

Rate limiting

Capture is throttled per IP: at most 50 events stored per IP per 5-minute window (counted in Redis). Above the cap, the hit is not stored — but the attacker still receives the same decoy response, so they can't detect the throttle, and the counter keeps incrementing so total probe volume is still tracked.