Get the FREE Ultimate OpenClaw Setup Guide →

ctfd

Scanned
npx machina-cli add skill Lu1sDV/skillsmd/ctfd --openclaw
Files (1)
SKILL.md
12.5 KB

CTFd Development & Operations

CTFd is a Python 3.11 Flask-based Capture The Flag platform. SQLAlchemy 1.4, Redis, Jinja2 themes, plugin system.

Architecture

nginx → ctfd (gunicorn+gevent) → MySQL/SQLite + Redis
         ├── plugins/          # Extensible plugin system
         └── themes/           # Jinja2 theme directories

Quick Commands

# Docker stack
docker compose up --build -d          # Build + start
docker compose logs -f ctfd           # Stream logs
docker compose exec ctfd bash         # Shell in
docker compose down                   # Stop

# Testing
pytest -rf --cov=CTFd -n auto         # Full suite parallel

# Local dev
python serve.py                       # Debug server (port 4000)

# Database
flask db upgrade                      # Apply migrations
flask db migrate -m "description"     # Generate migration

Docker Stack

ServiceDefault PortPurpose
ctfd8000Flask/Gunicorn, REVERSE_PROXY=true behind proxy
nginx80Reverse proxy, static files
cache6379Redis for sessions + config cache

Critical Gotchas

1. CSRF Protection (SILENT FAILURE)

All POST/PATCH/DELETE require CSRF-Token header matching session["nonce"]. Without it, requests silently 302 redirect to login returning HTML instead of JSON. No error message.

// Extract from any authenticated page
headers: { 'CSRF-Token': init.csrfNonce, 'Content-Type': 'application/json' }

Source: CTFd/utils/initialization/__init__.py. JSON requests check header, form requests check nonce field.

2. Password Hashing (DATA CORRUPTION)

ALWAYS use CTFd.utils.crypto.hash_password(). CTFd uses bcrypt_sha256 via passlib. Raw SQL UPDATE on password column causes ValueError: not a valid bcrypt_sha256 hash on next login — user is permanently locked out.

from CTFd.utils.crypto import hash_password, verify_password
user.password = hash_password("newpass")  # CORRECT
# NEVER: user.password = "newpass"

3. SQLAlchemy 1.4 Legacy Style

CTFd uses SQLAlchemy 1.4, NOT 2.x. Use legacy query API:

# CORRECT
User.query.filter_by(name="admin").first()
# WRONG (SA 2.x style)
db.session.execute(select(User).where(...))

4. Config Priority Chain

Environment variables > config.ini > defaults. Docker env vars in docker-compose.yml override everything. See EnvInterpolation in config.py.

5. Plugin requirements.txt

Plugin deps install at Docker build time via multi-stage Dockerfile. Add deps to CTFd/plugins/<name>/requirements.txt, NOT the root requirements.txt. Rebuild image after changes.

6. Git Submodules

CTFd may use submodules (e.g., plugins, docs). After clone:

git submodule update --init --recursive

Missing submodule init causes import errors at startup.

7. SECRET_KEY for Multi-Worker

Entrypoint refuses to start with WORKERS > 1 unless SECRET_KEY env var or .ctfd_secret_key file exists. Single worker auto-generates a key, but it changes on restart (invalidating sessions).

head -c 64 /dev/urandom > .ctfd_secret_key

Plugin Development

Plugin Structure

CTFd/plugins/<name>/
├── __init__.py          # load(app) function — entry point
├── config.json          # Metadata (name, route)
├── requirements.txt     # Python dependencies
├── templates/<ns>/      # Jinja2 templates (namespaced!)
└── assets/              # Static files (JS/CSS)

Key APIs

# Register routes
@app.route('/my-plugin', methods=['GET'])
def view(): return render_template('my_ns/page.html')

# Override existing routes
app.view_functions['challenges.challenges_view'] = my_function

# Override templates
from CTFd.utils.plugins import override_template
override_template('scoreboard.html', custom_html)

# Register static assets — NO leading/trailing slashes
register_plugin_assets_directory(app, base_path="plugins/my-plugin/assets")

# Database models
class MyModel(db.Model):
    id = db.Column(db.Integer, primary_key=True)
# In load(): app.db.create_all()

Custom Challenge Types

Extend BaseChallenge, implement: create, read, update, delete, attempt, solve, fail. Register in CHALLENGE_CLASSES dict. Model inherits from Challenges with polymorphic_identity. Frontend: create.html, update.html, view.html + matching .js.

Custom Flag Types

Extend BaseFlag, implement compare(chal_key_obj, provided) → bool. Set name and templates dict. Register in FLAG_CLASSES dict.

Plugin Loading Pitfalls

Blueprint template_folder breaks core templates: If a Blueprint uses template_folder="templates", Flask may prioritize plugin templates over CTFd core. Fix: use ChoiceLoader([core_loader, plugin_loader]).

Templates need namespace subdirectory: Plugin templates at plugins/<name>/templates/<namespace>/. Without the <namespace> dir, templates shadow core or fail to load.

register_plugin_assets_directory path format:

# CORRECT
register_plugin_assets_directory(app, base_path="plugins/my-plugin/assets")
# WRONG — causes 404
register_plugin_assets_directory(app, base_path="/plugins/my-plugin/assets/")

Theme Development

Theme Structure (Official)

themes/<name>/
├── assets/           # Uncompiled source (js/, scss/)
├── static/           # Compiled output (manifest.json auto-generated)
├── templates/        # Jinja2 templates (REQUIRED)
│   ├── base.html, challenges.html, challenge.html
│   ├── login.html, register.html, scoreboard.html
│   ├── settings.html, page.html, confirm.html
│   ├── components/   # navbar.html, errors.html, notifications.html
│   ├── macros/       # forms.html
│   ├── teams/        # team_enrollment, public, private, etc.
│   └── users/        # public, private, users listing
├── package.json
└── vite.config.js    # Build: Vite + Rollup, use Yarn

Build: yarn install && yarn build (or yarn dev for watch). Asset refs: {{ Assets.js('assets/js/index.js') }} → resolves via manifest. JS stack: Alpine.js (user-facing) + Vue.js (admin) + CTFd.js.

Theme Gotchas

Static files in static/ NOT assets/: URL route is /themes/<name>/static/.... Wrong directory = silent 404.

Must extend base.html: Direct HTML without extending breaks CSRF injection, asset pipeline, navigation:

{% extends "base.html" %}
{% block content %}...{% endblock %}

Bootstrap class collisions: CTFd base templates include Bootstrap. Namespace custom classes to avoid conflicts.

Alpine.js registration timing: Alpine.data() must run BEFORE Alpine.start(). If CTFd starts Alpine before theme JS loads, registrations silently fail. Use defer on Alpine script.

Testing Infrastructure Gotchas

Flask SERVER_NAME Breaks Subdomain Tests

Setting SERVER_NAME = "localhost" makes Flask reject non-localhost requests. Set SERVER_NAME = None in test config.

CSRF Hook Crashes Test Helpers

The before_request CSRF hook fires for client.get("/api/...") in tests. If test doesn't seed a session nonce, it crashes. Mock the check or use @app.test_request_context().

CAPTCHA_ENABLED String Gotcha

os.getenv("CAPTCHA_ENABLED") returns string "True", not bool. In tests: os.environ["CAPTCHA_ENABLED"] = "False" or recaptcha plugin crashes.

pytest rootdir and confcutdir

Plugin tests need --confcutdir=tests or --rootdir=. in pytest.ini to prevent pytest from loading the wrong conftest.py from parent directories.

max_overflow Incompatible with SQLite StaticPool

SQLAlchemy StaticPool (in-memory SQLite for tests) doesn't accept max_overflow. Set SQLALCHEMY_MAX_OVERFLOW = None in test config.

Hyphenated Plugin Directories

Plugin dirs like ctfd-my-plugin are invalid Python identifiers. Import via:

import importlib, sys
spec = importlib.util.spec_from_file_location(
    "ctfd_my_plugin", "CTFd/plugins/ctfd-my-plugin/__init__.py"
)
mod = importlib.util.module_from_spec(spec)
sys.modules["ctfd_my_plugin"] = mod

Warning: Don't re-execute spec.loader.exec_module(mod) if already loaded — corrupts SQLAlchemy mapper.

mock.patch() with Hyphenated Paths

mock.patch("CTFd.plugins.ctfd-my-plugin.config.X") fails. Use patch.object(sys.modules["ctfd_my_plugin"], "X", ...).

Playwright E2E Patterns

networkidle Never Completes

CTFd has persistent SSE connections. waitUntil: "networkidle" hangs forever. Use domcontentloaded:

await page.goto(url, wait_until="domcontentloaded")

Login Form Selector

CTFd login uses input#_submit (underscore prefix), NOT button[type=submit]:

await page.fill("input#name", "admin")
await page.fill("input#password", "admin")
await page.click("input#_submit")

CSRF Nonce in E2E Tests

nonce = await page.evaluate("() => init.csrfNonce")

First-Time Setup Redirect

Fresh CTFd redirects all pages to /setup. Tests must detect and complete setup first.

Troubleshooting Playbook

API Returns HTML Instead of JSON

Cause: Missing CSRF-Token header or expired session → 302 redirect to login. Fix: Include CSRF-Token and Content-Type: application/json headers.

Admin Page Returns 500

Cause: Template not found, broken asset path, or missing DB table. Fix: Check logs. Plugin templates at plugins/<name>/templates/<namespace>/. Run flask db upgrade.

Container Won't Start / Rebuild Fails

Cause: Stale image cache, SELinux denials, port conflict. Fix: docker compose down && docker compose build --no-cache && docker compose up -d

Multi-Worker Sessions Invalidated on Restart

Cause: No persistent SECRET_KEY. Fix: Set SECRET_KEY env var or create .ctfd_secret_key file.

Plugin DB Tables Missing

Cause: db.create_all() runs at plugin load but won't run migrations. Fix: flask db upgrade for schema changes.

Import Errors at Startup

Cause: Submodules not initialized. Fix: git submodule update --init --recursive

CTFd Reference

Core Concepts

  • User Modes: teams or users — set at setup, switching loses all submissions
  • Scoring: Standard (fixed) or Dynamic (parabolic decay: value = ((min-init)/(decay²)) * (solves²) + init)
  • Visibility: Challenge/Score/Account/Registration each independently public/private/admins-only
  • Tie-breaking: Lower solve ID in DB wins (first-to-solve advantage)

Flag Types

TypeHow It Works
StaticExact string match (case-sensitive by default)
RegexPython regex pattern match (test with Pythex)
HTTPDelegates to external endpoint; POST {"submission":"..."}, 202=correct
ProgrammablePython check(x) → bool (Hosted/Enterprise only)

Configuration Variables

VariablePurpose
SECRET_KEYSession signing
DATABASE_URLDB URI (default: SQLite; MySQL recommended for prod)
REDIS_URLRedis URI for cache/sessions
REVERSE_PROXYTrue/False or ProxyFix settings
UPLOAD_PROVIDERfilesystem or s3
UPLOAD_FOLDERDefault: CTFd/uploads
LOG_FOLDERDefault: CTFd/logs
APPLICATION_ROOTSubdirectory mount (e.g., /ctfd)
SERVER_SENT_EVENTSEnable SSE notifications
SWAGGER_UIEnable /api/v1/ docs
MAIL_*SMTP config
AWS_*S3 upload config

Webhooks

4 events: user_created, team_created, challenge_solved, first_blood. HMAC-SHA256 endpoint validation. Signature: CTFd-Webhook-Signature: t=<timestamp>,v1=<hmac>.

API Authentication

Token-based: Authorization: Token <access_token>. Generated in Settings → Access Tokens (30-day default). Swagger UI at /api/v1/.

Database Migrations

SQLite auto-builds from models (no migrations needed). MySQL requires Alembic migrations. Plugin tables: app.db.create_all() in load().

Code Style

  • Python: black + isort (profile=black) + ruff
  • JS/CSS: prettier
  • Templates: Jinja2, extend admin/base.html for admin pages
  • API responses: {"success": true, "data": ...} or {"success": false, "error": "..."}
  • SQLAlchemy: 1.4 legacy query style only

Source

git clone https://github.com/Lu1sDV/skillsmd/blob/main/ctfd/SKILL.mdView on GitHub

Overview

CTFd Development & Operations covers building, debugging, deploying, and maintaining a CTFd instance or fork. It explains stack architecture, Docker-based setup, migrations, theming, and the plugin system, plus practical gotchas you’ll hit in real deployments.

How This Skill Works

This skill consolidates best practices for running a CTFd stack (nginx → ctfd with gunicorn/gevent, Redis, MySQL/SQLite), using docker-compose, and applying migrations with Flask CLI. It also covers plugin development, theme templating, and debugging API responses and common errors.

When to Use It

  • Debugging CSRF 302 silent failures on POST/PATCH/DELETE requests
  • Setting up or maintaining a Docker-based CTFd stack and diagnosing stack issues
  • Developing, loading, or debugging a CTFd plugin or theme
  • Applying and validating database migrations with flask db upgrade/migrate
  • Resolving multi-worker SECRET_KEY issues and configuration precedence

Quick Start

  1. Step 1: Start the Docker stack: docker compose up --build -d
  2. Step 2: Check logs and run tests: docker compose logs -f ctfd; pytest -rf --cov=CTFd -n auto
  3. Step 3: Run migrations or dev server: flask db upgrade; or python serve.py for local dev

Best Practices

  • Start and monitor the stack with docker compose up --build -d and docker compose logs -f ctfd
  • Always hash passwords with CTFd.utils.crypto.hash_password(); never store plaintext
  • Use the SQLAlchemy 1.4 legacy style (e.g., User.query.filter_by(...)) rather than SA 2.x style
  • Put plugin dependencies in CTFd/plugins/<name>/requirements.txt and rebuild images
  • Ensure SECRET_KEY is set (or a .ctfd_secret_key file) for multi-worker deployments

Example Use Cases

  • Build and start the stack: docker compose up --build -d; inspect logs with docker compose logs -f ctfd
  • Run the full test suite: pytest -rf --cov=CTFd -n auto
  • Apply migrations: flask db upgrade and flask db migrate -m 'description'
  • Fix CSRF 302 issue by sending CSRF-Token header: headers: { 'CSRF-Token': init.csrfNonce } for JSON requests
  • Develop a plugin: create CTFd/plugins/<name>/ with __init__.py, config.json, and plugin-specific requirements.txt

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers