# 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
- Fixed an issue where Users could not unlock Hints

View File

@@ -6,13 +6,16 @@ from distutils.version import StrictVersion
import jinja2
from flask import Flask, Request
from flask.helpers import safe_join
from flask_migrate import upgrade
from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import cached_property
import CTFd.utils.config
from CTFd import utils
from CTFd.constants.themes import ADMIN_THEME, DEFAULT_THEME
from CTFd.plugins import init_plugins
from CTFd.utils.crypto import sha256
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.updates import update_check
__version__ = "3.2.1"
__version__ = "3.3.0"
__channel__ = "oss"
@@ -97,26 +100,34 @@ class SandboxedBaseEnvironment(SandboxedEnvironment):
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)
self.overriden_templates = {}
self.theme_name = theme_name
def get_source(self, environment, template):
# Check if the template has been overriden
if template in self.overriden_templates:
return self.overriden_templates[template], template, lambda: True
# Check if the template requested is for the admin panel
if template.startswith("admin/"):
template = template[6:] # Strip out admin/
template = "/".join(["admin", "templates", template])
return super(ThemeLoader, self).get_source(environment, template)
# Load regular theme data
theme = str(utils.get_config("ctf_theme"))
template = "/".join([theme, "templates", template])
# Refuse to load `admin/*` from a loader not for the admin theme
# Because there is a single template loader, themes can essentially
# 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
# theme provides.
if template.startswith(self._ADMIN_THEME_PREFIX):
if self.theme_name != ADMIN_THEME:
raise jinja2.TemplateNotFound(template)
template = template[len(self._ADMIN_THEME_PREFIX) :]
theme_name = self.theme_name or str(utils.get_config("ctf_theme"))
template = safe_join(theme_name, "templates", template)
return super(ThemeLoader, self).get_source(environment, template)
@@ -144,19 +155,34 @@ def create_app(config="CTFd.config.Config"):
with app.app_context():
app.config.from_object(config)
app.theme_loader = ThemeLoader(
os.path.join(app.root_path, "themes"), followlinks=True
)
# Weird nested solution for accessing plugin templates
app.plugin_loader = jinja2.PrefixLoader(
{
"plugins": jinja2.FileSystemLoader(
loaders = []
# We provide a `DictLoader` which may be used to override templates
app.overridden_templates = {}
loaders.append(jinja2.DictLoader(app.overridden_templates))
# A `ThemeLoader` with no `theme_name` will load from the current theme
loaders.append(ThemeLoader())
# 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
)
}
)
# Load from themes first but fallback to loading from the plugin folder
app.jinja_loader = jinja2.ChoiceLoader([app.theme_loader, app.plugin_loader])
loaders.append(jinja2.PrefixLoader({"plugins": plugin_loader}))
# Use a choice loader to find the first match from our list of loaders
app.jinja_loader = jinja2.ChoiceLoader(loaders)
from CTFd.models import ( # noqa: F401
db,
@@ -240,7 +266,7 @@ def create_app(config="CTFd.config.Config"):
utils.set_config("ctf_version", __version__)
if not utils.get_config("ctf_theme"):
utils.set_config("ctf_theme", "core")
utils.set_config("ctf_theme", DEFAULT_THEME)
update_check(force=True)
@@ -258,7 +284,7 @@ def create_app(config="CTFd.config.Config"):
from CTFd.admin import admin
from CTFd.api import api
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(teams)
@@ -271,10 +297,8 @@ def create_app(config="CTFd.config.Config"):
app.register_blueprint(admin)
app.register_error_handler(404, page_not_found)
app.register_error_handler(403, forbidden)
app.register_error_handler(500, general_error)
app.register_error_handler(502, gateway_error)
for code in {403, 404, 500, 502}:
app.register_error_handler(code, render_error)
init_logs(app)
init_events(app)

View File

@@ -164,7 +164,7 @@ def config():
clear_config()
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.remove(get_config("ctf_theme"))

View File

@@ -1,12 +1,16 @@
from flask import render_template
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.scores import get_standings, get_user_standings
@admin.route("/admin/scoreboard")
@admins_only
def scoreboard_listing():
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 = {}
for chal, count, name in solves:
for _chal, count, name in solves:
solve_data[name] = count
most_solved = None

View File

@@ -3,7 +3,9 @@ from typing import List
from flask import abort, render_template, request, url_for
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.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.modes import generate_account_url, get_model
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", 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("")
class ChallengeList(Resource):
@check_challenge_visibility
@@ -116,60 +163,68 @@ class ChallengeList(Resource):
location="query",
)
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
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Challenges, query=q, field=field)
# This can return None (unauth) if visibility is set to public
user = get_current_user()
# Admins get a shortcut to see all challenges despite pre-requisites
admin_view = is_admin() and request.args.get("view") == "admin"
# Admins can request to see everything
if is_admin() and request.args.get("view") == "admin":
challenges = (
Challenges.query.filter_by(**query_args)
.filter(*filters)
.order_by(Challenges.value)
.all()
)
solve_ids = set([challenge.id for challenge in challenges])
solve_counts, user_solves = {}, set()
# Build a query for to show challenge solve information. We only
# give an admin view if the request argument has been provided.
#
# NOTE: This is different behaviour to the challenge detail
# endpoint which only needs the current user to be an admin rather
# than also also having to provide `view=admin` as a query arg.
solves_q = _build_solves_query(admin_view=admin_view)
# 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:
challenges = (
Challenges.query.filter(
# Empty out the solves_count if we're hiding scores/accounts
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")
)
.filter_by(**query_args)
.filter(*filters)
.order_by(Challenges.value)
.all()
chal_q = (
chal_q.filter_by(**query_args).filter(*filters).order_by(Challenges.value)
)
if user:
solve_ids = (
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()
# Iterate through the list of challenges, adding to the object which
# will be JSONified back to the client
response = []
tag_schema = TagSchema(view="user", many=True)
for challenge in challenges:
for challenge in chal_q:
if challenge.requirements:
requirements = challenge.requirements.get("prerequisites", [])
anonymize = challenge.requirements.get("anonymize")
prereqs = set(requirements)
if solve_ids >= prereqs:
if user_solves >= prereqs:
pass
else:
if anonymize:
@@ -179,6 +234,8 @@ class ChallengeList(Resource):
"type": "hidden",
"name": "???",
"value": 0,
"solves": None,
"solved_by_me": False,
"category": "???",
"tags": [],
"template": "",
@@ -201,6 +258,8 @@ class ChallengeList(Resource):
"type": challenge_type.name,
"name": challenge.name,
"value": challenge.value,
"solves": solve_counts.get(challenge.id, solve_count_dfl),
"solved_by_me": challenge.id in user_solves,
"category": challenge.category,
"tags": tag_schema.dump(challenge.tags).data,
"template": challenge_type.templates["view"],
@@ -305,7 +364,7 @@ class Challenge(Resource):
else:
# We need to handle the case where a user is viewing challenges anonymously
solve_ids = []
solve_ids = set([value for value, in solve_ids])
solve_ids = {value for value, in solve_ids}
prereqs = set(requirements)
if solve_ids >= prereqs or is_admin():
pass
@@ -318,6 +377,8 @@ class Challenge(Resource):
"type": "hidden",
"name": "???",
"value": 0,
"solves": None,
"solved_by_me": False,
"category": "???",
"tags": [],
"template": "",
@@ -345,14 +406,12 @@ class Challenge(Resource):
if config.is_teams_mode() and team is None:
abort(403)
unlocked_hints = set(
[
unlocked_hints = {
u.target
for u in HintUnlocks.query.filter_by(
type="hints", account_id=user.account_id
)
]
)
}
files = []
for f in chal.files:
token = {
@@ -376,25 +435,20 @@ class Challenge(Resource):
response = chal_class.read(challenge=chal)
Model = get_model()
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,
solves_q = _build_solves_query(
admin_view=is_admin(), extra_filters=(Solves.challenge_id == chal.id,)
)
# Only show solves that happened before freeze time if configured
freeze = get_config("freeze")
if not is_admin() and freeze:
solves = solves.filter(Solves.date < unix_time_to_utc(freeze))
solves = solves.count()
response["solves"] = solves
# If there are no solves for this challenge ID then we have 0 rows
maybe_row = solves_q.first()
if maybe_row:
_, solve_count, solved_by_user = maybe_row
solved_by_user = bool(solved_by_user)
else:
response["solves"] = None
solves = None
solve_count, solved_by_user = 0, False
# Hide solve counts if we are hiding solves/accounts
if scores_visible() is False or accounts_visible() is False:
solve_count = None
if authed():
# Get current attempts for the user
@@ -404,6 +458,8 @@ class Challenge(Resource):
else:
attempts = 0
response["solves"] = solve_count
response["solved_by_me"] = solved_by_user
response["attempts"] = attempts
response["files"] = files
response["tags"] = tags
@@ -411,7 +467,8 @@ class Challenge(Resource):
response["view"] = render_template(
chal_class.templates["view"].lstrip("/"),
solves=solves,
solves=solve_count,
solved_by_me=solved_by_user,
files=files,
tags=tags,
hints=[Hints(**h) for h in hints],
@@ -532,7 +589,7 @@ class ChallengeAttempt(Resource):
.order_by(Solves.challenge_id.asc())
.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)
if solve_ids >= prereqs:
pass

View File

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

View File

@@ -1,10 +1,10 @@
from collections import defaultdict
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.models import Awards, Solves, Teams
from CTFd.models import Awards, Solves, Users, db
from CTFd.utils import get_config
from CTFd.utils.dates import isoformat, unix_time_to_utc
from CTFd.utils.decorators.visibility import (
@@ -31,25 +31,33 @@ class ScoreboardList(Resource):
account_type = get_mode_as_word()
if mode == TEAMS_MODE:
team_ids = []
for team in standings:
team_ids.append(team.account_id)
# Get team objects with members explicitly loaded in
teams = (
Teams.query.options(joinedload(Teams.members))
.filter(Teams.id.in_(team_ids))
.all()
r = db.session.execute(
select(
[
Users.id,
Users.name,
Users.oauth_id,
Users.team_id,
Users.hidden,
Users.banned,
]
).where(Users.team_id.isnot(None))
)
# Sort according to team_ids order
teams = [next(t for t in teams if t.id == id) for id in team_ids]
users = r.fetchall()
membership = defaultdict(dict)
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
user_standings = get_user_standings()
users = {}
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):
entry = {
@@ -63,33 +71,7 @@ class ScoreboardList(Resource):
}
if mode == TEAMS_MODE:
members = []
# 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
entry["members"] = list(membership[x.account_id].values())
response.append(entry)
return {"success": True, "data": response}
@@ -152,7 +134,7 @@ class ScoreboardDetail(Resource):
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] = {
"id": standings[i].account_id,
"name": standings[i].name,

View File

@@ -132,7 +132,6 @@ class UserList(Resource):
"data": response.data,
}
@users_namespace.doc()
@admins_only
@users_namespace.doc(
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.
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
# Specifies whether Flask should check for modifications to templates and reload them automatically. Defaults to true.
TEMPLATES_AUTO_RELOAD =

View File

@@ -55,7 +55,7 @@ def gen_secret_key():
try:
with open(".ctfd_secret_key", "rb") as secret:
key = secret.read()
except (OSError, IOError):
except OSError:
key = None
if not key:
@@ -66,7 +66,7 @@ def gen_secret_key():
with open(".ctfd_secret_key", "wb") as secret:
secret.write(key)
secret.flush()
except (OSError, IOError):
except OSError:
pass
return key
@@ -178,6 +178,8 @@ class ServerConfig(object):
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)
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 werkzeug.exceptions import InternalServerError
# 404
def page_not_found(error):
return render_template("errors/404.html", error=error.description), 404
# 403
def forbidden(error):
return render_template("errors/403.html", error=error.description), 403
# 500
def general_error(error):
if error.description == InternalServerError.description:
def render_error(error):
if (
isinstance(error, InternalServerError)
and error.description == InternalServerError.description
):
error.description = "An Internal Server Error has occurred"
return render_template("errors/500.html", error=error.description), 500
# 502
def gateway_error(error):
return render_template("errors/502.html", error=error.description), 502
try:
return (
render_template(
"errors/{}.html".format(error.code), error=error.description,
),
error.code,
)
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.validators import InputRequired
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
@@ -59,7 +60,7 @@ class SetupForm(BaseForm):
"Theme",
description="CTFd Theme to use",
choices=list(zip(get_themes(), get_themes())),
default="core",
default=DEFAULT_THEME,
validators=[InputRequired()],
)
theme_color = HiddenField(

View File

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

View File

@@ -316,6 +316,7 @@ class UserSchema(ma.ModelSchema):
"id",
"oauth_id",
"fields",
"team_id",
],
"self": [
"website",
@@ -328,6 +329,7 @@ class UserSchema(ma.ModelSchema):
"oauth_id",
"password",
"fields",
"team_id",
],
"admin": [
"website",
@@ -346,6 +348,7 @@ class UserSchema(ma.ModelSchema):
"type",
"verified",
"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 = {
hidden: action === "hidden" ? true : false
};
const reqs = [];
for (var accId of accountIDs) {
for (let accId of selectedAccounts.accounts) {
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 => {
window.location.reload();
});
}
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");
});
}
);
let userIDs = $(".tab-pane.active input[data-user-id]:checked").map(
function() {
return $(this).data("user-id");
}
);
let selectedUsers = {
accounts: accountIDs,
users: userIDs
};
ezAlert({
title: "Toggle Visibility",
@@ -74,7 +93,7 @@ function bulkToggleAccounts(_event) {
success: function() {
let data = $("#scoreboard-bulk-edit").serializeJSON(true);
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 Vue from "vue/dist/vue.esm.browser";
import CommentBox from "../components/comments/CommentBox.vue";
import UserAddForm from "../components/teams/UserAddForm.vue";
import { copyToClipboard } from "../../../../core/assets/js/utils";
function createTeam(event) {
@@ -398,6 +399,10 @@ $(() => {
copyToClipboard(e, "#team-invite-link");
});
$(".members-team").click(function(_e) {
$("#team-add-modal").modal("toggle");
});
$(".edit-captain").click(function(_e) {
$("#team-captain-modal").modal("toggle");
});
@@ -477,7 +482,7 @@ $(() => {
ezQuery({
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(window.TEAM_NAME) + "</strong>"
),
@@ -548,6 +553,16 @@ $(() => {
propsData: { type: "team", id: window.TEAM_ID }
}).$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;
({ 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>
</div>
<div class="modal-body">
{% include "config.html" ignore missing %}
{% include ctf_theme + "/config.html" ignore missing %}
</div>
</div>
</div>

View File

@@ -18,59 +18,38 @@
</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="col-md-12">
<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 class="tab-content">
<div class="tab-pane fade show active" id="standings" role="tabpanel">
{% include "admin/scoreboard/standings.html" %}
</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;
{% if Configs.user_mode == UserModeTypes.TEAMS %}
<div class="tab-pane fade" id="user-standings" role="tabpanel">
{% include "admin/scoreboard/users.html" %}
</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>
</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 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 class="modal-dialog">
<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"
title="Team Invite Link"></i>
</a>
<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 class="members-team text-dark">
<i class="btn-fa fas fa-user-plus fa-2x px-2" data-toggle="tooltip" data-placement="top"
title="Add Team Members"></i>
</a>
<a class="edit-captain text-dark">
<i class="btn-fa fas fa-user-tag fa-2x px-2" data-toggle="tooltip" data-placement="top"
@@ -216,6 +231,10 @@
</a>
</div>
<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">
<i class="btn-fa fas fa-network-wired fa-2x px-2" data-toggle="tooltip" data-placement="top" title="IP Addresses"></i>
</a>

View File

@@ -3,7 +3,8 @@ import time
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
@@ -33,6 +34,12 @@ def ctf_theme():
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():
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.cache import cache
from CTFd.constants.themes import DEFAULT_THEME
from CTFd.models import db, get_class_by_tablename
from CTFd.plugins import get_plugin_names
from CTFd.plugins.migrations import current as plugin_current
@@ -67,7 +68,7 @@ def export_ctf():
upload_folder = os.path.join(
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:
parent_dir = os.path.basename(root)
backup_zip.write(
@@ -353,5 +354,5 @@ def import_ctf(backup, erase=True):
cache.clear()
# 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)

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ from CTFd.utils.modes import get_model
@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)].
@@ -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.
"""
if fields is None:
fields = []
Model = get_model()
scores = (
@@ -117,7 +119,9 @@ def get_standings(count=None, admin=False, fields=[]):
@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 = (
db.session.query(
Solves.team_id.label("team_id"),
@@ -197,7 +201,9 @@ def get_team_standings(count=None, admin=False, fields=[]):
@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 = (
db.session.query(
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.oauth_id.label("oauth_id"),
Users.name.label("name"),
Users.team_id.label("team_id"),
Users.hidden,
Users.banned,
sumscores.columns.score,
@@ -259,6 +266,7 @@ def get_user_standings(count=None, admin=False, fields=[]):
Users.id.label("user_id"),
Users.oauth_id.label("oauth_id"),
Users.name.label("name"),
Users.team_id.label("team_id"),
sumscores.columns.score,
*fields,
)

View File

@@ -15,8 +15,7 @@ cleaner = Cleaner(
style=False,
safe_attrs=(
safe_attrs
| set(
[
| {
"style",
# Allow data attributes from bootstrap elements
"data-toggle",
@@ -45,8 +44,7 @@ cleaner = Cleaner(
"data-selector",
"data-content",
"data-trigger",
]
)
}
),
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)
.all()
)
return set([ip for (ip,) in addrs])
return {ip for (ip,) in addrs}
def get_wrong_submissions_per_minute(account_id):

View File

@@ -14,6 +14,7 @@ from CTFd.constants.config import (
RegistrationVisibilityTypes,
ScoreVisibilityTypes,
)
from CTFd.constants.themes import DEFAULT_THEME
from CTFd.models import (
Admins,
Files,
@@ -85,7 +86,7 @@ def setup():
f = upload_file(file=ctf_small_icon)
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)
theme_color = request.form.get("theme_color")
theme_header = get_config("theme_header")
@@ -449,8 +450,12 @@ def themes(theme, path):
:param path:
:return:
"""
filename = safe_join(app.root_path, "themes", theme, "static", path)
if os.path.isfile(filename):
return send_file(filename)
else:
for cand_path in (
safe_join(app.root_path, "themes", cand_theme, "static", path)
# The `theme` value passed in may not be the configured one, e.g. for
# 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)

View File

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

View File

@@ -1,6 +1,6 @@
{
"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.",
"main": "index.js",
"directories": {

View File

@@ -11,7 +11,7 @@ def test_generate_user_token():
with app.app_context():
user = gen_user(app.db)
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 Tokens.query.count() == 1
destroy_ctfd(app)

View File

@@ -154,6 +154,250 @@ def test_api_challenges_get_hidden_admin():
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():
"""Can a user post /api/v1/challenges if admin"""
app = create_ctfd()
@@ -325,6 +569,264 @@ def test_api_challenge_get_non_existing():
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():
"""Can a user patch /api/v1/challenges/<challenge_id> if not admin"""
app = create_ctfd()
@@ -439,7 +941,7 @@ def test_api_challenge_attempt_post_private():
challenge_id = gen_challenge(app.db).id
gen_flag(app.db, challenge_id)
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)
r = client.post(
"/api/v1/challenges/attempt",
@@ -480,7 +982,7 @@ def test_api_challenge_attempt_post_private():
challenge_id = gen_challenge(app.db).id
gen_flag(app.db, challenge_id)
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)
r = client.post(
"/api/v1/challenges/attempt",

View File

@@ -68,9 +68,10 @@ def test_api_files_post_admin():
r = client.post(
"/api/v1/files",
content_type="multipart/form-data",
data=dict(
file=(BytesIO(b"test file content"), "test.txt"), nonce=nonce
),
data={
"file": (BytesIO(b"test file content"), "test.txt"),
"nonce": nonce,
},
)
assert r.status_code == 200
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="")
assert r.status_code == 200
resp = r.get_json()
len(resp["data"]) == 1
assert len(resp["data"]) == 1
with login_as_user(app, name="user2") as client:
r = client.get("/api/v1/tokens", json="")
assert r.status_code == 200
resp = r.get_json()
len(resp["data"]) == 2
assert len(resp["data"]) == 2
destroy_ctfd(app)

View File

@@ -293,7 +293,7 @@ def test_dynamic_challenge_value_isnt_affected_by_hidden_users():
assert resp["status"] == "correct"
# 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)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user

View File

@@ -1,11 +1,17 @@
#!/usr/bin/env python
# -*- 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 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
@@ -142,3 +148,76 @@ def test_that_request_path_hijacking_works_properly():
with test_app.test_request_context("/challenges"):
assert request.path == "/challenges"
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()
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}
r = client.post("/api/v1/challenges/attempt", json=data)
@@ -282,7 +282,7 @@ def test_challenge_kpm_limit():
chal_id = chal.id
gen_flag(app.db, challenge_id=chal.id, content=u"flag")
for x in range(11):
for _ in range(11):
with client.session_transaction():
data = {"submission": "notflag", "challenge_id": chal_id}
r = client.post("/api/v1/challenges/attempt", json=data)

View File

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

View File

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