commit 2e06f92c6473efcd9d7e96e925f24b347a511771 Author: gkr Date: Thu Dec 25 09:39:21 2025 +0800 init CTFd source diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..35dff8b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,9 @@ +coverage: + status: + project: + default: + # Fail the status if coverage drops by >= 1% + threshold: 1 + patch: + default: + threshold: 1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7937a73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +CTFd/logs/*.log +CTFd/static/uploads +CTFd/uploads +CTFd/*.db +CTFd/uploads/**/* +.ctfd_secret_key +.data +.git +.codecov.yml +.dockerignore +.github +.gitignore +.prettierignore +.travis.yml +**/node_modules +**/*.pyc +**/__pycache__ +.venv* +venv* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d389b9f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + "env": { + "browser": true, + "es6": true + }, + "extends": "eslint:recommended", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + } +}; \ No newline at end of file diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..d41e74c --- /dev/null +++ b/.flaskenv @@ -0,0 +1,2 @@ +FLASK_ENV=development +FLASK_RUN_PORT=4000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d995728 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: CTFd diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..de174fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,19 @@ + + +**Environment**: + +- CTFd Version/Commit: +- Operating System: +- Web Browser and Version: + +**What happened?** + +**What did you expect to happen?** + +**How to reproduce your issue** + +**Any associated stack traces or error logs** diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..fdde0ea --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,46 @@ +name: Docker build image on release + +on: + release: + types: [published] + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Set repo name lowercase + id: repo + uses: ASzc/change-string-case-action@v2 + with: + string: ${{ github.repository }} + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ steps.repo.outputs.lowercase }}:latest + ghcr.io/${{ steps.repo.outputs.lowercase }}:latest + ${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }} + ghcr.io/${{ steps.repo.outputs.lowercase }}:${{ github.event.release.tag_name }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a0d866c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,47 @@ +--- +name: Linting + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.11'] + + name: Linting + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r linting.txt + sudo yarn --cwd CTFd/themes/admin install --non-interactive + sudo yarn global add prettier@3.2.5 + + - name: Lint + run: make lint + env: + TESTING_DATABASE_URL: 'sqlite://' + + - name: Lint Dockerfile + uses: brpaz/hadolint-action@master + with: + dockerfile: "Dockerfile" + + - name: Lint docker-compose + run: | + docker compose -f docker-compose.yml config + + - name: Lint translations + run: | + make translations-lint + diff --git a/.github/workflows/mariadb.yml b/.github/workflows/mariadb.yml new file mode 100644 index 0000000..21cdac8 --- /dev/null +++ b/.github/workflows/mariadb.yml @@ -0,0 +1,59 @@ +--- +name: CTFd MariaDB CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:10.11 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: ctfd + ports: + - 3306 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mariadb.ports[3306] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml diff --git a/.github/workflows/mirror-core-theme.yml b/.github/workflows/mirror-core-theme.yml new file mode 100644 index 0000000..9ae5a25 --- /dev/null +++ b/.github/workflows/mirror-core-theme.yml @@ -0,0 +1,30 @@ +name: Mirror core-theme +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full history for subtree + + - name: Setup SSH for deploy key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.CORE_THEME_DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan github.com >> ~/.ssh/known_hosts + + - name: Setup git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "username@users.noreply.github.com" + + - name: Push subtree + run: | + git subtree push --prefix="CTFd/themes/core" "git@github.com:CTFd/core-theme.git" main diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml new file mode 100644 index 0000000..e29918d --- /dev/null +++ b/.github/workflows/mysql.yml @@ -0,0 +1,59 @@ +--- +name: CTFd MySQL CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: password + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml diff --git a/.github/workflows/mysql8.yml b/.github/workflows/mysql8.yml new file mode 100644 index 0000000..49cf0d9 --- /dev/null +++ b/.github/workflows/mysql8.yml @@ -0,0 +1,59 @@ +--- +name: CTFd MySQL 8.0 CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + ports: + - 3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: mysql+pymysql://root:password@localhost:${{ job.services.mysql.ports[3306] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml new file mode 100644 index 0000000..15aa6f3 --- /dev/null +++ b/.github/workflows/postgres.yml @@ -0,0 +1,67 @@ +--- +name: CTFd Postgres CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + services: + postgres: + image: postgres + ports: + - 5432:5432 + env: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: ctfd + POSTGRES_PASSWORD: password + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: postgres://postgres:password@localhost:${{ job.services.postgres.ports[5432] }}/ctfd + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + diff --git a/.github/workflows/sqlite.yml b/.github/workflows/sqlite.yml new file mode 100644 index 0000000..64b049f --- /dev/null +++ b/.github/workflows/sqlite.yml @@ -0,0 +1,49 @@ +--- +name: CTFd SQLite CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.11'] + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r development.txt + sudo yarn install --non-interactive + sudo yarn global add prettier@1.17.0 + + - name: Test + run: | + sudo rm -f /etc/boto.cfg + make test + env: + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + TESTING_DATABASE_URL: 'sqlite://' + + - name: Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + diff --git a/.github/workflows/verify-themes.yml b/.github/workflows/verify-themes.yml new file mode 100644 index 0000000..5f5dacf --- /dev/null +++ b/.github/workflows/verify-themes.yml @@ -0,0 +1,38 @@ +--- +name: Theme Verification + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + + runs-on: ubuntu-latest + + name: Theme Verification + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 20.19 + + - name: Verify admin theme + run: | + pwd + yarn install --non-interactive + yarn verify + working-directory: ./CTFd/themes/admin + + # TODO: Replace in 4.0 with deprecation of previous core theme + - name: Verify core theme + run: | + pwd + yarn install --non-interactive + yarn verify + working-directory: ./CTFd/themes/core diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ccc42b --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv* +.venv* +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml + +# Translations +# TODO: CTFd 4.0 We should consider generating .mo files in a Docker image instead of saving them in git +# *.mo + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +.DS_Store + +*.db +*.log +*.log.* +.idea/ +.vscode/ +CTFd/static/uploads +CTFd/uploads +.data/ +.ctfd_secret_key +.*.swp + +# Vagrant +.vagrant + +# CTFd Exports +*.zip + +# JS +node_modules/ + +# Flask Profiler files +flask_profiler.sql diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..02d8a38 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +skip=migrations diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9c4da8c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +CTFd/themes/**/vendor/ +CTFd/themes/core-deprecated/ +CTFd/themes/core/static/ +CTFd/themes/core-beta/**/* +CTFd/themes/admin/static/**/* +*.html +*.njk +*.png +*.svg +*.ico +*.ai +*.svg +*.mp3 +*.webm +.pytest_cache +venv* +.venv* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6204374 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2390 @@ +# 3.8.1 / 2025-11-06 + +**Security** + +- Make challenge attempt ratelimit stricter +- Make reset password ratelimit stricter and apply per-account + +**General** + +- Integrates dynamic scoring into the standard challenge type + - All challenges will now have `initial`, `decay`, `minimum`, `function` columns available through the standard challenge type + - Scoring logic for challenges can be configured with the `function` field + - The dynamic value challenge plugin will remain supported until CTFd 4.0 +- Add `solved` solution visibility to challenge solutions + - This only allows a user to view a challenge's solution if they've solved the associated challenge + +**Admin Panel** + +- Add bulk editing for solution visibility in the Admin Panel Challenges page + +**API** + +- Change `GET /api/v1/solutions/[solution_id]` to return 404 if a solution is hidden instead of a 403 +- Add `/api/v1/challenges/[challenge_id]/solution` endpoint to check if a challenge solution is accessible + +**Themes** + +- CTFd.js has been bumped to `0.0.19` +- `challenges.js` now has additional functions `getSolutionState` and `setSolutionId` to allow the UI to determine if a solution is accessible + +**Deployment** + +- Fixes issues where preset admins would not be created +- Add `RUN_ID` config which specifies a token which will be used as a cache-buster URL parameter +- Add `EXTRA_CONFIGS_FORCE_TYPES` config to allow server admins to force types for configs specified in the `[extra]` section +- If `UPDATE_CHECK` is disabled the update prompt banner should be properly disabled +- Fix issue where users would be put into an infinite loop if confirm emails is enabled without having an email server configured + +**Translations** + +- Add Uzbek and Hebrew languages + +# 3.8.0 / 2025-09-04 + +**General** + +- Admins can now configure whether users can see their past submissions +- Admins can now store challenge solutions within CTFd to be viewed by users +- Participants can now leave upvotes/downvotes on challenges as well as their review of a challenge + - Ratings/Votes can be configured to be viewed by participants or only admins + - Reviews are only visible by admins +- Challenges now have the `logic` field which allows for challenge developers to control the flag collection behavior of a challenge: + - `any`: any flag is accepted for the challenge + - `all`: all flags for the challenge must be submitted + - `team`: all team members must submit any flag +- Max Attempts can now behave as a timeout instead of a lockout + - For example a user who submits 3 attempts will then be prevented from submitting another attempt for 5 minutes instead of being unable to submit entirely +- Social Shares for challenge completion are now enabled by default and admins may now control the social share template page +- Additional attempts after solving on challenges will now show if the submissions is correct/incorrect +- If email sending is available, email confirmation is enabled by default and users are nudged to complete email verification. +- Hints can now have a title that is shown before unlocking +- Hints now always require unlocking even if they require no cost + - Prevents accidental viewing and improves tracking of hint usage +- CTFd will now store a tracking event under `challenges.open` in the Tracking table when a challenge is opened for the first time by a user +- Challenges now report whether a flag is correct or incorrect even if the challenge has already been solved +- Fixes issue where admins could not download challenge files before CTF start when downloading anonymously + +**Admin Panel** + +- Added a matrix scoreboard to the Statistics page to show player progression through the CTF +- Added support for brackets in the Admin Panel scoreboard +- Added config option for minimum password length +- Added config option to control whether players can view their previous submissions +- Admins can now require users to change their password upon login +- Added config option to control Max Attempts behavior +- In the Admin Panel challenge preview, admins now only see free hints +- Fixed issue where the hint form was not resetting properly when creating multiple hints + +**API** + +- Added `/api/v1/users/me/submissions` for users to retrieve their own submissions +- Added `/api/v1/challenges/[challenge_id]/solutions` for users to retrieve challenge solutions +- Added `/api/v1/challenges/[challenge_id]/ratings` for users to submit ratings and for admins to retrieve them +- Added `ratings` and `rating` fields to the response of `/api/v1/challenges/[challenge_id]` +- Added `solution_id` to the response of `/api/v1/challenges/[challenge_id]` + - If no solution is available, the field is `null` +- Added `logic` field to the response of `/api/v1/challenges/[challenge_id]` +- Added `change_password` field to `/api/v1/users/[user_id]` when viewed as an admin +- Added `/api/v1/solutions` and `/api/v1/solutions/[solution_id]` endpoints +- `/api/v1/unlocks` is now also used to unlock solutions for user viewing + +**Deployment** + +- Added `PRESET_ADMIN_NAME`, `PRESET_ADMIN_EMAIL`, `PRESET_ADMIN_PASSWORD`, and `PRESET_ADMIN_TOKEN` to `config.ini` for pre-creating an admin user + - Useful for automated deployments and ensuring a known admin token exists +- Added `PRESET_CONFIGS` to `config.ini` for pre-setting server-side configs + - Useful for configuring CTFd without completing setup or using the API +- Added `EMAIL_CONFIRMATION_REQUIRE_INTERACTION` to `config.ini` to require additional interaction for email confirmation links + - Improves compatibility with certain anti-phishing defenses +- Email confirmation is now enabled whenever email sending is available +- Replaced `pybluemonday` with `nh3` (due to breakage in Python modules written in Golang) +- Updated Flask to 2.1.3 +- Updated Werkzeug to 2.2.3 + +**Plugins** + +- Challenge Type Plugins should now return a `ChallengeResponse` object instead of a `(status, message)` tuple + - Existing behavior is supported until CTFd 4.0 +- Added `BaseChallenge.partial` for challenge classes to indicate partial solves (for `all` flag logic) + +**Themes** + +- The `core-beta` theme has been promoted to `core` + - The `core-beta` repo has been replaced with the [core-theme repo](https://github.com/CTFd/core-theme). Future changes should be made in the main CTFd repo and these changes will be copied over to the core-theme repo. +- The previous `core` theme has been deprecated and renamed `core-deprecated` + +# 3.7.7 / 2025-04-14 + +**General** + +- Added ability to denylist/blacklist email domains from registering +- Hints can now include an optional title that is shown to users before unlocking + +**Admin Panel** + +- Challenge files now show the stored sha1sum + +**Deployment** + +- Fixed issue where the `/api/v1/scoreboard/top/` endpoint wouldn't cache different count values properly +- The `/api/v1/scoreboard/top/`endpoint will now return at most the top 50 accounts +- Updated gunicorn to 23.0.0 +- Updated Jinja2 to 3.1.6 + +# 3.7.6 / 2025-02-19 + +**Security** + +- Added the `TRUSTED_HOSTS` configuration to more easily restrict CTFd to valid host names + +**General** + +- Added language switcher on the main navigation bar +- Removed autocomplete=off from login, register, and reset password forms + +**Plugins** + +- Challenge type plugins can now raise `ChallengeCreateException` or `ChallengeUpdateException` to show input validation messages +- Plugins specifying a config route will now appear in the Admin Panel under the Plugins section + +**Translations** + +- Add Romanian, Greek, Finnish, Slovenian, Swedish languages + +# 3.7.5 / 2024-12-27 + +**Security** + +- Change confirmation and reset password emails to be single use instead of only expiring in 30 minutes + +**General** + +- Fix issue where users could set their own bracket after registration +- If a user or team do not have a password set we allow setting a password without providing a previous password confirmation +- Fix issue where dynamic challenges did not return their attribution over the API +- Language selection is now available in the main theme navigation bar + +**Admin Panel** + +- A point breakdown graph showing the amount of challenge points allocated to each category has been added to the Admin Panel +- Bracket ID and Bracket Name have been added to CSV scoreboard exports +- Fix issue with certain interactions in the Media Library + +**API** + +- Swagger specification has been updated to properly validate +- `/api/v1/flags/types` and `/api/v1/flags/types/` have been seperated into two seperate controllers + +**Deployment** + +- IP Tracking has been updated to only occur if we have not seen the IP before or on state changing methods +- Bump dependencies for `cmarkgfm` and `jinja2` + +# 3.7.4 / 2024-10-08 + +**Security** + +- Validate email length to be less than 320 chars to prevent Denial of Service in email validation + +**General** + +- Add attribution field to Challenges + +**Admin Panel** + +- Display brackets in the Admin Panel + +**Themes** + +- Display brackets for users/teams on listing pages and public/private pages +- Fix miscellaneous issues in core-beta +- Adds dark mode to core-beta theme +- Fix issue with long titles in challenge buttons +- Adds `type` and `extra` arguments to `Assets.js()` and default `defer` to `False` as `type="module"` automatically implies defer +- ECharts behavior for some graphs in core-beta can now be overriden using the following window objects `window.scoreboardChartOptions`, `window.teamScoreGraphChartOptions`, `window.userScoreGraphChartOptions` +- Update the scoreboard score graph to reflect the current active bracket changes + +**Deployment** + +- Add `.gitattributes` to keep LF line endings on .sh files under Windows +- Fix issues where None values are not cast to empty string +- Bump dependencies for `pybluemonday`, `requests`, and `boto3` + +# 3.7.3 / 2024-07-24 + +**Security** + +- Fix issue where challenge solves and account names could be seen despite accounts not being visible + +**Admin Panel** + +- Add a Localization section in the Config Panel +- Add the Default Language config in the Admin Panel to allow admins to configure a default language + - Previously CTFd would default to an auto-detected language specified by the user's browser. This setting allows for that default to be set by the admin instead of auto-detected. + +**Translations** + +- Fix issue where Simplified Chinese would be used instead of Traditional Chinese +- Update the language names for Simplified Chinese and Traditional Chinese for clarity +- Update Vietnamese translation +- Add Catalan translation + +# 3.7.2 / 2024-06-18 + +**Security** + +- Patches an issue where on certain browsers flags could be leaked with admin interaction on a malicious page + +**API** + +- Disable returning 404s in listing pages with pagination + - Instead of returning 404 these pages will now return 200 + - For API endpoints, the response will be a 200 with an empty listing instead of a 404 + +**Deployment** + +- CTFd will now add the `Cross-Origin-Opener-Policy` response header to all responses with the default value of `same-origin-allow-popups` +- Add `CROSS_ORIGIN_OPENER_POLICY` setting to control the `Cross-Origin-Opener-Policy` header + +# 3.7.1 / 2024-05-31 + +**Admin Panel** + +- The styling of the Config Panel has been updated to better organize different settings +- When switching user modes via the Admin Panel, all teams will now be removed +- Fix issues where importing CSVs comprised of JSON entries would fail +- Add `serializeJSON` function back into the Admin Panel + +**API** + +- The `/api/v1/exports/raw` API endpoint has been added to allow for exports to be generated via the API +- Update the ScoreboardDetail endpoint (`/api/v1/scoreboard/top/`) to return account URL, score, and bracket +- Add a query parameter to ScoreboardDetail endpoint (`/api/v1/scoreboard/top/`) to filter by bracket +- Return `function` field for DynamicValue challenges data read + +**General** + +- Add Italian and Vietnamese languages +- Switch to Crowdin for translations + +**Themes** + +- Add `defer` parameter to `Assets.js()` to allow controlling the defer attribute of inserted `' + url = url_for("views.themes_beta", theme=theme, path=entry) + html += f'' + return markup(html) + + def css(self, asset_key, theme=None): + if theme is None: + theme = ctf_theme() + asset = self.manifest(theme=theme)[asset_key] + entry = asset["file"] + url = url_for("views.themes_beta", theme=theme, path=entry) + return markup(f'') + + def file(self, asset_key, theme=None): + if theme is None: + theme = ctf_theme() + asset = self.manifest(theme=theme)[asset_key] + entry = asset["file"] + return url_for("views.themes_beta", theme=theme, path=entry) + + +Assets = _AssetsWrapper() diff --git a/CTFd/constants/config.py b/CTFd/constants/config.py new file mode 100644 index 0000000..ba66a12 --- /dev/null +++ b/CTFd/constants/config.py @@ -0,0 +1,74 @@ +import json + +from flask import url_for + +# TODO: CTFd 4.0. These imports previously specified in this file but have moved. We could consider removing these imports +from CTFd.constants.options import ( # noqa: F401 + AccountVisibilityTypes, + ChallengeVisibilityTypes, + ConfigTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, + UserModeTypes, +) +from CTFd.utils import get_config + + +class _ConfigsWrapper: + def __getattr__(self, attr): + return get_config(attr) + + @property + def ctf_name(self): + return get_config("ctf_name", default="CTFd") + + @property + def ctf_small_icon(self): + icon = get_config("ctf_small_icon") + if icon: + return url_for("views.files", path=icon) + return url_for("views.themes", path="img/favicon.ico") + + @property + def theme_header(self): + from CTFd.utils.helpers import markup + + return markup(get_config("theme_header", default="")) + + @property + def theme_footer(self): + from CTFd.utils.helpers import markup + + return markup(get_config("theme_footer", default="")) + + @property + def theme_settings(self): + try: + return json.loads(get_config("theme_settings", default="null")) + except json.JSONDecodeError: + return {"error": "invalid theme_settings"} + + @property + def tos_or_privacy(self): + tos = bool(get_config("tos_url") or get_config("tos_text")) + privacy = bool(get_config("privacy_url") or get_config("privacy_text")) + return tos or privacy + + @property + def tos_link(self): + return get_config("tos_url", default=url_for("views.tos")) + + @property + def privacy_link(self): + return get_config("privacy_url", default=url_for("views.privacy")) + + @property + def social_shares(self): + return get_config("social_shares", default=True) + + @property + def challenge_ratings(self): + return get_config("challenge_ratings", default="public") + + +Configs = _ConfigsWrapper() diff --git a/CTFd/constants/email.py b/CTFd/constants/email.py new file mode 100644 index 0000000..7f1a907 --- /dev/null +++ b/CTFd/constants/email.py @@ -0,0 +1,31 @@ +DEFAULT_VERIFICATION_EMAIL_SUBJECT = "Confirm your account for {ctf_name}" +DEFAULT_VERIFICATION_EMAIL_BODY = ( + "Welcome to {ctf_name}!\n\n" + "Click the following link to confirm and activate your account:\n" + "{url}" + "\n\n" + "If the link is not clickable, try copying and pasting it into your browser." +) +DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT = "Successfully registered for {ctf_name}" +DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY = ( + "You've successfully registered for {ctf_name}!" +) +DEFAULT_USER_CREATION_EMAIL_SUBJECT = "Message from {ctf_name}" +DEFAULT_USER_CREATION_EMAIL_BODY = ( + "A new account has been created for you for {ctf_name} at {url}. \n\n" + "Username: {name}\n" + "Password: {password}" +) +DEFAULT_PASSWORD_RESET_SUBJECT = "Password Reset Request from {ctf_name}" +DEFAULT_PASSWORD_RESET_BODY = ( + "Did you initiate a password reset on {ctf_name}? " + "If you didn't initiate this request you can ignore this email. \n\n" + "Click the following link to reset your password:\n{url}\n\n" + "If the link is not clickable, try copying and pasting it into your browser." +) +DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT = "Password Change Confirmation for {ctf_name}" +DEFAULT_PASSWORD_CHANGE_ALERT_BODY = ( + "Your password for {ctf_name} has been changed.\n\n" + "If you didn't request a password change you can reset your password here:\n{url}\n\n" + "If the link is not clickable, try copying and pasting it into your browser." +) diff --git a/CTFd/constants/languages.py b/CTFd/constants/languages.py new file mode 100644 index 0000000..159c565 --- /dev/null +++ b/CTFd/constants/languages.py @@ -0,0 +1,61 @@ +from CTFd.constants import RawEnum + + +class Languages(str, RawEnum): + ENGLISH = "en" + GERMAN = "de" + POLISH = "pl" + SPANISH = "es" + ARABIC = "ar" + CHINESE = "zh_CN" + TAIWANESE = "zh_TW" + FRENCH = "fr" + KOREAN = "ko" + RUSSIAN = "ru" + BRAZILIAN_PORTUGESE = "pt_BR" + SLOVAK = "sk" + JAPANESE = "ja" + ITALIAN = "it" + VIETNAMESE = "vi" + CATALAN = "ca" + GREEK = "el" + FINNISH = "fi" + ROMANIAN = "ro" + SLOVENIAN = "sl" + SWEDISH = "sv" + HEBREW = "he" + UZBEK = "uz" + + +LANGUAGE_NAMES = { + "en": "English", + "de": "Deutsch", + "pl": "Polski", + "es": "Español", + "ar": "اَلْعَرَبِيَّةُ", + "zh_CN": "简体中文", + "zh_TW": "繁體中文", + "fr": "Français", + "ko": "한국어", + "ru": "русский язык", + "pt_BR": "Português do Brasil", + "sk": "Slovenský jazyk", + "ja": "日本語", + "it": "Italiano", + "vi": "tiếng Việt", + "ca": "Català", + "el": "Ελληνικά", + "fi": "Suomi", + "ro": "Română", + "sl": "Slovenščina", + "sv": "Svenska", + "he": "עברית", + "uz": "oʻzbekcha", +} + +SELECT_LANGUAGE_LIST = [("", "")] + [ + (str(lang), LANGUAGE_NAMES.get(str(lang))) for lang in Languages +] + +Languages.names = LANGUAGE_NAMES +Languages.select_list = SELECT_LANGUAGE_LIST diff --git a/CTFd/constants/options.py b/CTFd/constants/options.py new file mode 100644 index 0000000..fdf2d4c --- /dev/null +++ b/CTFd/constants/options.py @@ -0,0 +1,43 @@ +from CTFd.constants import JinjaEnum, RawEnum + + +class ConfigTypes(str, RawEnum): + CHALLENGE_VISIBILITY = "challenge_visibility" + SCORE_VISIBILITY = "score_visibility" + ACCOUNT_VISIBILITY = "account_visibility" + REGISTRATION_VISIBILITY = "registration_visibility" + + +@JinjaEnum +class UserModeTypes(str, RawEnum): + USERS = "users" + TEAMS = "teams" + + +@JinjaEnum +class ChallengeVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + ADMINS = "admins" + + +@JinjaEnum +class ScoreVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + HIDDEN = "hidden" + ADMINS = "admins" + + +@JinjaEnum +class AccountVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + ADMINS = "admins" + + +@JinjaEnum +class RegistrationVisibilityTypes(str, RawEnum): + PUBLIC = "public" + PRIVATE = "private" + MLC = "mlc" diff --git a/CTFd/constants/plugins.py b/CTFd/constants/plugins.py new file mode 100644 index 0000000..3188746 --- /dev/null +++ b/CTFd/constants/plugins.py @@ -0,0 +1,54 @@ +from flask import current_app + +from CTFd.plugins import get_admin_plugin_menu_bar, get_user_page_menu_bar +from CTFd.utils.helpers import markup +from CTFd.utils.plugins import get_registered_scripts, get_registered_stylesheets + + +class _PluginWrapper: + @property + def scripts(self): + application_root = current_app.config.get("APPLICATION_ROOT") + subdir = application_root != "/" + scripts = [] + for script in get_registered_scripts(): + if script.startswith("http"): + scripts.append(f'') + elif subdir: + scripts.append( + f'' + ) + else: + scripts.append(f'') + return markup("\n".join(scripts)) + + @property + def styles(self): + application_root = current_app.config.get("APPLICATION_ROOT") + subdir = application_root != "/" + _styles = [] + for stylesheet in get_registered_stylesheets(): + if stylesheet.startswith("http"): + _styles.append( + f'' + ) + elif subdir: + _styles.append( + f'' + ) + else: + _styles.append( + f'' + ) + return markup("\n".join(_styles)) + + @property + def user_menu_pages(self): + return get_user_page_menu_bar() + + @property + def admin_menu_pages(self): + return get_admin_plugin_menu_bar() + + +Plugins = _PluginWrapper() diff --git a/CTFd/constants/sessions.py b/CTFd/constants/sessions.py new file mode 100644 index 0000000..a5417a5 --- /dev/null +++ b/CTFd/constants/sessions.py @@ -0,0 +1,18 @@ +from flask import session + + +class _SessionWrapper: + @property + def id(self): + return session.get("id", 0) + + @property + def nonce(self): + return session.get("nonce") + + @property + def hash(self): + return session.get("hash") + + +Session = _SessionWrapper() diff --git a/CTFd/constants/setup.py b/CTFd/constants/setup.py new file mode 100644 index 0000000..b09ca31 --- /dev/null +++ b/CTFd/constants/setup.py @@ -0,0 +1,21 @@ +from CTFd.constants.options import ( + AccountVisibilityTypes, + ChallengeVisibilityTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, + UserModeTypes, +) +from CTFd.constants.themes import DEFAULT_THEME + +DEFAULTS = { + # General Settings + "ctf_name": "CTFd", + "user_mode": UserModeTypes.USERS, + # Visual/Style Settings + "ctf_theme": DEFAULT_THEME, + # Visibility Settings + "challenge_visibility": ChallengeVisibilityTypes.PRIVATE, + "registration_visibility": RegistrationVisibilityTypes.PUBLIC, + "score_visibility": ScoreVisibilityTypes.PUBLIC, + "account_visibility": AccountVisibilityTypes.PUBLIC, +} diff --git a/CTFd/constants/static.py b/CTFd/constants/static.py new file mode 100644 index 0000000..93a380f --- /dev/null +++ b/CTFd/constants/static.py @@ -0,0 +1,14 @@ +from CTFd.constants import JinjaEnum, RawEnum + + +@JinjaEnum +class CacheKeys(str, RawEnum): + PUBLIC_SCOREBOARD_TABLE = "public_scoreboard_table" + + +# Placeholder object. Not used, just imported to force initialization of any Enums here +class _StaticsWrapper: + pass + + +Static = _StaticsWrapper() diff --git a/CTFd/constants/teams.py b/CTFd/constants/teams.py new file mode 100644 index 0000000..ce16225 --- /dev/null +++ b/CTFd/constants/teams.py @@ -0,0 +1,46 @@ +from collections import namedtuple + +# TODO: CTFd 4.0. Consider changing to a dataclass +TeamAttrsFields = [ + "id", + "oauth_id", + "name", + "email", + "secret", + "website", + "affiliation", + "country", + "bracket_id", + "hidden", + "banned", + "captain_id", + "created", +] +TeamAttrs = namedtuple( + "TeamAttrs", + TeamAttrsFields, + defaults=(None,) * len(TeamAttrsFields), +) + + +class _TeamAttrsWrapper: + def __getattr__(self, attr): + from CTFd.utils.user import get_current_team_attrs + + attrs = get_current_team_attrs() + return getattr(attrs, attr, None) + + @property + def place(self): + from CTFd.utils.user import get_team_place + + return get_team_place(team_id=self.id) + + @property + def score(self): + from CTFd.utils.user import get_team_score + + return get_team_score(team_id=self.id) + + +Team = _TeamAttrsWrapper() diff --git a/CTFd/constants/themes.py b/CTFd/constants/themes.py new file mode 100644 index 0000000..46c9285 --- /dev/null +++ b/CTFd/constants/themes.py @@ -0,0 +1,2 @@ +ADMIN_THEME = "admin" +DEFAULT_THEME = "core" diff --git a/CTFd/constants/users.py b/CTFd/constants/users.py new file mode 100644 index 0000000..99b667f --- /dev/null +++ b/CTFd/constants/users.py @@ -0,0 +1,48 @@ +from collections import namedtuple + +# TODO: CTFd 4.0. Consider changing to a dataclass +UserAttrsFields = [ + "id", + "oauth_id", + "name", + "email", + "type", + "secret", + "website", + "affiliation", + "country", + "bracket_id", + "hidden", + "banned", + "verified", + "language", + "team_id", + "created", + "change_password", +] +UserAttrs = namedtuple( + "UserAttrs", UserAttrsFields, defaults=(None,) * len(UserAttrsFields) +) + + +class _UserAttrsWrapper: + def __getattr__(self, attr): + from CTFd.utils.user import get_current_user_attrs + + attrs = get_current_user_attrs() + return getattr(attrs, attr, None) + + @property + def place(self): + from CTFd.utils.user import get_user_place + + return get_user_place(user_id=self.id) + + @property + def score(self): + from CTFd.utils.user import get_user_score + + return get_user_score(user_id=self.id) + + +User = _UserAttrsWrapper() diff --git a/CTFd/errors.py b/CTFd/errors.py new file mode 100644 index 0000000..5e65491 --- /dev/null +++ b/CTFd/errors.py @@ -0,0 +1,21 @@ +import jinja2.exceptions +from flask import render_template +from werkzeug.exceptions import InternalServerError + + +def render_error(error): + if ( + isinstance(error, InternalServerError) + and error.description == InternalServerError.description + ): + error.description = "An Internal Server Error has occurred" + try: + return ( + render_template( + "errors/{}.html".format(error.code), + error=error.description, + ), + error.code, + ) + except jinja2.exceptions.TemplateNotFound: + return error.get_response() diff --git a/CTFd/events/__init__.py b/CTFd/events/__init__.py new file mode 100644 index 0000000..57c8cab --- /dev/null +++ b/CTFd/events/__init__.py @@ -0,0 +1,26 @@ +from flask import Blueprint, Response, current_app, stream_with_context + +from CTFd.models import db +from CTFd.utils import get_app_config +from CTFd.utils.decorators import authed_only, ratelimit + +events = Blueprint("events", __name__) + + +@events.route("/events") +@authed_only +@ratelimit(method="GET", limit=150, interval=60) +def subscribe(): + @stream_with_context + def gen(): + for event in current_app.events_manager.subscribe(): + yield str(event) + + enabled = get_app_config("SERVER_SENT_EVENTS") + if enabled is False: + return ("", 204) + + # Close the db session to avoid OperationalError with MySQL connection errors + db.session.close() + + return Response(gen(), mimetype="text/event-stream") diff --git a/CTFd/exceptions/__init__.py b/CTFd/exceptions/__init__.py new file mode 100644 index 0000000..9788f12 --- /dev/null +++ b/CTFd/exceptions/__init__.py @@ -0,0 +1,14 @@ +class UserNotFoundException(Exception): + pass + + +class UserTokenExpiredException(Exception): + pass + + +class TeamTokenExpiredException(Exception): + pass + + +class TeamTokenInvalidException(Exception): + pass diff --git a/CTFd/exceptions/challenges.py b/CTFd/exceptions/challenges.py new file mode 100644 index 0000000..9518bd4 --- /dev/null +++ b/CTFd/exceptions/challenges.py @@ -0,0 +1,10 @@ +class ChallengeCreateException(Exception): + pass + + +class ChallengeUpdateException(Exception): + pass + + +class ChallengeSolveException(Exception): + pass diff --git a/CTFd/exceptions/email.py b/CTFd/exceptions/email.py new file mode 100644 index 0000000..8e94d06 --- /dev/null +++ b/CTFd/exceptions/email.py @@ -0,0 +1,6 @@ +class UserConfirmTokenInvalidException(Exception): + pass + + +class UserResetPasswordTokenInvalidException(Exception): + pass diff --git a/CTFd/fonts/OFL.txt b/CTFd/fonts/OFL.txt new file mode 100644 index 0000000..2e76eef --- /dev/null +++ b/CTFd/fonts/OFL.txt @@ -0,0 +1,88 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/CTFd/fonts/OpenSans-Bold.ttf b/CTFd/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000..b7fadfa Binary files /dev/null and b/CTFd/fonts/OpenSans-Bold.ttf differ diff --git a/CTFd/forms/__init__.py b/CTFd/forms/__init__.py new file mode 100644 index 0000000..51809a2 --- /dev/null +++ b/CTFd/forms/__init__.py @@ -0,0 +1,51 @@ +from wtforms import Form +from wtforms.csrf.core import CSRF + + +class CTFdCSRF(CSRF): + def generate_csrf_token(self, csrf_token_field): + from flask import session + + return session.get("nonce") + + +class BaseForm(Form): + class Meta: + csrf = True + csrf_class = CTFdCSRF + csrf_field_name = "nonce" + + +class _FormsWrapper: + pass + + +Forms = _FormsWrapper() + +from CTFd.forms import auth # noqa: I001 isort:skip +from CTFd.forms import self # noqa: I001 isort:skip +from CTFd.forms import teams # noqa: I001 isort:skip +from CTFd.forms import setup # noqa: I001 isort:skip +from CTFd.forms import submissions # noqa: I001 isort:skip +from CTFd.forms import users # noqa: I001 isort:skip +from CTFd.forms import challenges # noqa: I001 isort:skip +from CTFd.forms import language # noqa: I001 isort:skip +from CTFd.forms import notifications # noqa: I001 isort:skip +from CTFd.forms import config # noqa: I001 isort:skip +from CTFd.forms import pages # noqa: I001 isort:skip +from CTFd.forms import awards # noqa: I001 isort:skip +from CTFd.forms import email # noqa: I001 isort:skip + +Forms.auth = auth +Forms.self = self +Forms.teams = teams +Forms.setup = setup +Forms.submissions = submissions +Forms.users = users +Forms.challenges = challenges +Forms.language = language +Forms.notifications = notifications +Forms.config = config +Forms.pages = pages +Forms.awards = awards +Forms.email = email diff --git a/CTFd/forms/auth.py b/CTFd/forms/auth.py new file mode 100644 index 0000000..6de356a --- /dev/null +++ b/CTFd/forms/auth.py @@ -0,0 +1,88 @@ +from flask_babel import lazy_gettext as _l +from wtforms import PasswordField, StringField +from wtforms.fields.html5 import EmailField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.forms.users import ( + attach_custom_user_fields, + attach_registration_code_field, + attach_user_bracket_field, + build_custom_user_fields, + build_registration_code_field, + build_user_bracket_field, +) +from CTFd.utils import get_config + + +def RegistrationForm(*args, **kwargs): + password_min_length = int(get_config("password_min_length", default=0)) + password_description = _l("Password used to log into your account") + if password_min_length: + password_description += _l( + f" (Must be at least {password_min_length} characters)" + ) + + class _RegistrationForm(BaseForm): + name = StringField( + _l("User Name"), + description="Your username on the site", + validators=[InputRequired()], + render_kw={"autofocus": True}, + ) + email = EmailField( + _l("Email"), + description="Never shown to the public", + validators=[InputRequired()], + ) + password = PasswordField( + _l("Password"), + description=password_description, + validators=[InputRequired()], + ) + submit = SubmitField(_l("Submit")) + + @property + def extra(self): + return ( + build_custom_user_fields( + self, include_entries=False, blacklisted_items=() + ) + + build_registration_code_field(self) + + build_user_bracket_field(self) + ) + + attach_custom_user_fields(_RegistrationForm) + attach_registration_code_field(_RegistrationForm) + attach_user_bracket_field(_RegistrationForm) + + return _RegistrationForm(*args, **kwargs) + + +class LoginForm(BaseForm): + name = StringField( + _l("User Name or Email"), + validators=[InputRequired()], + render_kw={"autofocus": True}, + ) + password = PasswordField(_l("Password"), validators=[InputRequired()]) + submit = SubmitField(_l("Submit")) + + +class ConfirmForm(BaseForm): + submit = SubmitField(_l("Send Confirmation Email")) + + +class ResetPasswordRequestForm(BaseForm): + email = EmailField( + _l("Email"), validators=[InputRequired()], render_kw={"autofocus": True} + ) + submit = SubmitField(_l("Submit")) + + +class ResetPasswordForm(BaseForm): + password = PasswordField( + _l("Password"), validators=[InputRequired()], render_kw={"autofocus": True} + ) + submit = SubmitField(_l("Submit")) diff --git a/CTFd/forms/awards.py b/CTFd/forms/awards.py new file mode 100644 index 0000000..32819fa --- /dev/null +++ b/CTFd/forms/awards.py @@ -0,0 +1,30 @@ +from wtforms import RadioField, StringField, TextAreaField +from wtforms.fields.html5 import IntegerField + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class AwardCreationForm(BaseForm): + name = StringField("Name") + value = IntegerField("Value") + category = StringField("Category") + description = TextAreaField("Description") + submit = SubmitField("Create") + icon = RadioField( + "Icon", + choices=[ + ("", "None"), + ("shield", "Shield"), + ("bug", "Bug"), + ("crown", "Crown"), + ("crosshairs", "Crosshairs"), + ("ban", "Ban"), + ("lightning", "Lightning"), + ("skull", "Skull"), + ("brain", "Brain"), + ("code", "Code"), + ("cowboy", "Cowboy"), + ("angry", "Angry"), + ], + ) diff --git a/CTFd/forms/challenges.py b/CTFd/forms/challenges.py new file mode 100644 index 0000000..e5b5a4c --- /dev/null +++ b/CTFd/forms/challenges.py @@ -0,0 +1,30 @@ +from wtforms import MultipleFileField, SelectField, StringField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class ChallengeSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("name", "Name"), + ("id", "ID"), + ("category", "Category"), + ("type", "Type"), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") + + +class ChallengeFilesUploadForm(BaseForm): + file = MultipleFileField( + "Upload Files", + description="Attach multiple files using Control+Click or Cmd+Click.", + validators=[InputRequired()], + ) + submit = SubmitField("Upload") diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py new file mode 100644 index 0000000..d182e62 --- /dev/null +++ b/CTFd/forms/config.py @@ -0,0 +1,344 @@ +from wtforms import BooleanField, FileField, SelectField, StringField, TextAreaField +from wtforms.fields.html5 import IntegerField, URLField +from wtforms.widgets.html5 import NumberInput + +from CTFd.constants.config import ( + AccountVisibilityTypes, + ChallengeVisibilityTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, +) +from CTFd.constants.email import ( + DEFAULT_PASSWORD_CHANGE_ALERT_BODY, + DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT, + DEFAULT_PASSWORD_RESET_BODY, + DEFAULT_PASSWORD_RESET_SUBJECT, + DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY, + DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT, + DEFAULT_USER_CREATION_EMAIL_BODY, + DEFAULT_USER_CREATION_EMAIL_SUBJECT, + DEFAULT_VERIFICATION_EMAIL_BODY, + DEFAULT_VERIFICATION_EMAIL_SUBJECT, +) +from CTFd.constants.languages import SELECT_LANGUAGE_LIST +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.utils.csv import get_dumpable_tables +from CTFd.utils.social import BASE_TEMPLATE + + +class ResetInstanceForm(BaseForm): + accounts = BooleanField( + "Accounts", + description="Deletes all user and team accounts and their associated information", + ) + submissions = BooleanField( + "Submissions", + description="Deletes all records that accounts gained points or took an action", + ) + challenges = BooleanField( + "Challenges", description="Deletes all challenges and associated data" + ) + pages = BooleanField( + "Pages", description="Deletes all pages and their associated files" + ) + notifications = BooleanField( + "Notifications", description="Deletes all notifications" + ) + submit = SubmitField("Reset CTF") + + +class AccountSettingsForm(BaseForm): + domain_whitelist = StringField( + "Email Domain Allowlist", + description="Comma-seperated list of allowable email domains which users can register under (e.g. examplectf.com, example.com, *.example.com)", + ) + domain_blacklist = StringField( + "Email Domain Blocklist", + description="Comma-seperated list of disallowed email domains which users cannot register under (e.g. examplectf.com, example.com, *.example.com)", + ) + team_creation = SelectField( + "Team Creation", + description="Control whether users can create their own teams (Teams mode only)", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + team_size = IntegerField( + widget=NumberInput(min=0), + description="Maximum number of users per team (Teams mode only)", + ) + num_teams = IntegerField( + "Maximum Number of Teams", + widget=NumberInput(min=0), + description="Maximum number of teams allowed to register with this CTF (Teams mode only)", + ) + num_users = IntegerField( + "Maximum Number of Users", + widget=NumberInput(min=0), + description="Maximum number of user accounts allowed to register with this CTF", + ) + password_min_length = IntegerField( + "Minimum Password Length for Users", + widget=NumberInput(min=0), + description="Minimum Password Length for Users", + ) + verify_emails = SelectField( + "Verify Emails", + description="Control whether users must confirm their email addresses before playing", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + team_disbanding = SelectField( + "Team Disbanding", + description="Control whether team captains are allowed to disband their own teams", + choices=[ + ("inactive_only", "Enabled for Inactive Teams"), + ("disabled", "Disabled"), + ], + default="inactive_only", + ) + name_changes = SelectField( + "Name Changes", + description="Control whether users and teams can change their names", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + incorrect_submissions_per_min = IntegerField( + "Incorrect Submissions per Minute", + widget=NumberInput(min=1), + description="Number of submissions allowed per minute for flag bruteforce protection (default: 10)", + ) + + submit = SubmitField("Update") + + +class ExportCSVForm(BaseForm): + table = SelectField("Database Table", choices=get_dumpable_tables()) + submit = SubmitField("Download CSV") + + +class ImportCSVForm(BaseForm): + csv_type = SelectField( + "CSV Type", + choices=[("users", "Users"), ("teams", "Teams"), ("challenges", "Challenges")], + description="Type of CSV data", + ) + csv_file = FileField("CSV File", description="CSV file contents") + + +class SocialSettingsForm(BaseForm): + social_shares = SelectField( + "Social Shares", + description="Enable or Disable social sharing links for challenge solves", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + social_share_solve_template = TextAreaField( + "Social Share Solve Template", + description="HTML for Share Template", + default=BASE_TEMPLATE, + ) + submit = SubmitField("Update") + + +class LegalSettingsForm(BaseForm): + tos_url = URLField( + "Terms of Service URL", + description="External URL to a Terms of Service document hosted elsewhere", + ) + tos_text = TextAreaField( + "Terms of Service", + description="Text shown on the Terms of Service page", + ) + privacy_url = URLField( + "Privacy Policy URL", + description="External URL to a Privacy Policy document hosted elsewhere", + ) + privacy_text = TextAreaField( + "Privacy Policy", + description="Text shown on the Privacy Policy page", + ) + submit = SubmitField("Update") + + +class ChallengeSettingsForm(BaseForm): + view_self_submissions = SelectField( + "View Self Submissions", + description="Allow users to view their previous submissions", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + max_attempts_behavior = SelectField( + "Max Attempts Behavior", + description="Set Max Attempts behavior to be a lockout or a timeout", + choices=[("lockout", "lockout"), ("timeout", "timeout")], + default="lockout", + ) + max_attempts_timeout = IntegerField( + "Max Attempts Timeout Duration", + description="How long the timeout lasts in seconds for max attempts (if set to timeout). Default is 300 seconds", + default=300, + ) + hints_free_public_access = SelectField( + "Hints Free Public Access", + description="Control whether users must be logged in to see free hints (hints without cost or requirements)", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + challenge_ratings = SelectField( + "Challenge Ratings", + description="Control who can see and submit challenge ratings", + choices=[ + ("public", "Public (users can submit ratings and see aggregated ratings)"), + ( + "private", + "Private (users can submit ratings but cannot see aggregated ratings)", + ), + ( + "disabled", + "Disabled (users cannot submit ratings or see aggregated ratings)", + ), + ], + default="public", + ) + + +class VisibilitySettingsForm(BaseForm): + challenge_visibility = SelectField( + "Challenge Visibility", + description="Control whether users must be logged in to see challenges", + choices=[ + (ChallengeVisibilityTypes.PUBLIC, "Public"), + (ChallengeVisibilityTypes.PRIVATE, "Private"), + (ChallengeVisibilityTypes.ADMINS, "Admins Only"), + ], + default=ChallengeVisibilityTypes.PRIVATE, + ) + account_visibility = SelectField( + "Account Visibility", + description="Control whether accounts (users & teams) are shown to everyone, only to authenticated users, or only to admins", + choices=[ + (AccountVisibilityTypes.PUBLIC, "Public"), + (AccountVisibilityTypes.PRIVATE, "Private"), + (AccountVisibilityTypes.ADMINS, "Admins Only"), + ], + default=AccountVisibilityTypes.PUBLIC, + ) + score_visibility = SelectField( + "Score Visibility", + description="Control whether solves/score are shown to the public, to logged in users, hidden to all non-admins, or only shown to admins", + choices=[ + (ScoreVisibilityTypes.PUBLIC, "Public"), + (ScoreVisibilityTypes.PRIVATE, "Private"), + (ScoreVisibilityTypes.HIDDEN, "Hidden"), + (ScoreVisibilityTypes.ADMINS, "Admins Only"), + ], + default=ScoreVisibilityTypes.PUBLIC, + ) + registration_visibility = SelectField( + "Registration Visibility", + description="Control whether registration is enabled for everyone or disabled", + choices=[ + (RegistrationVisibilityTypes.PUBLIC, "Public"), + (RegistrationVisibilityTypes.PRIVATE, "Private"), + (RegistrationVisibilityTypes.MLC, "MajorLeagueCyber Only"), + ], + default=RegistrationVisibilityTypes.PUBLIC, + ) + + +class LocalizationForm(BaseForm): + default_locale = SelectField( + "Default Language", + description="Language to use if a user does not specify a language in their account settings. By default, CTFd will auto-detect the user's preferred language.", + choices=SELECT_LANGUAGE_LIST, + ) + + +class EmailSettingsForm(BaseForm): + # Mail Server Settings + mailfrom_addr = StringField( + "Mail From Address", description="Email address used to send email" + ) + mail_server = StringField( + "Mail Server Address", + description="Change the email server used by CTFd to send email", + ) + mail_port = IntegerField( + "Mail Server Port", + widget=NumberInput(min=1, max=65535), + description="Mail Server Port", + ) + mail_useauth = BooleanField("Use Mail Server Username and Password") + mail_username = StringField("Username", description="Mail Server Account Username") + mail_password = StringField("Password", description="Mail Server Account Password") + mail_ssl = BooleanField("TLS/SSL") + mail_tls = BooleanField("STARTTLS") + + # Mailgun Settings (deprecated) + mailgun_base_url = StringField( + "Mailgun API Base URL", description="Mailgun API Base URL" + ) + mailgun_api_key = StringField("Mailgun API Key", description="Mailgun API Key") + + # Registration Email + successful_registration_email_subject = StringField( + "Subject", + description="Subject line for registration confirmation email", + default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_SUBJECT, + ) + successful_registration_email_body = TextAreaField( + "Body", + description="Email body sent to users after they've finished registering", + default=DEFAULT_SUCCESSFUL_REGISTRATION_EMAIL_BODY, + ) + + # Verification Email + verification_email_subject = StringField( + "Subject", + description="Subject line for account verification email", + default=DEFAULT_VERIFICATION_EMAIL_SUBJECT, + ) + verification_email_body = TextAreaField( + "Body", + description="Email body sent to users to confirm their account at the address they provided during registration", + default=DEFAULT_VERIFICATION_EMAIL_BODY, + ) + + # Account Details Email + user_creation_email_subject = StringField( + "Subject", + description="Subject line for new account details email", + default=DEFAULT_USER_CREATION_EMAIL_SUBJECT, + ) + user_creation_email_body = TextAreaField( + "Body", + description="Email body sent to new users (manually created by an admin) with their initial account details", + default=DEFAULT_USER_CREATION_EMAIL_BODY, + ) + + # Password Reset Email + password_reset_subject = StringField( + "Subject", + description="Subject line for password reset request email", + default=DEFAULT_PASSWORD_RESET_SUBJECT, + ) + password_reset_body = TextAreaField( + "Body", + description="Email body sent when a user requests a password reset", + default=DEFAULT_PASSWORD_RESET_BODY, + ) + + # Password Reset Confirmation Email + password_change_alert_subject = StringField( + "Subject", + description="Subject line for password reset confirmation email", + default=DEFAULT_PASSWORD_CHANGE_ALERT_SUBJECT, + ) + password_change_alert_body = TextAreaField( + "Body", + description="Email body sent when a user successfully resets their password", + default=DEFAULT_PASSWORD_CHANGE_ALERT_BODY, + ) + + submit = SubmitField("Update") diff --git a/CTFd/forms/email.py b/CTFd/forms/email.py new file mode 100644 index 0000000..889cfac --- /dev/null +++ b/CTFd/forms/email.py @@ -0,0 +1,10 @@ +from wtforms import TextAreaField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class SendEmailForm(BaseForm): + text = TextAreaField("Message", validators=[InputRequired()]) + submit = SubmitField("Send") diff --git a/CTFd/forms/fields.py b/CTFd/forms/fields.py new file mode 100644 index 0000000..4c0a5bc --- /dev/null +++ b/CTFd/forms/fields.py @@ -0,0 +1,17 @@ +from wtforms import SubmitField as _SubmitField + + +class SubmitField(_SubmitField): + """ + This custom SubmitField exists because wtforms is dumb. + + See https://github.com/wtforms/wtforms/issues/205, https://github.com/wtforms/wtforms/issues/36 + The .submit() handler in JS will break if the form has an input with the name or id of "submit" so submit fields need to be changed. + """ + + def __init__(self, *args, **kwargs): + name = kwargs.pop("name", "_submit") + super().__init__(*args, **kwargs) + if self.name == "submit" or name: + self.id = name + self.name = name diff --git a/CTFd/forms/language.py b/CTFd/forms/language.py new file mode 100644 index 0000000..00c7939 --- /dev/null +++ b/CTFd/forms/language.py @@ -0,0 +1,19 @@ +from wtforms import RadioField + +from CTFd.constants.languages import LANGUAGE_NAMES +from CTFd.forms import BaseForm + + +def LanguageForm(*args, **kwargs): + from CTFd.utils.user import get_locale + + class _LanguageForm(BaseForm): + """Language form for only switching langauge without rendering all profile settings""" + + language = RadioField( + "", + choices=LANGUAGE_NAMES.items(), + default=get_locale(), + ) + + return _LanguageForm(*args, **kwargs) diff --git a/CTFd/forms/notifications.py b/CTFd/forms/notifications.py new file mode 100644 index 0000000..c590b6d --- /dev/null +++ b/CTFd/forms/notifications.py @@ -0,0 +1,26 @@ +from wtforms import BooleanField, RadioField, StringField, TextAreaField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class NotificationForm(BaseForm): + title = StringField("Title", description="Notification title") + content = TextAreaField( + "Content", + description="Notification contents. Can consist of HTML and/or Markdown.", + ) + type = RadioField( + "Notification Type", + choices=[("toast", "Toast"), ("alert", "Alert"), ("background", "Background")], + default="toast", + description="What type of notification users receive", + validators=[InputRequired()], + ) + sound = BooleanField( + "Play Sound", + default=True, + description="Play sound for users when they receive the notification", + ) + submit = SubmitField("Submit") diff --git a/CTFd/forms/pages.py b/CTFd/forms/pages.py new file mode 100644 index 0000000..0417767 --- /dev/null +++ b/CTFd/forms/pages.py @@ -0,0 +1,48 @@ +from wtforms import ( + BooleanField, + HiddenField, + MultipleFileField, + SelectField, + StringField, + TextAreaField, +) +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm + + +class PageEditForm(BaseForm): + title = StringField( + "Title", description="This is the title shown on the navigation bar" + ) + route = StringField( + "Route", + description="This is the URL route that your page will be at (e.g. /page). You can also enter links to link to that page.", + ) + draft = BooleanField("Draft") + hidden = BooleanField("Hidden") + auth_required = BooleanField("Authentication Required") + content = TextAreaField("Content") + format = SelectField( + "Format", + choices=[("markdown", "Markdown"), ("html", "HTML")], + default="markdown", + validators=[InputRequired()], + description="The markup format used to render the page", + ) + link_target = SelectField( + "Target", + choices=[("", "Current Page"), ("_blank", "New Tab")], + default="", + validators=[], + description="Context to open page in", + ) + + +class PageFilesUploadForm(BaseForm): + file = MultipleFileField( + "Upload Files", + description="Attach multiple files using Control+Click or Cmd+Click.", + validators=[InputRequired()], + ) + type = HiddenField("Page Type", default="page", validators=[InputRequired()]) diff --git a/CTFd/forms/self.py b/CTFd/forms/self.py new file mode 100644 index 0000000..1394e3f --- /dev/null +++ b/CTFd/forms/self.py @@ -0,0 +1,61 @@ +from flask import session +from flask_babel import lazy_gettext as _l +from wtforms import PasswordField, SelectField, StringField, TextAreaField +from wtforms.fields.html5 import DateField, URLField + +from CTFd.constants.languages import SELECT_LANGUAGE_LIST +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.forms.users import ( + attach_custom_user_fields, + attach_user_bracket_field, + build_custom_user_fields, + build_user_bracket_field, +) +from CTFd.utils.countries import SELECT_COUNTRIES_LIST +from CTFd.utils.user import get_current_user, get_current_user_attrs + + +def SettingsForm(*args, **kwargs): + class _SettingsForm(BaseForm): + name = StringField(_l("User Name")) + email = StringField(_l("Email")) + language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST) + password = PasswordField(_l("Password")) + confirm = PasswordField(_l("Current Password")) + affiliation = StringField(_l("Affiliation")) + website = URLField(_l("Website")) + country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST) + submit = SubmitField(_l("Submit")) + + @property + def extra(self): + user = get_current_user_attrs() + fields_kwargs = _SettingsForm.get_field_kwargs() + return build_custom_user_fields( + self, + include_entries=True, + fields_kwargs=fields_kwargs, + field_entries_kwargs={"user_id": session["id"]}, + ) + build_user_bracket_field(self, value=user.bracket_id) + + @staticmethod + def get_field_kwargs(): + user = get_current_user() + field_kwargs = {"editable": True} + if user.filled_all_required_fields is False: + # Show all fields + field_kwargs = {} + return field_kwargs + + field_kwargs = _SettingsForm.get_field_kwargs() + attach_custom_user_fields(_SettingsForm, **field_kwargs) + attach_user_bracket_field(_SettingsForm) + + return _SettingsForm(*args, **kwargs) + + +class TokensForm(BaseForm): + expiration = DateField(_l("Expiration")) + description = TextAreaField("Usage Description") + submit = SubmitField(_l("Generate")) diff --git a/CTFd/forms/setup.py b/CTFd/forms/setup.py new file mode 100644 index 0000000..7a0d1f2 --- /dev/null +++ b/CTFd/forms/setup.py @@ -0,0 +1,157 @@ +from flask_babel import lazy_gettext as _l +from wtforms import ( + FileField, + HiddenField, + IntegerField, + PasswordField, + RadioField, + SelectField, + StringField, + TextAreaField, +) +from wtforms.fields.html5 import EmailField +from wtforms.validators import InputRequired +from wtforms.widgets.html5 import NumberInput + +from CTFd.constants.config import ( + AccountVisibilityTypes, + ChallengeVisibilityTypes, + RegistrationVisibilityTypes, + ScoreVisibilityTypes, +) +from CTFd.constants.themes import DEFAULT_THEME +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.utils.config import get_themes + + +class SetupForm(BaseForm): + ctf_name = StringField( + _l("Event Name"), description=_l("The name of your CTF event/workshop") + ) + ctf_description = TextAreaField( + _l("Event Description"), description=_l("Description for the CTF") + ) + user_mode = RadioField( + _l("User Mode"), + choices=[("teams", _l("Team Mode")), ("users", _l("User Mode"))], + default="teams", + description=_l( + "Controls whether users join together in teams to play (Team Mode) or play as themselves (User Mode)" + ), + validators=[InputRequired()], + ) + + name = StringField( + _l("Admin Username"), + description=_l("Your username for the administration account"), + validators=[InputRequired()], + ) + email = EmailField( + _l("Admin Email"), + description=_l("Your email address for the administration account"), + validators=[InputRequired()], + ) + password = PasswordField( + _l("Admin Password"), + description=_l("Your password for the administration account"), + validators=[InputRequired()], + ) + + ctf_logo = FileField( + _l("Logo"), + description=_l( + "Logo to use for the website instead of a CTF name. Used as the home page button. Optional." + ), + ) + ctf_banner = FileField( + _l("Banner"), description=_l("Banner to use for the homepage. Optional.") + ) + ctf_small_icon = FileField( + _l("Small Icon"), + description=_l( + "favicon used in user's browsers. Only PNGs accepted. Must be 32x32px. Optional." + ), + ) + ctf_theme = SelectField( + _l("Theme"), + description=_l("CTFd Theme to use. Can be changed later."), + choices=list(zip(get_themes(), get_themes())), + default=DEFAULT_THEME, + validators=[InputRequired()], + ) + theme_color = HiddenField( + _l("Theme Color"), + description=_l( + "Color used by theme to control aesthetics. Requires theme support. Optional." + ), + ) + + verify_emails = SelectField( + _l("Verify Emails"), + description="Control whether users must confirm their email addresses before participating", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="false", + ) + team_size = IntegerField( + widget=NumberInput(min=0), + description="Amount of users per team (Teams mode only) Optional.", + ) + challenge_visibility = SelectField( + "Challenge Visibility", + description="Control whether users must be logged in to see challenges", + choices=[ + (ChallengeVisibilityTypes.PUBLIC, "Public"), + (ChallengeVisibilityTypes.PRIVATE, "Private"), + (ChallengeVisibilityTypes.ADMINS, "Admins Only"), + ], + default=ChallengeVisibilityTypes.PRIVATE, + ) + account_visibility = SelectField( + "Account Visibility", + description="Control whether accounts (users & teams) are shown to everyone, only to authenticated users, or only to admins", + choices=[ + (AccountVisibilityTypes.PUBLIC, "Public"), + (AccountVisibilityTypes.PRIVATE, "Private"), + (AccountVisibilityTypes.ADMINS, "Admins Only"), + ], + default=AccountVisibilityTypes.PUBLIC, + ) + score_visibility = SelectField( + "Score Visibility", + description="Control whether solves/score are shown to the public, to logged in users, hidden to all non-admins, or only shown to admins", + choices=[ + (ScoreVisibilityTypes.PUBLIC, "Public"), + (ScoreVisibilityTypes.PRIVATE, "Private"), + (ScoreVisibilityTypes.HIDDEN, "Hidden"), + (ScoreVisibilityTypes.ADMINS, "Admins Only"), + ], + default=AccountVisibilityTypes.PUBLIC, + ) + registration_visibility = SelectField( + "Registration Visibility", + description="Control whether registration is enabled for everyone or disabled", + choices=[ + (RegistrationVisibilityTypes.PUBLIC, "Public"), + (RegistrationVisibilityTypes.PRIVATE, "Private"), + (RegistrationVisibilityTypes.MLC, "MajorLeagueCyber Only"), + ], + default=RegistrationVisibilityTypes.PUBLIC, + ) + + start = StringField( + _l("Start Time"), + description=_l("Time when your CTF is scheduled to start. Optional."), + ) + end = StringField( + _l("End Time"), + description=_l("Time when your CTF is scheduled to end. Optional."), + ) + + social_shares = SelectField( + _l("Social Shares"), + description="Control whether users can share links commemorating their challenge solves", + choices=[("true", "Enabled"), ("false", "Disabled")], + default="true", + ) + submit = SubmitField(_l("Finish")) diff --git a/CTFd/forms/submissions.py b/CTFd/forms/submissions.py new file mode 100644 index 0000000..5b8faeb --- /dev/null +++ b/CTFd/forms/submissions.py @@ -0,0 +1,22 @@ +from wtforms import SelectField, StringField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField + + +class SubmissionSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("provided", "Provided"), + ("id", "ID"), + ("account_id", "Account ID"), + ("challenge_id", "Challenge ID"), + ("challenge_name", "Challenge Name"), + ], + default="provided", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") diff --git a/CTFd/forms/teams.py b/CTFd/forms/teams.py new file mode 100644 index 0000000..3d3a255 --- /dev/null +++ b/CTFd/forms/teams.py @@ -0,0 +1,289 @@ +from flask_babel import lazy_gettext as _l +from wtforms import BooleanField, PasswordField, SelectField, StringField +from wtforms.fields.html5 import EmailField, URLField +from wtforms.validators import InputRequired + +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.models import Brackets, TeamFieldEntries, TeamFields +from CTFd.utils.countries import SELECT_COUNTRIES_LIST +from CTFd.utils.user import get_current_team + + +def build_team_bracket_field(form_cls, value=None): + field = getattr(form_cls, "bracket_id", None) # noqa B009 + if field: + field.field_type = "select" + field.process_data(value) + return [field] + else: + return [] + + +def attach_team_bracket_field(form_cls): + brackets = Brackets.query.filter_by(type="teams").all() + if brackets: + choices = [("", "")] + [ + (bracket.id, f"{bracket.name} - {bracket.description}") + for bracket in brackets + ] + select_field = SelectField( + "Bracket", + description="Competition bracket for your team", + choices=choices, + validators=[InputRequired()], + ) + setattr(form_cls, "bracket_id", select_field) # noqa B010 + + +def build_custom_team_fields( + form_cls, + include_entries=False, + fields_kwargs=None, + field_entries_kwargs=None, + blacklisted_items=(), +): + if fields_kwargs is None: + fields_kwargs = {} + if field_entries_kwargs is None: + field_entries_kwargs = {} + + fields = [] + new_fields = TeamFields.query.filter_by(**fields_kwargs).all() + user_fields = {} + + # Only include preexisting values if asked + if include_entries is True: + for f in TeamFieldEntries.query.filter_by(**field_entries_kwargs).all(): + user_fields[f.field_id] = f.value + + for field in new_fields: + if field.name.lower() in blacklisted_items: + continue + + form_field = getattr(form_cls, f"fields[{field.id}]") + + # Add the field_type to the field so we know how to render it + form_field.field_type = field.field_type + + # Only include preexisting values if asked + if include_entries is True: + initial = user_fields.get(field.id, "") + form_field.data = initial + if form_field.render_kw: + form_field.render_kw["data-initial"] = initial + else: + form_field.render_kw = {"data-initial": initial} + + fields.append(form_field) + return fields + + +def attach_custom_team_fields(form_cls, **kwargs): + new_fields = TeamFields.query.filter_by(**kwargs).all() + for field in new_fields: + validators = [] + if field.required: + validators.append(InputRequired()) + + if field.field_type == "text": + input_field = StringField( + field.name, description=field.description, validators=validators + ) + elif field.field_type == "boolean": + input_field = BooleanField( + field.name, description=field.description, validators=validators + ) + + setattr(form_cls, f"fields[{field.id}]", input_field) + + +class TeamJoinForm(BaseForm): + name = StringField(_l("Team Name"), validators=[InputRequired()]) + password = PasswordField(_l("Team Password"), validators=[InputRequired()]) + submit = SubmitField(_l("Join")) + + +def TeamRegisterForm(*args, **kwargs): + class _TeamRegisterForm(BaseForm): + name = StringField(_l("Team Name"), validators=[InputRequired()]) + password = PasswordField(_l("Team Password"), validators=[InputRequired()]) + submit = SubmitField(_l("Create")) + + @property + def extra(self): + return build_custom_team_fields( + self, include_entries=False, blacklisted_items=() + ) + build_team_bracket_field(self) + + attach_custom_team_fields(_TeamRegisterForm) + attach_team_bracket_field(_TeamRegisterForm) + return _TeamRegisterForm(*args, **kwargs) + + +def TeamSettingsForm(*args, **kwargs): + class _TeamSettingsForm(BaseForm): + name = StringField( + _l("Team Name"), + description=_l("Your team's public name shown to other competitors"), + ) + password = PasswordField( + _l("New Team Password"), description=_l("Set a new team join password") + ) + confirm = PasswordField( + _l("Confirm Current Team Password"), + description=_l( + "Provide your current team password (or your password) to update your team's password" + ), + ) + affiliation = StringField( + _l("Affiliation"), + description=_l( + "Your team's affiliation publicly shown to other competitors" + ), + ) + website = URLField( + _l("Website"), + description=_l("Your team's website publicly shown to other competitors"), + ) + country = SelectField( + _l("Country"), + choices=SELECT_COUNTRIES_LIST, + description=_l("Your team's country publicly shown to other competitors"), + ) + submit = SubmitField(_l("Submit")) + + @property + def extra(self): + fields_kwargs = _TeamSettingsForm.get_field_kwargs() + return build_custom_team_fields( + self, + include_entries=True, + fields_kwargs=fields_kwargs, + field_entries_kwargs={"team_id": self.obj.id}, + ) + + def get_field_kwargs(): + team = get_current_team() + field_kwargs = {"editable": True} + if team.filled_all_required_fields is False: + # Show all fields + field_kwargs = {} + return field_kwargs + + def __init__(self, *args, **kwargs): + """ + Custom init to persist the obj parameter to the rest of the form + """ + super().__init__(*args, **kwargs) + obj = kwargs.get("obj") + if obj: + self.obj = obj + + field_kwargs = _TeamSettingsForm.get_field_kwargs() + attach_custom_team_fields(_TeamSettingsForm, **field_kwargs) + + return _TeamSettingsForm(*args, **kwargs) + + +class TeamCaptainForm(BaseForm): + # Choices are populated dynamically at form creation time + captain_id = SelectField( + _l("Team Captain"), choices=[], validators=[InputRequired()] + ) + submit = SubmitField("Submit") + + +class TeamSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("name", "Name"), + ("id", "ID"), + ("affiliation", "Affiliation"), + ("website", "Website"), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") + + +class PublicTeamSearchForm(BaseForm): + field = SelectField( + _l("Search Field"), + choices=[ + ("name", _l("Name")), + ("affiliation", _l("Affiliation")), + ("website", _l("Website")), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField(_l("Parameter"), validators=[InputRequired()]) + submit = SubmitField(_l("Search")) + + +class TeamBaseForm(BaseForm): + name = StringField(_l("Team Name"), validators=[InputRequired()]) + email = EmailField(_l("Email")) + password = PasswordField(_l("Password")) + website = URLField(_l("Website")) + affiliation = StringField(_l("Affiliation")) + country = SelectField(_l("Country"), choices=SELECT_COUNTRIES_LIST) + hidden = BooleanField(_l("Hidden")) + banned = BooleanField(_l("Banned")) + submit = SubmitField(_l("Submit")) + + +def TeamCreateForm(*args, **kwargs): + class _TeamCreateForm(TeamBaseForm): + pass + + @property + def extra(self): + return build_custom_team_fields( + self, include_entries=False + ) + build_team_bracket_field(self) + + attach_custom_team_fields(_TeamCreateForm) + attach_team_bracket_field(_TeamCreateForm) + + return _TeamCreateForm(*args, **kwargs) + + +def TeamEditForm(*args, **kwargs): + class _TeamEditForm(TeamBaseForm): + pass + + @property + def extra(self): + return build_custom_team_fields( + self, + include_entries=True, + fields_kwargs=None, + field_entries_kwargs={"team_id": self.obj.id}, + ) + build_team_bracket_field(self, value=self.obj.bracket_id) + + def __init__(self, *args, **kwargs): + """ + Custom init to persist the obj parameter to the rest of the form + """ + super().__init__(*args, **kwargs) + obj = kwargs.get("obj") + if obj: + self.obj = obj + + attach_custom_team_fields(_TeamEditForm) + attach_team_bracket_field(_TeamEditForm) + + return _TeamEditForm(*args, **kwargs) + + +class TeamInviteForm(BaseForm): + link = URLField(_l("Invite Link")) + + +class TeamInviteJoinForm(BaseForm): + submit = SubmitField(_l("Join")) diff --git a/CTFd/forms/users.py b/CTFd/forms/users.py new file mode 100644 index 0000000..ab47d7e --- /dev/null +++ b/CTFd/forms/users.py @@ -0,0 +1,235 @@ +from flask_babel import lazy_gettext as _l +from wtforms import BooleanField, PasswordField, SelectField, StringField +from wtforms.fields.html5 import EmailField +from wtforms.validators import InputRequired + +from CTFd.constants.config import Configs +from CTFd.constants.languages import SELECT_LANGUAGE_LIST +from CTFd.forms import BaseForm +from CTFd.forms.fields import SubmitField +from CTFd.models import Brackets, UserFieldEntries, UserFields +from CTFd.utils.countries import SELECT_COUNTRIES_LIST + + +def build_custom_user_fields( + form_cls, + include_entries=False, + fields_kwargs=None, + field_entries_kwargs=None, + blacklisted_items=(), +): + """ + Function used to reinject values back into forms for accessing by themes + """ + if fields_kwargs is None: + fields_kwargs = {} + if field_entries_kwargs is None: + field_entries_kwargs = {} + + fields = [] + new_fields = UserFields.query.filter_by(**fields_kwargs).all() + user_fields = {} + + # Only include preexisting values if asked + if include_entries is True: + for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all(): + user_fields[f.field_id] = f.value + + for field in new_fields: + if field.name.lower() in blacklisted_items: + continue + + form_field = getattr(form_cls, f"fields[{field.id}]") + + # Add the field_type to the field so we know how to render it + form_field.field_type = field.field_type + + # Only include preexisting values if asked + if include_entries is True: + initial = user_fields.get(field.id, "") + form_field.data = initial + if form_field.render_kw: + form_field.render_kw["data-initial"] = initial + else: + form_field.render_kw = {"data-initial": initial} + + fields.append(form_field) + return fields + + +def attach_custom_user_fields(form_cls, **kwargs): + """ + Function used to attach form fields to wtforms. + Not really a great solution but is approved by wtforms. + + https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition + """ + new_fields = UserFields.query.filter_by(**kwargs).all() + for field in new_fields: + validators = [] + if field.required: + validators.append(InputRequired()) + + if field.field_type == "text": + input_field = StringField( + field.name, description=field.description, validators=validators + ) + elif field.field_type == "boolean": + input_field = BooleanField( + field.name, description=field.description, validators=validators + ) + + setattr(form_cls, f"fields[{field.id}]", input_field) + + +def build_registration_code_field(form_cls): + """ + Build the appropriate field so we can render it via the extra property. + Add field_type so Jinja knows how to render it. + """ + if Configs.registration_code: + field = getattr(form_cls, "registration_code", None) # noqa B009 + field.field_type = "text" + return [field] + else: + return [] + + +def attach_registration_code_field(form_cls): + """ + If we have a registration code required, we attach it to the form similar + to attach_custom_user_fields + """ + if Configs.registration_code: + setattr( # noqa B010 + form_cls, + "registration_code", + StringField( + "Registration Code", + description="Registration code required to create account", + validators=[InputRequired()], + ), + ) + + +def build_user_bracket_field(form_cls, value=None): + field = getattr(form_cls, "bracket_id", None) # noqa B009 + if field: + field.field_type = "select" + field.process_data(value) + return [field] + else: + return [] + + +def attach_user_bracket_field(form_cls): + brackets = Brackets.query.filter_by(type="users").all() + if brackets: + choices = [("", "")] + [ + (bracket.id, f"{bracket.name} - {bracket.description}") + for bracket in brackets + ] + select_field = SelectField( + _l("Bracket"), + description=_l("Competition bracket for your user"), + choices=choices, + validators=[InputRequired()], + ) + setattr(form_cls, "bracket_id", select_field) # noqa B010 + + +class UserSearchForm(BaseForm): + field = SelectField( + "Search Field", + choices=[ + ("name", "Name"), + ("id", "ID"), + ("email", "Email"), + ("affiliation", "Affiliation"), + ("website", "Website"), + ("ip", "IP Address"), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField("Parameter", validators=[InputRequired()]) + submit = SubmitField("Search") + + +class PublicUserSearchForm(BaseForm): + field = SelectField( + _l("Search Field"), + choices=[ + ("name", _l("Name")), + ("affiliation", _l("Affiliation")), + ("website", _l("Website")), + ], + default="name", + validators=[InputRequired()], + ) + q = StringField( + _l("Parameter"), + description=_l("Search for matching users"), + validators=[InputRequired()], + ) + submit = SubmitField(_l("Search")) + + +class UserBaseForm(BaseForm): + name = StringField("User Name", validators=[InputRequired()]) + email = EmailField("Email", validators=[InputRequired()]) + language = SelectField(_l("Language"), choices=SELECT_LANGUAGE_LIST) + password = PasswordField("Password") + website = StringField("Website") + affiliation = StringField("Affiliation") + country = SelectField("Country", choices=SELECT_COUNTRIES_LIST) + type = SelectField("Type", choices=[("user", "User"), ("admin", "Admin")]) + verified = BooleanField("Verified") + hidden = BooleanField("Hidden") + banned = BooleanField("Banned") + change_password = BooleanField("Require password change on next login") + submit = SubmitField("Submit") + + +def UserEditForm(*args, **kwargs): + class _UserEditForm(UserBaseForm): + pass + + @property + def extra(self): + return build_custom_user_fields( + self, + include_entries=True, + fields_kwargs=None, + field_entries_kwargs={"user_id": self.obj.id}, + ) + build_user_bracket_field(self, value=self.obj.bracket_id) + + def __init__(self, *args, **kwargs): + """ + Custom init to persist the obj parameter to the rest of the form + """ + super().__init__(*args, **kwargs) + obj = kwargs.get("obj") + if obj: + self.obj = obj + + attach_custom_user_fields(_UserEditForm) + attach_user_bracket_field(_UserEditForm) + + return _UserEditForm(*args, **kwargs) + + +def UserCreateForm(*args, **kwargs): + class _UserCreateForm(UserBaseForm): + notify = BooleanField("Email account credentials to user", default=True) + + @property + def extra(self): + return build_custom_user_fields( + self, include_entries=False + ) + build_user_bracket_field(self) + + attach_custom_user_fields(_UserCreateForm) + attach_user_bracket_field(_UserCreateForm) + + return _UserCreateForm(*args, **kwargs) diff --git a/CTFd/logs/.gitkeep b/CTFd/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py new file mode 100644 index 0000000..8ea3c1b --- /dev/null +++ b/CTFd/models/__init__.py @@ -0,0 +1,1195 @@ +import datetime +from collections import defaultdict + +from flask_marshmallow import Marshmallow +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import column_property, validates + +from CTFd.cache import cache + +db = SQLAlchemy() +ma = Marshmallow() + + +def get_class_by_tablename(tablename): + """Return class reference mapped to table. + https://stackoverflow.com/a/66666783 + + :param tablename: String with name of table. + :return: Class reference or None. + """ + classes = [] + for m in db.Model.registry.mappers: + c = m.class_ + if hasattr(c, "__tablename__") and c.__tablename__ == tablename: + classes.append(c) + + # We didn't find this class + if len(classes) == 0: + return None + # This is a class where we have only one possible candidate. + # It's either a top level class or a polymorphic class with a specific hardcoded table name + elif len(classes) == 1: + return classes[0] + # In this case we are dealing with a polymorphic table where all of the tables have the same table name. + # However for us to identify the parent class we can look for the class that defines the polymorphic_on arg + else: + for c in classes: + mapper_args = dict(c.__mapper_args__) + if mapper_args.get("polymorphic_on") is not None: + return c + + +@compiles(db.DateTime, "mysql") +def compile_datetime_mysql(_type, _compiler, **kw): + """ + This decorator makes the default db.DateTime class always enable fsp to enable millisecond precision + https://dev.mysql.com/doc/refman/5.7/en/fractional-seconds.html + https://docs.sqlalchemy.org/en/14/core/custom_types.html#overriding-type-compilation + """ + return "DATETIME(6)" + + +class Notifications(db.Model): + __tablename__ = "notifications" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.Text) + content = db.Column(db.Text) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) + + user = db.relationship("Users", foreign_keys="Notifications.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Notifications.team_id", lazy="select") + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content)) + + def __init__(self, *args, **kwargs): + super(Notifications, self).__init__(**kwargs) + + +class Pages(db.Model): + __tablename__ = "pages" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(80)) + route = db.Column(db.String(128), unique=True) + content = db.Column(db.Text) + draft = db.Column(db.Boolean) + hidden = db.Column(db.Boolean) + auth_required = db.Column(db.Boolean) + format = db.Column(db.String(80), default="markdown") + link_target = db.Column(db.String(80), nullable=True) + + files = db.relationship("PageFiles", backref="page") + + @property + def html(self): + from CTFd.utils.config.pages import build_html, build_markdown + + if self.format == "markdown": + return build_markdown(self.content) + elif self.format == "html": + return build_html(self.content) + else: + return build_markdown(self.content) + + def __init__(self, *args, **kwargs): + super(Pages, self).__init__(**kwargs) + + def __repr__(self): + return "".format(self.route) + + +class Challenges(db.Model): + __tablename__ = "challenges" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80)) + description = db.Column(db.Text) + attribution = db.Column(db.Text) + connection_info = db.Column(db.Text) + next_id = db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="SET NULL")) + max_attempts = db.Column(db.Integer, default=0) + value = db.Column(db.Integer) + category = db.Column(db.String(80)) + type = db.Column(db.String(80)) + state = db.Column(db.String(80), nullable=False, default="visible") + logic = db.Column(db.String(80), nullable=False, default="any") + initial = db.Column(db.Integer, nullable=True) + minimum = db.Column(db.Integer, nullable=True) + decay = db.Column(db.Integer, nullable=True) + function = db.Column(db.String(32), default="static") + + requirements = db.Column(db.JSON) + + files = db.relationship("ChallengeFiles", backref="challenge") + tags = db.relationship("Tags", backref="challenge") + hints = db.relationship("Hints", backref="challenge") + flags = db.relationship("Flags", backref="challenge") + comments = db.relationship("ChallengeComments", backref="challenge") + topics = db.relationship("ChallengeTopics", backref="challenge") + solution = db.relationship("Solutions", backref="challenge", uselist=False) + ratings = db.relationship("Ratings", backref="challenge") + + class alt_defaultdict(defaultdict): + """ + This slightly modified defaultdict is intended to allow SQLAlchemy to + not fail when querying Challenges that contain a missing challenge type. + + e.g. Challenges.query.all() should not fail if `type` is `a_missing_type` + """ + + def __missing__(self, key): + return self["standard"] + + __mapper_args__ = { + "polymorphic_identity": "standard", + "polymorphic_on": type, + "_polymorphic_map": alt_defaultdict(), + } + + @property + def byline(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.attribution)) + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.description)) + + @property + def solution_id(self): + if self.solution: + return self.solution.id + return None + + @property + def plugin_class(self): + from CTFd.plugins.challenges import get_chal_class + + return get_chal_class(self.type) + + def __init__(self, *args, **kwargs): + super(Challenges, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.name + + +class Hints(db.Model): + __tablename__ = "hints" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(80)) + type = db.Column(db.String(80), default="standard") + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + content = db.Column(db.Text) + cost = db.Column(db.Integer, default=0) + requirements = db.Column(db.JSON) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + @property + def name(self): + return "Hint {id}".format(id=self.id) + + @property + def category(self): + return self.__tablename__ + + @property + def description(self): + return "Hint for {name}".format(name=self.challenge.name) + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content)) + + @property + def prerequisites(self): + if self.requirements: + return self.requirements.get("prerequisites", []) + return [] + + def __init__(self, *args, **kwargs): + super(Hints, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.content + + +class Awards(db.Model): + __tablename__ = "awards" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + type = db.Column(db.String(80), default="standard") + name = db.Column(db.String(80)) + description = db.Column(db.Text) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + value = db.Column(db.Integer) + category = db.Column(db.String(80)) + icon = db.Column(db.Text) + requirements = db.Column(db.JSON) + + user = db.relationship("Users", foreign_keys="Awards.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Awards.team_id", lazy="select") + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.user_id + + def __init__(self, *args, **kwargs): + super(Awards, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.name + + +class Tags(db.Model): + __tablename__ = "tags" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + value = db.Column(db.String(80)) + + def __init__(self, *args, **kwargs): + super(Tags, self).__init__(**kwargs) + + +class Topics(db.Model): + __tablename__ = "topics" + id = db.Column(db.Integer, primary_key=True) + value = db.Column(db.String(255), unique=True) + + def __init__(self, *args, **kwargs): + super(Topics, self).__init__(**kwargs) + + +class ChallengeTopics(db.Model): + __tablename__ = "challenge_topics" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + topic_id = db.Column(db.Integer, db.ForeignKey("topics.id", ondelete="CASCADE")) + + topic = db.relationship( + "Topics", foreign_keys="ChallengeTopics.topic_id", lazy="select" + ) + + def __init__(self, *args, **kwargs): + super(ChallengeTopics, self).__init__(**kwargs) + + +class Solutions(db.Model): + __tablename__ = "solutions" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), unique=True + ) + content = db.Column(db.Text) + state = db.Column(db.String(80), nullable=False, default="hidden") + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content)) + + def __init__(self, *args, **kwargs): + super(Solutions, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.id + + +class Files(db.Model): + __tablename__ = "files" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(80), default="standard") + location = db.Column(db.Text) + sha1sum = db.Column(db.String(40)) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Files, self).__init__(**kwargs) + + def __repr__(self): + return "".format( + type=self.type, location=self.location + ) + + +class ChallengeFiles(Files): + __mapper_args__ = {"polymorphic_identity": "challenge"} + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + + def __init__(self, *args, **kwargs): + super(ChallengeFiles, self).__init__(**kwargs) + + +class PageFiles(Files): + __mapper_args__ = {"polymorphic_identity": "page"} + page_id = db.Column(db.Integer, db.ForeignKey("pages.id")) + + def __init__(self, *args, **kwargs): + super(PageFiles, self).__init__(**kwargs) + + +class SolutionFiles(Files): + __mapper_args__ = {"polymorphic_identity": "solution"} + solution_id = db.Column(db.Integer, db.ForeignKey("solutions.id")) + + +class Flags(db.Model): + __tablename__ = "flags" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + type = db.Column(db.String(80)) + content = db.Column(db.Text) + data = db.Column(db.Text) + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Flags, self).__init__(**kwargs) + + def __repr__(self): + return "".format(self.content, self.challenge_id) + + +class Users(db.Model): + __tablename__ = "users" + __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {}) + # Core attributes + id = db.Column(db.Integer, primary_key=True) + oauth_id = db.Column(db.Integer, unique=True) + # User names are not constrained to be unique to allow for official/unofficial teams. + name = db.Column(db.String(128)) + password = db.Column(db.String(128)) + email = db.Column(db.String(128), unique=True) + type = db.Column(db.String(80)) + secret = db.Column(db.String(128)) + + # Supplementary attributes + website = db.Column(db.String(128)) + affiliation = db.Column(db.String(128)) + country = db.Column(db.String(32)) + bracket_id = db.Column( + db.Integer, db.ForeignKey("brackets.id", ondelete="SET NULL") + ) + hidden = db.Column(db.Boolean, default=False) + banned = db.Column(db.Boolean, default=False) + verified = db.Column(db.Boolean, default=False) + language = db.Column(db.String(32), nullable=True, default=None) + change_password = db.Column(db.Boolean, default=False) + + # Relationship for Teams + team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) + + # Relationship for Brackets + bracket = db.relationship("Brackets", foreign_keys=[bracket_id], lazy="joined") + + field_entries = db.relationship( + "UserFieldEntries", + foreign_keys="UserFieldEntries.user_id", + lazy="joined", + back_populates="user", + ) + + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + __mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type} + + def __init__(self, **kwargs): + super(Users, self).__init__(**kwargs) + + @validates("password") + def validate_password(self, key, plaintext): + from CTFd.utils.crypto import hash_password + + return hash_password(str(plaintext)) + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.id + + @hybrid_property + def account(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team + elif user_mode == "users": + return self + + @property + def fields(self): + return self.get_fields(admin=False) + + @property + def solves(self): + return self.get_solves(admin=False) + + @property + def fails(self): + return self.get_fails(admin=False) + + @property + def awards(self): + return self.get_awards(admin=False) + + @property + def score(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_score(admin=False) + else: + return None + + @property + def place(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_place(admin=False) + else: + return None + + @property + def filled_all_required_fields(self): + required_user_fields = { + u.id + for u in UserFields.query.with_entities(UserFields.id) + .filter_by(required=True) + .all() + } + submitted_user_fields = { + u.field_id + for u in UserFieldEntries.query.with_entities(UserFieldEntries.field_id) + .filter_by(user_id=self.id) + .all() + } + # Require that users select a bracket + missing_bracket = ( + Brackets.query.filter_by(type="users").count() + and self.bracket_id is not None + ) + return required_user_fields.issubset(submitted_user_fields) and missing_bracket + + def get_fields(self, admin=False): + if admin: + return self.field_entries + + return [ + entry for entry in self.field_entries if entry.field.public and entry.value + ] + + def get_solves(self, admin=False): + from CTFd.utils import get_config + + solves = Solves.query.filter_by(user_id=self.id).order_by(Solves.date.desc()) + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + solves = solves.filter(Solves.date < dt) + return solves.all() + + def get_fails(self, admin=False): + from CTFd.utils import get_config + + fails = Fails.query.filter_by(user_id=self.id).order_by(Fails.date.desc()) + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + fails = fails.filter(Fails.date < dt) + return fails.all() + + def get_awards(self, admin=False): + from CTFd.utils import get_config + + awards = Awards.query.filter_by(user_id=self.id).order_by(Awards.date.desc()) + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + awards = awards.filter(Awards.date < dt) + return awards.all() + + @cache.memoize() + def get_score(self, admin=False): + score = db.func.sum(Challenges.value).label("score") + user = ( + db.session.query(Solves.user_id, score) + .join(Users, Solves.user_id == Users.id) + .join(Challenges, Solves.challenge_id == Challenges.id) + .filter(Users.id == self.id) + ) + + award_score = db.func.sum(Awards.value).label("award_score") + award = db.session.query(award_score).filter_by(user_id=self.id) + + if not admin: + freeze = Configs.query.filter_by(key="freeze").first() + if freeze and freeze.value: + freeze = int(freeze.value) + freeze = datetime.datetime.utcfromtimestamp(freeze) + user = user.filter(Solves.date < freeze) + award = award.filter(Awards.date < freeze) + + user = user.group_by(Solves.user_id).first() + award = award.first() + + if user and award: + return int(user.score or 0) + int(award.award_score or 0) + elif user: + return int(user.score or 0) + elif award: + return int(award.award_score or 0) + else: + return 0 + + @cache.memoize() + def get_place(self, admin=False, numeric=False): + """ + This method is generally a clone of CTFd.scoreboard.get_standings. + The point being that models.py must be self-reliant and have little + to no imports within the CTFd application as importing from the + application itself will result in a circular import. + """ + from CTFd.utils.humanize.numbers import ordinalize + from CTFd.utils.scores import get_user_standings + + standings = get_user_standings(admin=admin) + + for i, user in enumerate(standings): + if user.user_id == self.id: + n = i + 1 + if numeric: + return n + return ordinalize(n) + else: + return None + + +class Admins(Users): + __tablename__ = "admins" + __mapper_args__ = {"polymorphic_identity": "admin"} + + +class Teams(db.Model): + __tablename__ = "teams" + __table_args__ = (db.UniqueConstraint("id", "oauth_id"), {}) + # Core attributes + id = db.Column(db.Integer, primary_key=True) + oauth_id = db.Column(db.Integer, unique=True) + # Team names are not constrained to be unique to allow for official/unofficial teams. + name = db.Column(db.String(128)) + email = db.Column(db.String(128), unique=True) + password = db.Column(db.String(128)) + secret = db.Column(db.String(128)) + + members = db.relationship( + "Users", backref="team", foreign_keys="Users.team_id", lazy="joined" + ) + + # Supplementary attributes + website = db.Column(db.String(128)) + affiliation = db.Column(db.String(128)) + country = db.Column(db.String(32)) + bracket_id = db.Column( + db.Integer, db.ForeignKey("brackets.id", ondelete="SET NULL") + ) + hidden = db.Column(db.Boolean, default=False) + banned = db.Column(db.Boolean, default=False) + + # Relationship for Users + captain_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + captain = db.relationship("Users", foreign_keys=[captain_id]) + + # Relationship for Brackets + bracket = db.relationship("Brackets", foreign_keys=[bracket_id], lazy="joined") + + field_entries = db.relationship( + "TeamFieldEntries", + foreign_keys="TeamFieldEntries.team_id", + lazy="joined", + back_populates="team", + ) + + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + def __init__(self, **kwargs): + super(Teams, self).__init__(**kwargs) + + @validates("password") + def validate_password(self, key, plaintext): + from CTFd.utils.crypto import hash_password + + return hash_password(str(plaintext)) + + @property + def fields(self): + return self.get_fields(admin=False) + + @property + def solves(self): + return self.get_solves(admin=False) + + @property + def fails(self): + return self.get_fails(admin=False) + + @property + def awards(self): + return self.get_awards(admin=False) + + @property + def score(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_score(admin=False) + else: + return None + + @property + def place(self): + from CTFd.utils.config.visibility import scores_visible + + if scores_visible(): + return self.get_place(admin=False) + else: + return None + + @property + def filled_all_required_fields(self): + required_team_fields = { + u.id + for u in TeamFields.query.with_entities(TeamFields.id) + .filter_by(required=True) + .all() + } + submitted_team_fields = { + u.field_id + for u in TeamFieldEntries.query.with_entities(TeamFieldEntries.field_id) + .filter_by(team_id=self.id) + .all() + } + missing_bracket = ( + Brackets.query.filter_by(type="teams").count() + and self.bracket_id is not None + ) + return required_team_fields.issubset(submitted_team_fields) and missing_bracket + + def get_fields(self, admin=False): + if admin: + return self.field_entries + + return [ + entry for entry in self.field_entries if entry.field.public and entry.value + ] + + def get_invite_code(self): + from flask import current_app # noqa: I001 + + from CTFd.utils.security.signing import hmac, serialize + + secret_key = current_app.config["SECRET_KEY"] + if isinstance(secret_key, str): + secret_key = secret_key.encode("utf-8") + + verification_secret = secret_key + if self.password: + team_password_key = self.password.encode("utf-8") + verification_secret += team_password_key + + invite_object = { + "id": self.id, + "v": hmac(str(self.id), secret=verification_secret), + } + code = serialize(data=invite_object, secret=secret_key) + return code + + @classmethod + def load_invite_code(cls, code): + from flask import current_app # noqa: I001 + + from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException + from CTFd.utils.security.signing import ( + BadSignature, + BadTimeSignature, + hmac, + unserialize, + ) + + secret_key = current_app.config["SECRET_KEY"] + if isinstance(secret_key, str): + secret_key = secret_key.encode("utf-8") + + # Unserialize the invite code + try: + # Links expire after 1 day + invite_object = unserialize(code, max_age=86400) + except BadTimeSignature: + raise TeamTokenExpiredException + except BadSignature: + raise TeamTokenInvalidException + + # Load the team by the ID in the invite + team_id = invite_object["id"] + team = cls.query.filter_by(id=team_id).first_or_404() + + # Create the team specific secret + verification_secret = secret_key + if team.password: + team_password_key = team.password.encode("utf-8") + verification_secret += team_password_key + + # Verify the team verficiation code + verified = hmac(str(team.id), secret=verification_secret) == invite_object["v"] + if verified is False: + raise TeamTokenInvalidException + return team + + def get_solves(self, admin=False): + from CTFd.utils import get_config + + member_ids = [member.id for member in self.members] + + solves = Solves.query.filter(Solves.user_id.in_(member_ids)).order_by( + Solves.date.desc() + ) + + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + solves = solves.filter(Solves.date < dt) + + return solves.all() + + def get_fails(self, admin=False): + from CTFd.utils import get_config + + member_ids = [member.id for member in self.members] + + fails = Fails.query.filter(Fails.user_id.in_(member_ids)).order_by( + Fails.date.desc() + ) + + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + fails = fails.filter(Fails.date < dt) + + return fails.all() + + def get_awards(self, admin=False): + from CTFd.utils import get_config + + member_ids = [member.id for member in self.members] + + awards = Awards.query.filter(Awards.user_id.in_(member_ids)).order_by( + Awards.date.desc() + ) + + freeze = get_config("freeze") + if freeze and admin is False: + dt = datetime.datetime.utcfromtimestamp(freeze) + awards = awards.filter(Awards.date < dt) + + return awards.all() + + @cache.memoize() + def get_score(self, admin=False): + score = 0 + for member in self.members: + score += member.get_score(admin=admin) + return score + + @cache.memoize() + def get_place(self, admin=False, numeric=False): + """ + This method is generally a clone of CTFd.scoreboard.get_standings. + The point being that models.py must be self-reliant and have little + to no imports within the CTFd application as importing from the + application itself will result in a circular import. + """ + from CTFd.utils.humanize.numbers import ordinalize + from CTFd.utils.scores import get_team_standings # noqa: I001 + + standings = get_team_standings(admin=admin) + + for i, team in enumerate(standings): + if team.team_id == self.id: + n = i + 1 + if numeric: + return n + return ordinalize(n) + else: + return None + + +class Submissions(db.Model): + __tablename__ = "submissions" + id = db.Column(db.Integer, primary_key=True) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + ip = db.Column(db.String(46)) + provided = db.Column(db.Text) + type = db.Column(db.String(32)) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + # Relationships + user = db.relationship("Users", foreign_keys="Submissions.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Submissions.team_id", lazy="select") + challenge = db.relationship( + "Challenges", foreign_keys="Submissions.challenge_id", lazy="select" + ) + + __mapper_args__ = {"polymorphic_on": type} + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.user_id + + @hybrid_property + def account(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team + elif user_mode == "users": + return self.user + + @staticmethod + def get_child(type): + child_classes = { + x.polymorphic_identity: x.class_ + for x in Submissions.__mapper__.self_and_descendants + } + return child_classes[type] + + def __repr__(self): + return f"" + + +class Solves(Submissions): + __tablename__ = "solves" + __table_args__ = ( + db.UniqueConstraint("challenge_id", "user_id"), + db.UniqueConstraint("challenge_id", "team_id"), + {}, + ) + id = db.Column( + None, db.ForeignKey("submissions.id", ondelete="CASCADE"), primary_key=True + ) + challenge_id = column_property( + db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")), + Submissions.challenge_id, + ) + user_id = column_property( + db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")), + Submissions.user_id, + ) + team_id = column_property( + db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")), + Submissions.team_id, + ) + + user = db.relationship("Users", foreign_keys="Solves.user_id", lazy="select") + team = db.relationship("Teams", foreign_keys="Solves.team_id", lazy="select") + challenge = db.relationship( + "Challenges", foreign_keys="Solves.challenge_id", lazy="select" + ) + + __mapper_args__ = {"polymorphic_identity": "correct"} + + +class Fails(Submissions): + __mapper_args__ = {"polymorphic_identity": "incorrect"} + + +class Partials(Submissions): + __mapper_args__ = {"polymorphic_identity": "partial"} + + +class Discards(Submissions): + __mapper_args__ = {"polymorphic_identity": "discard"} + + +class Ratelimiteds(Submissions): + __mapper_args__ = {"polymorphic_identity": "ratelimited"} + + +class Unlocks(db.Model): + __tablename__ = "unlocks" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + target = db.Column(db.Integer) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + type = db.Column(db.String(32)) + + __mapper_args__ = {"polymorphic_on": type} + + @hybrid_property + def account_id(self): + from CTFd.utils import get_config + + user_mode = get_config("user_mode") + if user_mode == "teams": + return self.team_id + elif user_mode == "users": + return self.user_id + + def __repr__(self): + return "" % self.id + + +class HintUnlocks(Unlocks): + __mapper_args__ = {"polymorphic_identity": "hints"} + + +class SolutionUnlocks(Unlocks): + __mapper_args__ = {"polymorphic_identity": "solutions"} + + +class Tracking(db.Model): + __tablename__ = "tracking" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(32)) + ip = db.Column(db.String(46)) + target = db.Column(db.Integer, nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + user = db.relationship("Users", foreign_keys="Tracking.user_id", lazy="select") + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Tracking, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.ip + + +class Configs(db.Model): + __tablename__ = "config" + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.Text) + value = db.Column(db.Text) + + def __init__(self, *args, **kwargs): + super(Configs, self).__init__(**kwargs) + + +class Tokens(db.Model): + __tablename__ = "tokens" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(32)) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + created = db.Column(db.DateTime, default=datetime.datetime.utcnow) + expiration = db.Column( + db.DateTime, + default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30), + ) + description = db.Column(db.Text) + value = db.Column(db.String(128), unique=True) + + user = db.relationship("Users", foreign_keys="Tokens.user_id", lazy="select") + + __mapper_args__ = {"polymorphic_on": type} + + def __init__(self, *args, **kwargs): + super(Tokens, self).__init__(**kwargs) + + def __repr__(self): + return "" % self.id + + +class UserTokens(Tokens): + __mapper_args__ = {"polymorphic_identity": "user"} + + +class Comments(db.Model): + __tablename__ = "comments" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(80), default="standard") + content = db.Column(db.Text) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + author_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + author = db.relationship("Users", foreign_keys="Comments.author_id", lazy="select") + + @property + def html(self): + from CTFd.utils.config.pages import build_markdown + from CTFd.utils.helpers import markup + + return markup(build_markdown(self.content, sanitize=True)) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + +class ChallengeComments(Comments): + __mapper_args__ = {"polymorphic_identity": "challenge"} + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + + +class UserComments(Comments): + __mapper_args__ = {"polymorphic_identity": "user"} + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + + +class TeamComments(Comments): + __mapper_args__ = {"polymorphic_identity": "team"} + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + + +class PageComments(Comments): + __mapper_args__ = {"polymorphic_identity": "page"} + page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE")) + + +class Fields(db.Model): + __tablename__ = "fields" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.Text) + type = db.Column(db.String(80), default="standard") + field_type = db.Column(db.String(80)) + description = db.Column(db.Text) + required = db.Column(db.Boolean, default=False) + public = db.Column(db.Boolean, default=False) + editable = db.Column(db.Boolean, default=False) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + +class UserFields(Fields): + __mapper_args__ = {"polymorphic_identity": "user"} + + +class TeamFields(Fields): + __mapper_args__ = {"polymorphic_identity": "team"} + + +class FieldEntries(db.Model): + __tablename__ = "field_entries" + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(80), default="standard") + value = db.Column(db.JSON) + field_id = db.Column(db.Integer, db.ForeignKey("fields.id", ondelete="CASCADE")) + + field = db.relationship( + "Fields", foreign_keys="FieldEntries.field_id", lazy="joined" + ) + + __mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type} + + @hybrid_property + def name(self): + return self.field.name + + @hybrid_property + def description(self): + return self.field.description + + +class UserFieldEntries(FieldEntries): + __mapper_args__ = {"polymorphic_identity": "user"} + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + user = db.relationship( + "Users", foreign_keys="UserFieldEntries.user_id", back_populates="field_entries" + ) + + +class TeamFieldEntries(FieldEntries): + __mapper_args__ = {"polymorphic_identity": "team"} + team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")) + team = db.relationship( + "Teams", foreign_keys="TeamFieldEntries.team_id", back_populates="field_entries" + ) + + +class Brackets(db.Model): + __tablename__ = "brackets" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255)) + description = db.Column(db.Text) + type = db.Column(db.String(80)) + + +class Ratings(db.Model): + __tablename__ = "ratings" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) + challenge_id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") + ) + value = db.Column(db.Integer) + review = db.Column(db.String(2000), nullable=True) + date = db.Column(db.DateTime, default=datetime.datetime.utcnow) + + user = db.relationship("Users", foreign_keys="Ratings.user_id", lazy="select") + + # Ensure one rating per user per challenge + __table_args__ = (db.UniqueConstraint("user_id", "challenge_id"),) + + def __init__(self, *args, **kwargs): + super(Ratings, self).__init__(**kwargs) + + def __repr__(self): + return "".format( + self.user_id, self.challenge_id, self.value + ) diff --git a/CTFd/plugins/__init__.py b/CTFd/plugins/__init__.py new file mode 100644 index 0000000..93094dc --- /dev/null +++ b/CTFd/plugins/__init__.py @@ -0,0 +1,209 @@ +import glob +import importlib +import os +from collections import namedtuple + +from flask import current_app as app +from flask import send_file, send_from_directory, url_for + +from CTFd.utils.config.pages import get_pages +from CTFd.utils.decorators import admins_only as admins_only_wrapper +from CTFd.utils.plugins import override_template as utils_override_template +from CTFd.utils.plugins import ( + register_admin_script as utils_register_admin_plugin_script, +) +from CTFd.utils.plugins import ( + register_admin_stylesheet as utils_register_admin_plugin_stylesheet, +) +from CTFd.utils.plugins import register_script as utils_register_plugin_script +from CTFd.utils.plugins import register_stylesheet as utils_register_plugin_stylesheet + +Menu = namedtuple("Menu", ["title", "route", "link_target"]) + + +def register_plugin_assets_directory(app, base_path, admins_only=False, endpoint=None): + """ + Registers a directory to serve assets + + :param app: A CTFd application + :param string base_path: The path to the directory + :param boolean admins_only: Whether or not the assets served out of the directory should be accessible to the public + :return: + """ + base_path = base_path.strip("/") + if endpoint is None: + endpoint = base_path.replace("/", ".") + + def assets_handler(path): + return send_from_directory(base_path, path) + + rule = "/" + base_path + "/" + app.add_url_rule(rule=rule, endpoint=endpoint, view_func=assets_handler) + + +def register_plugin_asset(app, asset_path, admins_only=False, endpoint=None): + """ + Registers an file path to be served by CTFd + + :param app: A CTFd application + :param string asset_path: The path to the asset file + :param boolean admins_only: Whether or not this file should be accessible to the public + :return: + """ + asset_path = asset_path.strip("/") + if endpoint is None: + endpoint = asset_path.replace("/", ".") + + def asset_handler(): + return send_file(asset_path, max_age=3600) + + if admins_only: + asset_handler = admins_only_wrapper(asset_handler) + rule = "/" + asset_path + app.add_url_rule(rule=rule, endpoint=endpoint, view_func=asset_handler) + + +def override_template(*args, **kwargs): + """ + Overrides a template with the provided html content. + + e.g. override_template('scoreboard.html', '

scores

') + """ + utils_override_template(*args, **kwargs) + + +def register_plugin_script(*args, **kwargs): + """ + Adds a given script to the base.html template which all pages inherit from + """ + utils_register_plugin_script(*args, **kwargs) + + +def register_plugin_stylesheet(*args, **kwargs): + """ + Adds a given stylesheet to the base.html template which all pages inherit from. + """ + utils_register_plugin_stylesheet(*args, **kwargs) + + +def register_admin_plugin_script(*args, **kwargs): + """ + Adds a given script to the base.html of the admin theme which all admin pages inherit from + :param args: + :param kwargs: + :return: + """ + utils_register_admin_plugin_script(*args, **kwargs) + + +def register_admin_plugin_stylesheet(*args, **kwargs): + """ + Adds a given stylesheet to the base.html of the admin theme which all admin pages inherit from + :param args: + :param kwargs: + :return: + """ + utils_register_admin_plugin_stylesheet(*args, **kwargs) + + +def register_admin_plugin_menu_bar(title, route, link_target=None): + """ + Registers links on the Admin Panel menubar/navbar + + :param name: A string that is shown on the navbar HTML + :param route: A string that is the href used by the link + :return: + """ + am = Menu(title=title, route=route, link_target=link_target) + app.admin_plugin_menu_bar.append(am) + + +def get_admin_plugin_menu_bar(): + """ + Access the list used to store the plugin menu bar + + :return: Returns a list of Menu namedtuples. They have name, and route attributes. + """ + return app.admin_plugin_menu_bar + + +def register_user_page_menu_bar(title, route, link_target=None): + """ + Registers links on the User side menubar/navbar + + :param name: A string that is shown on the navbar HTML + :param route: A string that is the href used by the link + :return: + """ + p = Menu(title=title, route=route, link_target=link_target) + app.plugin_menu_bar.append(p) + + +def get_user_page_menu_bar(): + """ + Access the list used to store the user page menu bar + + :return: Returns a list of Menu namedtuples. They have name, and route attributes. + """ + pages = [] + for p in get_pages() + app.plugin_menu_bar: + if p.route.startswith("http"): + route = p.route + else: + route = url_for("views.static_html", route=p.route) + pages.append(Menu(title=p.title, route=route, link_target=p.link_target)) + return pages + + +def bypass_csrf_protection(f): + """ + Decorator that allows a route to bypass the need for a CSRF nonce on POST requests. + + This should be considered beta and may change in future versions. + + :param f: A function that needs to bypass CSRF protection + :return: Returns a function with the _bypass_csrf attribute set which tells CTFd to not require CSRF protection. + """ + f._bypass_csrf = True + return f + + +def get_plugin_names(): + modules = sorted(glob.glob(app.plugins_dir + "/*")) + blacklist = {"__pycache__"} + plugins = [] + for module in modules: + module_name = os.path.basename(module) + if os.path.isdir(module) and module_name not in blacklist: + plugins.append(module_name) + return plugins + + +def init_plugins(app): + """ + Searches for the load function in modules in the CTFd/plugins folder. This function is called with the current CTFd + app as a parameter. This allows CTFd plugins to modify CTFd's behavior. + + :param app: A CTFd application + :return: + """ + app.admin_plugin_scripts = [] + app.admin_plugin_stylesheets = [] + app.plugin_scripts = [] + app.plugin_stylesheets = [] + + app.admin_plugin_menu_bar = [] + app.plugin_menu_bar = [] + app.plugins_dir = os.path.dirname(__file__) + + if app.config.get("SAFE_MODE", False) is False: + for plugin in get_plugin_names(): + module = "." + plugin + module = importlib.import_module(module, package="CTFd.plugins") + module.load(app) + print(" * Loaded module, %s" % module) + else: + print("SAFE_MODE is enabled. Skipping plugin loading.") + + app.jinja_env.globals.update(get_admin_plugin_menu_bar=get_admin_plugin_menu_bar) + app.jinja_env.globals.update(get_user_page_menu_bar=get_user_page_menu_bar) diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py new file mode 100644 index 0000000..b1d3ba5 --- /dev/null +++ b/CTFd/plugins/challenges/__init__.py @@ -0,0 +1,342 @@ +from dataclasses import dataclass + +from flask import Blueprint +from sqlalchemy.exc import IntegrityError + +from CTFd.exceptions.challenges import ( + ChallengeCreateException, + ChallengeSolveException, + ChallengeUpdateException, +) +from CTFd.models import ( + ChallengeFiles, + Challenges, + Fails, + Flags, + Hints, + Partials, + Ratelimiteds, + Solves, + Tags, + db, +) +from CTFd.plugins import register_plugin_assets_directory +from CTFd.plugins.challenges.decay import DECAY_FUNCTIONS, logarithmic +from CTFd.plugins.challenges.logic import ( + challenge_attempt_all, + challenge_attempt_any, + challenge_attempt_team, +) +from CTFd.utils.uploads import delete_file +from CTFd.utils.user import get_ip + + +@dataclass +class ChallengeResponse: + status: str + message: str + + def __iter__(self): + """Allow tuple-like unpacking for backwards compatibility.""" + # TODO: CTFd 4.0 remove this behavior as we should move away from the tuple strategy + yield (True if self.status == "correct" else False) + yield self.message + + +def calculate_value(challenge): + f = DECAY_FUNCTIONS.get(challenge.function, logarithmic) + value = f(challenge) + + challenge.value = value + db.session.commit() + return challenge + + +class BaseChallenge(object): + id = None + name = None + templates = {} + scripts = {} + challenge_model = Challenges + + @classmethod + def create(cls, request): + """ + This method is used to process the challenge creation request. + + :param request: + :return: + """ + data = request.form or request.get_json() + + challenge = cls.challenge_model(**data) + + if challenge.function in DECAY_FUNCTIONS: + if data.get("value") and not data.get("initial"): + challenge.initial = data["value"] + + for attr in ("initial", "minimum", "decay"): + db.session.rollback() + if getattr(challenge, attr) is None: + raise ChallengeCreateException( + f"Missing '{attr}' but function is {challenge.function}" + ) + + db.session.add(challenge) + db.session.commit() + + # If the challenge is dynamic we should calculate a new value + if challenge.function in DECAY_FUNCTIONS: + return calculate_value(challenge) + + return challenge + + @classmethod + def read(cls, challenge): + """ + This method is in used to access the data of a challenge in a format processable by the front end. + + :param challenge: + :return: Challenge object, data dictionary to be returned to the user + """ + data = { + "id": challenge.id, + "name": challenge.name, + "value": challenge.value, + "description": challenge.description, + "attribution": challenge.attribution, + "connection_info": challenge.connection_info, + "next_id": challenge.next_id, + "category": challenge.category, + "state": challenge.state, + "max_attempts": challenge.max_attempts, + "logic": challenge.logic, + "initial": challenge.initial if challenge.function != "static" else None, + "decay": challenge.decay if challenge.function != "static" else None, + "minimum": challenge.minimum if challenge.function != "static" else None, + "function": challenge.function, + "type": challenge.type, + "type_data": { + "id": cls.id, + "name": cls.name, + "templates": cls.templates, + "scripts": cls.scripts, + }, + } + return data + + @classmethod + def update(cls, challenge, request): + """ + This method is used to update the information associated with a challenge. This should be kept strictly to the + Challenges table and any child tables. + + :param challenge: + :param request: + :return: + """ + data = request.form or request.get_json() + for attr, value in data.items(): + # We need to set these to floats so that the next operations don't operate on strings + if attr in ("initial", "minimum", "decay"): + try: + value = float(value) + except (ValueError, TypeError): + db.session.rollback() + raise ChallengeUpdateException(f"Invalid input for '{attr}'") + setattr(challenge, attr, value) + + for attr in ("initial", "minimum", "decay"): + if ( + challenge.function in DECAY_FUNCTIONS + and getattr(challenge, attr) is None + ): + db.session.rollback() + raise ChallengeUpdateException( + f"Missing '{attr}' but function is {challenge.function}" + ) + + db.session.commit() + + # If the challenge is dynamic we should calculate a new value + if challenge.function in DECAY_FUNCTIONS: + return calculate_value(challenge) + + # If we don't support dynamic we just don't do anything + return challenge + + @classmethod + def delete(cls, challenge): + """ + This method is used to delete the resources used by a challenge. + + :param challenge: + :return: + """ + Fails.query.filter_by(challenge_id=challenge.id).delete() + Solves.query.filter_by(challenge_id=challenge.id).delete() + Flags.query.filter_by(challenge_id=challenge.id).delete() + files = ChallengeFiles.query.filter_by(challenge_id=challenge.id).all() + for f in files: + delete_file(f.id) + ChallengeFiles.query.filter_by(challenge_id=challenge.id).delete() + Tags.query.filter_by(challenge_id=challenge.id).delete() + Hints.query.filter_by(challenge_id=challenge.id).delete() + Challenges.query.filter_by(id=challenge.id).delete() + cls.challenge_model.query.filter_by(id=challenge.id).delete() + db.session.commit() + + @classmethod + def attempt(cls, challenge, request): + """ + This method is used to check whether a given input is right or wrong. It does not make any changes and should + return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the + user's input from the request itself. + + :param challenge: The Challenge object from the database + :param request: The request the user submitted + :return: (boolean, string) + """ + data = request.form or request.get_json() + submission = data["submission"].strip() + + flags = Flags.query.filter_by(challenge_id=challenge.id).all() + + if challenge.logic == "any": + return challenge_attempt_any(submission, challenge, flags) + elif challenge.logic == "all": + return challenge_attempt_all(submission, challenge, flags) + elif challenge.logic == "team": + return challenge_attempt_team(submission, challenge, flags) + else: + return challenge_attempt_any(submission, challenge, flags) + + @classmethod + def partial(cls, user, team, challenge, request): + data = request.form or request.get_json() + submission = data["submission"].strip() + partial = Partials( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(req=request), + provided=submission, + ) + db.session.add(partial) + db.session.commit() + + @classmethod + def ratelimited(cls, user, team, challenge, request): + data = request.form or request.get_json() + submission = data["submission"].strip() + partial = Ratelimiteds( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(req=request), + provided=submission, + ) + db.session.add(partial) + db.session.commit() + + @classmethod + def solve(cls, user, team, challenge, request): + """ + This method is used to insert Solves into the database in order to mark a challenge as solved. + + :param team: The Team object from the database + :param chal: The Challenge object from the database + :param request: The request the user submitted + :return: + """ + data = request.form or request.get_json() + submission = data["submission"].strip() + + solve = Solves( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(req=request), + provided=submission, + ) + + try: + db.session.add(solve) + db.session.commit() + except IntegrityError as e: + db.session.rollback() + raise ChallengeSolveException( + f"Duplicate solve for user {user.id} on challenge {challenge.id}" + ) from e + + # If the challenge is dynamic we should calculate a new value + if challenge.function in DECAY_FUNCTIONS: + calculate_value(challenge) + + @classmethod + def fail(cls, user, team, challenge, request): + """ + This method is used to insert Fails into the database in order to mark an answer incorrect. + + :param team: The Team object from the database + :param chal: The Challenge object from the database + :param request: The request the user submitted + :return: + """ + data = request.form or request.get_json() + submission = data["submission"].strip() + wrong = Fails( + user_id=user.id, + team_id=team.id if team else None, + challenge_id=challenge.id, + ip=get_ip(request), + provided=submission, + ) + db.session.add(wrong) + db.session.commit() + + +class CTFdStandardChallenge(BaseChallenge): + id = "standard" # Unique identifier used to register challenges + name = "standard" # Name of a challenge type + templates = { # Templates used for each aspect of challenge editing & viewing + "create": "/plugins/challenges/assets/create.html", + "update": "/plugins/challenges/assets/update.html", + "view": "/plugins/challenges/assets/view.html", + } + scripts = { # Scripts that are loaded when a template is loaded + "create": "/plugins/challenges/assets/create.js", + "update": "/plugins/challenges/assets/update.js", + "view": "/plugins/challenges/assets/view.js", + } + # Route at which files are accessible. This must be registered using register_plugin_assets_directory() + route = "/plugins/challenges/assets/" + # Blueprint used to access the static_folder directory. + blueprint = Blueprint( + "standard", __name__, template_folder="templates", static_folder="assets" + ) + challenge_model = Challenges + + +def get_chal_class(class_id): + """ + Utility function used to get the corresponding class from a class ID. + + :param class_id: String representing the class ID + :return: Challenge class + """ + cls = CHALLENGE_CLASSES.get(class_id) + if cls is None: + raise KeyError + return cls + + +""" +Global dictionary used to hold all the Challenge Type classes used by CTFd. Insert into this dictionary to register +your Challenge Type. +""" +CHALLENGE_CLASSES = {"standard": CTFdStandardChallenge} + + +def load(app): + register_plugin_assets_directory(app, base_path="/plugins/challenges/assets/") diff --git a/CTFd/plugins/challenges/assets/create.html b/CTFd/plugins/challenges/assets/create.html new file mode 100644 index 0000000..dd985c3 --- /dev/null +++ b/CTFd/plugins/challenges/assets/create.html @@ -0,0 +1 @@ +{% extends "admin/challenges/create.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/create.js b/CTFd/plugins/challenges/assets/create.js new file mode 100644 index 0000000..bcfe9c3 --- /dev/null +++ b/CTFd/plugins/challenges/assets/create.js @@ -0,0 +1,4 @@ +CTFd.plugin.run((_CTFd) => { + const $ = _CTFd.lib.$ + const md = _CTFd.lib.markdown() +}) diff --git a/CTFd/plugins/challenges/assets/update.html b/CTFd/plugins/challenges/assets/update.html new file mode 100644 index 0000000..d097e1c --- /dev/null +++ b/CTFd/plugins/challenges/assets/update.html @@ -0,0 +1 @@ +{% extends "admin/challenges/update.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/update.js b/CTFd/plugins/challenges/assets/update.js new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/challenges/assets/view.html b/CTFd/plugins/challenges/assets/view.html new file mode 100644 index 0000000..ab623af --- /dev/null +++ b/CTFd/plugins/challenges/assets/view.html @@ -0,0 +1 @@ +{% extends "challenge.html" %} \ No newline at end of file diff --git a/CTFd/plugins/challenges/assets/view.js b/CTFd/plugins/challenges/assets/view.js new file mode 100644 index 0000000..b03a28e --- /dev/null +++ b/CTFd/plugins/challenges/assets/view.js @@ -0,0 +1,37 @@ +CTFd._internal.challenge.data = undefined; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.renderer = null; + +CTFd._internal.challenge.preRender = function() {}; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.render = null; + +CTFd._internal.challenge.postRender = function() {}; + +CTFd._internal.challenge.submit = function(preview) { + var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); + var submission = CTFd.lib.$("#challenge-input").val(); + + var body = { + challenge_id: challenge_id, + submission: submission + }; + var params = {}; + if (preview) { + params["preview"] = true; + } + + return CTFd.api.post_challenge_attempt(params, body).then(function(response) { + if (response.status === 429) { + // User was ratelimited but process response + return response; + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response; + } + return response; + }); +}; diff --git a/CTFd/plugins/challenges/decay.py b/CTFd/plugins/challenges/decay.py new file mode 100644 index 0000000..8db468f --- /dev/null +++ b/CTFd/plugins/challenges/decay.py @@ -0,0 +1,75 @@ +from __future__ import division # Use floating point for math calculations + +import math + +from CTFd.models import Solves +from CTFd.utils.modes import get_model + + +def get_solve_count(challenge): + Model = get_model() + + solve_count = ( + Solves.query.join(Model, Solves.account_id == Model.id) + .filter( + Solves.challenge_id == challenge.id, + Model.hidden == False, + Model.banned == False, + ) + .count() + ) + return solve_count + + +def linear(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + value = challenge.initial - (challenge.decay * solve_count) + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +def logarithmic(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + # Handle situations where admins have entered a 0 decay + # This is invalid as it can cause a division by zero + if challenge.decay == 0: + challenge.decay = 1 + + # It is important that this calculation takes into account floats. + # Hence this file uses from __future__ import division + value = ( + ((challenge.minimum - challenge.initial) / (challenge.decay**2)) + * (solve_count**2) + ) + challenge.initial + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +DECAY_FUNCTIONS = { + "linear": linear, + "logarithmic": logarithmic, +} diff --git a/CTFd/plugins/challenges/logic.py b/CTFd/plugins/challenges/logic.py new file mode 100644 index 0000000..b826065 --- /dev/null +++ b/CTFd/plugins/challenges/logic.py @@ -0,0 +1,130 @@ +from CTFd.models import Partials +from CTFd.plugins.flags import FlagException, get_flag_class +from CTFd.utils.config import is_teams_mode +from CTFd.utils.user import get_current_team, get_current_user + + +def challenge_attempt_any(submission, challenge, flags): + from CTFd.plugins.challenges import ChallengeResponse + + for flag in flags: + try: + if get_flag_class(flag.type).compare(flag, submission): + return ChallengeResponse( + status="correct", + message="Correct", + ) + except FlagException as e: + return ChallengeResponse( + status="incorrect", + message=str(e), + ) + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) + + +def challenge_attempt_all(submission, challenge, flags): + from CTFd.plugins.challenges import ChallengeResponse + + user = get_current_user() + partials = Partials.query.filter_by( + account_id=user.account_id, challenge_id=challenge.id + ).all() + provideds = [partial.provided for partial in partials] + provideds.append(submission) + + target_flags_ids = {flag.id for flag in flags} + compared_flag_ids = [] + + for flag in flags: + # Skip flags that we have already evaluated as captured + if flag.id in compared_flag_ids: + continue + flag_class = get_flag_class(flag.type) + for provided in provideds: + if flag_class.compare(flag, provided): + compared_flag_ids.append(flag.id) + + # If we have captured against all flag IDs the challenge is correct + if target_flags_ids == set(compared_flag_ids): + return ChallengeResponse( + status="correct", + message="Correct", + ) + + # If we didn't capture all flag IDs we must be missing something. + for flag in flags: + if get_flag_class(flag.type).compare(flag, submission): + return ChallengeResponse( + status="partial", + message="Correct but more flags are required", + ) + + # Input is just wrong + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) + + +def challenge_attempt_team(submission, challenge, flags): + from CTFd.plugins.challenges import ChallengeResponse + + if is_teams_mode(): + user = get_current_user() + team = get_current_team() + partials = Partials.query.filter_by( + team_id=team.id, challenge_id=challenge.id + ).all() + + submitter_ids = {partial.user_id for partial in partials} + + # Check if the user's submission is correct + for flag in flags: + try: + if get_flag_class(flag.type).compare(flag, submission): + submitter_ids.add(user.id) + break + except FlagException as e: + return ChallengeResponse( + status="incorrect", + message=str(e), + ) + else: + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) + + # The submission is correct so compare if we have received from all team members + member_ids = {member.id for member in team.members} + if member_ids == submitter_ids: + return ChallengeResponse( + status="correct", + message="Correct", + ) + else: + # We have not received from all members + return ChallengeResponse( + status="partial", + message="Correct but all team members must submit a flag", + ) + else: + for flag in flags: + try: + if get_flag_class(flag.type).compare(flag, submission): + return ChallengeResponse( + status="correct", + message="Correct", + ) + except FlagException as e: + return ChallengeResponse( + status="incorrect", + message=str(e), + ) + return ChallengeResponse( + status="incorrect", + message="Incorrect", + ) diff --git a/CTFd/plugins/ctfd-whale/.gitignore b/CTFd/plugins/ctfd-whale/.gitignore new file mode 100644 index 0000000..4a5bb25 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.DS_Store diff --git a/CTFd/plugins/ctfd-whale/CHANGELOG.md b/CTFd/plugins/ctfd-whale/CHANGELOG.md new file mode 100644 index 0000000..c2360a8 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +## 2020-03-18 + +- Allow non-dynamic flag. + +## 2020-02-18 + +- Refine front for ctfd newer version.(@frankli0324) + +## 2019-11-21 + +- Add network prefix & timeout setting. +- Refine port and network range search +- Refine frp request +- Refine lock timeout + +## 2019-11-08 + +- Add Lan Domain + +## 2019-11-04 + +- Change backend to Docker Swarm. +- Support depoly different os image to different os node. + +You should init docker swarm, and add your node to it. And name them with following command: + +``` +docker node update --label-add name=windows-1 **** +docker node update --label-add name=linux-1 **** +``` + +Name of them should begin with windows- or linux-. + +And put them in the setting panel. + +Then if you want to deploy a instance to windows node, You should tag your name with prefix "windows", like "glzjin/super_sql:windows". + +And please modify the container network driver to 'Overlay'! + +## 2019-10-30 + +- Optimize for multi worker. +- Try to fix concurrency request problem. + +Now You should set the redis with REDIS_HOST environment varible. + +## 2019-09-26 + +- Add frp http port setting. + +You should config it at the settings for http redirect. + +## 2019-09-15 + +- Add Container Network Setting and DNS Setting. + +Now You can setup a DNS Server in your Container Network. +- For single-instance network, Just connect your dns server to it and input the ip address in the seeting panel. +- For multi-instance network, You should rename the dns server to a name include "dns", than add it to auto connect instance. It will be used as a dns server. + +## 2019-09-14 + +- Refine plugin path. + +## 2019-09-13 + +- Refine removal. + +## 2019-08-29 + +- Add CPU usage limit. +- Allow the multi-image challenge. + +Upgrade: +1. Execute this SQL in ctfd database. + +``` +alter table dynamic_docker_challenge add column cpu_limit float default 0.5 after memory_limit; +``` + +2. Setting the containers you want to plugin to a single multi-image network. (In settings panel) + +3. When you create a challenge you can set the docker image like this + +``` +{"socks": "serjs/go-socks5-proxy", "web": "blog_revenge_blog", "mysql": "blog_revenge_mysql", "oauth": "blog_revenge_oauth"} +``` + +The first one will be redirected the traffic. diff --git a/CTFd/plugins/ctfd-whale/LICENSE b/CTFd/plugins/ctfd-whale/LICENSE new file mode 100644 index 0000000..97f7a6d --- /dev/null +++ b/CTFd/plugins/ctfd-whale/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 glzjin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/CTFd/plugins/ctfd-whale/README.md b/CTFd/plugins/ctfd-whale/README.md new file mode 100644 index 0000000..799b089 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/README.md @@ -0,0 +1,40 @@ +# CTFd-Whale + +## [中文README](README.zh-cn.md) + +A plugin that empowers CTFd to bring up separate environments for each user + +## Features + +- Deploys containers with `frp` and `docker swarm` +- Supports subdomain access by utilizing `frp` +- Contestants can start/renew/destroy their environments with a single click +- flags and subdomains are generated automatically with configurable rules +- Administrators can get a full list of running containers, with full control over them. + +## Installation & Usage + +refer to [installation guide](docs/install.md) + +## Demo + +[BUUCTF](https://buuoj.cn) + +## Third-party Introductions (zh-CN) + +- [CTFd-Whale 推荐部署实践](https://www.zhaoj.in/read-6333.html) +- [手把手教你如何建立一个支持ctf动态独立靶机的靶场(ctfd+ctfd-whale)](https://blog.csdn.net/fjh1997/article/details/100850756) + +## Screenshots + +![](https://user-images.githubusercontent.com/20221896/105939593-7cca6f80-6094-11eb-92de-8a04554dc019.png) + +![image](https://user-images.githubusercontent.com/20221896/105940182-a637cb00-6095-11eb-9525-8291986520c1.png) + +![](https://user-images.githubusercontent.com/20221896/105939965-2e69a080-6095-11eb-9b31-7777a0cc41b9.png) + +![](https://user-images.githubusercontent.com/20221896/105940026-50632300-6095-11eb-8512-6f19dd12c776.png) + +## Twin Project + +- [CTFd-Owl](https://github.com/D0g3-Lab/H1ve/tree/master/CTFd/plugins/ctfd-owl) (支持部署compose) diff --git a/CTFd/plugins/ctfd-whale/README.zh-cn.md b/CTFd/plugins/ctfd-whale/README.zh-cn.md new file mode 100644 index 0000000..d59c3e8 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/README.zh-cn.md @@ -0,0 +1,39 @@ +# CTFd-Whale + +能够支持题目容器化部署的CTFd插件 + +## 功能 + +- 利用`frp`与`docker swarm`做到多容器部署 +- web题目支持利用frp的subdomain实现每个用户单独的域名访问 +- 参赛选手一键启动题目环境,支持容器续期 +- 自动生成随机flag,并通过环境变量传入容器 +- 管理员可以在后台查看启动的容器 +- 支持自定义flag生成方式与web题目子域名生成方式 + +## 使用方式 + +请参考[安装指南](docs/install.zh-cn.md) + +## Demo + +[BUUCTF](https://buuoj.cn) + +## 第三方使用说明 + +- [CTFd-Whale 推荐部署实践](https://www.zhaoj.in/read-6333.html) +- [手把手教你如何建立一个支持ctf动态独立靶机的靶场(ctfd+ctfd-whale)](https://blog.csdn.net/fjh1997/article/details/100850756) + +## 使用案例 + +![](https://user-images.githubusercontent.com/20221896/105939593-7cca6f80-6094-11eb-92de-8a04554dc019.png) + +![image](https://user-images.githubusercontent.com/20221896/105940182-a637cb00-6095-11eb-9525-8291986520c1.png) + +![](https://user-images.githubusercontent.com/20221896/105939965-2e69a080-6095-11eb-9b31-7777a0cc41b9.png) + +![](https://user-images.githubusercontent.com/20221896/105940026-50632300-6095-11eb-8512-6f19dd12c776.png) + +## 友情链接 + +- [CTFd-Owl](https://github.com/D0g3-Lab/H1ve/tree/master/CTFd/plugins/ctfd-owl) (支持部署compose) diff --git a/CTFd/plugins/ctfd-whale/__init__.py b/CTFd/plugins/ctfd-whale/__init__.py new file mode 100644 index 0000000..5f94f95 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/__init__.py @@ -0,0 +1,124 @@ +import fcntl +import warnings + +import requests +from flask import Blueprint, render_template, session, current_app, request +from flask_apscheduler import APScheduler + +from CTFd.api import CTFd_API_v1 +from CTFd.plugins import ( + register_plugin_assets_directory, + register_admin_plugin_menu_bar, +) +from CTFd.plugins.challenges import CHALLENGE_CLASSES +from CTFd.utils import get_config, set_config +from CTFd.utils.decorators import admins_only + +from .api import user_namespace, admin_namespace, AdminContainers +from .challenge_type import DynamicValueDockerChallenge +from .utils.checks import WhaleChecks +from .utils.control import ControlUtil +from .utils.db import DBContainer +from .utils.docker import DockerUtils +from .utils.exceptions import WhaleWarning +from .utils.setup import setup_default_configs +from .utils.routers import Router + + +def load(app): + app.config['RESTX_ERROR_404_HELP'] = False + # upgrade() + plugin_name = __name__.split('.')[-1] + set_config('whale:plugin_name', plugin_name) + app.db.create_all() + if not get_config("whale:setup"): + setup_default_configs() + + register_plugin_assets_directory( + app, base_path=f"/plugins/{plugin_name}/assets", + endpoint='plugins.ctfd-whale.assets' + ) + register_admin_plugin_menu_bar( + title='Whale', + route='/plugins/ctfd-whale/admin/settings' + ) + + DynamicValueDockerChallenge.templates = { + "create": f"/plugins/{plugin_name}/assets/create.html", + "update": f"/plugins/{plugin_name}/assets/update.html", + "view": f"/plugins/{plugin_name}/assets/view.html", + } + DynamicValueDockerChallenge.scripts = { + "create": "/plugins/ctfd-whale/assets/create.js", + "update": "/plugins/ctfd-whale/assets/update.js", + "view": "/plugins/ctfd-whale/assets/view.js", + } + CHALLENGE_CLASSES["dynamic_docker"] = DynamicValueDockerChallenge + + page_blueprint = Blueprint( + "ctfd-whale", + __name__, + template_folder="templates", + static_folder="assets", + url_prefix="/plugins/ctfd-whale" + ) + CTFd_API_v1.add_namespace(admin_namespace, path="/plugins/ctfd-whale/admin") + CTFd_API_v1.add_namespace(user_namespace, path="/plugins/ctfd-whale") + + worker_config_commit = None + + @page_blueprint.route('/admin/settings') + @admins_only + def admin_list_configs(): + nonlocal worker_config_commit + errors = WhaleChecks.perform() + if not errors and get_config("whale:refresh") != worker_config_commit: + worker_config_commit = get_config("whale:refresh") + DockerUtils.init() + Router.reset() + set_config("whale:refresh", "false") + return render_template('whale_config.html', errors=errors) + + @page_blueprint.route("/admin/containers") + @admins_only + def admin_list_containers(): + result = AdminContainers.get() + view_mode = request.args.get('mode', session.get('view_mode', 'list')) + session['view_mode'] = view_mode + return render_template("whale_containers.html", + plugin_name=plugin_name, + containers=result['data']['containers'], + pages=result['data']['pages'], + curr_page=abs(request.args.get("page", 1, type=int)), + curr_page_start=result['data']['page_start']) + + def auto_clean_container(): + with app.app_context(): + results = DBContainer.get_all_expired_container() + for r in results: + ControlUtil.try_remove_container(r.user_id) + + app.register_blueprint(page_blueprint) + + try: + Router.check_availability() + DockerUtils.init() + except Exception: + warnings.warn("Initialization Failed. Please check your configs.", WhaleWarning) + + try: + lock_file = open("/tmp/ctfd_whale.lock", "w") + lock_fd = lock_file.fileno() + fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + scheduler = APScheduler() + scheduler.init_app(app) + scheduler.start() + scheduler.add_job( + id='whale-auto-clean', func=auto_clean_container, + trigger="interval", seconds=10 + ) + + print("[CTFd Whale] Started successfully") + except IOError: + pass diff --git a/CTFd/plugins/ctfd-whale/api.py b/CTFd/plugins/ctfd-whale/api.py new file mode 100644 index 0000000..3944939 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/api.py @@ -0,0 +1,138 @@ +from datetime import datetime + +from flask import request +from flask_restx import Namespace, Resource, abort + +from CTFd.utils import get_config +from CTFd.utils import user as current_user +from CTFd.utils.decorators import admins_only, authed_only + +from .decorators import challenge_visible, frequency_limited +from .utils.control import ControlUtil +from .utils.db import DBContainer +from .utils.routers import Router + +admin_namespace = Namespace("ctfd-whale-admin") +user_namespace = Namespace("ctfd-whale-user") + + +@admin_namespace.errorhandler +@user_namespace.errorhandler +def handle_default(err): + return { + 'success': False, + 'message': 'Unexpected things happened' + }, 500 + + +@admin_namespace.route('/container') +class AdminContainers(Resource): + @staticmethod + @admins_only + def get(): + page = abs(request.args.get("page", 1, type=int)) + results_per_page = abs(request.args.get("per_page", 20, type=int)) + page_start = results_per_page * (page - 1) + page_end = results_per_page * (page - 1) + results_per_page + + count = DBContainer.get_all_alive_container_count() + containers = DBContainer.get_all_alive_container_page( + page_start, page_end) + + return {'success': True, 'data': { + 'containers': containers, + 'total': count, + 'pages': int(count / results_per_page) + (count % results_per_page > 0), + 'page_start': page_start, + }} + + @staticmethod + @admins_only + def patch(): + user_id = request.args.get('user_id', -1) + result, message = ControlUtil.try_renew_container(user_id=int(user_id)) + if not result: + abort(403, message, success=False) + return {'success': True, 'message': message} + + @staticmethod + @admins_only + def delete(): + user_id = request.args.get('user_id') + result, message = ControlUtil.try_remove_container(user_id) + return {'success': result, 'message': message} + + +@user_namespace.route("/container") +class UserContainers(Resource): + @staticmethod + @authed_only + @challenge_visible + def get(): + user_id = current_user.get_current_user().id + challenge_id = request.args.get('challenge_id') + container = DBContainer.get_current_containers(user_id=user_id) + if not container: + return {'success': True, 'data': {}} + timeout = int(get_config("whale:docker_timeout", "3600")) + c = container.challenge # build a url for quick jump. todo: escape dash in categories and names. + link = f'{c.name}' + if int(container.challenge_id) != int(challenge_id): + return abort(403, f'Container already started but not from this challenge ({link})', success=False) + return { + 'success': True, + 'data': { + 'lan_domain': str(user_id) + "-" + container.uuid, + 'user_access': Router.access(container), + 'remaining_time': timeout - (datetime.now() - container.start_time).seconds, + } + } + + @staticmethod + @authed_only + @challenge_visible + @frequency_limited + def post(): + user_id = current_user.get_current_user().id + ControlUtil.try_remove_container(user_id) + + current_count = DBContainer.get_all_alive_container_count() + if int(get_config("whale:docker_max_container_count")) <= int(current_count): + abort(403, 'Max container count exceed.', success=False) + + challenge_id = request.args.get('challenge_id') + result, message = ControlUtil.try_add_container( + user_id=user_id, + challenge_id=challenge_id + ) + if not result: + abort(403, message, success=False) + return {'success': True, 'message': message} + + @staticmethod + @authed_only + @challenge_visible + @frequency_limited + def patch(): + user_id = current_user.get_current_user().id + challenge_id = request.args.get('challenge_id') + docker_max_renew_count = int(get_config("whale:docker_max_renew_count", 5)) + container = DBContainer.get_current_containers(user_id) + if container is None: + abort(403, 'Instance not found.', success=False) + if int(container.challenge_id) != int(challenge_id): + abort(403, f'Container started but not from this challenge({container.challenge.name})', success=False) + if container.renew_count >= docker_max_renew_count: + abort(403, 'Max renewal count exceed.', success=False) + result, message = ControlUtil.try_renew_container(user_id=user_id) + return {'success': result, 'message': message} + + @staticmethod + @authed_only + @frequency_limited + def delete(): + user_id = current_user.get_current_user().id + result, message = ControlUtil.try_remove_container(user_id) + if not result: + abort(403, message, success=False) + return {'success': True, 'message': message} diff --git a/CTFd/plugins/ctfd-whale/assets/config.js b/CTFd/plugins/ctfd-whale/assets/config.js new file mode 100644 index 0000000..34839c2 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/config.js @@ -0,0 +1,27 @@ +const $ = CTFd.lib.$; + +$(".config-section > form:not(.form-upload)").submit(async function (event) { + event.preventDefault(); + const obj = $(this).serializeJSON(); + const params = {}; + for (let x in obj) { + if (obj[x] === "true") { + params[x] = true; + } else if (obj[x] === "false") { + params[x] = false; + } else { + params[x] = obj[x]; + } + } + params['whale:refresh'] = btoa(+new Date).slice(-7, -2); + + await CTFd.api.patch_config_list({}, params); + location.reload(); +}); +$(".config-section > form:not(.form-upload) > div > div > div > #router-type").change(async function () { + await CTFd.api.patch_config_list({}, { + 'whale:router_type': $(this).val(), + 'whale:refresh': btoa(+new Date).slice(-7, -2), + }); + location.reload(); +}); diff --git a/CTFd/plugins/ctfd-whale/assets/containers.js b/CTFd/plugins/ctfd-whale/assets/containers.js new file mode 100644 index 0000000..d7ae0d9 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/containers.js @@ -0,0 +1,120 @@ +const $ = CTFd.lib.$; + +function htmlentities(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function copyToClipboard(event, str) { + // Select element + const el = document.createElement('textarea'); + el.value = str; + el.setAttribute('readonly', ''); + el.style.position = 'absolute'; + el.style.left = '-9999px'; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + + $(event.target).tooltip({ + title: "Copied!", + trigger: "manual" + }); + $(event.target).tooltip("show"); + + setTimeout(function () { + $(event.target).tooltip("hide"); + }, 1500); +} + +$(".click-copy").click(function (e) { + copyToClipboard(e, $(this).data("copy")); +}) + +async function delete_container(user_id) { + let response = await CTFd.fetch("/api/v1/plugins/ctfd-whale/admin/container?user_id=" + user_id, { + method: "DELETE", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json" + } + }); + response = await response.json(); + return response.success; +} +async function renew_container(user_id) { + let response = await CTFd.fetch( + "/api/v1/plugins/ctfd-whale/admin/container?user_id=" + user_id, { + method: "PATCH", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json" + } + }); + response = await response.json(); + return response.success; +} + +$('#containers-renew-button').click(function (e) { + let users = $("input[data-user-id]:checked").map(function () { + return $(this).data("user-id"); + }); + CTFd.ui.ezq.ezQuery({ + title: "Renew Containers", + body: `Are you sure you want to renew the selected ${users.length} container(s)?`, + success: async function () { + await Promise.all(users.toArray().map((user) => renew_container(user))); + location.reload(); + } + }); +}); + +$('#containers-delete-button').click(function (e) { + let users = $("input[data-user-id]:checked").map(function () { + return $(this).data("user-id"); + }); + CTFd.ui.ezq.ezQuery({ + title: "Delete Containers", + body: `Are you sure you want to delete the selected ${users.length} container(s)?`, + success: async function () { + await Promise.all(users.toArray().map((user) => delete_container(user))); + location.reload(); + } + }); +}); + +$(".delete-container").click(function (e) { + e.preventDefault(); + let container_id = $(this).attr("container-id"); + let user_id = $(this).attr("user-id"); + + CTFd.ui.ezq.ezQuery({ + title: "Destroy Container", + body: "Are you sure you want to delete Container #{0}?".format( + htmlentities(container_id) + ), + success: async function () { + await delete_container(user_id); + location.reload(); + } + }); +}); + +$(".renew-container").click(function (e) { + e.preventDefault(); + let container_id = $(this).attr("container-id"); + let user_id = $(this).attr("user-id"); + + CTFd.ui.ezq.ezQuery({ + title: "Renew Container", + body: "Are you sure you want to renew Container #{0}?".format( + htmlentities(container_id) + ), + success: async function () { + await renew_container(user_id); + location.reload(); + }, + }); +}); \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/create.html b/CTFd/plugins/ctfd-whale/assets/create.html new file mode 100644 index 0000000..459d667 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/create.html @@ -0,0 +1,100 @@ +{% extends "admin/challenges/create.html" %} + +{% block header %} + +{% endblock %} + + +{% block value %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+{% endblock %} + +{% block type %} + +{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/create.js b/CTFd/plugins/ctfd-whale/assets/create.js new file mode 100644 index 0000000..78ad73e --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/create.js @@ -0,0 +1,30 @@ +// Markdown Preview +if ($ === undefined) $ = CTFd.lib.$ +$('#desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#desc-preview') { + var editor_value = $('#desc-editor').val(); + $(event.target.hash).html( + CTFd._internal.challenge.render(editor_value) + ); + } +}); +$('#new-desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#new-desc-preview') { + var editor_value = $('#new-desc-editor').val(); + $(event.target.hash).html( + CTFd._internal.challenge.render(editor_value) + ); + } +}); +$("#solve-attempts-checkbox").change(function() { + if (this.checked) { + $('#solve-attempts-input').show(); + } else { + $('#solve-attempts-input').hide(); + $('#max_attempts').val(''); + } +}); + +$(document).ready(function() { + $('[data-toggle="tooltip"]').tooltip(); +}); \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/update.html b/CTFd/plugins/ctfd-whale/assets/update.html new file mode 100644 index 0000000..a3415d8 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/update.html @@ -0,0 +1,94 @@ +{% extends "admin/challenges/update.html" %} + +{% block value %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/update.js b/CTFd/plugins/ctfd-whale/assets/update.js new file mode 100644 index 0000000..a45103a --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/update.js @@ -0,0 +1,52 @@ +if ($ === undefined) $ = CTFd.lib.$ +$('#submit-key').click(function(e) { + submitkey($('#chalid').val(), $('#answer').val()) +}); + +$('#submit-keys').click(function(e) { + e.preventDefault(); + $('#update-keys').modal('hide'); +}); + +$('#limit_max_attempts').change(function() { + if (this.checked) { + $('#chal-attempts-group').show(); + } else { + $('#chal-attempts-group').hide(); + $('#chal-attempts-input').val(''); + } +}); + +// Markdown Preview +$('#desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#desc-preview') { + var editor_value = $('#desc-editor').val(); + $(event.target.hash).html( + window.challenge.render(editor_value) + ); + } +}); +$('#new-desc-edit').on('shown.bs.tab', function(event) { + if (event.target.hash == '#new-desc-preview') { + var editor_value = $('#new-desc-editor').val(); + $(event.target.hash).html( + window.challenge.render(editor_value) + ); + } +}); + +function loadchal(id, update) { + $.get(script_root + '/admin/chal/' + id, function(obj) { + $('#desc-write-link').click(); // Switch to Write tab + if (typeof update === 'undefined') + $('#update-challenge').modal(); + }); +} + +function openchal(id) { + loadchal(id); +} + +$(document).ready(function() { + $('[data-toggle="tooltip"]').tooltip(); +}); \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/assets/view.html b/CTFd/plugins/ctfd-whale/assets/view.html new file mode 100644 index 0000000..5803732 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/view.html @@ -0,0 +1,36 @@ +{% extends "challenge.html" %} + +{% block description %} +{{ challenge.html }} +
+
+
+
+
Instance Info
+ +
+
+
+
+
Instance Info
+
+ Remaining Time: s +
+
+ Lan Domain: +
+

+ + +
+
+
+
+{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/assets/view.js b/CTFd/plugins/ctfd-whale/assets/view.js new file mode 100644 index 0000000..ee572f7 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/assets/view.js @@ -0,0 +1,239 @@ +CTFd._internal.challenge.data = undefined + +CTFd._internal.challenge.renderer = null; + +CTFd._internal.challenge.preRender = function () { +} + +CTFd._internal.challenge.render = null; + +CTFd._internal.challenge.postRender = function () { + loadInfo(); +} + +if (window.$ === undefined) window.$ = CTFd.lib.$; + +function loadInfo() { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + CTFd.fetch(url, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (window.t !== undefined) { + clearInterval(window.t); + window.t = undefined; + } + if (response.success) response = response.data; + else CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + if (response.remaining_time != undefined) { + $('#whale-challenge-user-access').html(response.user_access); + $('#whale-challenge-lan-domain').html(response.lan_domain); + $('#whale-challenge-count-down').text(response.remaining_time); + $('#whale-panel-stopped').hide(); + $('#whale-panel-started').show(); + + window.t = setInterval(() => { + const c = $('#whale-challenge-count-down').text(); + if (!c) return; + let second = parseInt(c) - 1; + if (second <= 0) { + loadInfo(); + } + $('#whale-challenge-count-down').text(second); + }, 1000); + } else { + $('#whale-panel-started').hide(); + $('#whale-panel-stopped').show(); + } + }); +}; + +CTFd._internal.challenge.destroy = function () { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + $('#whale-button-destroy').text("Waiting..."); + $('#whale-button-destroy').prop('disabled', true); + + var params = {}; + + CTFd.fetch(url, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (response.success) { + loadInfo(); + CTFd._functions.events.eventAlert({ + title: "Success", + html: "Your instance has been destroyed!", + button: "OK" + }); + } else { + CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + } + }).finally(() => { + $('#whale-button-destroy').text("Destroy this instance"); + $('#whale-button-destroy').prop('disabled', false); + }); +}; + +CTFd._internal.challenge.renew = function () { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + $('#whale-button-renew').text("Waiting..."); + $('#whale-button-renew').prop('disabled', true); + + var params = {}; + + CTFd.fetch(url, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (response.success) { + loadInfo(); + CTFd._functions.events.eventAlert({ + title: "Success", + html: "Your instance has been renewed!", + button: "OK" + }); + } else { + CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + } + }).finally(() => { + $('#whale-button-renew').text("Renew this instance"); + $('#whale-button-renew').prop('disabled', false); + }); +}; + +CTFd._internal.challenge.boot = function () { + var challenge_id = CTFd._internal.challenge.data.id; + var url = "/api/v1/plugins/ctfd-whale/container?challenge_id=" + challenge_id; + + $('#whale-button-boot').text("Waiting..."); + $('#whale-button-boot').prop('disabled', true); + + var params = {}; + + CTFd.fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response.json(); + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response.json(); + } + return response.json(); + }).then(function (response) { + if (response.success) { + loadInfo(); + CTFd._functions.events.eventAlert({ + title: "Success", + html: "Your instance has been deployed!", + button: "OK" + }); + } else { + CTFd._functions.events.eventAlert({ + title: "Fail", + html: response.message, + button: "OK" + }); + } + }).finally(() => { + $('#whale-button-boot').text("Launch an instance"); + $('#whale-button-boot').prop('disabled', false); + }); +}; + + +CTFd._internal.challenge.submit = function (preview) { + var challenge_id = CTFd._internal.challenge.data.id; + var submission = $('#challenge-input').val() + + var body = { + 'challenge_id': challenge_id, + 'submission': submission, + } + var params = {} + if (preview) + params['preview'] = true + + return CTFd.api.post_challenge_attempt(params, body).then(function (response) { + if (response.status === 429) { + // User was ratelimited but process response + return response + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response + } + return response + }) +}; diff --git a/CTFd/plugins/ctfd-whale/challenge_type.py b/CTFd/plugins/ctfd-whale/challenge_type.py new file mode 100644 index 0000000..628e6df --- /dev/null +++ b/CTFd/plugins/ctfd-whale/challenge_type.py @@ -0,0 +1,108 @@ +from flask import Blueprint + +from CTFd.models import ( + db, + Flags, +) +from CTFd.plugins.challenges import BaseChallenge +from CTFd.plugins.dynamic_challenges import DynamicValueChallenge +from CTFd.plugins.flags import get_flag_class +from CTFd.utils import user as current_user +from .models import WhaleContainer, DynamicDockerChallenge +from .utils.control import ControlUtil + + +class DynamicValueDockerChallenge(BaseChallenge): + id = "dynamic_docker" # Unique identifier used to register challenges + name = "dynamic_docker" # Name of a challenge type + # Blueprint used to access the static_folder directory. + blueprint = Blueprint( + "ctfd-whale-challenge", + __name__, + template_folder="templates", + static_folder="assets", + ) + challenge_model = DynamicDockerChallenge + + @classmethod + def read(cls, challenge): + challenge = DynamicDockerChallenge.query.filter_by(id=challenge.id).first() + data = { + "id": challenge.id, + "name": challenge.name, + "value": challenge.value, + "initial": challenge.initial, + "decay": challenge.decay, + "minimum": challenge.minimum, + "description": challenge.description, + "category": challenge.category, + "state": challenge.state, + "max_attempts": challenge.max_attempts, + "type": challenge.type, + "type_data": { + "id": cls.id, + "name": cls.name, + "templates": cls.templates, + "scripts": cls.scripts, + }, + } + return data + + @classmethod + def update(cls, challenge, request): + data = request.form or request.get_json() + + for attr, value in data.items(): + # We need to set these to floats so that the next operations don't operate on strings + if attr in ("initial", "minimum", "decay"): + value = float(value) + if attr == 'dynamic_score': + value = int(value) + setattr(challenge, attr, value) + + if challenge.dynamic_score == 1: + return DynamicValueChallenge.calculate_value(challenge) + + db.session.commit() + return challenge + + @classmethod + def attempt(cls, challenge, request): + data = request.form or request.get_json() + submission = data["submission"].strip() + + flags = Flags.query.filter_by(challenge_id=challenge.id).all() + + if len(flags) > 0: + for flag in flags: + if get_flag_class(flag.type).compare(flag, submission): + return True, "Correct" + return False, "Incorrect" + else: + user_id = current_user.get_current_user().id + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.user_id == user_id) + q = q.filter(WhaleContainer.challenge_id == challenge.id) + records = q.all() + if len(records) == 0: + return False, "Please solve it during the container is running" + + container = records[0] + if container.flag == submission: + return True, "Correct" + return False, "Incorrect" + + @classmethod + def solve(cls, user, team, challenge, request): + super().solve(user, team, challenge, request) + + if challenge.dynamic_score == 1: + DynamicValueChallenge.calculate_value(challenge) + + @classmethod + def delete(cls, challenge): + for container in WhaleContainer.query.filter_by( + challenge_id=challenge.id + ).all(): + ControlUtil.try_remove_container(container.user_id) + super().delete(challenge) diff --git a/CTFd/plugins/ctfd-whale/decorators.py b/CTFd/plugins/ctfd-whale/decorators.py new file mode 100644 index 0000000..aab3fae --- /dev/null +++ b/CTFd/plugins/ctfd-whale/decorators.py @@ -0,0 +1,53 @@ +import functools +import time +from flask import request, current_app, session +from flask_restx import abort +from sqlalchemy.sql import and_ + +from CTFd.models import Challenges +from CTFd.utils.user import is_admin, get_current_user +from .utils.cache import CacheProvider + + +def challenge_visible(func): + @functools.wraps(func) + def _challenge_visible(*args, **kwargs): + challenge_id = request.args.get('challenge_id') + if is_admin(): + if not Challenges.query.filter( + Challenges.id == challenge_id + ).first(): + abort(404, 'no such challenge', success=False) + else: + if not Challenges.query.filter( + Challenges.id == challenge_id, + and_(Challenges.state != "hidden", Challenges.state != "locked"), + ).first(): + abort(403, 'challenge not visible', success=False) + return func(*args, **kwargs) + + return _challenge_visible + + +def frequency_limited(func): + @functools.wraps(func) + def _frequency_limited(*args, **kwargs): + if is_admin(): + return func(*args, **kwargs) + redis_util = CacheProvider(app=current_app, user_id=get_current_user().id) + if not redis_util.acquire_lock(): + abort(403, 'Request Too Fast!', success=False) + # last request was unsuccessful. this is for protection. + + if "limit" not in session: + session["limit"] = int(time.time()) + else: + if int(time.time()) - session["limit"] < 60: + abort(403, 'Frequency limit, You should wait at least 1 min.', success=False) + session["limit"] = int(time.time()) + + result = func(*args, **kwargs) + redis_util.release_lock() # if any exception is raised, lock will not be released + return result + + return _frequency_limited diff --git a/CTFd/plugins/ctfd-whale/docker-compose.example.yml b/CTFd/plugins/ctfd-whale/docker-compose.example.yml new file mode 100644 index 0000000..686f067 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docker-compose.example.yml @@ -0,0 +1,105 @@ +version: '3.7' + +services: + ctfd: + build: . + user: root + restart: always + ports: + - "8000:8000" + environment: + - UPLOAD_FOLDER=/var/uploads + - DATABASE_URL=mysql+pymysql://ctfd:ctfd@db/ctfd + - REDIS_URL=redis://cache:6379 + - WORKERS=1 + - LOG_FOLDER=/var/log/CTFd + - ACCESS_LOG=- + - ERROR_LOG=- + - REVERSE_PROXY=true + volumes: + - .data/CTFd/logs:/var/log/CTFd + - .data/CTFd/uploads:/var/uploads + - .:/opt/CTFd:ro + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - db + networks: + default: + internal: + + nginx: + image: nginx:1.17 + restart: always + volumes: + - ./conf/nginx/http.conf:/etc/nginx/nginx.conf + ports: + - 80:80 + depends_on: + - ctfd + + db: + image: mariadb:10.4.12 + restart: always + environment: + - MYSQL_ROOT_PASSWORD=ctfd + - MYSQL_USER=ctfd + - MYSQL_PASSWORD=ctfd + - MYSQL_DATABASE=ctfd + volumes: + - .data/mysql:/var/lib/mysql + networks: + internal: + # This command is required to set important mariadb defaults + command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --wait_timeout=28800, --log-warnings=0] + + cache: + image: redis:4 + restart: always + volumes: + - .data/redis:/data + networks: + internal: + + frpc: + image: frankli0324/frp:frpc + restart: always + command: [ + "--server_addr=frps", + "--server_port=7000", + "--token=your_token", + "--admin_addr=0.0.0.0", + "--admin_port=7000", + "--admin_user=frank", + "--admin_pwd=qwer", + ] + networks: + frp: + internal: + containers: + + frps: + image: frankli0324/frp:frps + restart: always + command: [ + "--bind_addr=0.0.0.0", + "--bind_port=7000", + "--token=your_token", + "--subdomain_host=127.0.0.1.nip.io", + "--vhost_http_port=8080", + ] + ports: + - 8080:8080 + networks: + frp: + default: + +networks: + default: + internal: + internal: true + frp: + internal: true + containers: + internal: true + driver: overlay + attachable: true diff --git a/CTFd/plugins/ctfd-whale/docs/advanced.md b/CTFd/plugins/ctfd-whale/docs/advanced.md new file mode 100644 index 0000000..596cc83 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/advanced.md @@ -0,0 +1,156 @@ +# Advanced deployment + +## Note + +Please make sure that you have experienced the installation process on single node. This deployment method is *NOT* recommended on first try. + +It would be easy for you to understand what we are going to do if you have some experience in using `docker` and `frp`. + +## Goal + +The goal of this advanced deployment is to deploy the CTFd and challenge containers on seperate machines for better experiences. + +Overall, `ctfd-whale` can be decomposed into three compnents: `CTFd`, challenge containers along with frpc, and frps itself. The three components can be deployed seperately or together to satisfy different needs. + +For example, if you're in a school or an organization that has a number of high-performance dedicated server *BUT* no public IP for public access, you can refer to this tutorial. + +Here are some options: + +* deploy frps on a server with public access +* deploy challenge containers on a seperate sever by joining the server into the swarm you created earlier +* deploy challenge containers on *rootless* docker +* deploy challenge containers on a remote server with public access, *securely* + +You could achieve the first option with little effort by deploying the frps on the server and configure frpc with a different `server_addr`. +In a swarm with multiple nodes, you can configure CTFd to start challenge containers on nodes you specifies randomly. Just make sure the node `whale` controlls is a `Leader`. This is not covered in this guide. You'll find it rather simple, even if you have zero experience on docker swarm. +The [Docker docs](https://docs.docker.com/engine/security/rootless/) have a detailed introduction on how to set up a rootless docker, so it's also not covered in this guide. + +In following paragraphs, the last option is introduced. + +## Architecture + +In this tutorial, we have 2 separate machines which we'll call them `web` and `target` server later. We will deploy CTFd on `web` and challenge containers (along with frp) on `target`. + +This picture shows a brief glance. + +![architecture](imgs/arch.png) + +--- + +### Operate on `target` server + +> root user is NOT recommended +> if you want to expose your docker deployment, you might also want to use [rootless docker](https://docs.docker.com/engine/security/rootless/) + +Please read the [Docker docs](https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket) thoroughly before continuing. + +Setup docker swarm and clone this repo as described in [installation](./install.md), then follow the steps described in the Docker docs to sign your certificates. + +> protect your certificates carefully +> one can take over the user running `dockerd` effortlessly with them +> and in most cases, the user is, unfortunately, root. + +You can now create a network for your challenges by executing + +```bash +docker network create --driver overlay --attachable challenges +``` + +Then setup frp on this machine. You might want to setup frps first: + +```bash +# change to the version you prefer +wget https://github.com/fatedier/frp/releases/download/v0.37.0/frp_0.37.0_linux_amd64.tar.gz +tar xzvf frp_0.37.0_linux_amd64.tar.gz +cd frp_0.37.0_linux_amd64 +mkdir /etc/frp +configure_frps frps.ini # refer to [installation](./install.md) +cp systemd/frps.service /etc/systemd/system +systemctl daemon-reload +systemctl enable frps +systemctl start frps +``` + +Then frpc. Frpc should be running in the same network with the challenge containers, so make sure you connect frpc to the network you just created. + +```bash +docker run -it --restart=always -d --network challenges -p 7400:7400 frankli0324/frp:frpc \ + --server_addr=host_ip:host_port \ + --server_port=7000 \ + --admin_addr=7400 \ + --admin_port=7400 \ + --admin_user=username \ + --admin_pwd=password \ + --token=your_token +``` + +You could use `docker-compose` for better experience. + +Here are some pitfalls or problems you might run into: + +#### working with `systemd` + +Copy the systemd service file to `/etc/systemd` in order to prevent it from being overwritten by future updates. + +```bash +cp /lib/systemd/system/docker.service /etc/systemd/system/docker.service +``` + +Locate `ExecStart` in the file and change it into something like this: + +```systemd +ExecStart=/usr/bin/dockerd \ + --tlsverify \ + --tlscacert=/etc/docker/certs/ca.pem \ + --tlscert=/etc/docker/certs/server-cert.pem \ + --tlskey=/etc/docker/certs/server-key.pem \ + -H tcp://0.0.0.0:2376 \ + -H unix:///var/run/docker.sock +``` + +Remember to reload `systemd` before restarting `docker.service` + +```bash +systemctl daemon-reload +systemctl restart docker +``` + +#### cloud service providers + +Most service providers provides you with a basic virus scanner in their system images, for example, AliCloud images comes with `YunDun`. You might want to disable it. The challenge containers often comes with backdoors, and is often accessed in a way cloud providers don't like (they are obviously attacks). + +#### certificate security + +Please follow the best practices when signing your certificates. If you gets used to signing both the client and server certicates on a single machine, you might run into troubles in the future. + +If you feel inconvenient, at least sign them on your personal computer, and transfer only the needed files to client/server. + +#### challenge networks and frpc + +You could create an internal network for challenges, but you have to connect frpc to a different network *with* internet in order to map the ports so that CTFd can access the admin interface. Also, make sure frps is accessible by frpc. + +### Operate on `web` server + +Map your client certificates into docker. You might want to use `docker secrets`. Remember where the files are *inside the container*. In the case which you use `docker secrets`, the directory is `/run/secrets`. + +You may also delete everything related to `frp` like `frp_network` since we are not going to run challenge containers on `web` server anymore. But if you just has one public IP for `web` server, you can leave `frps` service running. + +Then recreate your containers: + +```bash +docker-compose down # needed for removing unwanted networks +docker-compose up -d +``` + +Now you can configure CTFd accordingly. +Sample configurations: + +![whale-config1](imgs/whale-config1.png) +![whale-config2](imgs/whale-config2.png) +![whale-config3](imgs/whale-config3.png) + +refer to [installation](./install.md) for explanations. + +--- + +Now you can add a challenge to test it out. diff --git a/CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md b/CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md new file mode 100644 index 0000000..711fe7a --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/advanced.zh-cn.md @@ -0,0 +1,268 @@ +# 高级部署 + +## 前提 + +请确认你有过单机部署的经验,不建议第一次就搞这样分布架构 + +建议有一定Docker部署及操作经验者阅读此文档 + +在进行以下步骤之前,你需要先安装好ctfd-whale插件 + +## 目的 + +分离靶机与ctfd网站服务器,CTFd通过tls api远程调用docker + +## 架构 + +两台vps + +- 一台作为安装CTFd的网站服务器,称为 `web` ,需要公网IP +- 一台作为给选手下发容器的服务器,称为 `target` ,此文档用到的服务器是有公网IP的,但如果没有,可也在 `web` 服务器用 `frps` 做转发 + +本部署方式的架构如图所示 + +![架构](imgs/arch.png) + +--- + +## 配置Docker的安全API + +参考来源:[Docker官方文档](https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket) + + +### target服务器配置 + +建议切换到 `root` 用户操作 + +### 克隆本仓库 + +```bash +$ git clone https://github.com/frankli0324/ctfd-whale +``` + +### 开启docker swarm + +```bash +$ docker swarm init +$ docker node update --label-add "name=linux-target-1" $(docker node ls -q) +``` + +把 `name` 记住了,后面会用到 + +创建文件夹 +```bash +$ mkdir /etc/docker/certs && cd /etc/docker/certs +``` + +设置口令,需要输入2次 +```bash +$ openssl genrsa -aes256 -out ca-key.pem 4096 +``` + +用OpenSSL创建CA, 服务器, 客户端的keys +```bash +$ openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem +``` + +生成server证书,如果你的靶机服务器没有公网IP,内网IP理论上也是可以的,只要web服务器能访问到 +```bash +$ openssl genrsa -out server-key.pem 4096 +$ openssl req -subj "/CN=" -sha256 -new -key server-key.pem -out server.csr +``` + +配置白名单 +```bash +$ echo subjectAltName = IP:0.0.0.0,IP:127.0.0.1 >> extfile.cnf +``` + +将Docker守护程序密钥的扩展使用属性设置为仅用于服务器身份验证 +```bash +$ echo extendedKeyUsage = serverAuth >> extfile.cnf +``` + +生成签名证书,此处需要输入你之前设置的口令 +```bash +$ openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \ +-CAcreateserial -out server-cert.pem -extfile extfile.cnf +``` + +生成客户端(web服务器)访问用的 `key.pem` +```bash +$ openssl genrsa -out key.pem 4096 +``` + +生成 `client.csr` ,此处IP与之前生成server证书的IP相同 +```bash +$ openssl req -subj "/CN=" -new -key key.pem -out client.csr +``` + +创建扩展配置文件,把密钥设置为客户端身份验证用 +```bash +$ echo extendedKeyUsage = clientAuth > extfile-client.cnf +``` + +生成 `cert.pem` +```bash +$ openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \ +-CAcreateserial -out cert.pem -extfile extfile-client.cnf +``` + +删掉配置文件和两个证书的签名请求,不再需要 +```bash +$ rm -v client.csr server.csr extfile.cnf extfile-client.cnf +``` + +为了防止私钥文件被更改以及被其他用户查看,修改其权限为所有者只读 +```bash +$ chmod -v 0400 ca-key.pem key.pem server-key.pem +``` + +为了防止公钥文件被更改,修改其权限为只读 +```bash +$ chmod -v 0444 ca.pem server-cert.pem cert.pem +``` + +打包公钥 +```bash +$ tar cf certs.tar *.pem +``` + +修改Docker配置,使Docker守护程序可以接受来自提供CA信任的证书的客户端的连接 + +拷贝安装包单元文件到 `/etc` ,这样就不会因为docker升级而被覆盖 +```bash +$ cp /lib/systemd/system/docker.service /etc/systemd/system/docker.service +``` + +将第 `13` 行 +``` +ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock +``` +改为如下形式 +``` +ExecStart=/usr/bin/dockerd --tlsverify \ +--tlscacert=/etc/docker/certs/ca.pem \ +--tlscert=/etc/docker/certs/server-cert.pem \ +--tlskey=/etc/docker/certs/server-key.pem \ +-H tcp://0.0.0.0:2376 \ +-H unix:///var/run/docker.sock +``` + +重新加载daemon并重启docker +```bash +$ systemctl daemon-reload +$ systemctl restart docker +``` + +**注意保存好生成的密钥,任何持有密钥的用户都可以拥有target服务器的root权限** + +--- + +### Web服务器配置 + +在`root`用户下配置 + +```bash +$ cd CTFd +$ mkdir docker-certs +``` + +先把刚才打包好的公钥 `certs.tar` 复制到这台服务器上 + +然后解压 +```bash +$ tar xf certs.tar +``` + +打开 `CTFd` 项目的 `docker-compose.yml` ,在`CTFd` 服务的 `volumes` 下加一条 +``` +./docker-certs:/etc/docker/certs:ro +``` + +顺便把 `frp` 有关的**所有**配置项删掉,比如`frp_network`之类 + + +然后执行 `docker-compose up -d` + +打开`CTFd-whale`的配置网页,按照如下配置docker + +![whale-config1](imgs/whale-config1.png) + +注意事项 + +- `API URL` 一定要写成 `https://:` 的形式 +- `Swarm Nodes` 写初始化 `docker swarm` 时添加的 `lable name` +- `SSL CA Certificates` 等三个路径都是CTFd容器里的地址,不要和物理机的地址搞混了,如果你按照上一个步骤更改好了 `CTFd` 的 `docker-compose.yml` ,这里的地址照着填就好 + +对于单容器的题目,`Auto Connect Network` 中的网络地址为`_`,如果没有改动,则默认为 `whale-target_frp_containers` + +![whale-config2](imgs/whale-config2.png) + +*多容器题目配置 未测试* + +--- + +## FRP配置 + +### 添加泛解析域名,用于HTTP模式访问 + +可以是这样 +``` +*.example.com +*.sub.example.com (以此为例) +``` + +### 在target服务器上配置 + +进入 `whale-target` 文件夹 +```bash +$ cd ctfd-whale/whale-target +``` + +修改 `frp` 配置文件 +```bash +$ cp frp/frps.ini.example frp/frps.ini +$ cp frp/frpc.ini.example frp/frpc.ini +``` + +打开 `frp/frps.ini` + +- 修改 `token` 字段, 此token用于frpc与frps通信的验证 +- 此处因为frps和frpc在同一台服务器中,不改也行 +- 如果你的target服务器处于内网中,可以将 `frps` 放在 `web` 服务器中,这时token就可以长一些,比如[生成一个随机UUID](https://www.uuidgenerator.net/) +- 注意 `vhost_http_port` 与 [docker-compose.yml](/whale-target/docker-compose.yml) 里 `frps` 映射的端口相同 +- `subdomain_host` 是你做泛解析之后的域名,如果泛解析记录为`*.sub.example.com`, 则填入`sub.example.com` + + + +#### 打开 `frp/frpc.ini` + +- 修改 `token` 字段与 `frps.ini` 里的相同 + +- 修改 `admin_user` 与 `admin_pwd`字段, 用于 `frpc` 的 basic auth + +--- + +### 在WEB服务器上配置 + +打开whale的设置页面,按照如下配置参数 + +![frp配置页面](imgs/whale-config3.png) + +网页中, + +- `API URL` 需要按照 `http://user:password@ip:port` 的形式来设置 +- `Http Domain Suffix` 需要与 `frps.ini` 中的 `subdomain_host` 保持一致 +- `HTTP Port` 与 `frps.ini` 的 `vhost_http_port` 保持一致 +- `Direct Minimum Port` 与 `Direct Maximum Port` 与 `whale-target/docker-compose.yml` 中的段口范围保持一致 +- 当 API 设置成功后,whale 会自动获取`frpc.ini`的内容作为模板 + +--- + +至此,分离部署的whale应该就能用了,可以找个题目来测试一下,不过注意docker_dynamic类型的题目似乎不可以被删除,请注意不要让其他管理员把测试题公开 + +你可以用 +```bash +$ docker-compose logs +``` +来查看日志并调试,Ctrl-C退出 diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/arch.png b/CTFd/plugins/ctfd-whale/docs/imgs/arch.png new file mode 100644 index 0000000..f22d827 Binary files /dev/null and b/CTFd/plugins/ctfd-whale/docs/imgs/arch.png differ diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/whale-config1.png b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config1.png new file mode 100644 index 0000000..c416fe6 Binary files /dev/null and b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config1.png differ diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/whale-config2.png b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config2.png new file mode 100644 index 0000000..fae48aa Binary files /dev/null and b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config2.png differ diff --git a/CTFd/plugins/ctfd-whale/docs/imgs/whale-config3.png b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config3.png new file mode 100644 index 0000000..1dfec89 Binary files /dev/null and b/CTFd/plugins/ctfd-whale/docs/imgs/whale-config3.png differ diff --git a/CTFd/plugins/ctfd-whale/docs/install.md b/CTFd/plugins/ctfd-whale/docs/install.md new file mode 100644 index 0000000..93af127 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/install.md @@ -0,0 +1,304 @@ +# Installation & Usage Guide + +## TLDR + +If you never deployed a CTFd instance before: + +```sh +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh +docker swarm init +docker node update --label-add='name=linux-1' $(docker node ls -q) + +git clone https://github.com/CTFd/CTFd --depth=1 +git clone https://github.com/frankli0324/ctfd-whale CTFd/CTFd/plugins/ctfd-whale --depth=1 +curl -fsSL https://cdn.jsdelivr.net/gh/frankli0324/ctfd-whale/docker-compose.example.yml -o CTFd/docker-compose.yml + +# make sure you have pip3 installed on your rig +pip3 install docker-compose +docker-compose -f CTFd/docker-compose.yml up -d +# wait till the containers are ready +docker-compose -f CTFd/docker-compose.yml exec ctfd python manage.py set_config whale:auto_connect_network +``` + +The commands above tries to install `docker-ce`,`python3-pip` and `docker-compose`. Make sure the following requirements are satisfied before you execute them: + +* have `curl`, `git`, `python3` and `pip` installed +* GitHub is reachable +* Docker Registry is reachable + +## Installation + +### Start from scratch + +First of all, you should initialize a docker swarm and label the nodes + +names of nodes running linux/windows should begin with `linux/windows-*` + +```bash +docker swarm init +docker node update --label-add "name=linux-1" $(docker node ls -q) +``` + +Taken advantage of the orchestration ability of `docker swarm`, `ctfd-whale` is able to distribute challenge containers to different nodes(machines). Each time a user request for a challenge container, `ctfd-whale` will randomly pick a suitable node for running the container. + +After initializing a swarm, make sure that CTFd runs as expected on your PC/server + +Note that the included compose file in CTFd 2.5.0+ starts an nginx container by default, which takes the http/80 port. make sure there's no conflicts. + +```bash +git clone https://github.com/CTFd/CTFd --depth=1 +cd CTFd # the cwd will not change throughout this guide from this line on +``` + +Change the first line of `docker-compose.yml` to support `attachable` property + +`version '2'` -> `version '3'` + +```bash +docker-compose up -d +``` + +take a look at (or port 8000) and setup CTFd + +### Configure frps + +frps could be started by docker-compose along with CTFd + +define a network for communication between frpc and frps, and create a frps service block + +```yml +services: + ... + frps: + image: glzjin/frp + restart: always + volumes: + - ./conf/frp:/conf + entrypoint: + - /usr/local/bin/frps + - -c + - /conf/frps.ini + ports: + - 10000-10100:10000-10100 # for "direct" challenges + - 8001:8001 # for "http" challenges + networks: + default: # frps ports should be mapped to host + frp_connect: + +networks: + ... + frp_connect: + driver: overlay + internal: true + ipam: + config: + - subnet: 172.1.0.0/16 +``` + +Create a folder in `conf/` called `frp` + +```bash +mkdir ./conf/frp +``` + +then create a configuration file for frps `./conf/frp/frps.ini`, and fill it with: + +```ini +[common] +# following ports must not overlap with "direct" port range defined in the compose file +bind_port = 7987 # port for frpc to connect to +vhost_http_port = 8001 # port for mapping http challenges +token = your_token +subdomain_host = node3.buuoj.cn +# hostname that's mapped to frps by some reverse proxy (or IS frps itself) +``` + +### Configure frpc + +Likewise, create a network and a service for frpc + +the network allows challenges to be accessed by frpc + +```yml +services: + ... + frpc: + image: glzjin/frp:latest + restart: always + volumes: + - ./conf/frp:/conf/ + entrypoint: + - /usr/local/bin/frpc + - -c + - /conf/frpc.ini + depends_on: + - frps #need frps to run first + networks: + frp_containers: + frp_connect: + ipv4_address: 172.1.0.3 + +networks: + ... + frp_containers: # challenge containers are attached to this network + driver: overlay + internal: true + # if challenge containers are allowed to access the internet, remove this line + attachable: true + ipam: + config: + - subnet: 172.2.0.0/16 +``` + +Likewise, create an frpc config file `./conf/frp/frpc.ini` + +```ini +[common] +token = your_token +server_addr = frps +server_port = 7897 # == frps.bind_port +admin_addr = 172.1.0.3 # refer to "Security" +admin_port = 7400 +``` + +### Verify frp configurations + +update compose stack with `docker-compose up -d` + +by executing `docker-compose logs frpc`, you should see that frpc produced following logs: + +```log +[service.go:224] login to server success, get run id [******], server udp port [******] +[service.go:109] admin server listen on ****** +``` + +by seeing this, you can confirm that frpc/frps is set up correctly. + +Note: folder layout in this guide: + +``` +CTFd/ + conf/ + nginx/ # included in CTFd 2.5.0+ + frp/ + frpc.ini + frps.ini + serve.py <- this is just an anchor +``` + +### Configure CTFd + +After finishing everything above: + +* map docker socket into CTFd container +* Attach CTFd container to frp_connect + +```yml +services: + ctfd: + ... + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - frpc #need frpc to run ahead + networks: + ... + frp_connect: +``` + +and then clone Whale into CTFd plugins directory (yes, finally) + +```bash +git clone https://github.com/frankli0324/CTFd-Whale CTFd/plugins/ctfd-whale --depth=1 +docker-compose build # for pip to find requirements.txt +docker-compose up -d +``` + +go to the Whale Configuration page (`/plugins/ctfd-whale/admin/settings`) + +#### Docker related configs + +`Auto Connect Network`, if you strictly followed the guide, should be `ctfd_frp_containers` + +If you're not sure about that, this command lists all networks in the current stack + +```bash +docker network ls -f "label=com.docker.compose.project=ctfd" --format "{{.Name}}" +``` + +#### frp related configs + +* `HTTP Domain Suffix` should be consistent with `subdomain_host` in frps +* `HTTP Port` with `vhost_http_port` in frps +* `Direct IP Address` should be a hostname/ip address that can be used to access frps +* `Direct Minimum Port` and `Direct Maximum Port`, you know what to do +* as long as `API URL` is filled in correctly, Whale will read the config of the connected frpc into `Frpc config template` +* setting `Frpc config template` will override contents in `frpc.ini` + +Whale should be kinda usable at this moment. + +### Configure nginx + +If you are using CTFd 2.5.0+, you can utilize the included nginx. + +remove the port mapping rule for frps vhost http port(8001) in the compose file + +If you wnat to go deeper: + +* add nginx to `default` and `internal` network +* remove CTFd from `default` and remove the mapped 8000 port + +add following server block to `./conf/nginx/nginx.conf`: + +```conf +server { + listen 80; + server_name *.node3.buuoj.cn; + location / { + proxy_pass http://frps:8001; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + } +} +``` + +## Challenge Deployment + +### Standalone Containers + +Take a look at + +In one word, a `FLAG` variable will be passed into the container when it's started. You should write your own startup script (usually with bash and sed) to: + +* replace your flag with the generated flag +* remove or override the `FLAG` variable + +PLEASE create challenge images with care. + +### Grouped Containers + +"name" the challenge image with a json object, for example: + +```json +{ + "hostname": "image", +} +``` + +Whale will keep the order of the keys in the json object, and take the first image as the "main container" of a challenge. The "main container" will be mapped to frp with same rules from standalone containers + +see how grouped containers are created in the [code](utils/docker.py#L58) + +## Security + +* Please do not allow untrusted people to access the admin account. Theoretically there's an SSTI vulnerability in the config page. +* Do not set bind_addr of the frpc to `0.0.0.0` if you are following this guide. This may enable contestants to override frpc configurations. +* If you are annoyed by the complicated configuration, and you just want to set bind_addr = 0.0.0.0, remember to enable Basic Auth included in frpc, and set API URL accordingly, for example, `http://username:password@frpc:7400` + +## Advanced Deployment + +To separate the target server (for lunching instance) and CTFd web server with TLS secured docker API, please refer to [this document](advanced.md) \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/docs/install.zh-cn.md b/CTFd/plugins/ctfd-whale/docs/install.zh-cn.md new file mode 100644 index 0000000..5d82dfc --- /dev/null +++ b/CTFd/plugins/ctfd-whale/docs/install.zh-cn.md @@ -0,0 +1,313 @@ +# 使用指南 + +## TLDR + +如果你从未部署过CTFd,你可以通过执行: + +```sh +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh --mirror Aliyun +docker swarm init +docker node update --label-add='name=linux-1' $(docker node ls -q) + +git clone https://github.com/CTFd/CTFd --depth=1 +git clone https://github.com/frankli0324/ctfd-whale CTFd/CTFd/plugins/ctfd-whale --depth=1 +curl -fsSL https://cdn.jsdelivr.net/gh/frankli0324/ctfd-whale/docker-compose.example.yml -o CTFd/docker-compose.yml + +# make sure you have pip3 installed on your rig +pip3 install docker-compose +docker-compose -f CTFd/docker-compose.yml up -d +docker-compose -f CTFd/docker-compose.yml exec ctfd python manage.py +``` + +脚本会在一台Linux机器上安装 ***docker.com版本的*** `docker-ce`,`python3-pip` 以及 `docker-compose`,请确保执行上述代码之前: + +* 安装好curl,git,python3以及pip +* 网络环境良好,能正常从GitHub克隆仓库 +* 网络环境良好,能正常从Docker Registry拖取镜像 + +## 手动安装 + +为了更好地理解ctfd-whale各个组件的作用,更充分地利用ctfd-whale,在真实使用ctfd-whale时建议用户手动、完整地从空白CTFd开始搭建一个实例。下面本文将引导你完成整个流程。 + +### 从零开始 + +首先需要初始化一个swarm集群并给节点标注名称 + +linux节点名称需要以 `linux-` 打头,windows节点则以 `windows-` 打头 + +```bash +docker swarm init +docker node update --label-add "name=linux-1" $(docker node ls -q) +``` + +`ctfd-whale`利用`docker swarm`的集群管理能力,能够将题目容器分发到不同的节点上运行。选手每次请求启动题目容器时,`ctfd-whale`都将随机选择一个合适的节点运行这个题目容器。 + +然后,我们需要确保CTFd可以正常运行。 + +注意,2.5.0+版本CTFd的 `docker-compose.yml` 中包含了一个 `nginx` 反代,占用了80端口 + +```bash +git clone https://github.com/CTFd/CTFd --depth=1 +cd CTFd # 注:以下全部内容的cwd均为此目录 +``` + +先将 `docker-compose.yml` 的第一行进行修改,以支持 `attachable` 参数 + +`version '2'` -> `version '3'` + +接着 + +```bash +docker-compose up -d +``` + +访问(或8000端口),对CTFd进行初始配置 + +### 配置frps + +frps可以直接通过docker-compose与CTFd同步启动。 + +首先在networks中添加一个网络,用于frpc与frps之间的通信,并添加frps service + +```yml +services: + ... + frps: + image: glzjin/frp + restart: always + volumes: + - ./conf/frp:/conf + entrypoint: + - /usr/local/bin/frps + - -c + - /conf/frps.ini + ports: + - 10000-10100:10000-10100 # 映射direct类型题目的端口 + - 8001:8001 # 映射http类型题目的端口 + networks: + default: # 需要将frps暴露到公网以正常访问题目容器 + frp_connect: + +networks: + ... + frp_connect: + driver: overlay + internal: true + ipam: + config: + - subnet: 172.1.0.0/16 +``` + +先创建目录 `./conf/frp` + +```bash +mkdir ./conf/frp +``` + +接着创建 `./conf/frp/frps.ini` 文件,填写: + +```ini +[common] +# 下面两个端口注意不要与direct类型题目端口范围重合 +bind_port = 7987 # frpc 连接到 frps 的端口 +vhost_http_port = 8001 # frps 映射http类型题目的端口 +token = your_token +subdomain_host = node3.buuoj.cn # 访问http题目容器的主机名 +``` + +### 配置frpc + +同样,在networks中再添加一个网络,用于frpc与题目容器之间的通信,并添加frpc service + +```yml +services: + ... + frpc: + image: glzjin/frp:latest + restart: always + volumes: + - ./conf/frp:/conf/ + entrypoint: + - /usr/local/bin/frpc + - -c + - /conf/frpc.ini + depends_on: + - frps #frps需要先成功运行 + networks: + frp_containers: # 供frpc访问题目容器 + frp_connect: # 供frpc访问frps, CTFd访问frpc + ipv4_address: 172.1.0.3 + +networks: + ... + frp_containers: + driver: overlay + internal: true # 如果允许题目容器访问外网,则可以去掉 + attachable: true + ipam: + config: + - subnet: 172.2.0.0/16 +``` + +同样,我们需要创建一个 `./conf/frp/frpc.ini` + +```ini +[common] +token = your_token +server_addr = frps +server_port = 7897 # 对应 frps 的 bind_port +admin_addr = 172.1.0.3 # 请参考“安全事项” +admin_port = 7400 +``` + +### 检查frp配置是否正确 + +此时可以执行 `docker-compose up -d` 更新compose配置 + +通过查看日志 `docker-compose logs frpc` ,应当能看到frpc产生了以下日志: + +```log +[service.go:224] login to server success, get run id [******], server udp port [******] +[service.go:109] admin server listen on ****** +``` + +说明frpc与frps皆配置正常 + +注:此例中目录结构为: + +``` +CTFd/ + conf/ + nginx # CTFd 2.5.0+中自带 + frp/ + frpc.ini + frps.ini + serve.py +``` + +### 配置CTFd + +前面的工作完成后,将本机docker的访问接口映射到CTFd所在容器内 + +并将CTFd添加到frpc所在network中(注意不是containers这个network) + +```yml +services: + ctfd: + ... + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - frpc #frpc需要先运行 + networks: + ... + frp_connect: +``` + +将CTFd-Whale克隆至CTFd的插件目录 + +```bash +git clone https://github.com/frankli0324/CTFd-Whale CTFd/plugins/ctfd-whale --depth=1 +docker-compose build # 需要安装依赖 +docker-compose up -d +``` + +进入Whale的配置页面( `/plugins/ctfd-whale/admin/settings` ),首先配置docker配置项 + +需要注意的是 `Auto Connect Network` ,如果按照上面的配置流程进行配置的话,应当是 `ctfd_frp_containers` + +如果不确定的话,可以通过下面的命令列出CTFd目录compose生成的所有network + +```bash +docker network ls -f "label=com.docker.compose.project=ctfd" --format "{{.Name}}" +``` + +然后检查frp配置项是否正确 + +* `HTTP Domain Suffix` 与 frps 的 `subdomain_host` 保持一致 +* `HTTP Port` 与 frps 的 `vhost_http_port` 保持一致 +* `Direct IP Address` 为能访问到 frps 相应端口(例子中为10000-10100) 的IP +* `Direct Minimum Port` 与 `Direct Maximum Port` 显然可得 +* 只要正确填写了 `API URL` ,Whale 会自动获取 frpc 的配置文件作为 `Frpc config template` +* 通过设置 `Frpc config template` 可以覆盖原有 `frpc.ini` 文件 + +至此,CTFd-Whale 已经马马虎虎可以正常使用了。 + +### 配置nginx + +如果你在使用2.5.0+版本的CTFd,那么你可以直接利用自带的nginx进行http题目的反代 + +首先去除docker-compose.yml中对frps http端口的映射(8001) +如果想贯彻到底的话,可以 + +* 为nginx添加internal与default两个network +* 去除CTFd的default network,并去除ports项 + +在 `./conf/nginx/nginx.conf` 的http block中添加以下server block + +```conf +server { + listen 80; + server_name *.node3.buuoj.cn; + location / { + proxy_pass http://frps:8001; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + } +} +``` + +## 部署题目 + +### 单容器题目环境 + +请参考中的镜像进行题目镜像制作(Dockerfile编写)。总体而言,题目在启动时会向**容器**内传入名为 `FLAG` 的环境变量,你需要编写一个启动脚本(一般为bash+sed组合拳)将flag写入自己的题目中,并删除这一环境变量。 + +请出题人制作镜像时请理清思路,不要搞混容器与镜像的概念。这样既方便自己,也方便部署人员。 + +### 多容器题目环境 + +在题目镜像名处填写一个json object,即可创建一道多容器的题目 + +```json +{ + "hostname": "image", +} +``` + +Whale会保留json的key顺序,并将第一个容器作为"主容器"映射到外网,映射方式与单容器相同 +以buuoj上的swpu2019 web2为例,可以配置如下: + +```json +{ + "ss": "shadowsocks-chall", + "web": "swpu2019-web2", + ... +} +``` + +其中shadowsocks-chall的Dockerfile: + +```dockerfile +FROM shadowsocks/shadowsocks-libev +ENV PASSWORD=123456 +ENV METHOD=aes-256-cfb +``` + +> 注:由于写README的并不是buuoj管理员,故上述仅作说明用,与实际情况可能有较大出入 + +## 安全事项 + +* 后台配置中flag与domain模版理论上存在ssti(feature),请不要将管理员账号给不可信第三方 +* 由于例子中frpc并没有开启鉴权,请不要将frpc的bind_addr设置为`0.0.0.0`。这样会导致利用任何一道能发起http请求的题目都能修改frpc配置。 +* 如果出于配置复杂性考虑,题目容器能够访问frpc,请开启frpc的Basic Auth,并以 `http://username:password@frpc:7400` 的格式设置frpc API URL + +## 高级部署 + +用于下发靶机实例的服务器与运行 `CTFd` 网站的服务器分离,`CTFd-whale` 通过启用了 `TLS/SSL` 验证的 `Dockers API`进行下发容器控制 + +参见 [advanced.zh-cn.md](advanced.zh-cn.md) \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/models.py b/CTFd/plugins/ctfd-whale/models.py new file mode 100644 index 0000000..f9263be --- /dev/null +++ b/CTFd/plugins/ctfd-whale/models.py @@ -0,0 +1,105 @@ +import random +import uuid +from datetime import datetime + +from jinja2 import Template + +from CTFd.utils import get_config +from CTFd.models import db +from CTFd.plugins.dynamic_challenges import DynamicChallenge + + +class WhaleConfig(db.Model): + key = db.Column(db.String(length=128), primary_key=True) + value = db.Column(db.Text) + + def __init__(self, key, value): + self.key = key + self.value = value + + def __repr__(self): + return "".format(self.key, self.value) + + +class WhaleRedirectTemplate(db.Model): + key = db.Column(db.String(20), primary_key=True) + frp_template = db.Column(db.Text) + access_template = db.Column(db.Text) + + def __init__(self, key, access_template, frp_template): + self.key = key + self.access_template = access_template + self.frp_template = frp_template + + def __repr__(self): + return "".format(self.key) + + +class DynamicDockerChallenge(DynamicChallenge): + __mapper_args__ = {"polymorphic_identity": "dynamic_docker"} + id = db.Column( + db.Integer, db.ForeignKey("dynamic_challenge.id", ondelete="CASCADE"), primary_key=True + ) + + memory_limit = db.Column(db.Text, default="128m") + cpu_limit = db.Column(db.Float, default=0.5) + dynamic_score = db.Column(db.Integer, default=0) + + docker_image = db.Column(db.Text, default=0) + redirect_type = db.Column(db.Text, default=0) + redirect_port = db.Column(db.Integer, default=0) + + def __init__(self, *args, **kwargs): + kwargs["initial"] = kwargs["value"] + super(DynamicDockerChallenge, self).__init__(**kwargs) + + +class WhaleContainer(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(None, db.ForeignKey("users.id")) + challenge_id = db.Column(None, db.ForeignKey("challenges.id")) + start_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + renew_count = db.Column(db.Integer, nullable=False, default=0) + status = db.Column(db.Integer, default=1) + uuid = db.Column(db.String(256)) + port = db.Column(db.Integer, nullable=True, default=0) + flag = db.Column(db.String(128), nullable=False) + + # Relationships + user = db.relationship( + "Users", foreign_keys="WhaleContainer.user_id", lazy="select") + challenge = db.relationship( + "DynamicDockerChallenge", foreign_keys="WhaleContainer.challenge_id", lazy="select" + ) + + @property + def http_subdomain(self): + return Template(get_config( + 'whale:template_http_subdomain', '{{ container.uuid }}' + )).render(container=self) + + def __init__(self, user_id, challenge_id): + self.user_id = user_id + self.challenge_id = challenge_id + self.start_time = datetime.now() + self.renew_count = 0 + self.uuid = str(uuid.uuid4()) + self.flag = Template(get_config( + 'whale:template_chall_flag', '{{ "flag{"+uuid.uuid4()|string+"}" }}' + )).render(container=self, uuid=uuid, random=random, get_config=get_config) + + @property + def user_access(self): + return Template(WhaleRedirectTemplate.query.filter_by( + key=self.challenge.redirect_type + ).first().access_template).render(container=self, get_config=get_config) + + @property + def frp_config(self): + return Template(WhaleRedirectTemplate.query.filter_by( + key=self.challenge.redirect_type + ).first().frp_template).render(container=self, get_config=get_config) + + def __repr__(self): + return "".format(self.id, self.user_id, self.challenge_id, + self.start_time, self.renew_count) diff --git a/CTFd/plugins/ctfd-whale/requirements.txt b/CTFd/plugins/ctfd-whale/requirements.txt new file mode 100644 index 0000000..ccabdeb --- /dev/null +++ b/CTFd/plugins/ctfd-whale/requirements.txt @@ -0,0 +1,4 @@ +docker==4.1.0 +Flask-APScheduler==1.11.0 +flask-redis==0.4.0 +redis==3.3.11 \ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/templates/config/base.router.config.html b/CTFd/plugins/ctfd-whale/templates/config/base.router.config.html new file mode 100644 index 0000000..f42df5f --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/base.router.config.html @@ -0,0 +1,24 @@ +
+ {% set value = get_config('whale:router_type') %} + {% set cur_type = get_config("whale:router_type", "frp") %} +
+ + +
+ {% set template = "config/" + cur_type + ".router.config.html" %} + {% include template %} +
+ +
+
diff --git a/CTFd/plugins/ctfd-whale/templates/config/challenges.config.html b/CTFd/plugins/ctfd-whale/templates/config/challenges.config.html new file mode 100644 index 0000000..6b824e0 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/challenges.config.html @@ -0,0 +1,25 @@ +
+ {% for config, val in { + "Subdomain Template": ("template_http_subdomain", "Controls how the subdomain of a container is generated"), + "Flag Template": ("template_chall_flag", "Controls how a flag is generated"), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + +
+ +
+
\ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/templates/config/docker.config.html b/CTFd/plugins/ctfd-whale/templates/config/docker.config.html new file mode 100644 index 0000000..a46bf1b --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/docker.config.html @@ -0,0 +1,122 @@ +
+
Common
+ + Common configurations for both standalone and grouped containers +
+ {% for config, val in { + "API URL": ("docker_api_url", "Docker API to connect to"), + "Credentials": ("docker_credentials", "docker.io username and password, separated by ':'. useful for private images"), + "Swarm Nodes": ("docker_swarm_nodes", "Will pick up one from it, You should set your node with label name=windows-* or name=linux-*. Separated by commas."), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + {% set use_ssl = get_config('whale:docker_use_ssl') %} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Standalone Containers
+ + Typical challenges. Under most circumstances you only need to set these. +
+ {% for config, val in { + "Auto Connect Network": ("docker_auto_connect_network", "The network connected for single-containers. It's usually the same network as the frpc is in."), + "Dns Setting": ("docker_dns", "Decide which dns will be used in container network."), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} +
+
Grouped Containers
+ + Designed for multi-container challenges +
+ {% for config, val in { + "Auto Connect Containers": ("docker_auto_connect_containers","Decide which container will be connected to multi-container-network automatically. Separated by commas."), + "Multi-Container Network Subnet": ("docker_subnet", "Subnet which will be used by auto created networks for multi-container challenges."), + "Multi-Container Network Subnet New Prefix": ("docker_subnet_new_prefix", "Prefix for auto created network.") + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + +
+ +
+
diff --git a/CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html b/CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html new file mode 100644 index 0000000..2ae8adf --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/frp.router.config.html @@ -0,0 +1,50 @@ +{% for config, val in { + "API URL": ("frp_api_url", "Frp API to connect to"), + "Http Domain Suffix": ("frp_http_domain_suffix", "Will be appended to the hash of a container"), + "External Http Port": ("frp_http_port", "Keep in sync with frps:vhost_http_port"), + "Direct IP Address":("frp_direct_ip_address","For direct redirect"), + "Direct Minimum Port": ("frp_direct_port_minimum", "For direct redirect (pwn challenges)"), + "Direct Maximum Port": ("frp_direct_port_maximum", "For direct redirect (pwn challenges)"), +}.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+{% endfor %} +{% set frpc_template = get_config("whale:frp_config_template", "") %} +
+ + +
+{% if frpc_template %} +
+ + +
+{% endif %} diff --git a/CTFd/plugins/ctfd-whale/templates/config/limits.config.html b/CTFd/plugins/ctfd-whale/templates/config/limits.config.html new file mode 100644 index 0000000..f07c7f3 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/limits.config.html @@ -0,0 +1,26 @@ +
+ {% for config, val in { + "Max Container Count": ("docker_max_container_count", "The maximum number of countainers allowed on the server"), + "Max Renewal Times": ("docker_max_renew_count", "The maximum times a user is allowed to renew a container"), + "Docker Container Timeout": ("docker_timeout", "A container times out after [timeout] seconds."), + }.items() %} + {% set value = get_config('whale:' + val[0]) %} +
+ + +
+ {% endfor %} + +
+ +
+
\ No newline at end of file diff --git a/CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html b/CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html new file mode 100644 index 0000000..671f8ba --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/config/trp.router.config.html @@ -0,0 +1,17 @@ +{% for config, val in { + "API URL": ("trp_api_url", "trp API to connect to"), + "Domain Suffix": ("trp_domain_suffix", "Will be used to generated the access link of a challenge"), + "Listening Port": ("trp_listening_port", "Will be used to generated the access link of a challenge"), +}.items() %} +{% set value = get_config('whale:' + val[0]) %} +
+ + +
+{% endfor %} diff --git a/CTFd/plugins/ctfd-whale/templates/containers/card.containers.html b/CTFd/plugins/ctfd-whale/templates/containers/card.containers.html new file mode 100644 index 0000000..7de8503 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/containers/card.containers.html @@ -0,0 +1,57 @@ + + +
+ {% for container in containers %} +
+
+
+
+ {{ container.challenge.name | truncate(15) }} + +
+
+ {{ container.user.name | truncate(5) }} + +
+

{{ container.user_access }}

+

{{ container.flag }}

+ Time Started: {{ container.start_time }} + + + + + + +
+
+
+ {% endfor %} +
diff --git a/CTFd/plugins/ctfd-whale/templates/containers/list.containers.html b/CTFd/plugins/ctfd-whale/templates/containers/list.containers.html new file mode 100644 index 0000000..188cc2d --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/containers/list.containers.html @@ -0,0 +1,78 @@ +
+
+ + + + + + + + {% for container in containers %} + + + + + + + + + + + + {% endfor %} + +
+
  + +
+
ID + User + Challenge + Access Method + Flag + Startup Time + Renewal Times + Delete +
+
  + +
+
+ {{ container.id }} + + + {{ container.user.name | truncate(12) }} + + + + {{ container.challenge.name }} + + + {{ container.challenge.redirect_type }}  + + + + + + + {{ container.renew_count }}  + + + +
+
+
diff --git a/CTFd/plugins/ctfd-whale/templates/whale_base.html b/CTFd/plugins/ctfd-whale/templates/whale_base.html new file mode 100644 index 0000000..98b430e --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/whale_base.html @@ -0,0 +1,25 @@ +{% extends "admin/base.html" %} + +{% block content %} +
+
+

CTFd Whale

+
+
+
+
+
+ +
+
+
+ {% block panel %} + {% endblock %} +
+
+
+
+{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/templates/whale_config.html b/CTFd/plugins/ctfd-whale/templates/whale_config.html new file mode 100644 index 0000000..5181e5b --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/whale_config.html @@ -0,0 +1,38 @@ +{% extends "whale_base.html" %} + +{% block menu %} + + + + + +{% endblock %} + +{% block panel %} + {% include "components/errors.html" %} +
+
+
+ {% include "config/docker.config.html" %} + {% include "config/base.router.config.html" %} + {% include "config/limits.config.html" %} + {% include "config/challenges.config.html" %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/templates/whale_containers.html b/CTFd/plugins/ctfd-whale/templates/whale_containers.html new file mode 100644 index 0000000..8e2bdeb --- /dev/null +++ b/CTFd/plugins/ctfd-whale/templates/whale_containers.html @@ -0,0 +1,69 @@ +{% extends "whale_base.html" %} + +{% block menu %} + + + + + + + + +{% endblock %} + +{% block panel %} + {% include "containers/" + session["view_mode"] + ".containers.html" %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/CTFd/plugins/ctfd-whale/utils/__init__.py b/CTFd/plugins/ctfd-whale/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/ctfd-whale/utils/cache.py b/CTFd/plugins/ctfd-whale/utils/cache.py new file mode 100644 index 0000000..cd24700 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/cache.py @@ -0,0 +1,150 @@ +import ipaddress +import warnings +from CTFd.cache import cache +from CTFd.utils import get_config +from flask_redis import FlaskRedis +from redis.exceptions import LockError + +from .db import DBContainer + + +class CacheProvider: + def __init__(self, app, *args, **kwargs): + if app.config['CACHE_TYPE'] == 'redis': + self.provider = RedisCacheProvider(app, *args, **kwargs) + elif app.config['CACHE_TYPE'] in ['filesystem', 'simple']: + if not hasattr(CacheProvider, 'cache'): + CacheProvider.cache = {} + self.provider = FilesystemCacheProvider(app, *args, **kwargs) + self.init_port_sets() + + def init_port_sets(self): + self.clear() + + containers = DBContainer.get_all_container() + used_port_list = [] + for container in containers: + if container.port != 0: + used_port_list.append(container.port) + for port in range(int(get_config("whale:frp_direct_port_minimum", 29000)), + int(get_config("whale:frp_direct_port_maximum", 28000)) + 1): + if port not in used_port_list: + self.add_available_port(port) + + from .docker import get_docker_client + client = get_docker_client() + + docker_subnet = get_config("whale:docker_subnet", "174.1.0.0/16") + docker_subnet_new_prefix = int( + get_config("whale:docker_subnet_new_prefix", "24")) + + exist_networks = [] + available_networks = [] + + for network in client.networks.list(filters={'label': 'prefix'}): + exist_networks.append(str(network.attrs['Labels']['prefix'])) + + for network in list(ipaddress.ip_network(docker_subnet).subnets(new_prefix=docker_subnet_new_prefix)): + if str(network) not in exist_networks: + available_networks.append(str(network)) + + self.add_available_network_range(*set(available_networks)) + + def __getattr__(self, name): + return self.provider.__getattribute__(name) + + +class FilesystemCacheProvider: + def __init__(self, app, *args, **kwargs): + warnings.warn( + '\n[CTFd Whale] Warning: looks like you are using filesystem cache. ' + '\nThis is for TESTING purposes only, DO NOT USE on production sites.', + RuntimeWarning + ) + self.key = 'ctfd_whale_lock-' + str(kwargs.get('user_id', 0)) + self.global_port_key = "ctfd_whale-port-set" + self.global_network_key = "ctfd_whale-network-set" + + def clear(self): + cache.set(self.global_port_key, set()) + cache.set(self.global_network_key, set()) + + def add_available_network_range(self, *ranges): + s = cache.get(self.global_network_key) + s.update(ranges) + cache.set(self.global_network_key, s) + + def get_available_network_range(self): + try: + s = cache.get(self.global_network_key) + r = s.pop() + cache.set(self.global_network_key, s) + return r + except KeyError: + return None + + def add_available_port(self, port): + s = cache.get(self.global_port_key) + s.add(port) + cache.set(self.global_port_key, s) + + def get_available_port(self): + try: + s = cache.get(self.global_port_key) + r = s.pop() + cache.set(self.global_port_key, s) + return r + except KeyError: + return None + + def acquire_lock(self): + # for testing purposes only, so no need to set this limit + return True + + def release_lock(self): + return True + + +class RedisCacheProvider(FlaskRedis): + def __init__(self, app, *args, **kwargs): + super().__init__(app) + self.key = 'ctfd_whale_lock-' + str(kwargs.get('user_id', 0)) + self.current_lock = None + self.global_port_key = "ctfd_whale-port-set" + self.global_network_key = "ctfd_whale-network-set" + + def clear(self): + self.delete(self.global_port_key) + self.delete(self.global_network_key) + + def add_available_network_range(self, *ranges): + self.sadd(self.global_network_key, *ranges) + + def get_available_network_range(self): + return self.spop(self.global_network_key).decode() + + def add_available_port(self, port): + self.sadd(self.global_port_key, str(port)) + + def get_available_port(self): + return int(self.spop(self.global_port_key)) + + def acquire_lock(self): + lock = self.lock(name=self.key, timeout=10) + + if not lock.acquire(blocking=True, blocking_timeout=2.0): + return False + + self.current_lock = lock + return True + + def release_lock(self): + if self.current_lock is None: + return False + + try: + self.current_lock.release() + + return True + except LockError: + return False diff --git a/CTFd/plugins/ctfd-whale/utils/checks.py b/CTFd/plugins/ctfd-whale/utils/checks.py new file mode 100644 index 0000000..ef1baee --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/checks.py @@ -0,0 +1,50 @@ +from docker.errors import DockerException, TLSParameterError, APIError, requests + +from CTFd.utils import get_config + +from .docker import get_docker_client +from .routers import Router, _routers + + +class WhaleChecks: + @staticmethod + def check_docker_api(): + try: + client = get_docker_client() + except TLSParameterError as e: + return f'Docker TLS Parameters incorrect ({e})' + except DockerException as e: + return f'Docker API url incorrect ({e})' + try: + client.ping() + except (APIError, requests.RequestException): + return f'Unable to connect to Docker API, check your API connectivity' + + credentials = get_config("whale:docker_credentials") + if credentials and credentials.count(':') == 1: + try: + client.login(*credentials.split(':')) + except DockerException: + return f'Unable to log into docker registry, check your credentials' + swarm = client.info()['Swarm'] + if not swarm['ControlAvailable']: + return f'Docker swarm not available. You should initialize a swarm first. ($ docker swarm init)' + + @staticmethod + def check_frp_connection(): + router_conftype = get_config("whale:router_type", "frp") + if router_conftype not in _routers: + return "invalid router type: " + router_conftype + ok, msg = _routers[router_conftype]().check_availability() + if not ok: + return msg + + @staticmethod + def perform(): + errors = [] + for attr in dir(WhaleChecks): + if attr.startswith('check_'): + err = getattr(WhaleChecks, attr)() + if err: + errors.append(err) + return errors diff --git a/CTFd/plugins/ctfd-whale/utils/control.py b/CTFd/plugins/ctfd-whale/utils/control.py new file mode 100644 index 0000000..09c2d6b --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/control.py @@ -0,0 +1,61 @@ +import datetime +import traceback + +from CTFd.utils import get_config +from .db import DBContainer, db +from .docker import DockerUtils +from .routers import Router + + +class ControlUtil: + @staticmethod + def try_add_container(user_id, challenge_id): + container = DBContainer.create_container_record(user_id, challenge_id) + try: + DockerUtils.add_container(container) + except Exception as e: + DBContainer.remove_container_record(user_id) + print(traceback.format_exc()) + return False, 'Docker Creation Error' + ok, msg = Router.register(container) + if not ok: + DockerUtils.remove_container(container) + DBContainer.remove_container_record(user_id) + return False, msg + return True, 'Container created' + + @staticmethod + def try_remove_container(user_id): + container = DBContainer.get_current_containers(user_id=user_id) + if not container: + return False, 'No such container' + for _ in range(3): # configurable? as "onerror_retry_cnt" + try: + ok, msg = Router.unregister(container) + if not ok: + return False, msg + DockerUtils.remove_container(container) + DBContainer.remove_container_record(user_id) + return True, 'Container destroyed' + except Exception as e: + print(traceback.format_exc()) + return False, 'Failed when destroying instance, please contact admin!' + + @staticmethod + def try_renew_container(user_id): + container = DBContainer.get_current_containers(user_id) + if not container: + return False, 'No such container' + timeout = int(get_config("whale:docker_timeout", "3600")) + container.start_time = container.start_time + \ + datetime.timedelta(seconds=timeout) + if container.start_time > datetime.datetime.now(): + container.start_time = datetime.datetime.now() + # race condition? useless maybe? + # useful when docker_timeout < poll timeout (10 seconds) + # doesn't make any sense + else: + return False, 'Invalid container' + container.renew_count += 1 + db.session.commit() + return True, 'Container Renewed' diff --git a/CTFd/plugins/ctfd-whale/utils/db.py b/CTFd/plugins/ctfd-whale/utils/db.py new file mode 100644 index 0000000..81a13ea --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/db.py @@ -0,0 +1,104 @@ +import datetime + +from CTFd.models import db +from CTFd.utils import get_config +from ..models import WhaleContainer, WhaleRedirectTemplate + + +class DBContainer: + @staticmethod + def create_container_record(user_id, challenge_id): + container = WhaleContainer(user_id=user_id, challenge_id=challenge_id) + db.session.add(container) + db.session.commit() + + return container + + @staticmethod + def get_current_containers(user_id): + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.user_id == user_id) + return q.first() + + @staticmethod + def get_container_by_port(port): + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.port == port) + return q.first() + + @staticmethod + def remove_container_record(user_id): + q = db.session.query(WhaleContainer) + q = q.filter(WhaleContainer.user_id == user_id) + q.delete() + db.session.commit() + + @staticmethod + def get_all_expired_container(): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time < + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + return q.all() + + @staticmethod + def get_all_alive_container(): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time >= + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + return q.all() + + @staticmethod + def get_all_container(): + q = db.session.query(WhaleContainer) + return q.all() + + @staticmethod + def get_all_alive_container_page(page_start, page_end): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time >= + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + q = q.slice(page_start, page_end) + return q.all() + + @staticmethod + def get_all_alive_container_count(): + timeout = int(get_config("whale:docker_timeout", "3600")) + + q = db.session.query(WhaleContainer) + q = q.filter( + WhaleContainer.start_time >= + datetime.datetime.now() - datetime.timedelta(seconds=timeout) + ) + return q.count() + + +class DBRedirectTemplate: + @staticmethod + def get_all_templates(): + return WhaleRedirectTemplate.query.all() + + @staticmethod + def create_template(name, access_template, frp_template): + if WhaleRedirectTemplate.query.filter_by(key=name).first(): + return # already existed + db.session.add(WhaleRedirectTemplate( + name, access_template, frp_template + )) + db.session.commit() + + @staticmethod + def delete_template(name): + WhaleRedirectTemplate.query.filter_by(key=name).delete() + db.session.commit() diff --git a/CTFd/plugins/ctfd-whale/utils/docker.py b/CTFd/plugins/ctfd-whale/utils/docker.py new file mode 100644 index 0000000..2fe1b42 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/docker.py @@ -0,0 +1,202 @@ +import json +import random +import uuid +from collections import OrderedDict + +import docker +from flask import current_app + +from CTFd.utils import get_config + +from .cache import CacheProvider +from .exceptions import WhaleError + + +def get_docker_client(): + if get_config("whale:docker_use_ssl", False): + tls_config = docker.tls.TLSConfig( + verify=True, + ca_cert=get_config("whale:docker_ssl_ca_cert") or None, + client_cert=( + get_config("whale:docker_ssl_client_cert"), + get_config("whale:docker_ssl_client_key") + ), + ) + return docker.DockerClient( + base_url=get_config("whale:docker_api_url"), + tls=tls_config, + ) + else: + return docker.DockerClient(base_url=get_config("whale:docker_api_url")) + + +class DockerUtils: + @staticmethod + def init(): + try: + DockerUtils.client = get_docker_client() + # docker-py is thread safe: https://github.com/docker/docker-py/issues/619 + except Exception: + raise WhaleError( + 'Docker Connection Error\n' + 'Please ensure the docker api url (first config item) is correct\n' + 'if you are using unix:///var/run/docker.sock, check if the socket is correctly mapped' + ) + credentials = get_config("whale:docker_credentials") + if credentials and credentials.count(':') == 1: + try: + DockerUtils.client.login(*credentials.split(':')) + except Exception: + raise WhaleError('docker.io failed to login, check your credentials') + + @staticmethod + def add_container(container): + if container.challenge.docker_image.startswith("{"): + DockerUtils._create_grouped_container(DockerUtils.client, container) + else: + DockerUtils._create_standalone_container(DockerUtils.client, container) + + @staticmethod + def _create_standalone_container(client, container): + dns = get_config("whale:docker_dns", "").split(",") + node = DockerUtils.choose_node( + container.challenge.docker_image, + get_config("whale:docker_swarm_nodes", "").split(",") + ) + + client.services.create( + image=container.challenge.docker_image, + name=f'{container.user_id}-{container.uuid}', + env={'FLAG': container.flag}, dns_config=docker.types.DNSConfig(nameservers=dns), + networks=[get_config("whale:docker_auto_connect_network", "ctfd_frp-containers")], + resources=docker.types.Resources( + mem_limit=DockerUtils.convert_readable_text( + container.challenge.memory_limit), + cpu_limit=int(container.challenge.cpu_limit * 1e9) + ), + labels={ + 'whale_id': f'{container.user_id}-{container.uuid}' + }, # for container deletion + constraints=['node.labels.name==' + node], + endpoint_spec=docker.types.EndpointSpec(mode='dnsrr', ports={}) + ) + + @staticmethod + def _create_grouped_container(client, container): + range_prefix = CacheProvider(app=current_app).get_available_network_range() + + ipam_pool = docker.types.IPAMPool(subnet=range_prefix) + ipam_config = docker.types.IPAMConfig( + driver='default', pool_configs=[ipam_pool]) + network_name = f'{container.user_id}-{container.uuid}' + network = client.networks.create( + network_name, internal=True, + ipam=ipam_config, attachable=True, + labels={'prefix': range_prefix}, + driver="overlay", scope="swarm" + ) + + dns = [] + containers = get_config("whale:docker_auto_connect_containers", "").split(",") + for c in containers: + if not c: + continue + network.connect(c) + if "dns" in c: + network.reload() + for name in network.attrs['Containers']: + if network.attrs['Containers'][name]['Name'] == c: + dns.append(network.attrs['Containers'][name]['IPv4Address'].split('/')[0]) + + has_processed_main = False + try: + images = json.loads( + container.challenge.docker_image, + object_pairs_hook=OrderedDict + ) + except json.JSONDecodeError: + raise WhaleError( + "Challenge Image Parse Error\n" + "plase check the challenge image string" + ) + for name, image in images.items(): + if has_processed_main: + container_name = f'{container.user_id}-{uuid.uuid4()}' + else: + container_name = f'{container.user_id}-{container.uuid}' + node = DockerUtils.choose_node(image, get_config("whale:docker_swarm_nodes", "").split(",")) + has_processed_main = True + client.services.create( + image=image, name=container_name, networks=[ + docker.types.NetworkAttachmentConfig(network_name, aliases=[name]) + ], + env={'FLAG': container.flag}, + dns_config=docker.types.DNSConfig(nameservers=dns), + resources=docker.types.Resources( + mem_limit=DockerUtils.convert_readable_text( + container.challenge.memory_limit + ), + cpu_limit=int(container.challenge.cpu_limit * 1e9)), + labels={ + 'whale_id': f'{container.user_id}-{container.uuid}' + }, # for container deletion + hostname=name, constraints=['node.labels.name==' + node], + endpoint_spec=docker.types.EndpointSpec(mode='dnsrr', ports={}) + ) + + @staticmethod + def remove_container(container): + whale_id = f'{container.user_id}-{container.uuid}' + + for s in DockerUtils.client.services.list(filters={'label': f'whale_id={whale_id}'}): + s.remove() + + networks = DockerUtils.client.networks.list(names=[whale_id]) + if len(networks) > 0: # is grouped containers + auto_containers = get_config("whale:docker_auto_connect_containers", "").split(",") + redis_util = CacheProvider(app=current_app) + for network in networks: + for container in auto_containers: + try: + network.disconnect(container, force=True) + except Exception: + pass + redis_util.add_available_network_range(network.attrs['Labels']['prefix']) + network.remove() + + @staticmethod + def convert_readable_text(text): + lower_text = text.lower() + + if lower_text.endswith("k"): + return int(text[:-1]) * 1024 + + if lower_text.endswith("m"): + return int(text[:-1]) * 1024 * 1024 + + if lower_text.endswith("g"): + return int(text[:-1]) * 1024 * 1024 * 1024 + + return 0 + + @staticmethod + def choose_node(image, nodes): + win_nodes = [] + linux_nodes = [] + for node in nodes: + if node.startswith("windows"): + win_nodes.append(node) + else: + linux_nodes.append(node) + try: + tag = image.split(":")[1:] + if len(tag) and tag[0].startswith("windows"): + return random.choice(win_nodes) + return random.choice(linux_nodes) + except IndexError: + raise WhaleError( + 'No Suitable Nodes.\n' + 'If you are using Whale for the first time, \n' + 'Please Setup Swarm Nodes Correctly and Lable Them with\n' + 'docker node update --label-add "name=linux-1" $(docker node ls -q)' + ) diff --git a/CTFd/plugins/ctfd-whale/utils/exceptions.py b/CTFd/plugins/ctfd-whale/utils/exceptions.py new file mode 100644 index 0000000..08886f7 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/exceptions.py @@ -0,0 +1,8 @@ +class WhaleError(Exception): + def __init__(self, msg): + super().__init__(msg) + self.message = msg + + +class WhaleWarning(Warning): + pass diff --git a/CTFd/plugins/ctfd-whale/utils/routers/__init__.py b/CTFd/plugins/ctfd-whale/utils/routers/__init__.py new file mode 100644 index 0000000..96f0174 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/__init__.py @@ -0,0 +1,34 @@ +from CTFd.utils import get_config + +from .frp import FrpRouter +from .trp import TrpRouter + +_routers = { + 'frp': FrpRouter, + 'trp': TrpRouter, +} + + +def instanciate(cls): + return cls() + + +@instanciate +class Router: + _name = '' + _router = None + + def __getattr__(self, name: str): + router_conftype = get_config("whale:router_type", "frp") + if Router._name != router_conftype: + Router._router = _routers[router_conftype]() + Router._name = router_conftype + return getattr(Router._router, name) + + @staticmethod + def reset(): + Router._name = '' + Router._router = None + + +__all__ = ["Router"] diff --git a/CTFd/plugins/ctfd-whale/utils/routers/base.py b/CTFd/plugins/ctfd-whale/utils/routers/base.py new file mode 100644 index 0000000..eba7118 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/base.py @@ -0,0 +1,25 @@ +import typing + +from ...models import WhaleContainer + + +class BaseRouter: + name = None + + def __init__(self): + pass + + def access(self, container: WhaleContainer): + pass + + def register(self, container: WhaleContainer): + pass + + def unregister(self, container: WhaleContainer): + pass + + def reload(self): + pass + + def check_availability(self) -> typing.Tuple[bool, str]: + pass diff --git a/CTFd/plugins/ctfd-whale/utils/routers/frp.py b/CTFd/plugins/ctfd-whale/utils/routers/frp.py new file mode 100644 index 0000000..08ba694 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/frp.py @@ -0,0 +1,132 @@ +import warnings + +from flask import current_app +from requests import session, RequestException + +from CTFd.models import db +from CTFd.utils import get_config, set_config, logging + +from .base import BaseRouter +from ..cache import CacheProvider +from ..db import DBContainer +from ..exceptions import WhaleError, WhaleWarning +from ...models import WhaleContainer + + +class FrpRouter(BaseRouter): + name = "frp" + types = { + 'direct': 'tcp', + 'http': 'http', + } + + class FrpRule: + def __init__(self, name, config): + self.name = name + self.config = config + + def __str__(self) -> str: + return f'[{self.name}]\n' + '\n'.join(f'{k} = {v}' for k, v in self.config.items()) + + def __init__(self): + super().__init__() + self.ses = session() + self.url = get_config("whale:frp_api_url").rstrip("/") + self.common = '' + try: + CacheProvider(app=current_app).init_port_sets() + except Exception: + warnings.warn( + "cache initialization failed", + WhaleWarning + ) + + def reload(self, exclude=None): + rules = [] + for container in DBContainer.get_all_alive_container(): + if container.uuid == exclude: + continue + name = f'{container.challenge.redirect_type}_{container.user_id}_{container.uuid}' + config = { + 'type': self.types[container.challenge.redirect_type], + 'local_ip': f'{container.user_id}-{container.uuid}', + 'local_port': container.challenge.redirect_port, + 'use_compression': 'true', + } + if config['type'] == 'http': + config['subdomain'] = container.http_subdomain + elif config['type'] == 'tcp': + config['remote_port'] = container.port + rules.append(self.FrpRule(name, config)) + + try: + if not self.common: + common = get_config("whale:frp_config_template", '') + if '[common]' in common: + self.common = common + else: + remote = self.ses.get(f'{self.url}/api/config') + assert remote.status_code == 200 + set_config("whale:frp_config_template", remote.text) + self.common = remote.text + config = self.common + '\n' + '\n'.join(str(r) for r in rules) + assert self.ses.put( + f'{self.url}/api/config', config, timeout=5 + ).status_code == 200 + assert self.ses.get( + f'{self.url}/api/reload', timeout=5 + ).status_code == 200 + except (RequestException, AssertionError) as e: + raise WhaleError( + '\nfrpc request failed\n' + + (f'{e}\n' if str(e) else '') + + 'please check the frp related configs' + ) from None + + def access(self, container: WhaleContainer): + if container.challenge.redirect_type == 'direct': + return f'nc {get_config("whale:frp_direct_ip_address", "127.0.0.1")} {container.port}' + elif container.challenge.redirect_type == 'http': + host = get_config("whale:frp_http_domain_suffix", "") + port = get_config("whale:frp_http_port", "80") + host += f':{port}' if port != 80 else '' + return f'Link to the Challenge' + return '' + + def register(self, container: WhaleContainer): + if container.challenge.redirect_type == 'direct': + if not container.port: + port = CacheProvider(app=current_app).get_available_port() + if not port: + return False, 'No available ports. Please wait for a few minutes.' + container.port = port + db.session.commit() + elif container.challenge.redirect_type == 'http': + # config['subdomain'] = container.http_subdomain + pass + self.reload() + return True, 'success' + + def unregister(self, container: WhaleContainer): + if container.challenge.redirect_type == 'direct': + try: + redis_util = CacheProvider(app=current_app) + redis_util.add_available_port(container.port) + except Exception as e: + logging.log( + 'whale', 'Error deleting port from cache', + name=container.user.name, + challenge_id=container.challenge_id, + ) + return False, 'Error deleting port from cache' + self.reload(exclude=container.uuid) + return True, 'success' + + def check_availability(self): + try: + resp = self.ses.get(f'{self.url}/api/status', timeout=2.0) + except RequestException as e: + return False, 'Unable to access frpc admin api' + if resp.status_code == 401: + return False, 'frpc admin api unauthorized' + return True, 'Available' diff --git a/CTFd/plugins/ctfd-whale/utils/routers/trp.py b/CTFd/plugins/ctfd-whale/utils/routers/trp.py new file mode 100644 index 0000000..9567b07 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/routers/trp.py @@ -0,0 +1,69 @@ +import traceback +from requests import session, RequestException, HTTPError + +from CTFd.utils import get_config +from .base import BaseRouter +from ..db import DBContainer, WhaleContainer + + +class TrpRouter(BaseRouter): + name = "trp" + + def __init__(self): + super().__init__() + self.ses = session() + self.url = get_config('whale:trp_api_url', '').rstrip("/") + self.common = '' + for container in DBContainer.get_all_alive_container(): + self.register(container) + + @staticmethod + def get_domain(container: WhaleContainer): + domain = get_config('whale:trp_domain_suffix', '127.0.0.1.nip.io').lstrip('.') + domain = f'{container.uuid}.{domain}' + return domain + + def access(self, container: WhaleContainer): + ch_type = container.challenge.redirect_type + domain = self.get_domain(container) + port = get_config('whale:trp_listening_port', 1443) + if ch_type == 'direct': + return f'from pwn import *
remote("{domain}", {port}, ssl=True).interactive()' + elif ch_type == 'http': + return f'https://{domain}' + (f':{port}' if port != 443 else '') + else: + return f'[ssl] {domain} {port}' + + def register(self, container: WhaleContainer): + try: + resp = self.ses.post(f'{self.url}/rule/{self.get_domain(container)}', json={ + 'target': f'{container.user_id}-{container.uuid}:{container.challenge.redirect_port}', + 'source': None, + }) + resp.raise_for_status() + return True, 'success' + except HTTPError as e: + return False, e.response.text + except RequestException as e: + print(traceback.format_exc()) + return False, 'unable to access trp Api' + + def unregister(self, container: WhaleContainer): + try: + resp = self.ses.delete(f'{self.url}/rule/{self.get_domain(container)}') + resp.raise_for_status() + return True, 'success' + except HTTPError as e: + return False, e.response.text + except RequestException as e: + print(traceback.format_exc()) + return False, 'unable to access trp Api' + + def check_availability(self): + try: + resp = self.ses.get(f'{self.url}/rules').json() + except RequestException as e: + return False, 'Unable to access trp admin api' + except Exception as e: + return False, 'Unknown trp error' + return True, 'Available' diff --git a/CTFd/plugins/ctfd-whale/utils/setup.py b/CTFd/plugins/ctfd-whale/utils/setup.py new file mode 100644 index 0000000..ca85e62 --- /dev/null +++ b/CTFd/plugins/ctfd-whale/utils/setup.py @@ -0,0 +1,60 @@ +from CTFd.utils import set_config + +from ..models import WhaleRedirectTemplate, db + + +def setup_default_configs(): + for key, val in { + 'setup': 'true', + 'docker_api_url': 'unix:///var/run/docker.sock', + 'docker_credentials': '', + 'docker_dns': '127.0.0.1', + 'docker_max_container_count': '100', + 'docker_max_renew_count': '5', + 'docker_subnet': '174.1.0.0/16', + 'docker_subnet_new_prefix': '24', + 'docker_swarm_nodes': 'linux-1', + 'docker_timeout': '3600', + 'frp_api_url': 'http://frpc:7400', + 'frp_http_port': '8080', + 'frp_http_domain_suffix': '127.0.0.1.nip.io', + 'frp_direct_port_maximum': '10100', + 'frp_direct_port_minimum': '10000', + 'template_http_subdomain': '{{ container.uuid }}', + 'template_chall_flag': '{{ "flag{"+uuid.uuid4()|string+"}" }}', + }.items(): + set_config('whale:' + key, val) + db.session.add(WhaleRedirectTemplate( + 'http', + 'http://{{ container.http_subdomain }}.' + '{{ get_config("whale:frp_http_domain_suffix", "") }}' + '{% if get_config("whale:frp_http_port", "80") != 80 %}:{{ get_config("whale:frp_http_port") }}{% endif %}/', + ''' +[http_{{ container.user_id|string }}-{{ container.uuid }}] +type = http +local_ip = {{ container.user_id|string }}-{{ container.uuid }} +local_port = {{ container.challenge.redirect_port }} +subdomain = {{ container.http_subdomain }} +use_compression = true +''' + )) + db.session.add(WhaleRedirectTemplate( + 'direct', + 'nc {{ get_config("whale:frp_direct_ip_address", "127.0.0.1") }} {{ container.port }}', + ''' +[direct_{{ container.user_id|string }}-{{ container.uuid }}] +type = tcp +local_ip = {{ container.user_id|string }}-{{ container.uuid }} +local_port = {{ container.challenge.redirect_port }} +remote_port = {{ container.port }} +use_compression = true + +[direct_{{ container.user_id|string }}-{{ container.uuid }}_udp] +type = udp +local_ip = {{ container.user_id|string }}-{{ container.uuid }} +local_port = {{ container.challenge.redirect_port }} +remote_port = {{ container.port }} +use_compression = true +''' + )) + db.session.commit() diff --git a/CTFd/plugins/dynamic_challenges/.gitignore b/CTFd/plugins/dynamic_challenges/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/CTFd/plugins/dynamic_challenges/README.md b/CTFd/plugins/dynamic_challenges/README.md new file mode 100644 index 0000000..6ffa28c --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/README.md @@ -0,0 +1,54 @@ +# Dynamic Value Challenges for CTFd + +It's becoming commonplace in CTF to see challenges whose point values decrease +after each solve. + +This CTFd plugin creates a dynamic challenge type which implements this +behavior. Each dynamic challenge starts with an initial point value and then +each solve will decrease the value of the challenge until a minimum point value. + +By reducing the value of the challenge on each solve, all users who have previously +solved the challenge will have lowered scores. Thus an easier and more solved +challenge will naturally have a lower point value than a harder and less solved +challenge. + +Within CTFd you are free to mix and match regular and dynamic challenges. + +The current implementation requires the challenge to keep track of three values: + +- Initial - The original point valuation +- Decay - The amount of solves before the challenge will be at the minimum +- Minimum - The lowest possible point valuation + +The value decay logic is implemented with the following math: + + + +![](https://raw.githubusercontent.com/CTFd/DynamicValueChallenge/master/function.png) + +or in pseudo code: + +``` +value = (((minimum - initial)/(decay**2)) * (solve_count**2)) + initial +value = math.ceil(value) +``` + +If the number generated is lower than the minimum, the minimum is chosen +instead. + +A parabolic function is chosen instead of an exponential or logarithmic decay function +so that higher valued challenges have a slower drop from their initial value. + +# Installation + +**REQUIRES: CTFd >= v1.2.0** + +1. Clone this repository to `CTFd/plugins`. It is important that the folder is + named `DynamicValueChallenge` so CTFd can serve the files in the `assets` + directory. diff --git a/CTFd/plugins/dynamic_challenges/__init__.py b/CTFd/plugins/dynamic_challenges/__init__.py new file mode 100644 index 0000000..45f51e3 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/__init__.py @@ -0,0 +1,154 @@ +from flask import Blueprint + +from CTFd.exceptions.challenges import ( + ChallengeCreateException, + ChallengeUpdateException, +) +from CTFd.models import Challenges, db +from CTFd.plugins import register_plugin_assets_directory +from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge +from CTFd.plugins.dynamic_challenges.decay import DECAY_FUNCTIONS, logarithmic +from CTFd.plugins.migrations import upgrade + + +class DynamicChallenge(Challenges): + __mapper_args__ = {"polymorphic_identity": "dynamic"} + id = db.Column( + db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True + ) + dynamic_initial = db.Column(db.Integer, default=0) + dynamic_minimum = db.Column(db.Integer, default=0) + dynamic_decay = db.Column(db.Integer, default=0) + dynamic_function = db.Column(db.String(32), default="logarithmic") + + @property + def initial(self): + return self.dynamic_initial + + @initial.setter + def initial(self, initial_value): + self.dynamic_initial = initial_value + + @property + def minimum(self): + return self.dynamic_minimum + + @minimum.setter + def minimum(self, minimum_value): + self.dynamic_minimum = minimum_value + + @property + def decay(self): + return self.dynamic_decay + + @decay.setter + def decay(self, decay_value): + self.dynamic_decay = decay_value + + @property + def function(self): + return self.dynamic_function + + @function.setter + def function(self, function_value): + self.dynamic_function = function_value + + def __init__(self, *args, **kwargs): + super(DynamicChallenge, self).__init__(**kwargs) + try: + self.value = kwargs["initial"] + except KeyError: + raise ChallengeCreateException("Missing initial value for challenge") + + +class DynamicValueChallenge(BaseChallenge): + id = "dynamic" # Unique identifier used to register challenges + name = "dynamic" # Name of a challenge type + templates = ( + { # Handlebars templates used for each aspect of challenge editing & viewing + "create": "/plugins/dynamic_challenges/assets/create.html", + "update": "/plugins/dynamic_challenges/assets/update.html", + "view": "/plugins/dynamic_challenges/assets/view.html", + } + ) + scripts = { # Scripts that are loaded when a template is loaded + "create": "/plugins/dynamic_challenges/assets/create.js", + "update": "/plugins/dynamic_challenges/assets/update.js", + "view": "/plugins/dynamic_challenges/assets/view.js", + } + # Route at which files are accessible. This must be registered using register_plugin_assets_directory() + route = "/plugins/dynamic_challenges/assets/" + # Blueprint used to access the static_folder directory. + blueprint = Blueprint( + "dynamic_challenges", + __name__, + template_folder="templates", + static_folder="assets", + ) + challenge_model = DynamicChallenge + + @classmethod + def calculate_value(cls, challenge): + f = DECAY_FUNCTIONS.get(challenge.function, logarithmic) + value = f(challenge) + + challenge.value = value + db.session.commit() + return challenge + + @classmethod + def read(cls, challenge): + """ + This method is in used to access the data of a challenge in a format processable by the front end. + + :param challenge: + :return: Challenge object, data dictionary to be returned to the user + """ + challenge = DynamicChallenge.query.filter_by(id=challenge.id).first() + data = super().read(challenge) + data.update( + { + "initial": challenge.initial, + "decay": challenge.decay, + "minimum": challenge.minimum, + "function": challenge.function, + } + ) + return data + + @classmethod + def update(cls, challenge, request): + """ + This method is used to update the information associated with a challenge. This should be kept strictly to the + Challenges table and any child tables. + + :param challenge: + :param request: + :return: + """ + data = request.form or request.get_json() + + for attr, value in data.items(): + # We need to set these to floats so that the next operations don't operate on strings + if attr in ("initial", "minimum", "decay"): + try: + value = float(value) + except (ValueError, TypeError): + raise ChallengeUpdateException(f"Invalid input for '{attr}'") + setattr(challenge, attr, value) + + return DynamicValueChallenge.calculate_value(challenge) + + @classmethod + def solve(cls, user, team, challenge, request): + super().solve(user, team, challenge, request) + + DynamicValueChallenge.calculate_value(challenge) + + +def load(app): + upgrade(plugin_name="dynamic_challenges") + CHALLENGE_CLASSES["dynamic"] = DynamicValueChallenge + register_plugin_assets_directory( + app, base_path="/plugins/dynamic_challenges/assets/" + ) diff --git a/CTFd/plugins/dynamic_challenges/assets/create.html b/CTFd/plugins/dynamic_challenges/assets/create.html new file mode 100644 index 0000000..72a7624 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/create.html @@ -0,0 +1,63 @@ +{% extends "admin/challenges/create.html" %} + +{% block header %} + +{% endblock %} + + +{% block value %} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+{% endblock %} + +{% block type %} + +{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/create.js b/CTFd/plugins/dynamic_challenges/assets/create.js new file mode 100644 index 0000000..bcfe9c3 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/create.js @@ -0,0 +1,4 @@ +CTFd.plugin.run((_CTFd) => { + const $ = _CTFd.lib.$ + const md = _CTFd.lib.markdown() +}) diff --git a/CTFd/plugins/dynamic_challenges/assets/update.html b/CTFd/plugins/dynamic_challenges/assets/update.html new file mode 100644 index 0000000..9c92afa --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/update.html @@ -0,0 +1,71 @@ +{% extends "admin/challenges/update.html" %} + +{% block value %} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+{% endblock %} + +{% block function %} +{% endblock %} + +{% block initial %} +{% endblock %} + +{% block decay %} +{% endblock %} + +{% block minimum %} +{% endblock %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/update.js b/CTFd/plugins/dynamic_challenges/assets/update.js new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/dynamic_challenges/assets/view.html b/CTFd/plugins/dynamic_challenges/assets/view.html new file mode 100644 index 0000000..ab623af --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/view.html @@ -0,0 +1 @@ +{% extends "challenge.html" %} \ No newline at end of file diff --git a/CTFd/plugins/dynamic_challenges/assets/view.js b/CTFd/plugins/dynamic_challenges/assets/view.js new file mode 100644 index 0000000..b03a28e --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/assets/view.js @@ -0,0 +1,37 @@ +CTFd._internal.challenge.data = undefined; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.renderer = null; + +CTFd._internal.challenge.preRender = function() {}; + +// TODO: Remove in CTFd v4.0 +CTFd._internal.challenge.render = null; + +CTFd._internal.challenge.postRender = function() {}; + +CTFd._internal.challenge.submit = function(preview) { + var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); + var submission = CTFd.lib.$("#challenge-input").val(); + + var body = { + challenge_id: challenge_id, + submission: submission + }; + var params = {}; + if (preview) { + params["preview"] = true; + } + + return CTFd.api.post_challenge_attempt(params, body).then(function(response) { + if (response.status === 429) { + // User was ratelimited but process response + return response; + } + if (response.status === 403) { + // User is not logged in or CTF is paused. + return response; + } + return response; + }); +}; diff --git a/CTFd/plugins/dynamic_challenges/decay.py b/CTFd/plugins/dynamic_challenges/decay.py new file mode 100644 index 0000000..8db468f --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/decay.py @@ -0,0 +1,75 @@ +from __future__ import division # Use floating point for math calculations + +import math + +from CTFd.models import Solves +from CTFd.utils.modes import get_model + + +def get_solve_count(challenge): + Model = get_model() + + solve_count = ( + Solves.query.join(Model, Solves.account_id == Model.id) + .filter( + Solves.challenge_id == challenge.id, + Model.hidden == False, + Model.banned == False, + ) + .count() + ) + return solve_count + + +def linear(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + value = challenge.initial - (challenge.decay * solve_count) + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +def logarithmic(challenge): + solve_count = get_solve_count(challenge) + + # If the solve count is 0 we shouldn't manipulate the solve count to + # let the math update back to normal + if solve_count != 0: + # We subtract -1 to allow the first solver to get max point value + solve_count -= 1 + + # Handle situations where admins have entered a 0 decay + # This is invalid as it can cause a division by zero + if challenge.decay == 0: + challenge.decay = 1 + + # It is important that this calculation takes into account floats. + # Hence this file uses from __future__ import division + value = ( + ((challenge.minimum - challenge.initial) / (challenge.decay**2)) + * (solve_count**2) + ) + challenge.initial + + value = math.ceil(value) + + if value < challenge.minimum: + value = challenge.minimum + + return value + + +DECAY_FUNCTIONS = { + "linear": linear, + "logarithmic": logarithmic, +} diff --git a/CTFd/plugins/dynamic_challenges/function.png b/CTFd/plugins/dynamic_challenges/function.png new file mode 100644 index 0000000..b35e45b Binary files /dev/null and b/CTFd/plugins/dynamic_challenges/function.png differ diff --git a/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py b/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py new file mode 100644 index 0000000..109de68 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/93284ed9c099_add_dynamic_prefix_to_dynamic_challenge_.py @@ -0,0 +1,142 @@ +"""Add dynamic prefix to dynamic_challenge table + +Revision ID: 93284ed9c099 +Revises: eb68f277ab61 +Create Date: 2025-10-10 02:07:16.055798 + +""" +import sqlalchemy as sa + +from CTFd.plugins.migrations import get_columns_for_table + +# revision identifiers, used by Alembic. +revision = "93284ed9c099" +down_revision = "eb68f277ab61" +branch_labels = None +depends_on = None + + +def upgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + # Add new columns with dynamic_ prefix + if "dynamic_initial" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("dynamic_initial", sa.Integer(), nullable=True), + ) + if "dynamic_minimum" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("dynamic_minimum", sa.Integer(), nullable=True), + ) + if "dynamic_decay" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("dynamic_decay", sa.Integer(), nullable=True) + ) + if "dynamic_function" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("dynamic_function", sa.String(length=32), nullable=True), + ) + + # Copy data from old columns to new columns + connection = op.get_bind() + url = str(connection.engine.url) + if url.startswith("postgres"): + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET dynamic_initial = initial, + dynamic_minimum = minimum, + dynamic_decay = decay, + dynamic_function = function + """ + ) + ) + else: + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET dynamic_initial = initial, + dynamic_minimum = minimum, + dynamic_decay = decay, + dynamic_function = `function` + """ + ) + ) + + # Drop old columns + if "minimum" in columns: + op.drop_column("dynamic_challenge", "minimum") + if "initial" in columns: + op.drop_column("dynamic_challenge", "initial") + if "function" in columns: + op.drop_column("dynamic_challenge", "function") + if "decay" in columns: + op.drop_column("dynamic_challenge", "decay") + + +def downgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + # Add old columns back + if "decay" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("decay", sa.Integer(), nullable=True) + ) + if "function" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("function", sa.String(length=32), nullable=True), + ) + if "initial" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("initial", sa.Integer(), nullable=True) + ) + if "minimum" not in columns: + op.add_column( + "dynamic_challenge", sa.Column("minimum", sa.Integer(), nullable=True) + ) + + # Copy data from new columns back to old columns + connection = op.get_bind() + url = str(connection.engine.url) + if url.startswith("postgres"): + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET initial = dynamic_initial, + minimum = dynamic_minimum, + decay = dynamic_decay, + function = dynamic_function + """ + ) + ) + else: + connection.execute( + sa.text( + """ + UPDATE dynamic_challenge + SET initial = dynamic_initial, + minimum = dynamic_minimum, + decay = dynamic_decay, + `function` = dynamic_function + """ + ) + ) + + # Drop new columns + if "dynamic_function" in columns: + op.drop_column("dynamic_challenge", "dynamic_function") + if "dynamic_decay" in columns: + op.drop_column("dynamic_challenge", "dynamic_decay") + if "dynamic_minimum" in columns: + op.drop_column("dynamic_challenge", "dynamic_minimum") + if "dynamic_initial" in columns: + op.drop_column("dynamic_challenge", "dynamic_initial") diff --git a/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py b/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py new file mode 100644 index 0000000..d704fac --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/b37fb68807ea_add_cascading_delete_to_dynamic_.py @@ -0,0 +1,58 @@ +"""Add cascading delete to dynamic challenges + +Revision ID: b37fb68807ea +Revises: +Create Date: 2020-05-06 12:21:39.373983 + +""" +import sqlalchemy + +revision = "b37fb68807ea" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(op=None): + bind = op.get_bind() + url = str(bind.engine.url) + + try: + if url.startswith("mysql"): + op.drop_constraint( + "dynamic_challenge_ibfk_1", "dynamic_challenge", type_="foreignkey" + ) + elif url.startswith("postgres"): + op.drop_constraint( + "dynamic_challenge_id_fkey", "dynamic_challenge", type_="foreignkey" + ) + except sqlalchemy.exc.InternalError as e: + print(str(e)) + + try: + op.create_foreign_key( + None, "dynamic_challenge", "challenges", ["id"], ["id"], ondelete="CASCADE" + ) + except sqlalchemy.exc.InternalError as e: + print(str(e)) + + +def downgrade(op=None): + bind = op.get_bind() + url = str(bind.engine.url) + try: + if url.startswith("mysql"): + op.drop_constraint( + "dynamic_challenge_ibfk_1", "dynamic_challenge", type_="foreignkey" + ) + elif url.startswith("postgres"): + op.drop_constraint( + "dynamic_challenge_id_fkey", "dynamic_challenge", type_="foreignkey" + ) + except sqlalchemy.exc.InternalError as e: + print(str(e)) + + try: + op.create_foreign_key(None, "dynamic_challenge", "challenges", ["id"], ["id"]) + except sqlalchemy.exc.InternalError as e: + print(str(e)) diff --git a/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py b/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py new file mode 100644 index 0000000..0998aa7 --- /dev/null +++ b/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py @@ -0,0 +1,44 @@ +"""Add func column to dynamic_challenges + +Revision ID: eb68f277ab61 +Revises: b37fb68807ea +Create Date: 2023-06-28 17:37:48.244827 + +""" +import sqlalchemy as sa + +from CTFd.plugins.migrations import get_columns_for_table + +revision = "eb68f277ab61" +down_revision = "b37fb68807ea" +branch_labels = None +depends_on = None + + +def upgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + if "function" not in columns: + op.add_column( + "dynamic_challenge", + sa.Column("function", sa.String(length=32), nullable=True), + ) + conn = op.get_bind() + url = str(conn.engine.url) + if url.startswith("postgres"): + conn.execute( + "UPDATE dynamic_challenge SET function = 'logarithmic' WHERE function IS NULL" + ) + else: + conn.execute( + "UPDATE dynamic_challenge SET `function` = 'logarithmic' WHERE `function` IS NULL" + ) + + +def downgrade(op=None): + columns = get_columns_for_table( + op=op, table_name="dynamic_challenge", names_only=True + ) + if "function" in columns: + op.drop_column("dynamic_challenge", "function") diff --git a/CTFd/plugins/flags/__init__.py b/CTFd/plugins/flags/__init__.py new file mode 100644 index 0000000..a468d11 --- /dev/null +++ b/CTFd/plugins/flags/__init__.py @@ -0,0 +1,83 @@ +import re + +from CTFd.plugins import register_plugin_assets_directory + + +class FlagException(Exception): + def __init__(self, message): + self.message = message + + def __str__(self): + return self.message + + +class BaseFlag(object): + name = None + templates = {} + + @staticmethod + def compare(self, saved, provided): + return True + + +class CTFdStaticFlag(BaseFlag): + name = "static" + templates = { # Nunjucks templates used for key editing & viewing + "create": "/plugins/flags/assets/static/create.html", + "update": "/plugins/flags/assets/static/edit.html", + } + + @staticmethod + def compare(chal_key_obj, provided): + saved = chal_key_obj.content + data = chal_key_obj.data + + if len(saved) != len(provided): + return False + result = 0 + + if data == "case_insensitive": + for x, y in zip(saved.lower(), provided.lower()): + result |= ord(x) ^ ord(y) + else: + for x, y in zip(saved, provided): + result |= ord(x) ^ ord(y) + return result == 0 + + +class CTFdRegexFlag(BaseFlag): + name = "regex" + templates = { # Nunjucks templates used for key editing & viewing + "create": "/plugins/flags/assets/regex/create.html", + "update": "/plugins/flags/assets/regex/edit.html", + } + + @staticmethod + def compare(chal_key_obj, provided): + saved = chal_key_obj.content + data = chal_key_obj.data + + try: + if data == "case_insensitive": + res = re.match(saved, provided, re.IGNORECASE) + else: + res = re.match(saved, provided) + # TODO: this needs plugin improvements. See #1425. + except re.error as e: + raise FlagException("Regex parse error occured") from e + + return res and res.group() == provided + + +FLAG_CLASSES = {"static": CTFdStaticFlag, "regex": CTFdRegexFlag} + + +def get_flag_class(class_id): + cls = FLAG_CLASSES.get(class_id) + if cls is None: + raise KeyError + return cls + + +def load(app): + register_plugin_assets_directory(app, base_path="/plugins/flags/assets/") diff --git a/CTFd/plugins/flags/assets/regex/create.html b/CTFd/plugins/flags/assets/regex/create.html new file mode 100644 index 0000000..cdad7b8 --- /dev/null +++ b/CTFd/plugins/flags/assets/regex/create.html @@ -0,0 +1,37 @@ + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
DescriptionFlag
Match any content inside of flag{}:flag{.*}
Match numeric flags:flag{(\d+)}
Accept flags with or without the flag format prefix:(flag{)?this_is_a_flag(})?
+
+ \ No newline at end of file diff --git a/CTFd/plugins/flags/assets/regex/edit.html b/CTFd/plugins/flags/assets/regex/edit.html new file mode 100644 index 0000000..e07e5de --- /dev/null +++ b/CTFd/plugins/flags/assets/regex/edit.html @@ -0,0 +1,19 @@ + +
+ +
+
+ +
+ + +
+
+ +
diff --git a/CTFd/plugins/flags/assets/static/create.html b/CTFd/plugins/flags/assets/static/create.html new file mode 100644 index 0000000..4f9fa8b --- /dev/null +++ b/CTFd/plugins/flags/assets/static/create.html @@ -0,0 +1,14 @@ + +
+ +
+
+ +
+ \ No newline at end of file diff --git a/CTFd/plugins/flags/assets/static/edit.html b/CTFd/plugins/flags/assets/static/edit.html new file mode 100644 index 0000000..1ef3e11 --- /dev/null +++ b/CTFd/plugins/flags/assets/static/edit.html @@ -0,0 +1,19 @@ + +
+ +
+
+ +
+ + +
+
+ +
diff --git a/CTFd/plugins/flags/tests/__init__.py b/CTFd/plugins/flags/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/plugins/flags/tests/test_flags.py b/CTFd/plugins/flags/tests/test_flags.py new file mode 100644 index 0000000..04d3a9b --- /dev/null +++ b/CTFd/plugins/flags/tests/test_flags.py @@ -0,0 +1,34 @@ +from CTFd.plugins.flags import CTFdRegexFlag + + +def test_valid_regex_match_case_sensitive(): + """ + Test a valid regex match in a case-sensitive manner using CTFdRegexFlag + """ + flag = CTFdRegexFlag() + flag.content = r"^[A-Z]\d{3}$" + flag.data = "case_sensitive" + provided_flag = "A123" + assert flag.compare(flag, provided_flag) # nosec + + +def test_valid_regex_match_case_insensitive(): + """ + Test a valid regex match in a case-insensitive manner using CTFdRegexFlag + """ + flag = CTFdRegexFlag() + flag.content = r"^[a-z]\d{3}$" + flag.data = "case_insensitive" + provided_flag = "A123" + assert flag.compare(flag, provided_flag) # nosec + + +def test_invalid_regex_match(): + """ + Test an invalid regex match using CTFdRegexFlag + """ + flag = CTFdRegexFlag() + flag.content = r"^[A-Z]\d{3}$" + flag.data = "case_sensitive" + provided_flag = "invalid" + assert not flag.compare(flag, provided_flag) # nosec diff --git a/CTFd/plugins/migrations.py b/CTFd/plugins/migrations.py new file mode 100644 index 0000000..36bcae4 --- /dev/null +++ b/CTFd/plugins/migrations.py @@ -0,0 +1,109 @@ +import inspect # noqa: I001 +import os + +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from alembic.script import ScriptDirectory +from flask import current_app +from sqlalchemy import create_engine +from sqlalchemy import inspect as SQLAInspect +from sqlalchemy import pool + +from CTFd.utils import _get_config, set_config + + +def get_all_tables(op): + """ + Function to list all the tables in the database from a migration + """ + inspector = SQLAInspect(op.get_bind()) + tables = inspector.get_table_names() + return tables + + +def get_columns_for_table(op, table_name, names_only=False): + """ + Function to list the columns in a table from a migration + """ + inspector = SQLAInspect(op.get_bind()) + columns = inspector.get_columns(table_name) + if names_only is True: + columns = [c["name"] for c in columns] + return columns + + +def current(plugin_name=None): + if plugin_name is None: + # Get the directory name of the plugin if unspecified + # Doing it this way doesn't waste the rest of the inspect.stack call + frame = inspect.currentframe() + caller_info = inspect.getframeinfo(frame.f_back) + caller_path = caller_info[0] + plugin_name = os.path.basename(os.path.dirname(caller_path)) + + # Specifically bypass the cached config so that we always get the database value + version = _get_config.__wrapped__(plugin_name + "_alembic_version") + if version == KeyError: + version = None + return version + + +def upgrade(plugin_name=None, revision=None, lower="current"): + database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI") + if database_url.startswith("sqlite"): + current_app.db.create_all() + return + + if plugin_name is None: + # Get the directory name of the plugin if unspecified + # Doing it this way doesn't waste the rest of the inspect.stack call + frame = inspect.currentframe() + caller_info = inspect.getframeinfo(frame.f_back) + caller_path = caller_info[0] + plugin_name = os.path.basename(os.path.dirname(caller_path)) + + # Check if the plugin has migraitons + migrations_path = os.path.join(current_app.plugins_dir, plugin_name, "migrations") + if os.path.isdir(migrations_path) is False: + return + + engine = create_engine(database_url, poolclass=pool.NullPool) + conn = engine.connect() + context = MigrationContext.configure(conn) + op = Operations(context) + + # Find the list of migrations to run + config = Config() + config.set_main_option("script_location", migrations_path) + config.set_main_option("version_locations", migrations_path) + script = ScriptDirectory.from_config(config) + + # Choose base revision for plugin upgrade + # "current" points to the current plugin version stored in config + # None represents the absolute base layer (e.g. first installation) + if lower == "current": + lower = current(plugin_name) + + # Do we upgrade to head or to a specific revision + if revision is None: + upper = script.get_current_head() + else: + upper = revision + + # Apply from lower to upper + revs = list(script.iterate_revisions(lower=lower, upper=upper)) + revs.reverse() + + try: + for r in revs: + with context.begin_transaction(): + r.module.upgrade(op=op) + # Set revision that succeeded so we don't need + # to start from the beginning on failure + set_config(plugin_name + "_alembic_version", r.revision) + finally: + conn.close() + + # Set the new latest revision + set_config(plugin_name + "_alembic_version", upper) diff --git a/CTFd/schemas/__init__.py b/CTFd/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/CTFd/schemas/awards.py b/CTFd/schemas/awards.py new file mode 100644 index 0000000..728e902 --- /dev/null +++ b/CTFd/schemas/awards.py @@ -0,0 +1,48 @@ +from CTFd.models import Awards, ma +from CTFd.utils import string_types + + +class AwardSchema(ma.ModelSchema): + class Meta: + model = Awards + include_fk = True + dump_only = ("id", "date") + + views = { + "admin": [ + "category", + "user_id", + "name", + "description", + "value", + "team_id", + "user", + "team", + "date", + "requirements", + "id", + "icon", + ], + "user": [ + "category", + "user_id", + "name", + "description", + "value", + "team_id", + "user", + "team", + "date", + "id", + "icon", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(AwardSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/brackets.py b/CTFd/schemas/brackets.py new file mode 100644 index 0000000..f8b6905 --- /dev/null +++ b/CTFd/schemas/brackets.py @@ -0,0 +1,8 @@ +from CTFd.models import Brackets, ma + + +class BracketSchema(ma.ModelSchema): + class Meta: + model = Brackets + include_fk = True + dump_only = ("id",) diff --git a/CTFd/schemas/challenges.py b/CTFd/schemas/challenges.py new file mode 100644 index 0000000..468ab32 --- /dev/null +++ b/CTFd/schemas/challenges.py @@ -0,0 +1,74 @@ +from marshmallow import validate +from marshmallow.exceptions import ValidationError +from marshmallow_sqlalchemy import field_for + +from CTFd.models import Challenges, ma + + +class ChallengeRequirementsValidator(validate.Validator): + default_message = "Error parsing challenge requirements" + + def __init__(self, error=None): + self.error = error or self.default_message + + def __call__(self, value): + if isinstance(value, dict) is False: + raise ValidationError(self.default_message) + + prereqs = value.get("prerequisites", []) + if all(prereqs) is False: + raise ValidationError( + "Challenge requirements cannot have a null prerequisite" + ) + + return value + + +class ChallengeSchema(ma.ModelSchema): + class Meta: + model = Challenges + include_fk = True + dump_only = ("id",) + + name = field_for( + Challenges, + "name", + validate=[ + validate.Length( + min=0, + max=80, + error="Challenge could not be saved. Challenge name too long", + ) + ], + ) + + category = field_for( + Challenges, + "category", + validate=[ + validate.Length( + min=0, + max=80, + error="Challenge could not be saved. Challenge category too long", + ) + ], + ) + + description = field_for( + Challenges, + "description", + allow_none=True, + validate=[ + validate.Length( + min=0, + max=65535, + error="Challenge could not be saved. Challenge description too long", + ) + ], + ) + + requirements = field_for( + Challenges, + "requirements", + validate=[ChallengeRequirementsValidator()], + ) diff --git a/CTFd/schemas/comments.py b/CTFd/schemas/comments.py new file mode 100644 index 0000000..d5ef43b --- /dev/null +++ b/CTFd/schemas/comments.py @@ -0,0 +1,14 @@ +from marshmallow import fields + +from CTFd.models import Comments, ma +from CTFd.schemas.users import UserSchema + + +class CommentSchema(ma.ModelSchema): + class Meta: + model = Comments + include_fk = True + dump_only = ("id", "date", "html", "author", "author_id", "type") + + author = fields.Nested(UserSchema(only=("name",))) + html = fields.String() diff --git a/CTFd/schemas/config.py b/CTFd/schemas/config.py new file mode 100644 index 0000000..266c18f --- /dev/null +++ b/CTFd/schemas/config.py @@ -0,0 +1,43 @@ +from marshmallow import fields +from marshmallow.exceptions import ValidationError +from marshmallow_sqlalchemy import field_for + +from CTFd.models import Configs, ma +from CTFd.utils import string_types + + +class ConfigValueField(fields.Field): + """ + Custom value field for Configs so that we can perform validation of values + """ + + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(value, str): + # 65535 bytes is the size of a TEXT column in MySQL + # You may be able to exceed this in other databases + # but MySQL is our database of record + if len(value) > 65535: + raise ValidationError(f'{data["key"]} config is too long') + return value + else: + return value + + +class ConfigSchema(ma.ModelSchema): + class Meta: + model = Configs + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "key", "value"]} + key = field_for(Configs, "key", required=True) + value = ConfigValueField(allow_none=True, required=True) + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(ConfigSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/fields.py b/CTFd/schemas/fields.py new file mode 100644 index 0000000..0199b9d --- /dev/null +++ b/CTFd/schemas/fields.py @@ -0,0 +1,38 @@ +from marshmallow import fields + +from CTFd.models import Fields, TeamFieldEntries, UserFieldEntries, db, ma + + +class FieldSchema(ma.ModelSchema): + class Meta: + model = Fields + include_fk = True + dump_only = ("id",) + + +class UserFieldEntriesSchema(ma.ModelSchema): + class Meta: + model = UserFieldEntries + sqla_session = db.session + include_fk = True + load_only = ("id",) + exclude = ("field", "user", "user_id") + dump_only = ("user_id", "name", "description", "type") + + name = fields.Nested(FieldSchema, only=("name"), attribute="field") + description = fields.Nested(FieldSchema, only=("description"), attribute="field") + type = fields.Nested(FieldSchema, only=("field_type"), attribute="field") + + +class TeamFieldEntriesSchema(ma.ModelSchema): + class Meta: + model = TeamFieldEntries + sqla_session = db.session + include_fk = True + load_only = ("id",) + exclude = ("field", "team", "team_id") + dump_only = ("team_id", "name", "description", "type") + + name = fields.Nested(FieldSchema, only=("name"), attribute="field") + description = fields.Nested(FieldSchema, only=("description"), attribute="field") + type = fields.Nested(FieldSchema, only=("field_type"), attribute="field") diff --git a/CTFd/schemas/files.py b/CTFd/schemas/files.py new file mode 100644 index 0000000..c401aa6 --- /dev/null +++ b/CTFd/schemas/files.py @@ -0,0 +1,18 @@ +from CTFd.models import Files, ma +from CTFd.utils import string_types + + +class FileSchema(ma.ModelSchema): + class Meta: + model = Files + include_fk = True + dump_only = ("id", "type", "location") + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(FileSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/flags.py b/CTFd/schemas/flags.py new file mode 100644 index 0000000..3af60bc --- /dev/null +++ b/CTFd/schemas/flags.py @@ -0,0 +1,18 @@ +from CTFd.models import Flags, ma +from CTFd.utils import string_types + + +class FlagSchema(ma.ModelSchema): + class Meta: + model = Flags + include_fk = True + dump_only = ("id",) + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(FlagSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/hints.py b/CTFd/schemas/hints.py new file mode 100644 index 0000000..32fbab0 --- /dev/null +++ b/CTFd/schemas/hints.py @@ -0,0 +1,43 @@ +from CTFd.models import Hints, ma +from CTFd.utils import string_types + + +class HintSchema(ma.ModelSchema): + class Meta: + model = Hints + include_fk = True + dump_only = ("id", "type", "html") + + views = { + "locked": ["id", "title", "type", "challenge", "challenge_id", "cost"], + "unlocked": [ + "id", + "title", + "type", + "challenge", + "challenge_id", + "content", + "html", + "cost", + ], + "admin": [ + "id", + "title", + "type", + "challenge", + "challenge_id", + "content", + "html", + "cost", + "requirements", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(HintSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/notifications.py b/CTFd/schemas/notifications.py new file mode 100644 index 0000000..8c3adf2 --- /dev/null +++ b/CTFd/schemas/notifications.py @@ -0,0 +1,23 @@ +from marshmallow import fields + +from CTFd.models import Notifications, ma +from CTFd.utils import string_types + + +class NotificationSchema(ma.ModelSchema): + class Meta: + model = Notifications + include_fk = True + dump_only = ("id", "date", "html") + + # Used to force the schema to include the html property from the model + html = fields.Str() + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(NotificationSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/pages.py b/CTFd/schemas/pages.py new file mode 100644 index 0000000..16a6c1e --- /dev/null +++ b/CTFd/schemas/pages.py @@ -0,0 +1,77 @@ +from marshmallow import pre_load, validate +from marshmallow_sqlalchemy import field_for + +from CTFd.models import Pages, ma +from CTFd.utils import string_types + + +class PageSchema(ma.ModelSchema): + class Meta: + model = Pages + include_fk = True + dump_only = ("id",) + + title = field_for( + Pages, + "title", + validate=[ + validate.Length( + min=0, + max=80, + error="Page could not be saved. Your title is too long.", + ) + ], + ) + + route = field_for( + Pages, + "route", + allow_none=True, + validate=[ + validate.Length( + min=0, + max=128, + error="Page could not be saved. Your route is too long.", + ) + ], + ) + + content = field_for( + Pages, + "content", + allow_none=True, + validate=[ + validate.Length( + min=0, + max=65535, + error="Page could not be saved. Your content is too long.", + ) + ], + ) + + link_target = field_for( + Pages, + "link_target", + allow_none=True, + validate=[ + validate.OneOf( + choices=[None, "_self", "_blank"], + error="Invalid link target", + ) + ], + ) + + @pre_load + def validate_route(self, data): + route = data.get("route") + if route and route.startswith("/"): + data["route"] = route.strip("/") + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(PageSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/ratings.py b/CTFd/schemas/ratings.py new file mode 100644 index 0000000..ddd677e --- /dev/null +++ b/CTFd/schemas/ratings.py @@ -0,0 +1,38 @@ +from marshmallow import fields + +from CTFd.models import Ratings, ma +from CTFd.schemas.challenges import ChallengeSchema +from CTFd.schemas.users import UserSchema +from CTFd.utils import string_types + + +class RatingSchema(ma.ModelSchema): + user = fields.Nested(UserSchema, only=["id", "name"]) + challenge = fields.Nested(ChallengeSchema, only=["id", "name", "category"]) + + class Meta: + model = Ratings + include_fk = True + dump_only = ("id", "date", "user_id", "challenge_id") + + views = { + "admin": [ + "id", + "user_id", + "user", + "challenge_id", + "challenge", + "value", + "review", + "date", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(RatingSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/solutions.py b/CTFd/schemas/solutions.py new file mode 100644 index 0000000..390cf54 --- /dev/null +++ b/CTFd/schemas/solutions.py @@ -0,0 +1,40 @@ +from CTFd.models import Solutions, ma +from CTFd.utils import string_types + + +class SolutionSchema(ma.ModelSchema): + class Meta: + model = Solutions + include_fk = True + dump_only = ("id",) + + views = { + "locked": [ + "id", + "challenge_id", + "state", + ], + "unlocked": [ + "id", + "challenge_id", + "content", + "html", + "state", + ], + "admin": [ + "id", + "challenge_id", + "content", + "html", + "state", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(SolutionSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/submissions.py b/CTFd/schemas/submissions.py new file mode 100644 index 0000000..62ef1ec --- /dev/null +++ b/CTFd/schemas/submissions.py @@ -0,0 +1,52 @@ +from marshmallow import fields + +from CTFd.models import Submissions, ma +from CTFd.schemas.challenges import ChallengeSchema +from CTFd.schemas.teams import TeamSchema +from CTFd.schemas.users import UserSchema +from CTFd.utils import string_types + + +class SubmissionSchema(ma.ModelSchema): + challenge = fields.Nested(ChallengeSchema, only=["id", "name", "category", "value"]) + user = fields.Nested(UserSchema, only=["id", "name"]) + team = fields.Nested(TeamSchema, only=["id", "name"]) + + class Meta: + model = Submissions + include_fk = True + dump_only = ("id",) + + views = { + "admin": [ + "provided", + "ip", + "challenge_id", + "challenge", + "user", + "team", + "date", + "type", + "id", + ], + "user": ["challenge_id", "challenge", "user", "team", "date", "type", "id"], + "self": [ + "challenge_id", + "challenge", + "user", + "team", + "date", + "type", + "id", + "provided", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(SubmissionSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/tags.py b/CTFd/schemas/tags.py new file mode 100644 index 0000000..ac4039e --- /dev/null +++ b/CTFd/schemas/tags.py @@ -0,0 +1,20 @@ +from CTFd.models import Tags, ma +from CTFd.utils import string_types + + +class TagSchema(ma.ModelSchema): + class Meta: + model = Tags + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "challenge", "value"], "user": ["value"]} + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(TagSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/teams.py b/CTFd/schemas/teams.py new file mode 100644 index 0000000..eb3de62 --- /dev/null +++ b/CTFd/schemas/teams.py @@ -0,0 +1,416 @@ +from marshmallow import ValidationError, post_dump, pre_load, validate +from marshmallow.fields import Nested +from marshmallow_sqlalchemy import field_for +from sqlalchemy.orm import load_only + +from CTFd.models import Brackets, TeamFieldEntries, TeamFields, Teams, Users, ma +from CTFd.schemas.fields import TeamFieldEntriesSchema +from CTFd.utils import get_config, string_types +from CTFd.utils.crypto import verify_password +from CTFd.utils.user import get_current_team, get_current_user, is_admin +from CTFd.utils.validators import validate_country_code + + +class TeamSchema(ma.ModelSchema): + class Meta: + model = Teams + include_fk = True + dump_only = ("id", "oauth_id", "created", "members") + load_only = ("password",) + + name = field_for( + Teams, + "name", + required=True, + allow_none=False, + validate=[ + validate.Length(min=1, max=128, error="Team names must not be empty") + ], + ) + email = field_for( + Teams, + "email", + allow_none=False, + validate=validate.Email("Emails must be a properly formatted email address"), + ) + password = field_for(Teams, "password", required=True, allow_none=False) + website = field_for( + Teams, + "website", + validate=[ + # This is a dirty hack to let website accept empty strings so you can remove your website + lambda website: validate.URL( + error="Websites must be a proper URL starting with http or https", + schemes={"http", "https"}, + )(website) + if website + else True + ], + ) + country = field_for(Teams, "country", validate=[validate_country_code]) + bracket_id = field_for(Teams, "bracket_id") + fields = Nested( + TeamFieldEntriesSchema, partial=True, many=True, attribute="field_entries" + ) + + @pre_load + def validate_name(self, data): + name = data.get("name") + if name is None: + return + name = name.strip() + + existing_team = Teams.query.filter_by(name=name).first() + current_team = get_current_team() + # Admins should be able to patch anyone but they cannot cause a collision. + if is_admin(): + team_id = int(data.get("id", 0)) + if team_id: + if existing_team and existing_team.id != team_id: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + else: + # If there's no Team ID it means that the admin is creating a team with no ID. + if existing_team: + if current_team: + if current_team.id != existing_team.id: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + else: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + else: + # We need to allow teams to edit themselves and allow the "conflict" + if data["name"] == current_team.name: + return data + else: + name_changes = get_config("name_changes", default=True) + if bool(name_changes) is False: + raise ValidationError( + "Name changes are disabled", field_names=["name"] + ) + + if existing_team: + raise ValidationError( + "Team name has already been taken", field_names=["name"] + ) + + @pre_load + def validate_email(self, data): + email = data.get("email") + if email is None: + return + + existing_team = Teams.query.filter_by(email=email).first() + if is_admin(): + team_id = data.get("id") + if team_id: + if existing_team and existing_team.id != team_id: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + if existing_team: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + current_team = get_current_team() + if email == current_team.email: + return data + else: + if existing_team: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + + @pre_load + def validate_password_confirmation(self, data): + password = data.get("password") + confirm = data.get("confirm") + + if is_admin(): + pass + else: + current_team = get_current_team() + current_user = get_current_user() + + if current_team.captain_id != current_user.id: + raise ValidationError( + "Only the captain can change the team password", + field_names=["captain_id"], + ) + + if current_team.password is None: + return + + if password and (bool(confirm) is False): + raise ValidationError( + "Please confirm your current password", field_names=["confirm"] + ) + + if password and confirm: + test_team = verify_password( + plaintext=confirm, ciphertext=current_team.password + ) + test_captain = verify_password( + plaintext=confirm, ciphertext=current_user.password + ) + if test_team is True or test_captain is True: + return data + else: + raise ValidationError( + "Your previous password is incorrect", field_names=["confirm"] + ) + else: + data.pop("password", None) + data.pop("confirm", None) + + @pre_load + def validate_captain_id(self, data): + captain_id = data.get("captain_id") + if captain_id is None: + return + + if is_admin(): + team_id = data.get("id") + if team_id: + target_team = Teams.query.filter_by(id=team_id).first() + else: + target_team = get_current_team() + captain = Users.query.filter_by(id=captain_id).first() + if captain in target_team.members: + return + else: + raise ValidationError("Invalid Captain ID", field_names=["captain_id"]) + else: + current_team = get_current_team() + current_user = get_current_user() + if current_team.captain_id == current_user.id: + captain = Users.query.filter_by(id=captain_id).first() + if captain in current_team.members: + return + else: + raise ValidationError( + "Only team members can be promoted to captain", + field_names=["captain_id"], + ) + else: + raise ValidationError( + "Only the captain can change team captain", + field_names=["captain_id"], + ) + + @pre_load + def validate_bracket_id(self, data): + bracket_id = data.get("bracket_id") + if is_admin(): + bracket = Brackets.query.filter_by(id=bracket_id).first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + current_team = get_current_team() + # Teams are not allowed to switch their bracket + if bracket_id is None: + # Remove bracket_id and short circuit processing + data.pop("bracket_id", None) + return + if ( + current_team.bracket_id == int(bracket_id) + or current_team.bracket_id is None + ): + bracket = Brackets.query.filter_by(id=bracket_id, type="teams").first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + raise ValidationError( + "Please contact an admin to change your bracket", + field_names=["bracket_id"], + ) + + @pre_load + def validate_fields(self, data): + """ + This validator is used to only allow users to update the field entry for their user. + It's not possible to exclude it because without the PK Marshmallow cannot load the right instance + """ + fields = data.get("fields") + if fields is None: + return + + current_team = get_current_team() + + if is_admin(): + team_id = data.get("id") + if team_id: + target_team = Teams.query.filter_by(id=data["id"]).first() + else: + target_team = current_team + + # We are editting an existing + if self.view == "admin" and self.instance: + target_team = self.instance + provided_ids = [] + for f in fields: + f.pop("id", None) + field_id = f.get("field_id") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = TeamFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = TeamFieldEntries.query.filter_by( + field_id=field.id, team_id=target_team.id + ).first() + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + TeamFieldEntries.query.options(load_only("id")) + .filter_by(team_id=target_team.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + else: + provided_ids = [] + for f in fields: + # Remove any existing set + f.pop("id", None) + field_id = f.get("field_id") + value = f.get("value") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = TeamFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = TeamFieldEntries.query.filter_by( + field_id=field.id, team_id=current_team.id + ).first() + + if field.required is True: + if isinstance(value, str): + if value.strip() == "": + raise ValidationError( + f"Field '{field.name}' is required", + field_names=["fields"], + ) + + if field.editable is False and entry is not None: + raise ValidationError( + f"Field '{field.name}' cannot be editted", + field_names=["fields"], + ) + + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + TeamFieldEntries.query.options(load_only("id")) + .filter_by(team_id=current_team.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + + @post_dump + def process_fields(self, data): + """ + Handle permissions levels for fields. + This is post_dump to manipulate JSON instead of the raw db object + + Admins can see all fields. + Users (self) can see their edittable and public fields + Public (user) can only see public fields + """ + # Gather all possible fields + removed_field_ids = [] + fields = TeamFields.query.all() + + # Select fields for removal based on current view and properties of the field + for field in fields: + if self.view == "user": + if field.public is False: + removed_field_ids.append(field.id) + elif self.view == "self": + if field.editable is False and field.public is False: + removed_field_ids.append(field.id) + + # Rebuild fuilds + fields = data.get("fields") + if fields: + data["fields"] = [ + field for field in fields if field["field_id"] not in removed_field_ids + ] + + views = { + "user": [ + "website", + "name", + "country", + "affiliation", + "bracket_id", + "members", + "id", + "oauth_id", + "captain_id", + "fields", + ], + "self": [ + "website", + "name", + "email", + "country", + "affiliation", + "bracket_id", + "members", + "id", + "oauth_id", + "password", + "captain_id", + "fields", + ], + "admin": [ + "website", + "name", + "created", + "country", + "banned", + "email", + "affiliation", + "secret", + "bracket_id", + "members", + "hidden", + "id", + "oauth_id", + "password", + "captain_id", + "fields", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + self.view = view + + super(TeamSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/tokens.py b/CTFd/schemas/tokens.py new file mode 100644 index 0000000..094a01d --- /dev/null +++ b/CTFd/schemas/tokens.py @@ -0,0 +1,31 @@ +from CTFd.models import Tokens, ma +from CTFd.utils import string_types + + +class TokenSchema(ma.ModelSchema): + class Meta: + model = Tokens + include_fk = True + dump_only = ("id", "expiration", "type") + + views = { + "admin": [ + "id", + "type", + "user_id", + "created", + "expiration", + "description", + "value", + ], + "user": ["id", "type", "created", "expiration", "description"], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(TokenSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/topics.py b/CTFd/schemas/topics.py new file mode 100644 index 0000000..c71e8d9 --- /dev/null +++ b/CTFd/schemas/topics.py @@ -0,0 +1,38 @@ +from CTFd.models import ChallengeTopics, Topics, ma +from CTFd.utils import string_types + + +class TopicSchema(ma.ModelSchema): + class Meta: + model = Topics + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "value"]} + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(TopicSchema, self).__init__(*args, **kwargs) + + +class ChallengeTopicSchema(ma.ModelSchema): + class Meta: + model = ChallengeTopics + include_fk = True + dump_only = ("id",) + + views = {"admin": ["id", "challenge_id", "topic_id"]} + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(ChallengeTopicSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/unlocks.py b/CTFd/schemas/unlocks.py new file mode 100644 index 0000000..964b453 --- /dev/null +++ b/CTFd/schemas/unlocks.py @@ -0,0 +1,23 @@ +from CTFd.models import Unlocks, ma +from CTFd.utils import string_types + + +class UnlockSchema(ma.ModelSchema): + class Meta: + model = Unlocks + include_fk = True + dump_only = ("id", "date") + + views = { + "admin": ["user_id", "target", "team_id", "date", "type", "id"], + "user": ["target", "date", "type", "id"], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + + super(UnlockSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py new file mode 100644 index 0000000..0fba350 --- /dev/null +++ b/CTFd/schemas/users.py @@ -0,0 +1,416 @@ +from marshmallow import ValidationError, post_dump, pre_load, validate +from marshmallow.fields import Nested +from marshmallow_sqlalchemy import field_for +from sqlalchemy.orm import load_only + +from CTFd.models import Brackets, UserFieldEntries, UserFields, Users, ma +from CTFd.schemas.fields import UserFieldEntriesSchema +from CTFd.utils import get_config, string_types +from CTFd.utils.crypto import verify_password +from CTFd.utils.email import check_email_is_blacklisted, check_email_is_whitelisted +from CTFd.utils.user import get_current_user, is_admin +from CTFd.utils.validators import validate_country_code, validate_language + + +class UserSchema(ma.ModelSchema): + class Meta: + model = Users + include_fk = True + dump_only = ("id", "oauth_id", "created", "team_id") + load_only = ("password",) + + name = field_for( + Users, + "name", + required=True, + allow_none=False, + validate=[ + validate.Length(min=1, max=128, error="User names must not be empty") + ], + ) + email = field_for( + Users, + "email", + allow_none=False, + validate=[ + validate.Email("Emails must be a properly formatted email address"), + validate.Length(min=1, max=128, error="Emails must not be empty"), + ], + ) + website = field_for( + Users, + "website", + validate=[ + # This is a dirty hack to let website accept empty strings so you can remove your website + lambda website: validate.URL( + error="Websites must be a proper URL starting with http or https", + schemes={"http", "https"}, + )(website) + if website + else True + ], + ) + language = field_for(Users, "language", validate=[validate_language]) + country = field_for(Users, "country", validate=[validate_country_code]) + password = field_for(Users, "password", required=True, allow_none=False) + bracket_id = field_for(Users, "bracket_id") + fields = Nested( + UserFieldEntriesSchema, partial=True, many=True, attribute="field_entries" + ) + + @pre_load + def validate_name(self, data): + name = data.get("name") + if name is None: + return + name = name.strip() + + existing_user = Users.query.filter_by(name=name).first() + current_user = get_current_user() + if is_admin(): + user_id = data.get("id") + if user_id: + if existing_user and existing_user.id != user_id: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + else: + if existing_user: + if current_user: + if current_user.id != existing_user.id: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + else: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + else: + if name == current_user.name: + return data + else: + name_changes = get_config("name_changes", default=True) + if bool(name_changes) is False: + raise ValidationError( + "Name changes are disabled", field_names=["name"] + ) + if existing_user: + raise ValidationError( + "User name has already been taken", field_names=["name"] + ) + + @pre_load + def validate_email(self, data): + email = data.get("email") + if email is None: + return + email = email.strip() + + existing_user = Users.query.filter_by(email=email).first() + current_user = get_current_user() + if is_admin(): + user_id = data.get("id") + if user_id: + if existing_user and existing_user.id != user_id: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + if existing_user: + if current_user: + if current_user.id != existing_user.id: + raise ValidationError( + "Email address has already been used", + field_names=["email"], + ) + else: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + else: + if email == current_user.email: + return data + else: + confirm = data.get("confirm") + + if bool(confirm) is False: + raise ValidationError( + "Please confirm your current password", field_names=["confirm"] + ) + + test = verify_password( + plaintext=confirm, ciphertext=current_user.password + ) + if test is False: + raise ValidationError( + "Your previous password is incorrect", field_names=["confirm"] + ) + + if existing_user: + raise ValidationError( + "Email address has already been used", field_names=["email"] + ) + if check_email_is_whitelisted(email) is False: + raise ValidationError( + "Email address is not from an allowed domain", + field_names=["email"], + ) + if check_email_is_blacklisted(email) is True: + raise ValidationError( + "Email address is not from an allowed domain", + field_names=["email"], + ) + if get_config("verify_emails"): + current_user.verified = False + + @pre_load + def validate_password_confirmation(self, data): + password = data.get("password") + confirm = data.get("confirm") + target_user = get_current_user() + + if is_admin(): + pass + else: + # If the user has no password set, allow them to set their password + if target_user.password is None: + return + + if password and (bool(confirm) is False): + raise ValidationError( + "Please confirm your current password", field_names=["confirm"] + ) + + if password and confirm: + password_min_length = int(get_config("password_min_length", default=0)) + if len(password) < password_min_length: + raise ValidationError( + f"Password must be at least {password_min_length} characters", + field_names=["password"], + ) + + test = verify_password( + plaintext=confirm, ciphertext=target_user.password + ) + if test is True: + return data + else: + raise ValidationError( + "Your previous password is incorrect", field_names=["confirm"] + ) + else: + data.pop("password", None) + data.pop("confirm", None) + + @pre_load + def validate_bracket_id(self, data): + bracket_id = data.get("bracket_id") + if is_admin(): + bracket = Brackets.query.filter_by(id=bracket_id, type="users").first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + current_user = get_current_user() + # Users are not allowed to switch their bracket + if bracket_id is None: + # Remove bracket_id and short circuit processing + data.pop("bracket_id", None) + return + if ( + current_user.bracket_id == int(bracket_id) + or current_user.bracket_id is None + ): + bracket = Brackets.query.filter_by(id=bracket_id, type="users").first() + if bracket is None: + ValidationError( + "Please provide a valid bracket id", field_names=["bracket_id"] + ) + else: + raise ValidationError( + "Please contact an admin to change your bracket", + field_names=["bracket_id"], + ) + + @pre_load + def validate_fields(self, data): + """ + This validator is used to only allow users to update the field entry for their user. + It's not possible to exclude it because without the PK Marshmallow cannot load the right instance + """ + fields = data.get("fields") + if fields is None: + return + + current_user = get_current_user() + + if is_admin(): + user_id = data.get("id") + if user_id: + target_user = Users.query.filter_by(id=data["id"]).first() + else: + target_user = current_user + + # We are editting an existing user + if self.view == "admin" and self.instance: + target_user = self.instance + provided_ids = [] + for f in fields: + f.pop("id", None) + field_id = f.get("field_id") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = UserFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = UserFieldEntries.query.filter_by( + field_id=field.id, user_id=target_user.id + ).first() + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + UserFieldEntries.query.options(load_only("id")) + .filter_by(user_id=target_user.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + else: + provided_ids = [] + for f in fields: + # Remove any existing set + f.pop("id", None) + field_id = f.get("field_id") + value = f.get("value") + + # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce + field = UserFields.query.filter_by(id=field_id).first_or_404() + + # Get the existing field entry if one exists + entry = UserFieldEntries.query.filter_by( + field_id=field.id, user_id=current_user.id + ).first() + + if field.required is True: + if isinstance(value, str): + if value.strip() == "": + raise ValidationError( + f"Field '{field.name}' is required", + field_names=["fields"], + ) + + if field.editable is False and entry is not None: + raise ValidationError( + f"Field '{field.name}' cannot be editted", + field_names=["fields"], + ) + + if entry: + f["id"] = entry.id + provided_ids.append(entry.id) + + # Extremely dirty hack to prevent deleting previously provided data. + # This needs a better soln. + entries = ( + UserFieldEntries.query.options(load_only("id")) + .filter_by(user_id=current_user.id) + .all() + ) + for entry in entries: + if entry.id not in provided_ids: + fields.append({"id": entry.id}) + + @post_dump + def process_fields(self, data): + """ + Handle permissions levels for fields. + This is post_dump to manipulate JSON instead of the raw db object + + Admins can see all fields. + Users (self) can see their edittable and public fields + Public (user) can only see public fields + """ + # Gather all possible fields + removed_field_ids = [] + fields = UserFields.query.all() + + # Select fields for removal based on current view and properties of the field + for field in fields: + if self.view == "user": + if field.public is False: + removed_field_ids.append(field.id) + elif self.view == "self": + if field.editable is False and field.public is False: + removed_field_ids.append(field.id) + + # Rebuild fuilds + fields = data.get("fields") + if fields: + data["fields"] = [ + field for field in fields if field["field_id"] not in removed_field_ids + ] + + views = { + "user": [ + "website", + "name", + "country", + "affiliation", + "bracket_id", + "id", + "oauth_id", + "fields", + "team_id", + ], + "self": [ + "website", + "name", + "email", + "language", + "country", + "affiliation", + "bracket_id", + "id", + "oauth_id", + "password", + "fields", + "team_id", + ], + "admin": [ + "website", + "name", + "created", + "country", + "banned", + "email", + "language", + "affiliation", + "secret", + "bracket_id", + "hidden", + "id", + "oauth_id", + "password", + "type", + "verified", + "change_password", + "fields", + "team_id", + ], + } + + def __init__(self, view=None, *args, **kwargs): + if view: + if isinstance(view, string_types): + kwargs["only"] = self.views[view] + elif isinstance(view, list): + kwargs["only"] = view + self.view = view + + super(UserSchema, self).__init__(*args, **kwargs) diff --git a/CTFd/scoreboard.py b/CTFd/scoreboard.py new file mode 100644 index 0000000..261813b --- /dev/null +++ b/CTFd/scoreboard.py @@ -0,0 +1,29 @@ +from flask import Blueprint, render_template + +from CTFd.utils import config +from CTFd.utils.config.visibility import scores_visible +from CTFd.utils.decorators.visibility import ( + check_account_visibility, + check_score_visibility, +) +from CTFd.utils.helpers import get_infos +from CTFd.utils.scores import get_standings +from CTFd.utils.user import is_admin + +scoreboard = Blueprint("scoreboard", __name__) + + +@scoreboard.route("/scoreboard") +@check_account_visibility +@check_score_visibility +def listing(): + infos = get_infos() + + if config.is_scoreboard_frozen(): + infos.append("Scoreboard has been frozen") + + if is_admin() is True and scores_visible() is False: + infos.append("Scores are not currently visible to users") + + standings = get_standings() + return render_template("scoreboard.html", standings=standings, infos=infos) diff --git a/CTFd/share.py b/CTFd/share.py new file mode 100644 index 0000000..d991a94 --- /dev/null +++ b/CTFd/share.py @@ -0,0 +1,36 @@ +from flask import Blueprint, abort, request + +from CTFd.utils import get_config +from CTFd.utils.social import get_social_share + +social = Blueprint("social", __name__) + + +@social.route("/share//assets/") +def assets(type, path): + if bool(get_config("social_shares", default=True)) is False: + abort(403) + SocialShare = get_social_share(type) + if SocialShare is None: + abort(404) + + s = SocialShare() + if path != s.mac + ".png": + abort(404) + + return s.asset(path) + + +@social.route("/share/") +def share(type): + if bool(get_config("social_shares", default=True)) is False: + abort(403) + SocialShare = get_social_share(type) + if SocialShare is None: + abort(404) + + s = SocialShare() + if request.args.get("mac") != s.mac: + abort(404) + + return s.content diff --git a/CTFd/teams.py b/CTFd/teams.py new file mode 100644 index 0000000..192d576 --- /dev/null +++ b/CTFd/teams.py @@ -0,0 +1,403 @@ +from flask import Blueprint, abort, redirect, render_template, request, url_for + +from CTFd.cache import clear_team_session, clear_user_session +from CTFd.exceptions import TeamTokenExpiredException, TeamTokenInvalidException +from CTFd.models import Brackets, TeamFieldEntries, TeamFields, Teams, db +from CTFd.utils import config, get_config, validators +from CTFd.utils.crypto import verify_password +from CTFd.utils.decorators import authed_only, ratelimit, registered_only +from CTFd.utils.decorators.modes import require_team_mode +from CTFd.utils.decorators.visibility import ( + check_account_visibility, + check_score_visibility, +) +from CTFd.utils.helpers import get_errors, get_infos +from CTFd.utils.humanize.words import pluralize +from CTFd.utils.user import get_current_user, get_current_user_attrs +from CTFd.utils.validators import ValidationError + +teams = Blueprint("teams", __name__) + + +@teams.route("/teams") +@check_account_visibility +@require_team_mode +def listing(): + q = request.args.get("q") + field = request.args.get("field", "name") + filters = [] + + if field not in ("name", "affiliation", "website"): + field = "name" + + if q: + filters.append(getattr(Teams, field).like("%{}%".format(q))) + + teams = ( + Teams.query.filter_by(hidden=False, banned=False) + .filter(*filters) + .order_by(Teams.id.asc()) + .paginate(per_page=50, error_out=False) + ) + + args = dict(request.args) + args.pop("page", 1) + + return render_template( + "teams/teams.html", + teams=teams, + prev_page=url_for(request.endpoint, page=teams.prev_num, **args), + next_page=url_for(request.endpoint, page=teams.next_num, **args), + q=q, + field=field, + ) + + +@teams.route("/teams/invite", methods=["GET", "POST"]) +@registered_only +@require_team_mode +def invite(): + infos = get_infos() + errors = get_errors() + code = request.args.get("code") + + if code is None: + abort(404) + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + try: + team = Teams.load_invite_code(code) + except TeamTokenExpiredException: + abort(403, description="This invite URL has expired") + except TeamTokenInvalidException: + abort(403, description="This invite URL is invalid") + + team_size_limit = get_config("team_size", default=0) + + if request.method == "GET": + if team_size_limit: + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=pluralize(number=team_size_limit) + ) + ) + + return render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ) + + if request.method == "POST": + if errors: + return ( + render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ), + 403, + ) + + if team_size_limit and len(team.members) >= team_size_limit: + errors.append( + "{name} has already reached the team size limit of {limit}".format( + name=team.name, limit=team_size_limit + ) + ) + return ( + render_template( + "teams/invite.html", team=team, infos=infos, errors=errors + ), + 403, + ) + + user = get_current_user() + user.team_id = team.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + + +@teams.route("/teams/join", methods=["GET", "POST"]) +@authed_only +@require_team_mode +@ratelimit(method="POST", limit=10, interval=5) +def join(): + infos = get_infos() + errors = get_errors() + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + if request.method == "GET": + team_size_limit = get_config("team_size", default=0) + if team_size_limit: + plural = "" if team_size_limit == 1 else "s" + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=plural + ) + ) + return render_template("teams/join_team.html", infos=infos, errors=errors) + + if request.method == "POST": + teamname = request.form.get("name") + passphrase = request.form.get("password", "").strip() + + team = Teams.query.filter_by(name=teamname).first() + + if errors: + return ( + render_template("teams/join_team.html", infos=infos, errors=errors), + 403, + ) + + if team and verify_password(passphrase, team.password): + team_size_limit = get_config("team_size", default=0) + if team_size_limit and len(team.members) >= team_size_limit: + errors.append( + "{name} has already reached the team size limit of {limit}".format( + name=team.name, limit=team_size_limit + ) + ) + return render_template( + "teams/join_team.html", infos=infos, errors=errors + ) + + user = get_current_user() + user.team_id = team.id + db.session.commit() + + if len(team.members) == 1: + team.captain_id = user.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + else: + errors.append("That information is incorrect") + return render_template("teams/join_team.html", infos=infos, errors=errors) + + +@teams.route("/teams/new", methods=["GET", "POST"]) +@authed_only +@require_team_mode +def new(): + infos = get_infos() + errors = get_errors() + + if bool(get_config("team_creation", default=True)) is False: + abort( + 403, + description="Team creation is currently disabled. Please join an existing team.", + ) + + num_teams_limit = int(get_config("num_teams", default=0)) + num_teams = Teams.query.filter_by(banned=False, hidden=False).count() + if num_teams_limit and num_teams >= num_teams_limit: + abort( + 403, + description=f"Reached the maximum number of teams ({num_teams_limit}). Please join an existing team.", + ) + + user = get_current_user_attrs() + if user.team_id: + errors.append("You are already in a team. You cannot join another.") + + if request.method == "GET": + team_size_limit = get_config("team_size", default=0) + if team_size_limit: + plural = "" if team_size_limit == 1 else "s" + infos.append( + "Teams are limited to {limit} member{plural}".format( + limit=team_size_limit, plural=plural + ) + ) + return render_template("teams/new_team.html", infos=infos, errors=errors) + + elif request.method == "POST": + teamname = request.form.get("name", "").strip() + passphrase = request.form.get("password", "").strip() + + website = request.form.get("website") + affiliation = request.form.get("affiliation") + country = request.form.get("country") + bracket_id = request.form.get("bracket_id", None) + + user = get_current_user() + + existing_team = Teams.query.filter_by(name=teamname).first() + if existing_team: + errors.append("That team name is already taken") + if not teamname: + errors.append("That team name is invalid") + + # Process additional user fields + fields = {} + for field in TeamFields.query.all(): + fields[field.id] = field + + entries = {} + for field_id, field in fields.items(): + value = request.form.get(f"fields[{field_id}]", "").strip() + if field.required is True and (value is None or value == ""): + errors.append("Please provide all required fields") + break + + if field.field_type == "boolean": + entries[field_id] = bool(value) + else: + entries[field_id] = value + + if website: + valid_website = validators.validate_url(website) + else: + valid_website = True + + if affiliation: + valid_affiliation = len(affiliation) < 128 + else: + valid_affiliation = True + + if bracket_id: + valid_bracket = bool( + Brackets.query.filter_by(id=bracket_id, type="teams").first() + ) + else: + if Brackets.query.filter_by(type="teams").count(): + valid_bracket = False + else: + valid_bracket = True + + if country: + try: + validators.validate_country_code(country) + valid_country = True + except ValidationError: + valid_country = False + else: + valid_country = True + + if valid_website is False: + errors.append("Websites must be a proper URL starting with http or https") + if valid_affiliation is False: + errors.append("Please provide a shorter affiliation") + if valid_country is False: + errors.append("Invalid country") + if valid_bracket is False: + errors.append("Please provide a valid bracket") + + if errors: + return render_template("teams/new_team.html", errors=errors), 403 + + # Hide the created team if the creator is an admin + hidden = False + if user.type == "admin": + hidden = True + + team = Teams( + name=teamname, + password=passphrase, + captain_id=user.id, + hidden=hidden, + bracket_id=bracket_id, + ) + + if website: + team.website = website + if affiliation: + team.affiliation = affiliation + if country: + team.country = country + + db.session.add(team) + db.session.commit() + + for field_id, value in entries.items(): + entry = TeamFieldEntries(field_id=field_id, value=value, team_id=team.id) + db.session.add(entry) + db.session.commit() + + user.team_id = team.id + db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + + return redirect(url_for("challenges.listing")) + + +@teams.route("/team") +@authed_only +@require_team_mode +def private(): + infos = get_infos() + errors = get_errors() + + user = get_current_user() + if not user.team_id: + return render_template("teams/team_enrollment.html") + + team_id = user.team_id + + team = Teams.query.filter_by(id=team_id).first_or_404() + solves = team.get_solves() + awards = team.get_awards() + + place = team.place + score = team.get_score(admin=True) + + if config.is_scoreboard_frozen(): + infos.append("Scoreboard has been frozen") + + return render_template( + "teams/private.html", + solves=solves, + awards=awards, + user=user, + team=team, + score=score, + place=place, + score_frozen=config.is_scoreboard_frozen(), + infos=infos, + errors=errors, + ) + + +@teams.route("/teams/") +@check_account_visibility +@check_score_visibility +@require_team_mode +def public(team_id): + infos = get_infos() + errors = get_errors() + team = Teams.query.filter_by(id=team_id, banned=False, hidden=False).first_or_404() + solves = team.get_solves() + awards = team.get_awards() + + place = team.place + score = team.score + + if errors: + return render_template("teams/public.html", team=team, errors=errors) + + if config.is_scoreboard_frozen(): + infos.append("Scoreboard has been frozen") + + return render_template( + "teams/public.html", + solves=solves, + awards=awards, + team=team, + score=score, + place=place, + score_frozen=config.is_scoreboard_frozen(), + infos=infos, + errors=errors, + ) diff --git a/CTFd/themes/admin/assets/css/admin.scss b/CTFd/themes/admin/assets/css/admin.scss new file mode 100644 index 0000000..a764234 --- /dev/null +++ b/CTFd/themes/admin/assets/css/admin.scss @@ -0,0 +1,128 @@ +@import "includes/sticky-footer.css"; + +.section-title { + border-top: 1px solid lightgray; + margin: 15px 0 5px 0; + font-size: 14px; + font-weight: bold; + padding: 10px 0 0 20px; + color: #636c76; +} + +// Intended for the Bootstrap navbar icons +.action-icon { + margin-right: 8px; + display: inline-block; + width: 1.25em; + text-align: center; +} + +// Custom color picker on Theme page +input[type="color"].custom-color-picker { + padding: 5px; + margin-right: 8px; + border: none; + height: 50px; + width: 50px; + vertical-align: middle; +} + +#score-graph { + min-height: 400px; + display: block; + clear: both; +} + +#solves-graph { + display: block; + height: 350px; +} + +#keys-pie-graph { + min-height: 400px; + display: block; +} + +#categories-pie-graph { + min-height: 400px; + display: block; +} + +#points-pie-graph { + min-height: 400px; + display: block; +} + +#solve-percentages-graph { + min-height: 400px; + display: block; +} + +#score-distribution-graph { + min-height: 400px; + display: block; +} + +.no-decoration { + color: inherit !important; + text-decoration: none !important; +} + +.no-decoration:hover { + color: inherit !important; + text-decoration: none !important; +} + +.table td, +.table th { + vertical-align: inherit; +} + +pre { + white-space: pre-wrap; + margin: 0; + padding: 0; +} + +.form-control { + position: relative; + display: block; + /*padding: 0.8em;*/ + border-radius: 0; + /*background: #f0f0f0;*/ + /*color: #aaa;*/ + font-weight: 400; + font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif; + -webkit-appearance: none; +} + +tbody tr:hover { + background-color: rgba(0, 0, 0, 0.1) !important; +} + +[data-href] { + cursor: pointer; +} + +.sort-col { + cursor: pointer; +} + +input[type="checkbox"] { + cursor: pointer; +} + +.card-radio:checked + .card { + background-color: transparent !important; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} +.card-radio:checked + .card .card-radio-clone { + visibility: visible !important; +} +.card:hover { + cursor: pointer; +} diff --git a/CTFd/themes/admin/assets/css/challenge-board.scss b/CTFd/themes/admin/assets/css/challenge-board.scss new file mode 100644 index 0000000..ef6bbbd --- /dev/null +++ b/CTFd/themes/admin/assets/css/challenge-board.scss @@ -0,0 +1,68 @@ +.chal-desc { + padding-left: 30px; + padding-right: 30px; + font-size: 14px; +} + +.chal-desc img { + max-width: 100%; + height: auto; +} + +.modal-content { + border-radius: 0px; + max-width: 1000px; + padding: 1em; + margin: 0 auto; +} + +.btn-info { + background-color: #5b7290 !important; +} + +.badge-info { + background-color: #5b7290 !important; +} + +.challenge-button { + box-shadow: 3px 3px 3px grey; +} + +.solved-challenge { + background-color: #37d63e !important; + opacity: 0.4; + border: none; +} + +.corner-button-check { + margin-top: -10px; + margin-right: 25px; + position: absolute; + right: 0; +} + +.key-submit .btn { + height: 51px; +} + +#challenge-window .form-control { + position: relative; + display: block; + padding: 0.8em; + border-radius: 0; + background: #f0f0f0; + color: #aaa; + font-weight: 400; + font-family: "Avenir Next", "Helvetica Neue", Helvetica, Arial, sans-serif; + -webkit-appearance: none; + height: auto !important; +} + +#challenge-window .form-control:focus { + background-color: transparent; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} diff --git a/CTFd/themes/admin/assets/css/codemirror.scss b/CTFd/themes/admin/assets/css/codemirror.scss new file mode 100644 index 0000000..09b6af4 --- /dev/null +++ b/CTFd/themes/admin/assets/css/codemirror.scss @@ -0,0 +1,6 @@ +@import "~/codemirror/lib/codemirror.css"; +@import "includes/easymde.scss"; +.CodeMirror.cm-s-default { + font-size: 12px; + border: 1px solid lightgray; +} diff --git a/CTFd/themes/admin/assets/css/fonts.scss b/CTFd/themes/admin/assets/css/fonts.scss new file mode 100644 index 0000000..145309b --- /dev/null +++ b/CTFd/themes/admin/assets/css/fonts.scss @@ -0,0 +1,28 @@ +@use "sass:map"; +@use "~/@fontsource/lato/scss/mixins.scss" as Lato; +@use "~/@fontsource/raleway/scss/mixins.scss" as Raleway; + +@include Lato.faces( + $subsets: all, + $weights: ( + 400, + 700, + ), + $styles: all, + $directory: "../webfonts" +); + +@include Raleway.faces( + $subsets: all, + $weights: ( + 500, + ), + $styles: all, + $directory: "../webfonts" +); + +$fa-font-display: auto !default; +@import "~/@fortawesome/fontawesome-free/scss/fontawesome.scss"; +@import "~/@fortawesome/fontawesome-free/scss/regular.scss"; +@import "~/@fortawesome/fontawesome-free/scss/solid.scss"; +@import "~/@fortawesome/fontawesome-free/scss/brands.scss"; diff --git a/CTFd/themes/admin/assets/css/includes/award-icons.scss b/CTFd/themes/admin/assets/css/includes/award-icons.scss new file mode 100644 index 0000000..149f39a --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/award-icons.scss @@ -0,0 +1,39 @@ +.award-icon { + font-family: "Font Awesome 5 Free", "Font Awesome 5 Free Offline"; + font-weight: 900; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; +} + +.award-shield:before { + content: "\f3ed"; +} +.award-bug:before { + content: "\f188"; +} +.award-crown:before { + content: "\f521"; +} +.award-crosshairs:before { + content: "\f05b"; +} +.award-ban:before { + content: "\f05e"; +} +.award-lightning:before { + content: "\f0e7"; +} +.award-code:before { + content: "\f121"; +} +.award-cowboy:before { + content: "\f8c0"; +} +.award-angry:before { + content: "\f556"; +} diff --git a/CTFd/themes/admin/assets/css/includes/easymde.scss b/CTFd/themes/admin/assets/css/includes/easymde.scss new file mode 100644 index 0000000..fa6f858 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/easymde.scss @@ -0,0 +1,381 @@ +.CodeMirror.cm-s-easymde { + box-sizing: border-box; + height: auto; + border: 1px solid lightgray; + padding: 10px; + font: inherit; + z-index: 0; + word-wrap: break-word; +} + +.CodeMirror-scroll { + overflow-y: hidden; + overflow-x: auto; +} + +.editor-toolbar { + position: relative; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + padding: 0 10px; + border-top: 1px solid #bbb; + border-left: 1px solid #bbb; + border-right: 1px solid #bbb; +} + +.editor-toolbar:after, +.editor-toolbar:before { + display: block; + content: " "; + height: 1px; +} + +.editor-toolbar:before { + margin-bottom: 8px; +} + +.editor-toolbar:after { + margin-top: 8px; +} + +.editor-toolbar.fullscreen { + width: 100%; + height: 50px; + padding-top: 10px; + padding-bottom: 10px; + box-sizing: border-box; + background: #fff; + border: 0; + position: fixed; + top: 0; + left: 0; + opacity: 1; + z-index: 9; +} + +.editor-toolbar.fullscreen::before { + width: 20px; + height: 50px; + background: -moz-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: -webkit-gradient( + linear, + left top, + right top, + color-stop(0%, rgba(255, 255, 255, 1)), + color-stop(100%, rgba(255, 255, 255, 0)) + ); + background: -webkit-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: -o-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: -ms-linear-gradient( + left, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + background: linear-gradient( + to right, + rgba(255, 255, 255, 1) 0%, + rgba(255, 255, 255, 0) 100% + ); + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0; +} + +.editor-toolbar.fullscreen::after { + width: 20px; + height: 50px; + background: -moz-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: -webkit-gradient( + linear, + left top, + right top, + color-stop(0%, rgba(255, 255, 255, 0)), + color-stop(100%, rgba(255, 255, 255, 1)) + ); + background: -webkit-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: -o-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: -ms-linear-gradient( + left, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + background: linear-gradient( + to right, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + position: fixed; + top: 0; + right: 0; + margin: 0; + padding: 0; +} + +.editor-toolbar button, +.editor-toolbar .easymde-dropdown { + background: transparent; + display: inline-block; + text-align: center; + text-decoration: none !important; + height: 30px; + margin: 0; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; +} + +.editor-toolbar button { + width: 30px; +} + +.editor-toolbar button.active, +.editor-toolbar button:hover { + background: #fcfcfc; + border-color: #95a5a6; +} + +.editor-toolbar i.separator { + display: inline-block; + width: 0; + border-left: 1px solid #d9d9d9; + border-right: 1px solid #fff; + color: transparent; + text-indent: -10px; + margin: 0 6px; +} + +.editor-toolbar button:after { + font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + font-size: 65%; + vertical-align: text-bottom; + position: relative; + top: 2px; +} + +.editor-toolbar button.heading-1:after { + content: "1"; +} + +.editor-toolbar button.heading-2:after { + content: "2"; +} + +.editor-toolbar button.heading-3:after { + content: "3"; +} + +.editor-toolbar button.heading-bigger:after { + content: "▲"; +} + +.editor-toolbar button.heading-smaller:after { + content: "▼"; +} + +.editor-toolbar.disabled-for-preview button:not(.no-disable) { + opacity: 0.6; + pointer-events: none; +} + +@media only screen and (max-width: 700px) { + .editor-toolbar i.no-mobile { + display: none; + } +} + +.editor-statusbar { + padding: 8px 10px; + font-size: 12px; + color: #959694; + text-align: right; +} + +.editor-statusbar span { + display: inline-block; + min-width: 4em; + margin-left: 1em; +} + +.editor-statusbar .lines:before { + content: "lines: "; +} + +.editor-statusbar .words:before { + content: "words: "; +} + +.editor-statusbar .characters:before { + content: "characters: "; +} + +.editor-preview-full { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 7; + overflow: auto; + display: none; + box-sizing: border-box; +} + +.editor-preview-side { + position: fixed; + bottom: 0; + width: 50%; + top: 50px; + right: 0; + z-index: 9; + overflow: auto; + display: none; + box-sizing: border-box; + border: 1px solid #ddd; + word-wrap: break-word; +} + +.editor-preview-active-side { + display: block; +} + +.editor-preview-active { + display: block; +} + +.editor-preview { + padding: 10px; + background: #fafafa; +} + +.editor-preview > p { + margin-top: 0; +} + +.editor-preview pre { + background: #eee; + margin-bottom: 10px; +} + +.editor-preview table td, +.editor-preview table th { + border: 1px solid #ddd; + padding: 5px; +} + +.cm-s-easymde .cm-tag { + color: #63a35c; +} + +.cm-s-easymde .cm-attribute { + color: #795da3; +} + +.cm-s-easymde .cm-string { + color: #183691; +} + +.cm-s-easymde .cm-header-1 { + font-size: 200%; + line-height: 200%; +} + +.cm-s-easymde .cm-header-2 { + font-size: 160%; + line-height: 160%; +} + +.cm-s-easymde .cm-header-3 { + font-size: 125%; + line-height: 125%; +} + +.cm-s-easymde .cm-header-4 { + font-size: 110%; + line-height: 110%; +} + +.cm-s-easymde .cm-comment { + background: rgba(0, 0, 0, 0.05); + border-radius: 2px; +} + +.cm-s-easymde .cm-link { + color: #7f8c8d; +} + +.cm-s-easymde .cm-url { + color: #aab2b3; +} + +.cm-s-easymde .cm-quote { + color: #7f8c8d; + font-style: italic; +} + +.editor-toolbar .easymde-dropdown { + position: relative; + background: linear-gradient( + to bottom right, + #fff 0%, + #fff 84%, + #333 50%, + #333 100% + ); + border-radius: 0; + border: 1px solid #fff; +} + +.editor-toolbar .easymde-dropdown:hover { + background: linear-gradient( + to bottom right, + #fff 0%, + #fff 84%, + #333 50%, + #333 100% + ); +} + +.easymde-dropdown-content { + display: none; + position: absolute; + background-color: #f9f9f9; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); + padding: 8px; + z-index: 2; + top: 30px; +} + +.easymde-dropdown:active .easymde-dropdown-content, +.easymde-dropdown:focus .easymde-dropdown-content { + display: block; +} diff --git a/CTFd/themes/admin/assets/css/includes/flag-icons.scss b/CTFd/themes/admin/assets/css/includes/flag-icons.scss new file mode 100644 index 0000000..0b8d791 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/flag-icons.scss @@ -0,0 +1,511 @@ +$flags: ( + ad: "🇦🇩", + // Andorra + ae: "🇦🇪", + // United Arab Emirates + af: "🇦🇫", + // Afghanistan + ag: "🇦🇬", + // Antigua and Barbuda + ai: "🇦🇮", + // Anguilla + al: "🇦🇱", + // Albania + am: "🇦🇲", + // Armenia + ao: "🇦🇴", + // Angola + aq: "🇦🇶", + // Antarctica + ar: "🇦🇷", + // Argentina + as: "🇦🇸", + // American Samoa + at: "🇦🇹", + // Austria + au: "🇦🇺", + // Australia + aw: "🇦🇼", + // Aruba + ax: "🇦🇽", + // Åland Islands + az: "🇦🇿", + // Azerbaijan + ba: "🇧🇦", + // Bosnia and Herzegovina + bb: "🇧🇧", + // Barbados + bd: "🇧🇩", + // Bangladesh + be: "🇧🇪", + // Belgium + bf: "🇧🇫", + // Burkina Faso + bg: "🇧🇬", + // Bulgaria + bh: "🇧🇭", + // Bahrain + bi: "🇧🇮", + // Burundi + bj: "🇧🇯", + // Benin + bl: "🇧🇱", + // Saint Barthélemy + bm: "🇧🇲", + // Bermuda + bn: "🇧🇳", + // Brunei Darussalam + bo: "🇧🇴", + // Bolivia + bq: "🇧🇶", + // Bonaire, Sint Eustatius and Saba + br: "🇧🇷", + // Brazil + bs: "🇧🇸", + // Bahamas + bt: "🇧🇹", + // Bhutan + bv: "🇧🇻", + // Bouvet Island + bw: "🇧🇼", + // Botswana + by: "🇧🇾", + // Belarus + bz: "🇧🇿", + // Belize + ca: "🇨🇦", + // Canada + cc: "🇨🇨", + // Cocos (Keeling) Islands + cd: "🇨🇩", + // Congo + cf: "🇨🇫", + // Central African Republic + cg: "🇨🇬", + // Congo + ch: "🇨🇭", + // Switzerland + ci: "🇨🇮", + // Côte D'Ivoire + ck: "🇨🇰", + // Cook Islands + cl: "🇨🇱", + // Chile + cm: "🇨🇲", + // Cameroon + cn: "🇨🇳", + // China + co: "🇨🇴", + // Colombia + cr: "🇨🇷", + // Costa Rica + cu: "🇨🇺", + // Cuba + cv: "🇨🇻", + // Cape Verde + cw: "🇨🇼", + // Curaçao + cx: "🇨🇽", + // Christmas Island + cy: "🇨🇾", + // Cyprus + cz: "🇨🇿", + // Czech Republic + de: "🇩🇪", + // Germany + dj: "🇩🇯", + // Djibouti + dk: "🇩🇰", + // Denmark + dm: "🇩🇲", + // Dominica + do: "🇩🇴", + // Dominican Republic + dz: "🇩🇿", + // Algeria + ec: "🇪🇨", + // Ecuador + ee: "🇪🇪", + // Estonia + eg: "🇪🇬", + // Egypt + eh: "🇪🇭", + // Western Sahara + er: "🇪🇷", + // Eritrea + es: "🇪🇸", + // Spain + et: "🇪🇹", + // Ethiopia + fi: "🇫🇮", + // Finland + fj: "🇫🇯", + // Fiji + fk: "🇫🇰", + // Falkland Islands (Malvinas) + fm: "🇫🇲", + // Micronesia + fo: "🇫🇴", + // Faroe Islands + fr: "🇫🇷", + // France + ga: "🇬🇦", + // Gabon + gb: "🇬🇧", + // United Kingdom + gd: "🇬🇩", + // Grenada + ge: "🇬🇪", + // Georgia + gf: "🇬🇫", + // French Guiana + gg: "🇬🇬", + // Guernsey + gh: "🇬🇭", + // Ghana + gi: "🇬🇮", + // Gibraltar + gl: "🇬🇱", + // Greenland + gm: "🇬🇲", + // Gambia + gn: "🇬🇳", + // Guinea + gp: "🇬🇵", + // Guadeloupe + gq: "🇬🇶", + // Equatorial Guinea + gr: "🇬🇷", + // Greece + gs: "🇬🇸", + // South Georgia + gt: "🇬🇹", + // Guatemala + gu: "🇬🇺", + // Guam + gw: "🇬🇼", + // Guinea-Bissau + gy: "🇬🇾", + // Guyana + hk: "🇭🇰", + // Hong Kong + hm: "🇭🇲", + // Heard Island and Mcdonald Islands + hn: "🇭🇳", + // Honduras + hr: "🇭🇷", + // Croatia + ht: "🇭🇹", + // Haiti + hu: "🇭🇺", + // Hungary + id: "🇮🇩", + // Indonesia + ie: "🇮🇪", + // Ireland + il: "🇮🇱", + // Israel + im: "🇮🇲", + // Isle of Man + in: "🇮🇳", + // India + io: "🇮🇴", + // British Indian Ocean Territory + iq: "🇮🇶", + // Iraq + ir: "🇮🇷", + // Iran + is: "🇮🇸", + // Iceland + it: "🇮🇹", + // Italy + je: "🇯🇪", + // Jersey + jm: "🇯🇲", + // Jamaica + jo: "🇯🇴", + // Jordan + jp: "🇯🇵", + // Japan + ke: "🇰🇪", + // Kenya + kg: "🇰🇬", + // Kyrgyzstan + kh: "🇰🇭", + // Cambodia + ki: "🇰🇮", + // Kiribati + km: "🇰🇲", + // Comoros + kn: "🇰🇳", + // Saint Kitts and Nevis + kp: "🇰🇵", + // North Korea + kr: "🇰🇷", + // South Korea + kw: "🇰🇼", + // Kuwait + ky: "🇰🇾", + // Cayman Islands + kz: "🇰🇿", + // Kazakhstan + la: "🇱🇦", + // Lao People's Democratic Republic + lb: "🇱🇧", + // Lebanon + lc: "🇱🇨", + // Saint Lucia + li: "🇱🇮", + // Liechtenstein + lk: "🇱🇰", + // Sri Lanka + lr: "🇱🇷", + // Liberia + ls: "🇱🇸", + // Lesotho + lt: "🇱🇹", + // Lithuania + lu: "🇱🇺", + // Luxembourg + lv: "🇱🇻", + // Latvia + ly: "🇱🇾", + // Libya + ma: "🇲🇦", + // Morocco + mc: "🇲🇨", + // Monaco + md: "🇲🇩", + // Moldova + me: "🇲🇪", + // Montenegro + mf: "🇲🇫", + // Saint Martin (French Part) + mg: "🇲🇬", + // Madagascar + mh: "🇲🇭", + // Marshall Islands + mk: "🇲🇰", + // Macedonia + ml: "🇲🇱", + // Mali + mm: "🇲🇲", + // Myanmar + mn: "🇲🇳", + // Mongolia + mo: "🇲🇴", + // Macao + mp: "🇲🇵", + // Northern Mariana Islands + mq: "🇲🇶", + // Martinique + mr: "🇲🇷", + // Mauritania + ms: "🇲🇸", + // Montserrat + mt: "🇲🇹", + // Malta + mu: "🇲🇺", + // Mauritius + mv: "🇲🇻", + // Maldives + mw: "🇲🇼", + // Malawi + mx: "🇲🇽", + // Mexico + my: "🇲🇾", + // Malaysia + mz: "🇲🇿", + // Mozambique + na: "🇳🇦", + // Namibia + nc: "🇳🇨", + // New Caledonia + ne: "🇳🇪", + // Niger + nf: "🇳🇫", + // Norfolk Island + ng: "🇳🇬", + // Nigeria + ni: "🇳🇮", + // Nicaragua + nl: "🇳🇱", + // Netherlands + no: "🇳🇴", + // Norway + np: "🇳🇵", + // Nepal + nr: "🇳🇷", + // Nauru + nu: "🇳🇺", + // Niue + nz: "🇳🇿", + // New Zealand + om: "🇴🇲", + // Oman + pa: "🇵🇦", + // Panama + pe: "🇵🇪", + // Peru + pf: "🇵🇫", + // French Polynesia + pg: "🇵🇬", + // Papua New Guinea + ph: "🇵🇭", + // Philippines + pk: "🇵🇰", + // Pakistan + pl: "🇵🇱", + // Poland + pm: "🇵🇲", + // Saint Pierre and Miquelon + pn: "🇵🇳", + // Pitcairn + pr: "🇵🇷", + // Puerto Rico + ps: "🇵🇸", + // Palestinian Territory + pt: "🇵🇹", + // Portugal + pw: "🇵🇼", + // Palau + py: "🇵🇾", + // Paraguay + qa: "🇶🇦", + // Qatar + re: "🇷🇪", + // Réunion + ro: "🇷🇴", + // Romania + rs: "🇷🇸", + // Serbia + ru: "🇷🇺", + // Russia + rw: "🇷🇼", + // Rwanda + sa: "🇸🇦", + // Saudi Arabia + sb: "🇸🇧", + // Solomon Islands + sc: "🇸🇨", + // Seychelles + sd: "🇸🇩", + // Sudan + se: "🇸🇪", + // Sweden + sg: "🇸🇬", + // Singapore + sh: "🇸🇭", + // Saint Helena, Ascension and Tristan Da Cunha + si: "🇸🇮", + // Slovenia + sj: "🇸🇯", + // Svalbard and Jan Mayen + sk: "🇸🇰", + // Slovakia + sl: "🇸🇱", + // Sierra Leone + sm: "🇸🇲", + // San Marino + sn: "🇸🇳", + // Senegal + so: "🇸🇴", + // Somalia + sr: "🇸🇷", + // Suriname + ss: "🇸🇸", + // South Sudan + st: "🇸🇹", + // Sao Tome and Principe + sv: "🇸🇻", + // El Salvador + sx: "🇸🇽", + // Sint Maarten (Dutch Part) + sy: "🇸🇾", + // Syrian Arab Republic + sz: "🇸🇿", + // Swaziland + tc: "🇹🇨", + // Turks and Caicos Islands + td: "🇹🇩", + // Chad + tf: "🇹🇫", + // French Southern Territories + tg: "🇹🇬", + // Togo + th: "🇹🇭", + // Thailand + tj: "🇹🇯", + // Tajikistan + tk: "🇹🇰", + // Tokelau + tl: "🇹🇱", + // Timor-Leste + tm: "🇹🇲", + // Turkmenistan + tn: "🇹🇳", + // Tunisia + to: "🇹🇴", + // Tonga + tr: "🇹🇷", + // Turkey + tt: "🇹🇹", + // Trinidad and Tobago + tv: "🇹🇻", + // Tuvalu + tw: "🇹🇼", + // Taiwan + tz: "🇹🇿", + // Tanzania + ua: "🇺🇦", + // Ukraine + ug: "🇺🇬", + // Uganda + um: "🇺🇲", + // United States Minor Outlying Islands + us: "🇺🇸", + // United States + uy: "🇺🇾", + // Uruguay + uz: "🇺🇿", + // Uzbekistan + va: "🇻🇦", + // Vatican City + vc: "🇻🇨", + // Saint Vincent and The Grenadines + ve: "🇻🇪", + // Venezuela + vg: "🇻🇬", + // Virgin Islands, British + vi: "🇻🇮", + // Virgin Islands, U.S. + vn: "🇻🇳", + // Viet Nam + vu: "🇻🇺", + // Vanuatu + wf: "🇼🇫", + // Wallis and Futuna + ws: "🇼🇸", + // Samoa + ye: "🇾🇪", + // Yemen + yt: "🇾🇹", + // Mayotte + za: "🇿🇦", + // South Africa + zm: "🇿🇲", + // Zambia + zw: "🇿🇼", + // Zimbabwe +); + +[class^="flag-"] { + font-style: normal; +} + +// generate classes +@each $name, $icon in $flags { + .flag-#{$name}:before { + content: $icon; + } +} diff --git a/CTFd/themes/admin/assets/css/includes/jumbotron.css b/CTFd/themes/admin/assets/css/includes/jumbotron.css new file mode 100644 index 0000000..b377b0c --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/jumbotron.css @@ -0,0 +1,4 @@ +/* Move down content because we have a fixed navbar that is 3.5rem tall */ +main { + padding-top: 3.5rem; +} diff --git a/CTFd/themes/admin/assets/css/includes/min-height.scss b/CTFd/themes/admin/assets/css/includes/min-height.scss new file mode 100644 index 0000000..0741fb0 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/min-height.scss @@ -0,0 +1,15 @@ +.min-vh-0 { + min-height: 0vh !important; +} +.min-vh-25 { + min-height: 25vh !important; +} +.min-vh-50 { + min-height: 50vh !important; +} +.min-vh-75 { + min-height: 75vh !important; +} +.min-vh-100 { + min-height: 100vh !important; +} diff --git a/CTFd/themes/admin/assets/css/includes/opacity.scss b/CTFd/themes/admin/assets/css/includes/opacity.scss new file mode 100644 index 0000000..7763e27 --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/opacity.scss @@ -0,0 +1,15 @@ +.opacity-0 { + opacity: 0 !important; +} +.opacity-25 { + opacity: 0.25 !important; +} +.opacity-50 { + opacity: 0.5 !important; +} +.opacity-75 { + opacity: 0.75 !important; +} +.opacity-100 { + opacity: 1 !important; +} diff --git a/CTFd/themes/admin/assets/css/includes/sticky-footer.css b/CTFd/themes/admin/assets/css/includes/sticky-footer.css new file mode 100644 index 0000000..940b9dc --- /dev/null +++ b/CTFd/themes/admin/assets/css/includes/sticky-footer.css @@ -0,0 +1,30 @@ +/* Sticky footer styles +-------------------------------------------------- */ +html { + position: relative; + min-height: 100%; +} + +body { + margin-bottom: 60px; /* Margin bottom by footer height */ +} + +.footer { + position: absolute; + + /* prevent scrollbars from showing on pages that don't use the full page height */ + bottom: 1px; + + width: 100%; + + /* Set the fixed height of the footer here */ + height: 60px; + + /* Override line-height from core because we have two lines in the admin panel */ + line-height: normal !important; + + /* Avoid covering things */ + z-index: -20; + + /*background-color: #f5f5f5;*/ +} diff --git a/CTFd/themes/admin/assets/css/main.scss b/CTFd/themes/admin/assets/css/main.scss new file mode 100644 index 0000000..fb7c669 --- /dev/null +++ b/CTFd/themes/admin/assets/css/main.scss @@ -0,0 +1,160 @@ +@import "~/bootstrap/scss/bootstrap.scss"; +@import "includes/jumbotron.css"; +@import "includes/sticky-footer.css"; +@import "includes/award-icons.scss"; +@import "includes/flag-icons.scss"; +@import "includes/opacity.scss"; +@import "includes/min-height.scss"; + +html, +body, +.container { + font-family: "Lato", "LatoOffline", sans-serif; +} + +h1, +h2 { + font-family: "Raleway", "RalewayOffline", sans-serif; + font-weight: 500; + letter-spacing: 2px; +} + +a { + color: #337ab7; + text-decoration: none; +} + +table > thead > tr > td { + /* Remove border line from thead of all tables */ + /* It can overlap with other element styles */ + border-top: none !important; +} + +blockquote { + border-left: 4px solid $secondary; + padding-left: 15px; +} + +.table thead th { + white-space: nowrap; +} + +.fa-spin.spinner { + text-align: center; + opacity: 0.5; +} + +.spinner-error { + padding-top: 20vh; + text-align: center; + opacity: 0.5; +} + +.jumbotron { + border-radius: 0; + text-align: center; +} + +.form-control:focus { + background-color: transparent; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} + +.input-filled-valid { + background-color: transparent !important; + border-color: #a3d39c; + box-shadow: 0 0 0 0.1rem #a3d39c; + transition: + background-color 0.3s, + border-color 0.3s; +} + +.input-filled-invalid { + background-color: transparent !important; + border-color: #d46767; + box-shadow: 0 0 0 0.1rem #d46767; + transition: + background-color 0.3s, + border-color 0.3s; +} + +.btn-outlined.btn-theme { + background: none; + color: #545454; + border-color: #545454; + border: 3px solid; +} + +.btn-outlined { + border-radius: 0; + -webkit-transition: all 0.3s; + -moz-transition: all 0.3s; + transition: all 0.3s; +} + +.btn { + letter-spacing: 1px; + text-decoration: none; + -moz-user-select: none; + border-radius: 0; + cursor: pointer; + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + white-space: nowrap; + font-size: 14px; + line-height: 20px; + font-weight: 700; + padding: 8px 20px; +} + +.btn-info { + background-color: #5b7290 !important; + border-color: #5b7290 !important; +} + +.badge-info { + background-color: #5b7290 !important; +} + +.alert { + border-radius: 0 !important; + padding: 0.8em; +} + +.btn-fa { + cursor: pointer; +} + +.close { + cursor: pointer; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-help { + cursor: help; +} + +.modal-content { + -webkit-border-radius: 0 !important; + -moz-border-radius: 0 !important; + border-radius: 0 !important; +} + +.fa-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.badge-notification { + vertical-align: top; + margin-left: -1.5em; + font-size: 50%; +} diff --git a/CTFd/themes/admin/assets/js/challenges/challenge.js b/CTFd/themes/admin/assets/js/challenges/challenge.js new file mode 100644 index 0000000..18363d5 --- /dev/null +++ b/CTFd/themes/admin/assets/js/challenges/challenge.js @@ -0,0 +1,200 @@ +import $ from "jquery"; +import "../compat/json"; +import "../compat/format"; +import { ezToast, ezQuery } from "../compat/ezq"; +import { htmlEntities } from "@ctfdio/ctfd-js/utils/html"; +import CTFd from "../compat/CTFd"; +import nunjucks from "nunjucks"; + +function renderSubmissionResponse(response, cb) { + const result = response.data; + + const result_message = $("#result-message"); + const result_notification = $("#result-notification"); + const answer_input = $("#submission-input"); + result_notification.removeClass(); + result_message.text(result.message); + + if (result.status === "authentication_required") { + window.location = + CTFd.config.urlRoot + + "/login?next=" + + CTFd.config.urlRoot + + window.location.pathname + + window.location.hash; + return; + } else if (result.status === "incorrect") { + // Incorrect key + result_notification.addClass( + "alert alert-danger alert-dismissable text-center", + ); + result_notification.slideDown(); + + answer_input.removeClass("correct"); + answer_input.addClass("wrong"); + setTimeout(function () { + answer_input.removeClass("wrong"); + }, 3000); + } else if (result.status === "correct") { + // Challenge Solved + result_notification.addClass( + "alert alert-success alert-dismissable text-center", + ); + result_notification.slideDown(); + + $(".challenge-solves").text( + parseInt($(".challenge-solves").text().split(" ")[0]) + 1 + " Solves", + ); + + answer_input.val(""); + answer_input.removeClass("wrong"); + answer_input.addClass("correct"); + } else if (result.status === "already_solved") { + // Challenge already solved + result_notification.addClass( + "alert alert-info alert-dismissable text-center", + ); + result_notification.slideDown(); + + answer_input.addClass("correct"); + } else if (result.status === "paused") { + // CTF is paused + result_notification.addClass( + "alert alert-warning alert-dismissable text-center", + ); + result_notification.slideDown(); + } else if (result.status === "ratelimited") { + // Keys per minute too high + result_notification.addClass( + "alert alert-warning alert-dismissable text-center", + ); + result_notification.slideDown(); + + answer_input.addClass("too-fast"); + setTimeout(function () { + answer_input.removeClass("too-fast"); + }, 3000); + } + setTimeout(function () { + $(".alert").slideUp(); + $("#submit-key").removeClass("disabled-button"); + $("#submit-key").prop("disabled", false); + }, 3000); + + if (cb) { + cb(result); + } +} + +$(() => { + $(".preview-challenge").click(function (_event) { + window.challenge = new Object(); + $.get( + CTFd.config.urlRoot + "/api/v1/challenges/" + window.CHALLENGE_ID, + function (response) { + const challenge_data = response.data; + challenge_data["solves"] = null; + + $.getScript( + CTFd.config.urlRoot + challenge_data.type_data.scripts.view, + function () { + $.get( + CTFd.config.urlRoot + challenge_data.type_data.templates.view, + function (template_data) { + $("#challenge-window").empty(); + const template = nunjucks.compile(template_data); + window.challenge.data = challenge_data; + window.challenge.preRender(); + + challenge_data["description"] = window.challenge.render( + challenge_data["description"], + ); + challenge_data["script_root"] = CTFd.config.urlRoot; + + $("#challenge-window").append(template.render(challenge_data)); + + $(".nav-tabs a").click(function (event) { + event.preventDefault(); + $(this).tab("show"); + }); + + // Handle modal toggling + $("#challenge-window").on("hide.bs.modal", function (_event) { + $("#submission-input").removeClass("wrong"); + $("#submission-input").removeClass("correct"); + $("#incorrect-key").slideUp(); + $("#correct-key").slideUp(); + $("#already-solved").slideUp(); + $("#too-fast").slideUp(); + }); + + $("#submit-key").click(function (event) { + event.preventDefault(); + $("#submit-key").addClass("disabled-button"); + $("#submit-key").prop("disabled", true); + window.challenge.submit(function (data) { + renderSubmissionResponse(data); + }, true); + // Preview passed as true + }); + + $("#submission-input").keyup(function (event) { + if (event.keyCode == 13) { + $("#submit-key").click(); + } + }); + + window.challenge.postRender(); + window.location.replace( + window.location.href.split("#")[0] + "#preview", + ); + + $("#challenge-window").modal(); + }, + ); + }, + ); + }, + ); + }); + + $(".delete-challenge").click(function (_event) { + ezQuery({ + title: "Delete Challenge", + body: "Are you sure you want to delete {0}".format( + "" + htmlEntities(window.CHALLENGE_NAME) + "", + ), + success: function () { + CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { + method: "DELETE", + }).then(function (response) { + if (response.success) { + window.location = CTFd.config.urlRoot + "/admin/challenges"; + } + }); + }, + }); + }); + + $("#challenge-update-container > form").submit(function (event) { + event.preventDefault(); + const params = $(event.target).serializeJSON(true); + + CTFd.fetch("/api/v1/challenges/" + window.CHALLENGE_ID, { + method: "PATCH", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }).then(function (data) { + if (data.success) { + ezToast({ + title: "Success", + body: "Your challenge has been updated!", + }); + } + }); + }); +}); diff --git a/CTFd/themes/admin/assets/js/challenges/new.js b/CTFd/themes/admin/assets/js/challenges/new.js new file mode 100644 index 0000000..478bc09 --- /dev/null +++ b/CTFd/themes/admin/assets/js/challenges/new.js @@ -0,0 +1,81 @@ +import CTFd from "../compat/CTFd"; +import nunjucks from "nunjucks"; +import $ from "jquery"; +import "../compat/json"; + +window.challenge = new Object(); + +function loadChalTemplate(challenge) { + $.getScript(CTFd.config.urlRoot + challenge.scripts.view, function () { + $.get( + CTFd.config.urlRoot + challenge.templates.create, + function (template_data) { + const template = nunjucks.compile(template_data); + $("#create-chal-entry-div").html( + template.render({ + nonce: CTFd.config.csrfNonce, + script_root: CTFd.config.urlRoot, + }), + ); + + $.getScript( + CTFd.config.urlRoot + challenge.scripts.create, + function () { + $("#create-chal-entry-div form").submit(function (event) { + event.preventDefault(); + const params = $("#create-chal-entry-div form").serializeJSON(); + CTFd.fetch("/api/v1/challenges", { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }).then(function (response) { + if (response.success) { + window.location = + CTFd.config.urlRoot + + "/admin/challenges/" + + response.data.id; + } + }); + }); + }, + ); + }, + ); + }); +} + +$.get(CTFd.config.urlRoot + "/api/v1/challenges/types", function (response) { + $("#create-chals-select").empty(); + const data = response.data; + const chal_type_amt = Object.keys(data).length; + if (chal_type_amt > 1) { + const option = ""; + $("#create-chals-select").append(option); + for (const key in data) { + const challenge = data[key]; + const option = $("