# 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

@@ -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",