From 710ce6d500f8902cc3a173d8ae493241c38a0150 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Tue, 24 Oct 2017 21:06:56 -0400 Subject: [PATCH] Custom challenge tables (#425) * Allows Challenges to have custom tables which allow them to have custom behavior * Adding create, update, delete staticmethods giving Challenge Types a static interface to implement --- CTFd/admin/challenges.py | 74 +++-------- CTFd/models.py | 4 + CTFd/plugins/challenges/__init__.py | 109 +++++++++++++++- .../assets/standard-challenge-update.js | 47 ++++--- CTFd/themes/admin/static/js/chalboard.js | 8 +- tests/admin/test_admin_facing.py | 120 +++++++++++++++++- 6 files changed, 276 insertions(+), 86 deletions(-) diff --git a/CTFd/admin/challenges.py b/CTFd/admin/challenges.py index 146d2759..74ad5617 100644 --- a/CTFd/admin/challenges.py +++ b/CTFd/admin/challenges.py @@ -73,6 +73,18 @@ def admin_chals(): return render_template('admin/chals.html') +@admin_challenges.route('/admin/chals/', methods=['GET', 'POST']) +@admins_only +def admin_chal_detail(chalid): + if request.method == 'POST': + pass + elif request.method == 'GET': + chal = Challenges.query.filter_by(id=chalid).first_or_404() + chal_class = get_chal_class(chal.type) + obj, data = chal_class.read(chal) + return jsonify(data) + + @admin_challenges.route('/admin/tags/', methods=['GET', 'POST']) @admins_only def admin_tags(chalid): @@ -236,41 +248,9 @@ def admin_get_values(chalid, prop): @admins_only def admin_create_chal(): if request.method == 'POST': - files = request.files.getlist('files[]') - - # Create challenge - chal = Challenges( - name=request.form['name'], - description=request.form['desc'], - value=request.form['value'], - category=request.form['category'], - type=request.form['chaltype'] - ) - - if 'hidden' in request.form: - chal.hidden = True - else: - chal.hidden = False - - max_attempts = request.form.get('max_attempts') - if max_attempts and max_attempts.isdigit(): - chal.max_attempts = int(max_attempts) - - db.session.add(chal) - db.session.flush() - - flag = Keys(chal.id, request.form['key'], request.form['key_type[0]']) - if request.form.get('keydata'): - flag.data = request.form.get('keydata') - db.session.add(flag) - - db.session.commit() - - for f in files: - utils.upload_file(file=f, chalid=chal.id) - - db.session.commit() - db.session.close() + chal_type = request.form['chaltype'] + chal_class = get_chal_class(chal_type) + chal_class.create(request) return redirect(url_for('admin_challenges.admin_chals')) else: return render_template('admin/chals/create.html') @@ -280,17 +260,8 @@ def admin_create_chal(): @admins_only def admin_delete_chal(): challenge = Challenges.query.filter_by(id=request.form['id']).first_or_404() - WrongKeys.query.filter_by(chalid=challenge.id).delete() - Solves.query.filter_by(chalid=challenge.id).delete() - Keys.query.filter_by(chal=challenge.id).delete() - files = Files.query.filter_by(chal=challenge.id).all() - for f in files: - utils.delete_file(f.id) - Files.query.filter_by(chal=challenge.id).delete() - Tags.query.filter_by(chal=challenge.id).delete() - Challenges.query.filter_by(id=challenge.id).delete() - db.session.commit() - db.session.close() + chal_class = get_chal_class(challenge.type) + chal_class.delete(challenge) return '1' @@ -298,13 +269,6 @@ def admin_delete_chal(): @admins_only def admin_update_chal(): challenge = Challenges.query.filter_by(id=request.form['id']).first_or_404() - challenge.name = request.form['name'] - challenge.description = request.form['desc'] - challenge.value = int(request.form.get('value', 0)) if request.form.get('value', 0) else 0 - challenge.max_attempts = int(request.form.get('max_attempts', 0)) if request.form.get('max_attempts', 0) else 0 - challenge.category = request.form['category'] - challenge.hidden = 'hidden' in request.form - db.session.add(challenge) - db.session.commit() - db.session.close() + chal_class = get_chal_class(challenge.type) + chal_class.update(challenge, request) return redirect(url_for('admin_challenges.admin_chals')) diff --git a/CTFd/models.py b/CTFd/models.py index ffb8ef72..7121b65b 100644 --- a/CTFd/models.py +++ b/CTFd/models.py @@ -50,6 +50,10 @@ class Challenges(db.Model): category = db.Column(db.String(80)) type = db.Column(db.String(80)) hidden = db.Column(db.Boolean) + __mapper_args__ = { + 'polymorphic_identity': 'standard', + 'polymorphic_on': type + } def __init__(self, name, description, value, category, type='standard'): self.name = name diff --git a/CTFd/plugins/challenges/__init__.py b/CTFd/plugins/challenges/__init__.py index 0ee477da..055a2ae6 100644 --- a/CTFd/plugins/challenges/__init__.py +++ b/CTFd/plugins/challenges/__init__.py @@ -1,6 +1,6 @@ from CTFd.plugins import register_plugin_assets_directory from CTFd.plugins.keys import get_key_class -from CTFd.models import db, Solves, WrongKeys, Keys +from CTFd.models import db, Solves, WrongKeys, Keys, Challenges, Files, Tags from CTFd import utils @@ -25,6 +25,113 @@ class CTFdStandardChallenge(BaseChallenge): 'modal': '/plugins/challenges/assets/standard-challenge-modal.js', } + @staticmethod + def create(request): + """ + This method is used to process the challenge creation request. + + :param request: + :return: + """ + files = request.files.getlist('files[]') + + # Create challenge + chal = Challenges( + name=request.form['name'], + description=request.form['desc'], + value=request.form['value'], + category=request.form['category'], + type=request.form['chaltype'] + ) + + if 'hidden' in request.form: + chal.hidden = True + else: + chal.hidden = False + + max_attempts = request.form.get('max_attempts') + if max_attempts and max_attempts.isdigit(): + chal.max_attempts = int(max_attempts) + + db.session.add(chal) + db.session.commit() + + flag = Keys(chal.id, request.form['key'], request.form['key_type[0]']) + if request.form.get('keydata'): + flag.data = request.form.get('keydata') + db.session.add(flag) + + db.session.commit() + + for f in files: + utils.upload_file(file=f, chalid=chal.id) + + db.session.commit() + + @staticmethod + def read(challenge): + """ + This method is in used to access the data of a challenge in a format processable by the front end. + + :param challenge: + :return: Challenge object, data dictionary to be returned to the user + """ + data = { + 'id': challenge.id, + 'name': challenge.name, + 'value': challenge.value, + 'description': challenge.description, + 'category': challenge.category, + 'hidden': challenge.hidden, + 'max_attempts': challenge.max_attempts, + 'type': challenge.type, + 'type_data': { + 'id': CTFdStandardChallenge.id, + 'name': CTFdStandardChallenge.name, + 'templates': CTFdStandardChallenge.templates, + 'scripts': CTFdStandardChallenge.scripts, + } + } + return challenge, data + + @staticmethod + def update(challenge, request): + """ + This method is used to update the information associated with a challenge. This should be kept strictly to the + Challenges table and any child tables. + + :param challenge: + :param request: + :return: + """ + challenge.name = request.form['name'] + challenge.description = request.form['desc'] + challenge.value = int(request.form.get('value', 0)) if request.form.get('value', 0) else 0 + challenge.max_attempts = int(request.form.get('max_attempts', 0)) if request.form.get('max_attempts', 0) else 0 + challenge.category = request.form['category'] + challenge.hidden = 'hidden' in request.form + db.session.commit() + db.session.close() + + @staticmethod + def delete(challenge): + """ + This method is used to delete the resources used by a challenge. + + :param challenge: + :return: + """ + WrongKeys.query.filter_by(chalid=challenge.id).delete() + Solves.query.filter_by(chalid=challenge.id).delete() + Keys.query.filter_by(chal=challenge.id).delete() + files = Files.query.filter_by(chal=challenge.id).all() + for f in files: + utils.delete_file(f.id) + Files.query.filter_by(chal=challenge.id).delete() + Tags.query.filter_by(chal=challenge.id).delete() + Challenges.query.filter_by(id=challenge.id).delete() + db.session.commit() + @staticmethod def attempt(chal, request): """ diff --git a/CTFd/plugins/challenges/assets/standard-challenge-update.js b/CTFd/plugins/challenges/assets/standard-challenge-update.js index 265b8b1d..253b46f4 100644 --- a/CTFd/plugins/challenges/assets/standard-challenge-update.js +++ b/CTFd/plugins/challenges/assets/standard-challenge-update.js @@ -354,32 +354,29 @@ $('#hint-modal-submit').submit(function (e) { }); function loadchal(id, update) { - // $('#chal *').show() - // $('#chal > h1').hide() - obj = $.grep(challenges['game'], function (e) { - return e.id == id; - })[0] - $('#desc-write-link').click() // Switch to Write tab - $('.chal-title').text(obj.name); - $('.chal-name').val(obj.name); - $('.chal-desc').val(obj.description); - $('.chal-value').val(obj.value); - if (parseInt(obj.max_attempts) > 0){ - $('.chal-attempts').val(obj.max_attempts); - $('#limit_max_attempts').prop('checked', true); - $('#chal-attempts-group').show(); + $.get(script_root + '/admin/chals/' + id, function(obj){ + $('#desc-write-link').click() // Switch to Write tab + $('.chal-title').text(obj.name); + $('.chal-name').val(obj.name); + $('.chal-desc').val(obj.description); + $('.chal-value').val(obj.value); + if (parseInt(obj.max_attempts) > 0){ + $('.chal-attempts').val(obj.max_attempts); + $('#limit_max_attempts').prop('checked', true); + $('#chal-attempts-group').show(); + } + $('.chal-category').val(obj.category); + $('.chal-id').val(obj.id); + $('.chal-hidden').prop('checked', false); + if (obj.hidden) { + $('.chal-hidden').prop('checked', true); + } + //$('#update-challenge .chal-delete').attr({ + // 'href': '/admin/chal/close/' + (id + 1) + //}) + if (typeof update === 'undefined') + $('#update-challenge').modal(); } - $('.chal-category').val(obj.category); - $('.chal-id').val(obj.id); - $('.chal-hidden').prop('checked', false); - if (obj.hidden) { - $('.chal-hidden').prop('checked', true); - } - //$('#update-challenge .chal-delete').attr({ - // 'href': '/admin/chal/close/' + (id + 1) - //}) - if (typeof update === 'undefined') - $('#update-challenge').modal(); } function openchal(id){ diff --git a/CTFd/themes/admin/static/js/chalboard.js b/CTFd/themes/admin/static/js/chalboard.js index f7a28390..2664cc02 100644 --- a/CTFd/themes/admin/static/js/chalboard.js +++ b/CTFd/themes/admin/static/js/chalboard.js @@ -40,9 +40,9 @@ function load_edit_key_modal(key_id, key_type_name) { } function load_chal_template(id, success_cb){ - obj = $.grep(challenges['game'], function (e) { + var obj = $.grep(challenges['game'], function (e) { return e.id == id; - })[0] + })[0]; $.get(script_root + obj.type_data.templates.update, function(template_data){ var template = Handlebars.compile(template_data); $("#update-modals-entry-div").html(template({'nonce':$('#nonce').val(), 'script_root':script_root})); @@ -50,7 +50,7 @@ function load_chal_template(id, success_cb){ url: script_root + obj.type_data.scripts.update, dataType: "script", success: success_cb, - cache: true, + cache: false, }); }); } @@ -72,7 +72,7 @@ function loadchals(){ }; for (var i = 0; i <= challenges['game'].length - 1; i++) { - var chal = challenges['game'][i] + var chal = challenges['game'][i]; var chal_button = $(''.format(chal.id, chal.name, chal.value, Math.round(chal.percentage_solved * 100))); $('#' + challenges['game'][i].category.replace(/ /g,"-").hashCode()).append(chal_button); }; diff --git a/tests/admin/test_admin_facing.py b/tests/admin/test_admin_facing.py index d306dec6..18221ea5 100644 --- a/tests/admin/test_admin_facing.py +++ b/tests/admin/test_admin_facing.py @@ -1,9 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + from tests.helpers import * -from CTFd.models import Teams +from CTFd.models import Teams, Challenges from CTFd.utils import get_config, set_config, override_template, sendmail, verify_email, ctf_started, ctf_ended +from CTFd.plugins.challenges import get_chal_class from freezegun import freeze_time from mock import patch +import json + def test_admin_panel(): """Does the admin panel return a 200 by default""" @@ -103,3 +109,115 @@ def test_admins_can_access_challenges_before_ctftime(): solve_count = app.db.session.query(app.db.func.count(Solves.id)).first()[0] assert solve_count == 1 destroy_ctfd(app) + + +def test_admins_can_create_challenges(): + '''Test that admins can create new challenges''' + app = create_ctfd() + with app.app_context(): + client = login_as_user(app, name="admin", password="password") + + with client.session_transaction() as sess: + data = { + 'name': '💫', + 'category': '💫', + 'desc': 'description', + 'value': 100, + 'key_type[0]': 'static', + 'max_attempts': '', + 'nonce': sess.get('nonce'), + 'chaltype': 'standard' + } + r = client.post('/admin/chal/new', data=data) + + assert Challenges.query.count() == 1 + destroy_ctfd(app) + + +def test_admins_can_update_challenges(): + '''Test that admins can update challenges''' + app = create_ctfd() + with app.app_context(): + client = login_as_user(app, name="admin", password="password") + + chal = gen_challenge(app.db) + chal_id = chal.id + + assert Challenges.query.count() == 1 + + with client.session_transaction() as sess: + data = { + 'id': chal_id, + 'name': '💫', + 'category': '💫', + 'desc': 'description', + 'value': 100, + 'key_type[0]': 'static', + 'max_attempts': '', + 'nonce': sess.get('nonce'), + 'chaltype': 'standard' + } + r = client.post('/admin/chal/update', data=data) + + assert Challenges.query.count() == 1 + chal_check = Challenges.query.filter_by(id=chal_id).first() + assert chal_check.name == '💫' + destroy_ctfd(app) + + +def test_admins_can_delete_challenges(): + '''Test that admins can delete challenges''' + app = create_ctfd() + with app.app_context(): + client = login_as_user(app, name="admin", password="password") + + chal = gen_challenge(app.db) + chal_id = chal.id + + assert Challenges.query.count() == 1 + + with client.session_transaction() as sess: + data = { + 'id': chal_id, + 'nonce': sess.get('nonce'), + } + r = client.post('/admin/chal/delete', data=data) + assert r.get_data(as_text=True) == '1' + + assert Challenges.query.count() == 0 + destroy_ctfd(app) + + +def test_admin_chal_detail_returns_proper_data(): + """Test that the /admin/chals/ endpoint returns the proper data""" + app = create_ctfd() + with app.app_context(): + client = login_as_user(app, name="admin", password="password") + + chal = gen_challenge(app.db) + chal_class = get_chal_class(chal.type) + data = { + 'id': chal.id, + 'name': chal.name, + 'value': chal.value, + 'description': chal.description, + 'category': chal.category, + 'hidden': chal.hidden, + 'max_attempts': chal.max_attempts, + 'type': chal.type, + 'type_data': { + 'id': chal_class.id, + 'name': chal_class.name, + 'templates': chal_class.templates, + 'scripts': chal_class.scripts, + } + } + + assert Challenges.query.count() == 1 + + r = client.get('/admin/chals/1') + response = json.loads(r.get_data(as_text=True)) + + assert data == response + + destroy_ctfd(app)