Ghost CMS Security Testing: Admin API Key Exposure, Theme Code Injection, and Content API Enumeration

Ghost is a modern open-source headless CMS and publishing platform used by thousands of blogs, media organizations, and membership sites. Its dual API architecture creates distinct security considerations: the Admin API key, when used in server-side code, should never appear in client-side JavaScript — if it does, any user who reads the page source can perform full administrative actions including creating/modifying posts, uploading themes, and injecting code into site headers; Ghost themes are Handlebars templates with JavaScript — a malicious theme uploaded by an admin user can execute arbitrary Node.js code on the Ghost server through custom theme helpers; the Content API is designed to be public (its key is embedded in frontends) but returns all content including unpublished drafts and scheduled posts when queried with ?filter=status:draft; Ghost's webhook feature sends POST requests to configured URLs on content events — if webhook URLs are configurable by authenticated users without restriction, this enables SSRF to internal services; and Ghost's member management API exposes subscriber email addresses and membership tier information to admin users. This guide covers systematic Ghost CMS security assessment.

Table of Contents

  1. Ghost CMS Discovery
  2. Admin and Content API Key Identification
  3. Content API Draft and Private Content Access
  4. Member Data Exfiltration
  5. Ghost CMS Security Hardening

Admin and Content API Key Identification

# Ghost Admin panel: /ghost/
# Admin API: /ghost/api/admin/
# Content API: /ghost/api/content/

# Ghost version from meta tag
curl -s "https://example.com" 2>/dev/null | \
  grep -oE 'ghost/[0-9]+\.[0-9]+\.[0-9]+' | head -1

# Check if Ghost Admin is accessible (login page = Ghost detected)
curl -s -o /dev/null -w "%{http_code}" \
  "https://example.com/ghost/" 2>/dev/null

# Search client-side JavaScript for API keys
# Ghost API keys follow the pattern: hex_id:hex_secret (Admin) or hex string (Content)
# Dangerous pattern: Admin API key in browser-side JavaScript

# Grep page source and linked JavaScript files for Admin API key pattern
curl -s "https://example.com" 2>/dev/null | \
  grep -oE '[0-9a-f]{24}:[0-9a-f]{64}' | head -5

# Content API key (shorter, 26 hex chars)
curl -s "https://example.com" 2>/dev/null | \
  grep -oE 'key=[0-9a-f]{26}' | head -5

# Test Content API key access (Content API keys are intentionally public)
CONTENT_KEY="your-content-api-key"
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&limit=5" \
  2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
posts = d.get('posts', [])
print(f'Posts returned: {len(posts)}')
for p in posts:
    print(f\"  [{p.get('status','?')}] {p.get('title','?')}\")
" 2>/dev/null

Content API Draft and Private Content Access

# The Content API returns published posts by default
# But it can be queried with status filters to access drafts

CONTENT_KEY="your-content-api-key"

# Test if draft posts are accessible via Content API
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&filter=status:draft&limit=50" \
  2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
posts = d.get('posts', [])
if posts:
    print(f'EXPOSED: {len(posts)} DRAFT posts accessible via Content API')
    for p in posts:
        print(f\"  DRAFT: {p.get('title','?')}\")
else:
    err = d.get('errors', [])
    if err:
        print(f'Drafts protected: {err[0].get(\"message\",\"?\")}')
" 2>/dev/null

# Check for scheduled posts
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&filter=status:scheduled" \
  2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
posts = d.get('posts', [])
if posts:
    print(f'{len(posts)} scheduled posts accessible')
" 2>/dev/null

# With Admin API key: list all members (subscription email addresses)
# Admin API uses JWT authentication
ADMIN_KEY="id:secret"  # format: hex_id:hex_secret
ID="${ADMIN_KEY%%:*}"
SECRET="${ADMIN_KEY##*:}"
python3 -c "
import jwt, time, datetime, requests
iat = int(time.time())
header = {'alg': 'HS256', 'typ': 'JWT', 'kid': '${ID}'}
payload = {'iat': iat, 'exp': iat + 300, 'aud': '/ghost/api/admin/'}
import hmac, hashlib, base64, json
h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=').decode()
p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
sig_b = hmac.new(bytes.fromhex('${SECRET}'), f'{h}.{p}'.encode(), hashlib.sha256).digest()
sig = base64.urlsafe_b64encode(sig_b).rstrip(b'=').decode()
print(f'{h}.{p}.{sig}')
" 2>/dev/null

Ghost CMS Security Hardening

Ghost CMS Security Hardening Checklist:
Security TestMethodRisk
Admin API key in client-side JavaScriptGrep page source for [0-9a-f]{24}:[0-9a-f]{64} pattern — Admin key enables full content and user managementCritical
Draft posts accessible via Content API filterGET /ghost/api/content/posts/?key={key}&filter=status:draft — exposes unpublished content and scheduled releasesHigh
Member email list accessible with Admin APIGET /ghost/api/admin/members/ with Admin JWT — retrieves all subscriber email addresses and membership tiersHigh
Ghost Admin panel publicly accessibleGET /ghost/ returns login page — brute force or credential stuffing possible without rate limitingHigh
Webhook SSRF via admin-configurable URLsConfigure webhook pointing to internal service URL — Ghost POSTs to internal target on content eventsHigh
Theme upload for server-side code injectionUpload malicious theme zip via Admin API /themes/ endpoint — custom Handlebars helpers execute Node.js codeCritical

Automate Ghost CMS Security Testing

Ironimo tests Ghost CMS deployments for Admin API keys exposed in client-side JavaScript enabling full content management, draft and scheduled post exposure via the Content API filter parameter, member subscriber email addresses accessible via the Admin API, Ghost admin panel publicly accessible from the internet without IP restriction, webhook configurations pointing to internal service URLs enabling SSRF, code injections added to site headers via Ghost's Code Injection feature, and weak admin account passwords susceptible to brute-force via the session API.

Start free scan