# 3.3.0 / UNRELEASED

**General**

- Don't require a team for viewing challenges if Challenge visibility is set to public
- Add a `THEME_FALLBACK` config to help develop themes. See **Themes** section for details.

**API**

- Implement a faster `/api/v1/scoreboard` endpoint in Teams Mode
- Add the `solves` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine how many solves a challenge has
- Add the `solved_by_me` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine if the current account has solved the challenge
- Prevent admins from deleting themselves through `DELETE /api/v1/users/[user_id]`
- Add length checking to some sensitive fields in the Pages and Challenges schemas
- Fix issue where `PATCH /api/v1/users[user_id]` returned a list instead of a dict
- Fix exception that occured on demoting admins through `PATCH /api/v1/users[user_id]`
- Add `team_id` to `GET /api/v1/users` to determine if a user is already in a team

**Themes**

- Add a `THEME_FALLBACK` config to help develop themes.
  - `THEME_FALLBACK` will configure CTFd to try to find missing theme files in the default built-in `core` theme.
  - This makes it easier to develop themes or use incomplete themes.
- Allow for one theme to reference and inherit from another theme through approaches like `{% extends "core/page.html" %}`
- Allow for the automatic date rendering format to be overridden by specifying a `data-time-format` attribute.
- Add styling for the `<blockquote>` element.
- Fix scoreboard table identifier to switch between User/Team depending on configured user mode
- Switch to using Bootstrap's scss in `core/main.scss` to allow using Bootstrap variables
- Consolidate Jinja error handlers into a single function and better handle issues where error templates can't be found

**Plugins**

- Set plugin migration version after successful migrations
- Fix issue where Page URLs injected into the navbar were relative instead of absolute

**Admin Panel**

- Add User standings as well as Teams standings to the admin scoreboard when in Teams Mode
- Add a UI for adding members to a team from the team's admin page
- Add ability for admins to disable public team creation
- Link directly to users who submitted something in the submissions page if the CTF is in Teams Mode
- Fix Challenge Requirements interface in Admin Panel to not allow empty/null requirements to be added
- Fixed an issue where config times (start, end, freeze times) could not be removed
- Fix an exception that occurred when demoting an Admin user
- Adds a temporary hack for re-enabling Javascript snippets in Flag editor templates. (See #1779)

**Deployment**

- Install `python3-dev` instead of `python-dev` in apt
- Bump lxml to 4.6.2
- Bump pip-compile to 5.4.0

**Miscellaneous**

- Cache Docker builds more by copying and installing Python dependencies before copying CTFd
- Change the default emails slightly and rework confirmation email page to make some recommendations clearer
- Use `examplectf.com` as testing/development domain instead of `ctfd.io`
- Fixes issue where user's name and email would not appear in logs properly
- Add more linting by also linting with `flake8-comprehensions` and `flake8-bugbear`
This commit is contained in:
Kevin Chung
2021-03-18 18:08:46 -04:00
committed by GitHub
parent 8a70d9527f
commit 8de9819bd4
49 changed files with 1472 additions and 310 deletions

View File

@@ -1,3 +1,63 @@
# 3.3.0 / UNRELEASED
**General**
- Don't require a team for viewing challenges if Challenge visibility is set to public
- Add a `THEME_FALLBACK` config to help develop themes. See **Themes** section for details.
**API**
- Implement a faster `/api/v1/scoreboard` endpoint in Teams Mode
- Add the `solves` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine how many solves a challenge has
- Add the `solved_by_me` item to both `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]` to more easily determine if the current account has solved the challenge
- Prevent admins from deleting themselves through `DELETE /api/v1/users/[user_id]`
- Add length checking to some sensitive fields in the Pages and Challenges schemas
- Fix issue where `PATCH /api/v1/users[user_id]` returned a list instead of a dict
- Fix exception that occured on demoting admins through `PATCH /api/v1/users[user_id]`
- Add `team_id` to `GET /api/v1/users` to determine if a user is already in a team
**Themes**
- Add a `THEME_FALLBACK` config to help develop themes.
- `THEME_FALLBACK` will configure CTFd to try to find missing theme files in the default built-in `core` theme.
- This makes it easier to develop themes or use incomplete themes.
- Allow for one theme to reference and inherit from another theme through approaches like `{% extends "core/page.html" %}`
- Allow for the automatic date rendering format to be overridden by specifying a `data-time-format` attribute.
- Add styling for the `<blockquote>` element.
- Fix scoreboard table identifier to switch between User/Team depending on configured user mode
- Switch to using Bootstrap's scss in `core/main.scss` to allow using Bootstrap variables
- Consolidate Jinja error handlers into a single function and better handle issues where error templates can't be found
**Plugins**
- Set plugin migration version after successful migrations
- Fix issue where Page URLs injected into the navbar were relative instead of absolute
**Admin Panel**
- Add User standings as well as Teams standings to the admin scoreboard when in Teams Mode
- Add a UI for adding members to a team from the team's admin page
- Add ability for admins to disable public team creation
- Link directly to users who submitted something in the submissions page if the CTF is in Teams Mode
- Fix Challenge Requirements interface in Admin Panel to not allow empty/null requirements to be added
- Fixed an issue where config times (start, end, freeze times) could not be removed
- Fix an exception that occurred when demoting an Admin user
- Adds a temporary hack for re-enabling Javascript snippets in Flag editor templates. (See #1779)
**Deployment**
- Install `python3-dev` instead of `python-dev` in apt
- Bump lxml to 4.6.2
- Bump pip-compile to 5.4.0
**Miscellaneous**
- Cache Docker builds more by copying and installing Python dependencies before copying CTFd
- Change the default emails slightly and rework confirmation email page to make some recommendations clearer
- Use `examplectf.com` as testing/development domain instead of `ctfd.io`
- Fixes issue where user's name and email would not appear in logs properly
- Add more linting by also linting with `flake8-comprehensions` and `flake8-bugbear`
# 3.2.1 / 2020-12-09 # 3.2.1 / 2020-12-09
- Fixed an issue where Users could not unlock Hints - Fixed an issue where Users could not unlock Hints

View File

@@ -6,13 +6,16 @@ from distutils.version import StrictVersion
import jinja2 import jinja2
from flask import Flask, Request from flask import Flask, Request
from flask.helpers import safe_join
from flask_migrate import upgrade from flask_migrate import upgrade
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
import CTFd.utils.config
from CTFd import utils from CTFd import utils
from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME
from CTFd.plugins import init_plugins from CTFd.plugins import init_plugins
from CTFd.utils.crypto import sha256 from CTFd.utils.crypto import sha256
from CTFd.utils.initialization import ( from CTFd.utils.initialization import (
@@ -26,7 +29,7 @@ from CTFd.utils.migrations import create_database, migrations, stamp_latest_revi
from CTFd.utils.sessions import CachingSessionInterface from CTFd.utils.sessions import CachingSessionInterface
from CTFd.utils.updates import update_check from CTFd.utils.updates import update_check
__version__ = "3.2.1" __version__ = "3.3.0"
__channel__ = "oss" __channel__ = "oss"
@@ -97,26 +100,34 @@ class SandboxedBaseEnvironment(SandboxedEnvironment):
class ThemeLoader(FileSystemLoader): class ThemeLoader(FileSystemLoader):
"""Custom FileSystemLoader that switches themes based on the configuration value""" """Custom FileSystemLoader that is aware of theme structure and config.
"""
def __init__(self, searchpath, encoding="utf-8", followlinks=False): DEFAULT_THEMES_PATH = os.path.join(os.path.dirname(__file__), "themes")
_ADMIN_THEME_PREFIX = ADMIN_THEME + "/"
def __init__(
self,
searchpath=DEFAULT_THEMES_PATH,
theme_name=None,
encoding="utf-8",
followlinks=False,
):
super(ThemeLoader, self).__init__(searchpath, encoding, followlinks) super(ThemeLoader, self).__init__(searchpath, encoding, followlinks)
self.overriden_templates = {} self.theme_name = theme_name
def get_source(self, environment, template): def get_source(self, environment, template):
# Check if the template has been overriden # Refuse to load `admin/*` from a loader not for the admin theme
if template in self.overriden_templates: # Because there is a single template loader, themes can essentially
return self.overriden_templates[template], template, lambda: True # provide files for other themes. This could end up causing issues if
# an admin theme references a file that doesn't exist that a malicious
# Check if the template requested is for the admin panel # theme provides.
if template.startswith("admin/"): if template.startswith(self._ADMIN_THEME_PREFIX):
template = template[6:] # Strip out admin/ if self.theme_name != ADMIN_THEME:
template = "/".join(["admin", "templates", template]) raise jinja2.TemplateNotFound(template)
return super(ThemeLoader, self).get_source(environment, template) template = template[len(self._ADMIN_THEME_PREFIX) :]
theme_name = self.theme_name or str(utils.get_config("ctf_theme"))
# Load regular theme data template = safe_join(theme_name, "templates", template)
theme = str(utils.get_config("ctf_theme"))
template = "/".join([theme, "templates", template])
return super(ThemeLoader, self).get_source(environment, template) return super(ThemeLoader, self).get_source(environment, template)
@@ -144,19 +155,34 @@ def create_app(config="CTFd.config.Config"):
with app.app_context(): with app.app_context():
app.config.from_object(config) app.config.from_object(config)
app.theme_loader = ThemeLoader( loaders = []
os.path.join(app.root_path, "themes"), followlinks=True # We provide a `DictLoader` which may be used to override templates
) app.overridden_templates = {}
# Weird nested solution for accessing plugin templates loaders.append(jinja2.DictLoader(app.overridden_templates))
app.plugin_loader = jinja2.PrefixLoader( # A `ThemeLoader` with no `theme_name` will load from the current theme
{ loaders.append(ThemeLoader())
"plugins": jinja2.FileSystemLoader( # If `THEME_FALLBACK` is set and true, we add another loader which will
# load from the `DEFAULT_THEME` - this mirrors the order implemented by
# `config.ctf_theme_candidates()`
if bool(app.config.get("THEME_FALLBACK")):
loaders.append(ThemeLoader(theme_name=DEFAULT_THEME))
# All themes including admin can be accessed by prefixing their name
prefix_loader_dict = {ADMIN_THEME: ThemeLoader(theme_name=ADMIN_THEME)}
for theme_name in CTFd.utils.config.get_themes():
prefix_loader_dict[theme_name] = ThemeLoader(theme_name=theme_name)
loaders.append(jinja2.PrefixLoader(prefix_loader_dict))
# Plugin templates are also accessed via prefix but we just point a
# normal `FileSystemLoader` at the plugin tree rather than validating
# each plugin here (that happens later in `init_plugins()`). We
# deliberately don't add this to `prefix_loader_dict` defined above
# because to do so would break template loading from a theme called
# `prefix` (even though that'd be weird).
plugin_loader = jinja2.FileSystemLoader(
searchpath=os.path.join(app.root_path, "plugins"), followlinks=True searchpath=os.path.join(app.root_path, "plugins"), followlinks=True
) )
} loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader}))
) # Use a choice loader to find the first match from our list of loaders
# Load from themes first but fallback to loading from the plugin folder app.jinja_loader = jinja2.ChoiceLoader(loaders)
app.jinja_loader = jinja2.ChoiceLoader([app.theme_loader, app.plugin_loader])
from CTFd.models import ( # noqa: F401 from CTFd.models import ( # noqa: F401
db, db,
@@ -240,7 +266,7 @@ def create_app(config="CTFd.config.Config"):
utils.set_config("ctf_version", __version__) utils.set_config("ctf_version", __version__)
if not utils.get_config("ctf_theme"): if not utils.get_config("ctf_theme"):
utils.set_config("ctf_theme", "core") utils.set_config("ctf_theme", DEFAULT_THEME)
update_check(force=True) update_check(force=True)
@@ -258,7 +284,7 @@ def create_app(config="CTFd.config.Config"):
from CTFd.admin import admin from CTFd.admin import admin
from CTFd.api import api from CTFd.api import api
from CTFd.events import events from CTFd.events import events
from CTFd.errors import page_not_found, forbidden, general_error, gateway_error from CTFd.errors import render_error
app.register_blueprint(views) app.register_blueprint(views)
app.register_blueprint(teams) app.register_blueprint(teams)
@@ -271,10 +297,8 @@ def create_app(config="CTFd.config.Config"):
app.register_blueprint(admin) app.register_blueprint(admin)
app.register_error_handler(404, page_not_found) for code in {403, 404, 500, 502}:
app.register_error_handler(403, forbidden) app.register_error_handler(code, render_error)
app.register_error_handler(500, general_error)
app.register_error_handler(502, gateway_error)
init_logs(app) init_logs(app)
init_events(app) init_events(app)

View File

@@ -164,7 +164,7 @@ def config():
clear_config() clear_config()
configs = Configs.query.all() configs = Configs.query.all()
configs = dict([(c.key, get_config(c.key)) for c in configs]) configs = {c.key: get_config(c.key) for c in configs}
themes = ctf_config.get_themes() themes = ctf_config.get_themes()
themes.remove(get_config("ctf_theme")) themes.remove(get_config("ctf_theme"))

View File

@@ -1,12 +1,16 @@
from flask import render_template from flask import render_template
from CTFd.admin import admin from CTFd.admin import admin
from CTFd.scoreboard import get_standings from CTFd.utils.config import is_teams_mode
from CTFd.utils.decorators import admins_only from CTFd.utils.decorators import admins_only
from CTFd.utils.scores import get_standings, get_user_standings
@admin.route("/admin/scoreboard") @admin.route("/admin/scoreboard")
@admins_only @admins_only
def scoreboard_listing(): def scoreboard_listing():
standings = get_standings(admin=True) standings = get_standings(admin=True)
return render_template("admin/scoreboard.html", standings=standings) user_standings = get_user_standings(admin=True) if is_teams_mode() else None
return render_template(
"admin/scoreboard.html", standings=standings, user_standings=user_standings
)

View File

@@ -61,7 +61,7 @@ def statistics():
) )
solve_data = {} solve_data = {}
for chal, count, name in solves: for _chal, count, name in solves:
solve_data[name] = count solve_data[name] = count
most_solved = None most_solved = None

View File

@@ -3,7 +3,9 @@ from typing import List
from flask import abort, render_template, request, url_for from flask import abort, render_template, request, url_for
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from sqlalchemy.sql import and_ from sqlalchemy import func as sa_func
from sqlalchemy import types as sa_types
from sqlalchemy.sql import and_, cast, false, true
from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
@@ -48,7 +50,14 @@ from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.logging import log from CTFd.utils.logging import log
from CTFd.utils.modes import generate_account_url, get_model from CTFd.utils.modes import generate_account_url, get_model
from CTFd.utils.security.signing import serialize from CTFd.utils.security.signing import serialize
from CTFd.utils.user import authed, get_current_team, get_current_user, is_admin from CTFd.utils.user import (
authed,
get_current_team,
get_current_team_attrs,
get_current_user,
get_current_user_attrs,
is_admin,
)
challenges_namespace = Namespace( challenges_namespace = Namespace(
"challenges", description="Endpoint to retrieve Challenges" "challenges", description="Endpoint to retrieve Challenges"
@@ -75,6 +84,44 @@ challenges_namespace.schema_model(
) )
def _build_solves_query(extra_filters=(), admin_view=False):
# This can return None (unauth) if visibility is set to public
user = get_current_user()
# We only set a condition for matching user solves if there is a user and
# they have an account ID (user mode or in a team in teams mode)
if user is not None and user.account_id is not None:
user_solved_cond = Solves.account_id == user.account_id
else:
user_solved_cond = false()
# We have to filter solves to exclude any made after the current freeze
# time unless we're in an admin view as determined by the caller.
freeze = get_config("freeze")
if freeze and not admin_view:
freeze_cond = Solves.date < unix_time_to_utc(freeze)
else:
freeze_cond = true()
# Finally, we never count solves made by hidden or banned users/teams, even
# if we are an admin. This is to match the challenge detail API.
AccountModel = get_model()
exclude_solves_cond = and_(
AccountModel.banned == false(), AccountModel.hidden == false(),
)
# This query counts the number of solves per challenge, as well as the sum
# of correct solves made by the current user per the condition above (which
# should probably only be 0 or 1!)
solves_q = (
db.session.query(
Solves.challenge_id,
sa_func.count(Solves.challenge_id),
sa_func.sum(cast(user_solved_cond, sa_types.Integer)),
)
.join(AccountModel)
.filter(*extra_filters, freeze_cond, exclude_solves_cond)
.group_by(Solves.challenge_id)
)
return solves_q
@challenges_namespace.route("") @challenges_namespace.route("")
class ChallengeList(Resource): class ChallengeList(Resource):
@check_challenge_visibility @check_challenge_visibility
@@ -116,60 +163,68 @@ class ChallengeList(Resource):
location="query", location="query",
) )
def get(self, query_args): def get(self, query_args):
# Require a team if in teams mode
# TODO: Convert this into a re-useable decorator
# TODO: The require_team decorator doesnt work because of no admin passthru
if get_current_user_attrs():
if is_admin():
pass
else:
if config.is_teams_mode() and get_current_team_attrs() is None:
abort(403)
# Build filtering queries # Build filtering queries
q = query_args.pop("q", None) q = query_args.pop("q", None)
field = str(query_args.pop("field", None)) field = str(query_args.pop("field", None))
filters = build_model_filters(model=Challenges, query=q, field=field) filters = build_model_filters(model=Challenges, query=q, field=field)
# This can return None (unauth) if visibility is set to public # Admins get a shortcut to see all challenges despite pre-requisites
user = get_current_user() admin_view = is_admin() and request.args.get("view") == "admin"
# Admins can request to see everything solve_counts, user_solves = {}, set()
if is_admin() and request.args.get("view") == "admin": # Build a query for to show challenge solve information. We only
challenges = ( # give an admin view if the request argument has been provided.
Challenges.query.filter_by(**query_args) #
.filter(*filters) # NOTE: This is different behaviour to the challenge detail
.order_by(Challenges.value) # endpoint which only needs the current user to be an admin rather
.all() # than also also having to provide `view=admin` as a query arg.
) solves_q = _build_solves_query(admin_view=admin_view)
solve_ids = set([challenge.id for challenge in challenges]) # Aggregate the query results into the hashes defined at the top of
# this block for later use
for chal_id, solve_count, solved_by_user in solves_q:
solve_counts[chal_id] = solve_count
if solved_by_user:
user_solves.add(chal_id)
if scores_visible() and accounts_visible():
solve_count_dfl = 0
else: else:
challenges = ( # Empty out the solves_count if we're hiding scores/accounts
Challenges.query.filter( solve_counts = {}
# This is necessary to match the challenge detail API which returns
# `None` for the solve count if visiblity checks fail
solve_count_dfl = None
# Build the query for the challenges which may be listed
chal_q = Challenges.query
# Admins can see hidden and locked challenges in the admin view
if admin_view is False:
chal_q = chal_q.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked") and_(Challenges.state != "hidden", Challenges.state != "locked")
) )
.filter_by(**query_args) chal_q = (
.filter(*filters) chal_q.filter_by(**query_args).filter(*filters).order_by(Challenges.value)
.order_by(Challenges.value)
.all()
) )
if user: # Iterate through the list of challenges, adding to the object which
solve_ids = ( # will be JSONified back to the client
Solves.query.with_entities(Solves.challenge_id)
.filter_by(account_id=user.account_id)
.order_by(Solves.challenge_id.asc())
.all()
)
solve_ids = set([value for value, in solve_ids])
# TODO: Convert this into a re-useable decorator
if is_admin():
pass
else:
if config.is_teams_mode() and get_current_team() is None:
abort(403)
else:
solve_ids = set()
response = [] response = []
tag_schema = TagSchema(view="user", many=True) tag_schema = TagSchema(view="user", many=True)
for challenge in challenges: for challenge in chal_q:
if challenge.requirements: if challenge.requirements:
requirements = challenge.requirements.get("prerequisites", []) requirements = challenge.requirements.get("prerequisites", [])
anonymize = challenge.requirements.get("anonymize") anonymize = challenge.requirements.get("anonymize")
prereqs = set(requirements) prereqs = set(requirements)
if solve_ids >= prereqs: if user_solves >= prereqs:
pass pass
else: else:
if anonymize: if anonymize:
@@ -179,6 +234,8 @@ class ChallengeList(Resource):
"type": "hidden", "type": "hidden",
"name": "???", "name": "???",
"value": 0, "value": 0,
"solves": None,
"solved_by_me": False,
"category": "???", "category": "???",
"tags": [], "tags": [],
"template": "", "template": "",
@@ -201,6 +258,8 @@ class ChallengeList(Resource):
"type": challenge_type.name, "type": challenge_type.name,
"name": challenge.name, "name": challenge.name,
"value": challenge.value, "value": challenge.value,
"solves": solve_counts.get(challenge.id, solve_count_dfl),
"solved_by_me": challenge.id in user_solves,
"category": challenge.category, "category": challenge.category,
"tags": tag_schema.dump(challenge.tags).data, "tags": tag_schema.dump(challenge.tags).data,
"template": challenge_type.templates["view"], "template": challenge_type.templates["view"],
@@ -305,7 +364,7 @@ class Challenge(Resource):
else: else:
# We need to handle the case where a user is viewing challenges anonymously # We need to handle the case where a user is viewing challenges anonymously
solve_ids = [] solve_ids = []
solve_ids = set([value for value, in solve_ids]) solve_ids = {value for value, in solve_ids}
prereqs = set(requirements) prereqs = set(requirements)
if solve_ids >= prereqs or is_admin(): if solve_ids >= prereqs or is_admin():
pass pass
@@ -318,6 +377,8 @@ class Challenge(Resource):
"type": "hidden", "type": "hidden",
"name": "???", "name": "???",
"value": 0, "value": 0,
"solves": None,
"solved_by_me": False,
"category": "???", "category": "???",
"tags": [], "tags": [],
"template": "", "template": "",
@@ -345,14 +406,12 @@ class Challenge(Resource):
if config.is_teams_mode() and team is None: if config.is_teams_mode() and team is None:
abort(403) abort(403)
unlocked_hints = set( unlocked_hints = {
[
u.target u.target
for u in HintUnlocks.query.filter_by( for u in HintUnlocks.query.filter_by(
type="hints", account_id=user.account_id type="hints", account_id=user.account_id
) )
] }
)
files = [] files = []
for f in chal.files: for f in chal.files:
token = { token = {
@@ -376,25 +435,20 @@ class Challenge(Resource):
response = chal_class.read(challenge=chal) response = chal_class.read(challenge=chal)
Model = get_model() solves_q = _build_solves_query(
admin_view=is_admin(), extra_filters=(Solves.challenge_id == chal.id,)
if scores_visible() is True and accounts_visible() is True:
solves = Solves.query.join(Model, Solves.account_id == Model.id).filter(
Solves.challenge_id == chal.id,
Model.banned == False,
Model.hidden == False,
) )
# If there are no solves for this challenge ID then we have 0 rows
# Only show solves that happened before freeze time if configured maybe_row = solves_q.first()
freeze = get_config("freeze") if maybe_row:
if not is_admin() and freeze: _, solve_count, solved_by_user = maybe_row
solves = solves.filter(Solves.date < unix_time_to_utc(freeze)) solved_by_user = bool(solved_by_user)
solves = solves.count()
response["solves"] = solves
else: else:
response["solves"] = None solve_count, solved_by_user = 0, False
solves = None
# Hide solve counts if we are hiding solves/accounts
if scores_visible() is False or accounts_visible() is False:
solve_count = None
if authed(): if authed():
# Get current attempts for the user # Get current attempts for the user
@@ -404,6 +458,8 @@ class Challenge(Resource):
else: else:
attempts = 0 attempts = 0
response["solves"] = solve_count
response["solved_by_me"] = solved_by_user
response["attempts"] = attempts response["attempts"] = attempts
response["files"] = files response["files"] = files
response["tags"] = tags response["tags"] = tags
@@ -411,7 +467,8 @@ class Challenge(Resource):
response["view"] = render_template( response["view"] = render_template(
chal_class.templates["view"].lstrip("/"), chal_class.templates["view"].lstrip("/"),
solves=solves, solves=solve_count,
solved_by_me=solved_by_user,
files=files, files=files,
tags=tags, tags=tags,
hints=[Hints(**h) for h in hints], hints=[Hints(**h) for h in hints],
@@ -532,7 +589,7 @@ class ChallengeAttempt(Resource):
.order_by(Solves.challenge_id.asc()) .order_by(Solves.challenge_id.asc())
.all() .all()
) )
solve_ids = set([solve_id for solve_id, in solve_ids]) solve_ids = {solve_id for solve_id, in solve_ids}
prereqs = set(requirements) prereqs = set(requirements)
if solve_ids >= prereqs: if solve_ids >= prereqs:
pass pass

View File

@@ -6,11 +6,13 @@ from sqlalchemy.orm.properties import ColumnProperty
def sqlalchemy_to_pydantic( def sqlalchemy_to_pydantic(
db_model: Type, *, exclude: Container[str] = [] db_model: Type, *, exclude: Container[str] = None
) -> Type[BaseModel]: ) -> Type[BaseModel]:
""" """
Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy Mostly copied from https://github.com/tiangolo/pydantic-sqlalchemy
""" """
if exclude is None:
exclude = []
mapper = inspect(db_model) mapper = inspect(db_model)
fields = {} fields = {}
for attr in mapper.attrs: for attr in mapper.attrs:

View File

@@ -1,10 +1,10 @@
from collections import defaultdict from collections import defaultdict
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from sqlalchemy.orm import joinedload from sqlalchemy import select
from CTFd.cache import cache, make_cache_key from CTFd.cache import cache, make_cache_key
from CTFd.models import Awards, Solves, Teams from CTFd.models import Awards, Solves, Users, db
from CTFd.utils import get_config from CTFd.utils import get_config
from CTFd.utils.dates import isoformat, unix_time_to_utc from CTFd.utils.dates import isoformat, unix_time_to_utc
from CTFd.utils.decorators.visibility import ( from CTFd.utils.decorators.visibility import (
@@ -31,25 +31,33 @@ class ScoreboardList(Resource):
account_type = get_mode_as_word() account_type = get_mode_as_word()
if mode == TEAMS_MODE: if mode == TEAMS_MODE:
team_ids = [] r = db.session.execute(
for team in standings: select(
team_ids.append(team.account_id) [
Users.id,
# Get team objects with members explicitly loaded in Users.name,
teams = ( Users.oauth_id,
Teams.query.options(joinedload(Teams.members)) Users.team_id,
.filter(Teams.id.in_(team_ids)) Users.hidden,
.all() Users.banned,
]
).where(Users.team_id.isnot(None))
) )
users = r.fetchall()
# Sort according to team_ids order membership = defaultdict(dict)
teams = [next(t for t in teams if t.id == id) for id in team_ids] for u in users:
if u.hidden is False and u.banned is False:
membership[u.team_id][u.id] = {
"id": u.id,
"oauth_id": u.oauth_id,
"name": u.name,
"score": 0,
}
# Get user_standings as a dict so that we can more quickly get member scores # Get user_standings as a dict so that we can more quickly get member scores
user_standings = get_user_standings() user_standings = get_user_standings()
users = {}
for u in user_standings: for u in user_standings:
users[u.user_id] = u membership[u.team_id][u.user_id]["score"] = int(u.score)
for i, x in enumerate(standings): for i, x in enumerate(standings):
entry = { entry = {
@@ -63,33 +71,7 @@ class ScoreboardList(Resource):
} }
if mode == TEAMS_MODE: if mode == TEAMS_MODE:
members = [] entry["members"] = list(membership[x.account_id].values())
# This code looks like it would be slow
# but it is faster than accessing each member's score individually
for member in teams[i].members:
user = users.get(member.id)
if user:
members.append(
{
"id": user.user_id,
"oauth_id": user.oauth_id,
"name": user.name,
"score": int(user.score),
}
)
else:
if member.hidden is False and member.banned is False:
members.append(
{
"id": member.id,
"oauth_id": member.oauth_id,
"name": member.name,
"score": 0,
}
)
entry["members"] = members
response.append(entry) response.append(entry)
return {"success": True, "data": response} return {"success": True, "data": response}
@@ -152,7 +134,7 @@ class ScoreboardDetail(Resource):
solves_mapper[team_id], key=lambda k: k["date"] solves_mapper[team_id], key=lambda k: k["date"]
) )
for i, team in enumerate(team_ids): for i, _team in enumerate(team_ids):
response[i + 1] = { response[i + 1] = {
"id": standings[i].account_id, "id": standings[i].account_id,
"name": standings[i].name, "name": standings[i].name,

View File

@@ -132,7 +132,6 @@ class UserList(Resource):
"data": response.data, "data": response.data,
} }
@users_namespace.doc()
@admins_only @admins_only
@users_namespace.doc( @users_namespace.doc(
description="Endpoint to create a User object", description="Endpoint to create a User object",

View File

@@ -143,6 +143,11 @@ LOG_FOLDER =
# If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1. # If you specify `true` CTFd will default to the above behavior with all proxy settings set to 1.
REVERSE_PROXY = REVERSE_PROXY =
# THEME_FALLBACK
# Specifies whether CTFd will fallback to the default "core" theme for missing pages/content. Useful for developing themes or using incomplete themes.
# Defaults to false.
THEME_FALLBACK =
# TEMPLATES_AUTO_RELOAD # TEMPLATES_AUTO_RELOAD
# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true. # Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true.
TEMPLATES_AUTO_RELOAD = TEMPLATES_AUTO_RELOAD =

View File

@@ -55,7 +55,7 @@ def gen_secret_key():
try: try:
with open(".ctfd_secret_key", "rb") as secret: with open(".ctfd_secret_key", "rb") as secret:
key = secret.read() key = secret.read()
except (OSError, IOError): except OSError:
key = None key = None
if not key: if not key:
@@ -66,7 +66,7 @@ def gen_secret_key():
with open(".ctfd_secret_key", "wb") as secret: with open(".ctfd_secret_key", "wb") as secret:
secret.write(key) secret.write(key)
secret.flush() secret.flush()
except (OSError, IOError): except OSError:
pass pass
return key return key
@@ -178,6 +178,8 @@ class ServerConfig(object):
TEMPLATES_AUTO_RELOAD: bool = empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True) TEMPLATES_AUTO_RELOAD: bool = empty_str_cast(config_ini["optional"]["TEMPLATES_AUTO_RELOAD"], default=True)
THEME_FALLBACK: bool = empty_str_cast(config_ini["optional"]["THEME_FALLBACK"], default=False)
SQLALCHEMY_TRACK_MODIFICATIONS: bool = empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False) SQLALCHEMY_TRACK_MODIFICATIONS: bool = empty_str_cast(config_ini["optional"]["SQLALCHEMY_TRACK_MODIFICATIONS"], default=False)
SWAGGER_UI: bool = empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False) SWAGGER_UI: bool = empty_str_cast(config_ini["optional"]["SWAGGER_UI"], default=False)

2
CTFd/constants/themes.py Normal file
View File

@@ -0,0 +1,2 @@
ADMIN_THEME = "admin"
DEFAULT_THEME = "core"

View File

@@ -1,25 +1,20 @@
import jinja2.exceptions
from flask import render_template from flask import render_template
from werkzeug.exceptions import InternalServerError from werkzeug.exceptions import InternalServerError
# 404 def render_error(error):
def page_not_found(error): if (
return render_template("errors/404.html", error=error.description), 404 isinstance(error, InternalServerError)
and error.description == InternalServerError.description
):
# 403
def forbidden(error):
return render_template("errors/403.html", error=error.description), 403
# 500
def general_error(error):
if error.description == InternalServerError.description:
error.description = "An Internal Server Error has occurred" error.description = "An Internal Server Error has occurred"
try:
return render_template("errors/500.html", error=error.description), 500 return (
render_template(
"errors/{}.html".format(error.code), error=error.description,
# 502 ),
def gateway_error(error): error.code,
return render_template("errors/502.html", error=error.description), 502 )
except jinja2.exceptions.TemplateNotFound:
return error.get_response()

View File

@@ -10,6 +10,7 @@ from wtforms import (
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from wtforms.validators import InputRequired from wtforms.validators import InputRequired
from CTFd.constants.themes import DEFAULT_THEME
from CTFd.forms import BaseForm from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField from CTFd.forms.fields import SubmitField
from CTFd.utils.config import get_themes from CTFd.utils.config import get_themes
@@ -59,7 +60,7 @@ class SetupForm(BaseForm):
"Theme", "Theme",
description="CTFd Theme to use", description="CTFd Theme to use",
choices=list(zip(get_themes(), get_themes())), choices=list(zip(get_themes(), get_themes())),
default="core", default=DEFAULT_THEME,
validators=[InputRequired()], validators=[InputRequired()],
) )
theme_color = HiddenField( theme_color = HiddenField(

View File

@@ -123,7 +123,7 @@ class BaseChallenge(object):
if get_flag_class(flag.type).compare(flag, submission): if get_flag_class(flag.type).compare(flag, submission):
return True, "Correct" return True, "Correct"
except FlagException as e: except FlagException as e:
return False, e.message return False, str(e)
return False, "Incorrect" return False, "Incorrect"
@classmethod @classmethod

View File

@@ -316,6 +316,7 @@ class UserSchema(ma.ModelSchema):
"id", "id",
"oauth_id", "oauth_id",
"fields", "fields",
"team_id",
], ],
"self": [ "self": [
"website", "website",
@@ -328,6 +329,7 @@ class UserSchema(ma.ModelSchema):
"oauth_id", "oauth_id",
"password", "password",
"fields", "fields",
"team_id",
], ],
"admin": [ "admin": [
"website", "website",
@@ -346,6 +348,7 @@ class UserSchema(ma.ModelSchema):
"type", "type",
"verified", "verified",
"fields", "fields",
"team_id",
], ],
} }

View File

@@ -0,0 +1,229 @@
<template>
<div>
<div class="form-group">
<label>Search Users</label>
<input
type="text"
class="form-control"
placeholder="Search for users"
v-model="searchedName"
@keyup.down="moveCursor('down')"
@keyup.up="moveCursor('up')"
@keyup.enter="selectUser()"
/>
</div>
<div class="form-group">
<span
class="badge badge-primary mr-1"
v-for="user in selectedUsers"
:key="user.id"
>
{{ user.name }}
<a class="btn-fa" @click="removeSelectedUser(user.id)"> &#215;</a>
</span>
</div>
<div class="form-group">
<div
class="text-center"
v-if="
userResults.length == 0 &&
this.searchedName != '' &&
awaitingSearch == false
"
>
<span class="text-muted">
No users found
</span>
</div>
<ul class="list-group">
<li
:class="{
'list-group-item': true,
active: idx === selectedResultIdx
}"
v-for="(user, idx) in userResults"
:key="user.id"
@click="selectUser(idx)"
>
{{ user.name }}
<small
v-if="user.team_id"
:class="{
'float-right': true,
'text-white': idx === selectedResultIdx,
'text-muted': idx !== selectedResultIdx
}"
>
already in a team
</small>
</li>
</ul>
</div>
<div class="form-group">
<button
class="btn btn-success d-inline-block float-right"
@click="addUsers()"
>
Add Users
</button>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { ezQuery } from "core/ezq";
import { htmlEntities } from "core/utils";
export default {
name: "UserAddForm",
props: {
team_id: Number
},
data: function() {
return {
searchedName: "",
awaitingSearch: false,
emptyResults: false,
userResults: [],
selectedResultIdx: 0,
selectedUsers: []
};
},
methods: {
searchUsers: function() {
this.selectedResultIdx = 0;
if (this.searchedName == "") {
this.userResults = [];
return;
}
CTFd.fetch(`/api/v1/users?view=admin&field=name&q=${this.searchedName}`, {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success) {
this.userResults = response.data.slice(0, 10);
}
});
},
moveCursor: function(dir) {
switch (dir) {
case "up":
if (this.selectedResultIdx) {
this.selectedResultIdx -= 1;
}
break;
case "down":
if (this.selectedResultIdx < this.userResults.length - 1) {
this.selectedResultIdx += 1;
}
break;
}
},
selectUser: function(idx) {
if (idx === undefined) {
idx = this.selectedResultIdx;
}
let user = this.userResults[idx];
// Avoid duplicates
const found = this.selectedUsers.some(
searchUser => searchUser.id === user.id
);
if (found === false) {
this.selectedUsers.push(user);
}
this.userResults = [];
this.searchedName = "";
},
removeSelectedUser: function(user_id) {
this.selectedUsers = this.selectedUsers.filter(
user => user.id !== user_id
);
},
handleAddUsersRequest: function() {
let reqs = [];
this.selectedUsers.forEach(user => {
let body = { user_id: user.id };
reqs.push(
CTFd.fetch(`/api/v1/teams/${this.$props.team_id}/members`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
);
});
return Promise.all(reqs);
},
handleRemoveUsersFromTeams: function() {
let reqs = [];
this.selectedUsers.forEach(user => {
let body = { user_id: user.id };
reqs.push(
CTFd.fetch(`/api/v1/teams/${user.team_id}/members`, {
method: "DELETE",
body: JSON.stringify(body)
})
);
});
return Promise.all(reqs);
},
addUsers: function() {
let usersInTeams = [];
this.selectedUsers.forEach(user => {
if (user.team_id) {
usersInTeams.push(user.name);
}
});
if (usersInTeams.length) {
let users = htmlEntities(usersInTeams.join(", "));
ezQuery({
title: "Confirm Team Removal",
body: `The following users are currently in teams:<br><br> ${users} <br><br>Are you sure you want to remove them from their current teams and add them to this one? <br><br>All of their challenge solves, attempts, awards, and unlocked hints will also be deleted!`,
success: () => {
this.handleRemoveUsersFromTeams().then(_resps => {
this.handleAddUsersRequest().then(_resps => {
window.location.reload();
});
});
}
});
} else {
this.handleAddUsersRequest().then(_resps => {
window.location.reload();
});
}
}
},
watch: {
searchedName: function(val) {
if (this.awaitingSearch === false) {
// 1 second delay after typing
setTimeout(() => {
this.searchUsers();
this.awaitingSearch = false;
}, 1000);
}
this.awaitingSearch = true;
}
}
};
</script>
<style scoped></style>

View File

@@ -38,23 +38,42 @@ function toggleAccount() {
}); });
} }
function toggleSelectedAccounts(accountIDs, action) { function toggleSelectedAccounts(selectedAccounts, action) {
const params = { const params = {
hidden: action === "hidden" ? true : false hidden: action === "hidden" ? true : false
}; };
const reqs = []; const reqs = [];
for (var accId of accountIDs) { for (let accId of selectedAccounts.accounts) {
reqs.push(api_func[CTFd.config.userMode](accId, params)); reqs.push(api_func[CTFd.config.userMode](accId, params));
} }
for (let accId of selectedAccounts.users) {
reqs.push(api_func["users"](accId, params));
}
Promise.all(reqs).then(_responses => { Promise.all(reqs).then(_responses => {
window.location.reload(); window.location.reload();
}); });
} }
function bulkToggleAccounts(_event) { function bulkToggleAccounts(_event) {
let accountIDs = $("input[data-account-id]:checked").map(function() { // Get selected account and user IDs but only on the active tab.
// Technically this could work for both tabs at the same time but that seems like
// bad behavior. We don't want to accidentally unhide a user/team accidentally
let accountIDs = $(".tab-pane.active input[data-account-id]:checked").map(
function() {
return $(this).data("account-id"); return $(this).data("account-id");
}); }
);
let userIDs = $(".tab-pane.active input[data-user-id]:checked").map(
function() {
return $(this).data("user-id");
}
);
let selectedUsers = {
accounts: accountIDs,
users: userIDs
};
ezAlert({ ezAlert({
title: "Toggle Visibility", title: "Toggle Visibility",
@@ -74,7 +93,7 @@ function bulkToggleAccounts(_event) {
success: function() { success: function() {
let data = $("#scoreboard-bulk-edit").serializeJSON(true); let data = $("#scoreboard-bulk-edit").serializeJSON(true);
let state = data.visibility; let state = data.visibility;
toggleSelectedAccounts(accountIDs, state); toggleSelectedAccounts(selectedUsers, state);
} }
}); });
} }

View File

@@ -6,6 +6,7 @@ import { ezAlert, ezQuery, ezBadge } from "core/ezq";
import { createGraph, updateGraph } from "core/graphs"; import { createGraph, updateGraph } from "core/graphs";
import Vue from "vue/dist/vue.esm.browser"; import Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue"; import CommentBox from "../components/comments/CommentBox.vue";
import UserAddForm from "../components/teams/UserAddForm.vue";
import { copyToClipboard } from "../../../../core/assets/js/utils"; import { copyToClipboard } from "../../../../core/assets/js/utils";
function createTeam(event) { function createTeam(event) {
@@ -398,6 +399,10 @@ $(() => {
copyToClipboard(e, "#team-invite-link"); copyToClipboard(e, "#team-invite-link");
}); });
$(".members-team").click(function(_e) {
$("#team-add-modal").modal("toggle");
});
$(".edit-captain").click(function(_e) { $(".edit-captain").click(function(_e) {
$("#team-captain-modal").modal("toggle"); $("#team-captain-modal").modal("toggle");
}); });
@@ -477,7 +482,7 @@ $(() => {
ezQuery({ ezQuery({
title: "Remove Member", title: "Remove Member",
body: "Are you sure you want to remove {0} from {1}? <br><br><strong>All of their challenges solves, attempts, awards, and unlocked hints will also be deleted!</strong>".format( body: "Are you sure you want to remove {0} from {1}? <br><br><strong>All of their challenge solves, attempts, awards, and unlocked hints will also be deleted!</strong>".format(
"<strong>" + htmlEntities(member_name) + "</strong>", "<strong>" + htmlEntities(member_name) + "</strong>",
"<strong>" + htmlEntities(window.TEAM_NAME) + "</strong>" "<strong>" + htmlEntities(window.TEAM_NAME) + "</strong>"
), ),
@@ -548,6 +553,16 @@ $(() => {
propsData: { type: "team", id: window.TEAM_ID } propsData: { type: "team", id: window.TEAM_ID }
}).$mount(vueContainer); }).$mount(vueContainer);
// Insert team member addition form
const userAddForm = Vue.extend(UserAddForm);
let memberFormContainer = document.createElement("div");
document
.querySelector("#team-add-modal .modal-body")
.appendChild(memberFormContainer);
new userAddForm({
propsData: { team_id: window.TEAM_ID }
}).$mount(memberFormContainer);
let type, id, name, account_id; let type, id, name, account_id;
({ type, id, name, account_id } = window.stats_data); ({ type, id, name, account_id } = window.stats_data);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -78,7 +78,7 @@
<h5 class="modal-title">Theme Settings</h5> <h5 class="modal-title">Theme Settings</h5>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{% include "config.html" ignore missing %} {% include ctf_theme + "/config.html" ignore missing %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -18,59 +18,38 @@
</div> </div>
</div> </div>
</div> </div>
{% if Configs.user_mode == UserModeTypes.TEAMS %}
<div class="row pb-4">
<div class="col-md-12">
<ul class="nav nav-tabs nav-fill" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-toggle="tab" href="#standings" role="tab">
Teams
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="tab" href="#user-standings" role="tab">
Users
</a>
</li>
</ul>
</div>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<table id="scoreboard" class="table table-striped border"> <div class="tab-content">
<thead> <div class="tab-pane fade show active" id="standings" role="tabpanel">
<tr> {% include "admin/scoreboard/standings.html" %}
<th class="border-right" data-checkbox>
<div class="form-check text-center">
<input type="checkbox" class="form-check-input" id="scoreboard-bulk-select" data-checkbox-all>&nbsp;
</div> </div>
</th> {% if Configs.user_mode == UserModeTypes.TEAMS %}
<th class="sort-col text-center"><b>Place</b></th> <div class="tab-pane fade" id="user-standings" role="tabpanel">
<th class="sort-col"><b>{{ get_mode_as_word(capitalize=True) }}</b></th> {% include "admin/scoreboard/users.html" %}
<th class="sort-col"><b>Score</b></th>
<th class="sort-col"><b>Visibility</b></th>
</tr>
</thead>
<tbody>
{% for standing in standings %}
<tr data-href="{{ generate_account_url(standing.account_id, admin=True) }}">
<td class="border-right text-center" data-checkbox>
<div class="form-check">
<input type="checkbox" class="form-check-input" value="{{ standing.account_id }}" data-account-id="{{ standing.account_id }}">&nbsp;
</div> </div>
</td>
<td class="text-center" width="10%">{{ loop.index }}</td>
<td>
<a href="{{ generate_account_url(standing.account_id, admin=True) }}">
{{ standing.name }}
{% if standing.oauth_id %}
{% if get_config('user_mode') == 'teams' %}
<a href="https://majorleaguecyber.org/t/{{ standing.name }}">
<span class="badge badge-primary">Official</span>
</a>
{% elif get_config('user_mode') == 'users' %}
<a href="https://majorleaguecyber.org/u/{{ standing.name }}">
<span class="badge badge-primary">Official</span>
</a>
{% endif %} {% endif %}
{% endif %} </div>
</a>
</td>
<td>{{ standing.score }}</td>
<td>
{% if standing.hidden %}
<span class="badge badge-danger">hidden</span>
{% else %}
<span class="badge badge-success">visible</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,51 @@
<table id="scoreboard" class="table table-striped border">
<thead>
<tr>
<th class="border-right" data-checkbox>
<div class="form-check text-center">
<input type="checkbox" class="form-check-input" id="scoreboard-bulk-select" data-checkbox-all>&nbsp;
</div>
</th>
<th class="sort-col text-center"><b>Place</b></th>
<th class="sort-col"><b>{{ get_mode_as_word(capitalize=True) }}</b></th>
<th class="sort-col"><b>Score</b></th>
<th class="sort-col"><b>Visibility</b></th>
</tr>
</thead>
<tbody>
{% for standing in standings %}
<tr data-href="{{ generate_account_url(standing.account_id, admin=True) }}">
<td class="border-right text-center" data-checkbox>
<div class="form-check">
<input type="checkbox" class="form-check-input" value="{{ standing.account_id }}" data-account-id="{{ standing.account_id }}">&nbsp;
</div>
</td>
<td class="text-center" width="10%">{{ loop.index }}</td>
<td>
<a href="{{ generate_account_url(standing.account_id, admin=True) }}">
{{ standing.name }}
{% if standing.oauth_id %}
{% if get_config('user_mode') == 'teams' %}
<a href="https://majorleaguecyber.org/t/{{ standing.name }}">
<span class="badge badge-primary">Official</span>
</a>
{% elif get_config('user_mode') == 'users' %}
<a href="https://majorleaguecyber.org/u/{{ standing.name }}">
<span class="badge badge-primary">Official</span>
</a>
{% endif %}
{% endif %}
</a>
</td>
<td>{{ standing.score }}</td>
<td>
{% if standing.hidden %}
<span class="badge badge-danger">hidden</span>
{% else %}
<span class="badge badge-success">visible</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,51 @@
<table id="scoreboard" class="table table-striped border">
<thead>
<tr>
<th class="border-right" data-checkbox>
<div class="form-check text-center">
<input type="checkbox" class="form-check-input" id="scoreboard-bulk-select" data-checkbox-all>&nbsp;
</div>
</th>
<th class="sort-col text-center"><b>Place</b></th>
<th class="sort-col"><b>User</b></th>
<th class="sort-col"><b>Score</b></th>
<th class="sort-col"><b>Visibility</b></th>
</tr>
</thead>
<tbody>
{% for standing in user_standings %}
<tr data-href="{{ url_for('admin.users_detail', user_id=standing.user_id) }}">
<td class="border-right text-center" data-checkbox>
<div class="form-check">
<input type="checkbox" class="form-check-input" value="{{ standing.user_id }}" data-user-id="{{ standing.user_id }}">&nbsp;
</div>
</td>
<td class="text-center" width="10%">{{ loop.index }}</td>
<td>
<a href="{{ url_for('admin.users_detail', user_id=standing.user_id) }}">
{{ standing.name }}
{% if standing.oauth_id %}
{% if get_config('user_mode') == 'teams' %}
<a href="https://majorleaguecyber.org/t/{{ standing.name }}">
<span class="badge badge-primary">Official</span>
</a>
{% elif get_config('user_mode') == 'users' %}
<a href="https://majorleaguecyber.org/u/{{ standing.name }}">
<span class="badge badge-primary">Official</span>
</a>
{% endif %}
{% endif %}
</a>
</td>
<td>{{ standing.score }}</td>
<td>
{% if standing.hidden %}
<span class="badge badge-danger">hidden</span>
{% else %}
<span class="badge badge-success">visible</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -20,6 +20,21 @@
</div> </div>
</div> </div>
<div id="team-add-modal" class="modal fade">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-action text-center w-100">Add Team Members</h2>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body clearfix">
</div>
</div>
</div>
</div>
<div id="team-invite-modal" class="modal fade"> <div id="team-invite-modal" class="modal fade">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@@ -199,9 +214,9 @@
<i class="btn-fa fas fa-ticket-alt fa-2x px-2" data-toggle="tooltip" data-placement="top" <i class="btn-fa fas fa-ticket-alt fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Team Invite Link"></i> title="Team Invite Link"></i>
</a> </a>
<a class="statistics-team text-dark"> <a class="members-team text-dark">
<i class="btn-fa fas fa-chart-pie fa-2x px-2" data-toggle="tooltip" data-placement="top" <i class="btn-fa fas fa-user-plus fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Team Statistics"></i> title="Add Team Members"></i>
</a> </a>
<a class="edit-captain text-dark"> <a class="edit-captain text-dark">
<i class="btn-fa fas fa-user-tag fa-2x px-2" data-toggle="tooltip" data-placement="top" <i class="btn-fa fas fa-user-tag fa-2x px-2" data-toggle="tooltip" data-placement="top"
@@ -216,6 +231,10 @@
</a> </a>
</div> </div>
<div class="pt-3"> <div class="pt-3">
<a class="statistics-team text-dark">
<i class="btn-fa fas fa-chart-pie fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Team Statistics"></i>
</a>
<a class="addresses-team text-dark"> <a class="addresses-team text-dark">
<i class="btn-fa fas fa-network-wired fa-2x px-2" data-toggle="tooltip" data-placement="top" title="IP Addresses"></i> <i class="btn-fa fas fa-network-wired fa-2x px-2" data-toggle="tooltip" data-placement="top" title="IP Addresses"></i>
</a> </a>

View File

@@ -3,7 +3,8 @@ import time
from flask import current_app as app from flask import current_app as app
from CTFd.utils import get_config from CTFd.constants.themes import DEFAULT_THEME
from CTFd.utils import get_app_config, get_config
from CTFd.utils.modes import TEAMS_MODE, USERS_MODE from CTFd.utils.modes import TEAMS_MODE, USERS_MODE
@@ -33,6 +34,12 @@ def ctf_theme():
return theme if theme else "" return theme if theme else ""
def ctf_theme_candidates():
yield ctf_theme()
if bool(get_app_config("THEME_FALLBACK")):
yield DEFAULT_THEME
def is_setup(): def is_setup():
return bool(get_config("setup")) is True return bool(get_config("setup")) is True

View File

@@ -15,6 +15,7 @@ from sqlalchemy.sql import sqltypes
from CTFd import __version__ as CTFD_VERSION from CTFd import __version__ as CTFD_VERSION
from CTFd.cache import cache from CTFd.cache import cache
from CTFd.constants.themes import DEFAULT_THEME
from CTFd.models import db, get_class_by_tablename from CTFd.models import db, get_class_by_tablename
from CTFd.plugins import get_plugin_names from CTFd.plugins import get_plugin_names
from CTFd.plugins.migrations import current as plugin_current from CTFd.plugins.migrations import current as plugin_current
@@ -67,7 +68,7 @@ def export_ctf():
upload_folder = os.path.join( upload_folder = os.path.join(
os.path.normpath(app.root_path), app.config.get("UPLOAD_FOLDER") os.path.normpath(app.root_path), app.config.get("UPLOAD_FOLDER")
) )
for root, dirs, files in os.walk(upload_folder): for root, _dirs, files in os.walk(upload_folder):
for file in files: for file in files:
parent_dir = os.path.basename(root) parent_dir = os.path.basename(root)
backup_zip.write( backup_zip.write(
@@ -353,5 +354,5 @@ def import_ctf(backup, erase=True):
cache.clear() cache.clear()
# Set default theme in case the current instance or the import does not provide it # Set default theme in case the current instance or the import does not provide it
set_config("ctf_theme", "core") set_config("ctf_theme", DEFAULT_THEME)
set_config("ctf_version", CTFD_VERSION) set_config("ctf_version", CTFD_VERSION)

View File

@@ -35,7 +35,7 @@ class JSONSerializer(object):
return result return result
def close(self): def close(self):
for path, result in self.buckets.items(): for _path, result in self.buckets.items():
result = self.wrap(result) result = self.wrap(result)
# Certain databases (MariaDB) store JSON as LONGTEXT. # Certain databases (MariaDB) store JSON as LONGTEXT.

View File

@@ -38,7 +38,7 @@ def get_registered_admin_stylesheets():
def override_template(template, html): def override_template(template, html):
app.theme_loader.overriden_templates[template] = html app.overridden_templates[template] = html
def get_configurable_plugins(): def get_configurable_plugins():

View File

@@ -8,7 +8,7 @@ from CTFd.utils.modes import get_model
@cache.memoize(timeout=60) @cache.memoize(timeout=60)
def get_standings(count=None, admin=False, fields=[]): def get_standings(count=None, admin=False, fields=None):
""" """
Get standings as a list of tuples containing account_id, name, and score e.g. [(account_id, team_name, score)]. Get standings as a list of tuples containing account_id, name, and score e.g. [(account_id, team_name, score)].
@@ -17,6 +17,8 @@ def get_standings(count=None, admin=False, fields=[]):
Challenges & Awards with a value of zero are filtered out of the calculations to avoid incorrect tie breaks. Challenges & Awards with a value of zero are filtered out of the calculations to avoid incorrect tie breaks.
""" """
if fields is None:
fields = []
Model = get_model() Model = get_model()
scores = ( scores = (
@@ -117,7 +119,9 @@ def get_standings(count=None, admin=False, fields=[]):
@cache.memoize(timeout=60) @cache.memoize(timeout=60)
def get_team_standings(count=None, admin=False, fields=[]): def get_team_standings(count=None, admin=False, fields=None):
if fields is None:
fields = []
scores = ( scores = (
db.session.query( db.session.query(
Solves.team_id.label("team_id"), Solves.team_id.label("team_id"),
@@ -197,7 +201,9 @@ def get_team_standings(count=None, admin=False, fields=[]):
@cache.memoize(timeout=60) @cache.memoize(timeout=60)
def get_user_standings(count=None, admin=False, fields=[]): def get_user_standings(count=None, admin=False, fields=None):
if fields is None:
fields = []
scores = ( scores = (
db.session.query( db.session.query(
Solves.user_id.label("user_id"), Solves.user_id.label("user_id"),
@@ -245,6 +251,7 @@ def get_user_standings(count=None, admin=False, fields=[]):
Users.id.label("user_id"), Users.id.label("user_id"),
Users.oauth_id.label("oauth_id"), Users.oauth_id.label("oauth_id"),
Users.name.label("name"), Users.name.label("name"),
Users.team_id.label("team_id"),
Users.hidden, Users.hidden,
Users.banned, Users.banned,
sumscores.columns.score, sumscores.columns.score,
@@ -259,6 +266,7 @@ def get_user_standings(count=None, admin=False, fields=[]):
Users.id.label("user_id"), Users.id.label("user_id"),
Users.oauth_id.label("oauth_id"), Users.oauth_id.label("oauth_id"),
Users.name.label("name"), Users.name.label("name"),
Users.team_id.label("team_id"),
sumscores.columns.score, sumscores.columns.score,
*fields, *fields,
) )

View File

@@ -15,8 +15,7 @@ cleaner = Cleaner(
style=False, style=False,
safe_attrs=( safe_attrs=(
safe_attrs safe_attrs
| set( | {
[
"style", "style",
# Allow data attributes from bootstrap elements # Allow data attributes from bootstrap elements
"data-toggle", "data-toggle",
@@ -45,8 +44,7 @@ cleaner = Cleaner(
"data-selector", "data-selector",
"data-content", "data-content",
"data-trigger", "data-trigger",
] }
)
), ),
annoying_tags=False, annoying_tags=False,
) )

View File

@@ -182,7 +182,7 @@ def get_user_recent_ips(user_id):
.filter(Tracking.user_id == user_id, Tracking.date >= hour_ago) .filter(Tracking.user_id == user_id, Tracking.date >= hour_ago)
.all() .all()
) )
return set([ip for (ip,) in addrs]) return {ip for (ip,) in addrs}
def get_wrong_submissions_per_minute(account_id): def get_wrong_submissions_per_minute(account_id):

View File

@@ -14,6 +14,7 @@ from CTFd.constants.config import (
RegistrationVisibilityTypes, RegistrationVisibilityTypes,
ScoreVisibilityTypes, ScoreVisibilityTypes,
) )
from CTFd.constants.themes import DEFAULT_THEME
from CTFd.models import ( from CTFd.models import (
Admins, Admins,
Files, Files,
@@ -85,7 +86,7 @@ def setup():
f = upload_file(file=ctf_small_icon) f = upload_file(file=ctf_small_icon)
set_config("ctf_small_icon", f.location) set_config("ctf_small_icon", f.location)
theme = request.form.get("ctf_theme", "core") theme = request.form.get("ctf_theme", DEFAULT_THEME)
set_config("ctf_theme", theme) set_config("ctf_theme", theme)
theme_color = request.form.get("theme_color") theme_color = request.form.get("theme_color")
theme_header = get_config("theme_header") theme_header = get_config("theme_header")
@@ -449,8 +450,12 @@ def themes(theme, path):
:param path: :param path:
:return: :return:
""" """
filename = safe_join(app.root_path, "themes", theme, "static", path) for cand_path in (
if os.path.isfile(filename): safe_join(app.root_path, "themes", cand_theme, "static", path)
return send_file(filename) # The `theme` value passed in may not be the configured one, e.g. for
else: # admin pages, so we check that first
for cand_theme in (theme, *config.ctf_theme_candidates())
):
if os.path.isfile(cand_path):
return send_file(cand_path)
abort(404) abort(404)

View File

@@ -20,3 +20,5 @@ Faker==4.1.0
pipdeptree==0.13.2 pipdeptree==0.13.2
black==19.10b0 black==19.10b0
pytest-sugar==0.9.4 pytest-sugar==0.9.4
flake8-comprehensions==3.3.1
flake8-bugbear==20.11.1

View File

@@ -1,6 +1,6 @@
{ {
"name": "ctfd", "name": "ctfd",
"version": "3.1.1", "version": "3.3.0",
"description": "CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes.", "description": "CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes.",
"main": "index.js", "main": "index.js",
"directories": { "directories": {

View File

@@ -11,7 +11,7 @@ def test_generate_user_token():
with app.app_context(): with app.app_context():
user = gen_user(app.db) user = gen_user(app.db)
token = generate_user_token(user, expiration=None) token = generate_user_token(user, expiration=None)
token.user_id == user.id assert token.user_id == user.id
assert token.expiration > datetime.datetime.utcnow() assert token.expiration > datetime.datetime.utcnow()
assert Tokens.query.count() == 1 assert Tokens.query.count() == 1
destroy_ctfd(app) destroy_ctfd(app)

View File

@@ -154,6 +154,250 @@ def test_api_challenges_get_hidden_admin():
destroy_ctfd(app) destroy_ctfd(app)
def test_api_challenges_get_solve_status():
"""Does the challenge list API show the current user's solve status?"""
app = create_ctfd()
with app.app_context():
chal_id = gen_challenge(app.db).id
register_user(app)
client = login_as_user(app)
# First request - unsolved
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solved_by_me"] is False
# Solve and re-request
gen_solve(app.db, user_id=2, challenge_id=chal_id)
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solved_by_me"] is True
destroy_ctfd(app)
def test_api_challenges_get_solve_count():
"""Does the challenge list API show the solve count?"""
# This is checked with public requests against the API after each generated
# user makes a solve
app = create_ctfd()
with app.app_context():
set_config("challenge_visibility", "public")
chal_id = gen_challenge(app.db).id
with app.test_client() as client:
_USER_BASE = 2 # First user we create will have this ID
_MAX = 3 # arbitrarily selected
for i in range(_MAX):
# Confirm solve count against `i` first
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == i
# Generate a new user and solve for the challenge
uname = "user{}".format(i)
uemail = uname + "@examplectf.com"
register_user(app, name=uname, email=uemail)
gen_solve(app.db, user_id=_USER_BASE + i, challenge_id=chal_id)
# Confirm solve count one final time against `_MAX`
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == _MAX
destroy_ctfd(app)
def test_api_challenges_get_solve_info_score_visibility():
"""Does the challenge list API show solve info if scores are hidden?"""
app = create_ctfd()
with app.app_context(), app.test_client() as pub_client:
set_config("challenge_visibility", "public")
# Generate a challenge, user and solve to test the API with
chal_id = gen_challenge(app.db).id
register_user(app)
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# With the public setting any unauthed user should see the solve
set_config("score_visibility", "public")
r = pub_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] == False
# With the private setting only an authed user should see the solve
set_config("score_visibility", "private")
# Test public user
r = pub_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is False
# Test authed user
user_client = login_as_user(app)
r = user_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is True
# With the admins setting only admins should see the solve
set_config("score_visibility", "admins")
# Test authed user
r = user_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is True
# Test admin
admin_client = login_as_user(app, "admin", "password")
r = admin_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
# With the hidden setting nobody should see the solve
set_config("score_visibility", "hidden")
r = admin_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] is None
destroy_ctfd(app)
def test_api_challenges_get_solve_info_account_visibility():
"""Does the challenge list API show solve info if accounts are hidden?"""
app = create_ctfd()
with app.app_context(), app.test_client() as pub_client:
set_config("challenge_visibility", "public")
# Generate a challenge, user and solve to test the API with
chal_id = gen_challenge(app.db).id
register_user(app)
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# With the public setting any unauthed user should see the solve
set_config("account_visibility", "public")
r = pub_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
# With the private setting only an authed user should see the solve
set_config("account_visibility", "private")
# Test public user
r = pub_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is False
# Test user
user_client = login_as_user(app)
r = user_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is True
# With the admins setting only admins should see the solve
set_config("account_visibility", "admins")
# Test user
r = user_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is True
# Test admin user
admin_client = login_as_user(app, "admin", "password")
r = admin_client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
destroy_ctfd(app)
def test_api_challenges_get_solve_count_frozen():
"""Does the challenge list API count solves made during a freeze?"""
app = create_ctfd()
with app.app_context(), app.test_client() as client:
set_config("challenge_visibility", "public")
set_config("freeze", "1507262400")
chal_id = gen_challenge(app.db).id
with freeze_time("2017-10-4"):
# Create a user and generate a solve from before the freeze time
register_user(app, name="user1", email="user1@examplectf.com")
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# Confirm solve count is now `1`
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
with freeze_time("2017-10-8"):
# Create a user and generate a solve from after the freeze time
register_user(app, name="user2", email="user2@examplectf.com")
gen_solve(app.db, user_id=3, challenge_id=chal_id)
# Confirm solve count is still `1` despite the new solve
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
destroy_ctfd(app)
def test_api_challenges_get_solve_count_hidden_user():
"""Does the challenge list API show solve counts for hidden users?"""
app = create_ctfd()
with app.app_context():
set_config("challenge_visibility", "public")
chal_id = gen_challenge(app.db).id
# The admin is expected to be hidden by default
gen_solve(app.db, user_id=1, challenge_id=chal_id)
with app.test_client() as client:
# Confirm solve count is `0` despite the hidden admin having solved
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 0
destroy_ctfd(app)
def test_api_challenges_get_solve_count_banned_user():
"""Does the challenge list API show solve counts for banned users?"""
app = create_ctfd()
with app.app_context():
set_config("challenge_visibility", "public")
chal_id = gen_challenge(app.db).id
# Create a banned user and generate a solve for the challenge
register_user(app)
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# Confirm that the solve is there
with app.test_client() as client:
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 1
# Ban the user
Users.query.get(2).banned = True
app.db.session.commit()
with app.test_client() as client:
# Confirm solve count is `0` despite the banned user having solved
r = client.get("/api/v1/challenges")
assert r.status_code == 200
chal_data = r.get_json()["data"].pop()
assert chal_data["solves"] == 0
destroy_ctfd(app)
def test_api_challenges_post_admin(): def test_api_challenges_post_admin():
"""Can a user post /api/v1/challenges if admin""" """Can a user post /api/v1/challenges if admin"""
app = create_ctfd() app = create_ctfd()
@@ -325,6 +569,264 @@ def test_api_challenge_get_non_existing():
destroy_ctfd(app) destroy_ctfd(app)
def test_api_challenge_get_solve_status():
"""Does the challenge detail API show the current user's solve status?"""
app = create_ctfd()
with app.app_context():
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
register_user(app)
client = login_as_user(app)
# First request - unsolved
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solved_by_me"] is False
# Solve and re-request
gen_solve(app.db, user_id=2, challenge_id=chal_id)
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solved_by_me"] is True
destroy_ctfd(app)
def test_api_challenge_get_solve_info_score_visibility():
"""Does the challenge detail API show solve info if scores are hidden?"""
app = create_ctfd()
with app.app_context(), app.test_client() as pub_client:
set_config("challenge_visibility", "public")
# Generate a challenge, user and solve to test the API with
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
register_user(app)
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# With the public setting any unauthed user should see the solve
set_config("score_visibility", "public")
r = pub_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
# With the private setting only an authed user should see the solve
set_config("score_visibility", "private")
# Test public user
r = pub_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is False
# Test user
user_client = login_as_user(app)
r = user_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is True
# With the admins setting only admins should see the solve
set_config("score_visibility", "admins")
# Test user
r = user_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is True
# Test admin user
admin_client = login_as_user(app, "admin", "password")
r = admin_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
# With the hidden setting nobody should see the solve
set_config("score_visibility", "hidden")
r = admin_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] is None
destroy_ctfd(app)
def test_api_challenge_get_solve_info_account_visibility():
"""Does the challenge detail API show solve info if accounts are hidden?"""
app = create_ctfd()
with app.app_context(), app.test_client() as pub_client:
set_config("challenge_visibility", "public")
# Generate a challenge, user and solve to test the API with
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
register_user(app)
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# With the public setting any unauthed user should see the solve
set_config("account_visibility", "public")
r = pub_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
# With the private setting only an authed user should see the solve
set_config("account_visibility", "private")
# Test public user
r = pub_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is False
# Test user
user_client = login_as_user(app)
r = user_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is True
# With the admins setting only admins should see the solve
set_config("account_visibility", "admins")
# Test user
r = user_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] is None
assert chal_data["solved_by_me"] is True
# Test admin user
admin_client = login_as_user(app, "admin", "password")
r = admin_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
assert chal_data["solved_by_me"] is False
# With the hidden setting admins can still see the solve
# because the challenge detail endpoint doesn't have an admin specific view
set_config("account_visibility", "hidden")
r = admin_client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
destroy_ctfd(app)
def test_api_challenge_get_solve_count():
"""Does the challenge detail API show the solve count?"""
# This is checked with public requests against the API after each generated
# user makes a solve
app = create_ctfd()
with app.app_context():
set_config("challenge_visibility", "public")
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
with app.test_client() as client:
_USER_BASE = 2 # First user we create will have this ID
_MAX = 3 # arbitrarily selected
for i in range(_MAX):
# Confirm solve count against `i` first
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == i
# Generate a new user and solve for the challenge
uname = "user{}".format(i)
uemail = uname + "@examplectf.com"
register_user(app, name=uname, email=uemail)
gen_solve(app.db, user_id=_USER_BASE + i, challenge_id=chal_id)
# Confirm solve count one final time against `_MAX`
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == _MAX
destroy_ctfd(app)
def test_api_challenge_get_solve_count_frozen():
"""Does the challenge detail API count solves made during a freeze?"""
app = create_ctfd()
with app.app_context(), app.test_client() as client:
set_config("challenge_visibility", "public")
# Friday, October 6, 2017 4:00:00 AM
set_config("freeze", "1507262400")
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
with freeze_time("2017-10-4"):
# Create a user and generate a solve from before the freeze time
register_user(app, name="user1", email="user1@examplectf.com")
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# Confirm solve count is now `1`
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
with freeze_time("2017-10-8"):
# Create a user and generate a solve from after the freeze time
register_user(app, name="user2", email="user2@examplectf.com")
gen_solve(app.db, user_id=3, challenge_id=chal_id)
# Confirm solve count is still `1` despite the new solve
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
destroy_ctfd(app)
def test_api_challenge_get_solve_count_hidden_user():
"""Does the challenge detail API show solve counts for hidden users?"""
app = create_ctfd()
with app.app_context():
set_config("challenge_visibility", "public")
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
# The admin is expected to be hidden by default
gen_solve(app.db, user_id=1, challenge_id=chal_id)
with app.test_client() as client:
# Confirm solve count is `0` despite the hidden admin having solved
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 0
destroy_ctfd(app)
def test_api_challenge_get_solve_count_banned_user():
"""Does the challenge detail API show solve counts for banned users?"""
app = create_ctfd()
with app.app_context():
set_config("challenge_visibility", "public")
chal_id = gen_challenge(app.db).id
chal_uri = "/api/v1/challenges/{}".format(chal_id)
# Create a user and generate a solve for the challenge
register_user(app)
gen_solve(app.db, user_id=2, challenge_id=chal_id)
# Confirm that the solve is there
with app.test_client() as client:
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 1
# Ban the user
Users.query.get(2).banned = True
app.db.session.commit()
# Confirm solve count is `0` despite the banned user having solved
with app.test_client() as client:
r = client.get(chal_uri)
assert r.status_code == 200
chal_data = r.get_json()["data"]
assert chal_data["solves"] == 0
destroy_ctfd(app)
def test_api_challenge_patch_non_admin(): def test_api_challenge_patch_non_admin():
"""Can a user patch /api/v1/challenges/<challenge_id> if not admin""" """Can a user patch /api/v1/challenges/<challenge_id> if not admin"""
app = create_ctfd() app = create_ctfd()
@@ -439,7 +941,7 @@ def test_api_challenge_attempt_post_private():
challenge_id = gen_challenge(app.db).id challenge_id = gen_challenge(app.db).id
gen_flag(app.db, challenge_id) gen_flag(app.db, challenge_id)
with login_as_user(app) as client: with login_as_user(app) as client:
for i in range(10): for _ in range(10):
gen_fail(app.db, user_id=2, challenge_id=challenge_id) gen_fail(app.db, user_id=2, challenge_id=challenge_id)
r = client.post( r = client.post(
"/api/v1/challenges/attempt", "/api/v1/challenges/attempt",
@@ -480,7 +982,7 @@ def test_api_challenge_attempt_post_private():
challenge_id = gen_challenge(app.db).id challenge_id = gen_challenge(app.db).id
gen_flag(app.db, challenge_id) gen_flag(app.db, challenge_id)
with login_as_user(app) as client: with login_as_user(app) as client:
for i in range(10): for _ in range(10):
gen_fail(app.db, user_id=2, team_id=team_id, challenge_id=challenge_id) gen_fail(app.db, user_id=2, team_id=team_id, challenge_id=challenge_id)
r = client.post( r = client.post(
"/api/v1/challenges/attempt", "/api/v1/challenges/attempt",

View File

@@ -68,9 +68,10 @@ def test_api_files_post_admin():
r = client.post( r = client.post(
"/api/v1/files", "/api/v1/files",
content_type="multipart/form-data", content_type="multipart/form-data",
data=dict( data={
file=(BytesIO(b"test file content"), "test.txt"), nonce=nonce "file": (BytesIO(b"test file content"), "test.txt"),
), "nonce": nonce,
},
) )
assert r.status_code == 200 assert r.status_code == 200
f = Files.query.filter_by(id=1).first() f = Files.query.filter_by(id=1).first()

View File

@@ -49,13 +49,13 @@ def test_api_tag_list_get():
r = client.get("/api/v1/tokens", json="") r = client.get("/api/v1/tokens", json="")
assert r.status_code == 200 assert r.status_code == 200
resp = r.get_json() resp = r.get_json()
len(resp["data"]) == 1 assert len(resp["data"]) == 1
with login_as_user(app, name="user2") as client: with login_as_user(app, name="user2") as client:
r = client.get("/api/v1/tokens", json="") r = client.get("/api/v1/tokens", json="")
assert r.status_code == 200 assert r.status_code == 200
resp = r.get_json() resp = r.get_json()
len(resp["data"]) == 2 assert len(resp["data"]) == 2
destroy_ctfd(app) destroy_ctfd(app)

View File

@@ -293,7 +293,7 @@ def test_dynamic_challenge_value_isnt_affected_by_hidden_users():
assert resp["status"] == "correct" assert resp["status"] == "correct"
# Make solves as hidden users. Also should not affect value # Make solves as hidden users. Also should not affect value
for i, team_id in enumerate(range(2, 26)): for _, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id) name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id) email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user # We need to bypass rate-limiting so gen_user instead of register_user

View File

@@ -1,11 +1,17 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from flask import request import os
import shutil
import pytest
from flask import render_template, render_template_string, request
from jinja2.exceptions import TemplateNotFound
from jinja2.sandbox import SecurityError from jinja2.sandbox import SecurityError
from werkzeug.test import Client from werkzeug.test import Client
from CTFd.utils import get_config from CTFd.config import TestingConfig
from CTFd.utils import get_config, set_config
from tests.helpers import create_ctfd, destroy_ctfd, gen_user, login_as_user from tests.helpers import create_ctfd, destroy_ctfd, gen_user, login_as_user
@@ -142,3 +148,76 @@ def test_that_request_path_hijacking_works_properly():
with test_app.test_request_context("/challenges"): with test_app.test_request_context("/challenges"):
assert request.path == "/challenges" assert request.path == "/challenges"
destroy_ctfd(app) destroy_ctfd(app)
def test_theme_fallback_config():
"""Test that the `THEME_FALLBACK` config properly falls themes back to the core theme"""
app = create_ctfd()
# Make an empty theme
try:
os.mkdir(os.path.join(app.root_path, "themes", "foo"))
except OSError:
pass
# Without theme fallback, missing themes should disappear
with app.app_context():
set_config("ctf_theme", "foo")
assert app.config["THEME_FALLBACK"] == False
with app.test_client() as client:
try:
r = client.get("/")
except TemplateNotFound:
pass
try:
r = client.get("/themes/foo/static/js/pages/main.dev.js")
except TemplateNotFound:
pass
destroy_ctfd(app)
class ThemeFallbackConfig(TestingConfig):
THEME_FALLBACK = True
app = create_ctfd(config=ThemeFallbackConfig)
with app.app_context():
set_config("ctf_theme", "foo")
assert app.config["THEME_FALLBACK"] == True
with app.test_client() as client:
r = client.get("/")
assert r.status_code == 200
r = client.get("/themes/foo/static/js/pages/main.dev.js")
assert r.status_code == 200
destroy_ctfd(app)
# Remove empty theme
os.rmdir(os.path.join(app.root_path, "themes", "foo"))
def test_theme_template_loading_by_prefix():
"""Test that we can load theme files by their folder prefix"""
app = create_ctfd()
with app.test_request_context():
tpl1 = render_template_string("{% extends 'core/page.html' %}", content="test")
tpl2 = render_template("page.html", content="test")
assert tpl1 == tpl2
def test_theme_template_disallow_loading_admin_templates():
"""Test that admin files in a theme will not be loaded"""
app = create_ctfd()
with app.app_context():
try:
# Make an empty malicious theme
filename = os.path.join(
app.root_path, "themes", "foo", "admin", "malicious.html"
)
os.makedirs(os.path.dirname(filename), exist_ok=True)
with open(filename, "w") as f:
f.write("malicious")
with pytest.raises(TemplateNotFound):
render_template_string("{% include 'admin/malicious.html' %}")
finally:
# Remove empty theme
shutil.rmtree(
os.path.join(app.root_path, "themes", "foo"), ignore_errors=True
)

View File

@@ -252,7 +252,7 @@ def test_challenges_with_max_attempts():
app.db.session.commit() app.db.session.commit()
gen_flag(app.db, challenge_id=chal.id, content=u"flag") gen_flag(app.db, challenge_id=chal.id, content=u"flag")
for x in range(3): for _ in range(3):
data = {"submission": "notflag", "challenge_id": chal_id} data = {"submission": "notflag", "challenge_id": chal_id}
r = client.post("/api/v1/challenges/attempt", json=data) r = client.post("/api/v1/challenges/attempt", json=data)
@@ -282,7 +282,7 @@ def test_challenge_kpm_limit():
chal_id = chal.id chal_id = chal.id
gen_flag(app.db, challenge_id=chal.id, content=u"flag") gen_flag(app.db, challenge_id=chal.id, content=u"flag")
for x in range(11): for _ in range(11):
with client.session_transaction(): with client.session_transaction():
data = {"submission": "notflag", "challenge_id": chal_id} data = {"submission": "notflag", "challenge_id": chal_id}
r = client.post("/api/v1/challenges/attempt", json=data) r = client.post("/api/v1/challenges/attempt", json=data)

View File

@@ -33,7 +33,7 @@ def test_user_set_profile():
r = client.get("/settings") r = client.get("/settings")
resp = r.get_data(as_text=True) resp = r.get_data(as_text=True)
for k, v in data.items(): for _k, v in data.items():
assert v in resp assert v in resp
data = { data = {

View File

@@ -14,11 +14,11 @@ def test_ratelimit_on_auth():
"password": "wrong_password", "password": "wrong_password",
"nonce": sess.get("nonce"), "nonce": sess.get("nonce"),
} }
for x in range(10): for _ in range(10):
r = client.post("/login", data=data) r = client.post("/login", data=data)
assert r.status_code == 200 assert r.status_code == 200
for x in range(5): for _ in range(5):
r = client.post("/login", data=data) r = client.post("/login", data=data)
assert r.status_code == 429 assert r.status_code == 429
destroy_ctfd(app) destroy_ctfd(app)