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:
- Exact matches first — non-regex routes are compared literally against the path.
- Then regex routes — patterns are pre-compiled and matched with
re.search. - Within each pass, routes are ordered by
priority(highest first), then bypath_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/.envand/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.