mirror of
https://github.com/aljazceru/CTFd.git
synced 2025-12-18 06:24:23 +01:00
Require CSRF-Token header on state changing API requests, require CSRF nonces on more than just POSTs, replace usage of fetch() with custom CTFd.fetch() implementation (#827)
* Require CSRF-Token header on state changing API requests * Require CSRF nonces on more than just POSTs, * Replace usage of `fetch()` with custom `CTFd.fetch()` implementation
This commit is contained in:
@@ -32,7 +32,7 @@ window.challenge.submit = function (cb, preview) {
|
||||
'submission': submission
|
||||
};
|
||||
|
||||
fetch(script_root + url, {
|
||||
CTFd.fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -32,7 +32,7 @@ window.challenge.submit = function (cb, preview) {
|
||||
'submission': submission
|
||||
};
|
||||
|
||||
fetch(script_root + url, {
|
||||
CTFd.fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -143,9 +143,12 @@ $(document).ready(function () {
|
||||
title: "Delete Challenge",
|
||||
body: "Are you sure you want to delete {0}".format("<strong>" + htmlentities(CHALLENGE_NAME) + "</strong>"),
|
||||
success: function () {
|
||||
var route = script_root + '/api/v1/challenges/' + CHALLENGE_ID;
|
||||
$.delete(route, {}, function (data) {
|
||||
if (data.success) {
|
||||
CTFd.fetch('/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
window.location = script_root + '/admin/challenges';
|
||||
}
|
||||
});
|
||||
@@ -159,7 +162,7 @@ $(document).ready(function () {
|
||||
console.log(params);
|
||||
|
||||
|
||||
fetch(script_root + '/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
CTFd.fetch('/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -27,14 +27,16 @@ $(document).ready(function () {
|
||||
title: "Delete Files",
|
||||
body: "Are you sure you want to delete this file?",
|
||||
success: function () {
|
||||
$.delete(script_root + '/api/v1/files/' + file_id, {}, function (data) {
|
||||
if (data.success) {
|
||||
row.remove();
|
||||
CTFd.fetch('/api/v1/files/' + file_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $(this).serializeJSON(true);
|
||||
params['challenge'] = CHALLENGE_ID;
|
||||
fetch(script_root + '/api/v1/flags', {
|
||||
CTFd.fetch('/api/v1/flags', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -70,7 +70,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $('#edit-flags form').serializeJSON();
|
||||
|
||||
fetch(script_root + '/api/v1/flags/' + flag_id, {
|
||||
CTFd.fetch('/api/v1/flags/' + flag_id, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -101,9 +101,12 @@ $(document).ready(function () {
|
||||
title: "Delete Flag",
|
||||
body: "Are you sure you want to delete this flag?",
|
||||
success: function () {
|
||||
var route = script_root + '/api/v1/flags/' + flag_id;
|
||||
$.delete(route, {}, function (data) {
|
||||
if (data.success) {
|
||||
CTFd.fetch('/api/v1/flags/' + flag_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function hint(id) {
|
||||
return fetch(script_root + '/api/v1/hints/' + id + '?preview=true', {
|
||||
return CTFd.fetch('/api/v1/hints/' + id + '?preview=true', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -61,9 +61,12 @@ $(document).ready(function () {
|
||||
title: "Delete Hint",
|
||||
body: "Are you sure you want to delete this hint?",
|
||||
success: function () {
|
||||
var route = script_root + '/api/v1/hints/' + hint_id;
|
||||
$.delete(route, {}, function (data) {
|
||||
if (data.success) {
|
||||
CTFd.fetch('/api/v1/hints/' + hint_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
@@ -75,7 +78,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var hint_id = $(this).attr('hint-id');
|
||||
|
||||
fetch(script_root + '/api/v1/hints/' + hint_id + '?preview=true', {
|
||||
CTFd.fetch('/api/v1/hints/' + hint_id + '?preview=true', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -119,7 +122,7 @@ $(document).ready(function () {
|
||||
method = 'PATCH';
|
||||
url = '/api/v1/hints/' + params.id;
|
||||
}
|
||||
fetch(script_root + url, {
|
||||
CTFd.fetch(url, {
|
||||
method: method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -13,7 +13,7 @@ function load_chal_template(challenge){
|
||||
$("#create-chal-entry-div form").submit(function (e) {
|
||||
e.preventDefault();
|
||||
var params = $("#create-chal-entry-div form").serializeJSON();
|
||||
fetch(script_root + '/api/v1/challenges', {
|
||||
CTFd.fetch('/api/v1/challenges', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -10,7 +10,7 @@ $(document).ready(function () {
|
||||
'requirements': CHALLENGE_REQUIREMENTS
|
||||
};
|
||||
|
||||
fetch(script_root + '/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
CTFd.fetch('/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -38,7 +38,7 @@ $(document).ready(function () {
|
||||
'requirements': CHALLENGE_REQUIREMENTS
|
||||
};
|
||||
|
||||
fetch(script_root + '/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
CTFd.fetch('/api/v1/challenges/' + CHALLENGE_ID, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
function delete_tag(elem){
|
||||
var elem = $(elem);
|
||||
var tag_id = elem.attr('tag-id');
|
||||
$.delete(script_root + '/api/v1/tags/' + tag_id, function(){
|
||||
$(elem).parent().remove()
|
||||
});
|
||||
|
||||
CTFd.fetch('/api/v1/tags/' + tag_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
$(elem).parent().remove()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
@@ -16,7 +22,7 @@ $(document).ready(function () {
|
||||
challenge: CHALLENGE_ID
|
||||
};
|
||||
|
||||
fetch(script_root + '/api/v1/tags', {
|
||||
CTFd.fetch('/api/v1/tags', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -105,7 +105,7 @@ function update_configs(obj){
|
||||
}
|
||||
});
|
||||
|
||||
fetch(script_root + target, {
|
||||
CTFd.fetch(target, {
|
||||
method: method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -127,7 +127,7 @@ function upload_logo(form) {
|
||||
var params = {
|
||||
'value': upload.location
|
||||
};
|
||||
fetch(script_root + '/api/v1/configs/ctf_logo', {
|
||||
CTFd.fetch('/api/v1/configs/ctf_logo', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -160,7 +160,7 @@ function remove_logo() {
|
||||
var params = {
|
||||
'value': null
|
||||
};
|
||||
fetch(script_root + '/api/v1/configs/ctf_logo', {
|
||||
CTFd.fetch('/api/v1/configs/ctf_logo', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -4,7 +4,7 @@ $(document).ready(function () {
|
||||
var form = $('#notifications_form');
|
||||
var params = form.serializeJSON();
|
||||
|
||||
fetch(script_root + '/api/v1/notifications', {
|
||||
CTFd.fetch('/api/v1/notifications', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -32,8 +32,11 @@ $(document).ready(function () {
|
||||
title: 'Delete Notification',
|
||||
body: "Are you sure you want to delete this notification?",
|
||||
success: function () {
|
||||
var delete_route = script_root + '/api/v1/notifications/' + notif_id;
|
||||
$.delete(delete_route, {}, function (response) {
|
||||
CTFd.fetch('/api/v1/notifications/' + notif_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
elem.parent().remove();
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ function submit_form() {
|
||||
method = 'PATCH';
|
||||
}
|
||||
|
||||
fetch(script_root + target, {
|
||||
CTFd.fetch(target, {
|
||||
method: method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -49,8 +49,8 @@ function get_filetype_icon_class(filename){
|
||||
}
|
||||
|
||||
function get_page_files(){
|
||||
return fetch(
|
||||
script_root + '/api/v1/files?type=page', {
|
||||
return CTFd.fetch(
|
||||
'/api/v1/files?type=page', {
|
||||
credentials: 'same-origin',
|
||||
}
|
||||
).then(function (response) {
|
||||
|
||||
@@ -9,8 +9,11 @@ $(document).ready(function () {
|
||||
"<strong>" + htmlentities(name) + "</strong>"
|
||||
),
|
||||
success: function () {
|
||||
var page_delete_route = '{{ request.script_root }}/admin/pages/delete';
|
||||
$.delete(script_root + '/api/v1/pages/' + page_id, {}, function (response) {
|
||||
CTFd.fetch('/api/v1/pages/' + page_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
elem.parent().parent().remove();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function toggle_account(elem) {
|
||||
'hidden': hidden
|
||||
};
|
||||
|
||||
fetch(script_root + '/api/v1/'+ user_mode +'/' + teamId, {
|
||||
CTFd.fetch('/api/v1/'+ user_mode +'/' + teamId, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -17,8 +17,11 @@ $(document).ready(function () {
|
||||
"<strong>" + htmlentities(chal_name) + "</strong>"
|
||||
),
|
||||
success: function () {
|
||||
var route = script_root + '/api/v1/submissions/' + key_id;
|
||||
$.delete(route, function (response) {
|
||||
CTFd.fetch('/api/v1/submissions/' + key_id, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
td_row.remove();
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ $(document).ready(function () {
|
||||
title: "Delete Team",
|
||||
body: "Are you sure you want to delete {0}".format("<strong>" + htmlentities(TEAM_NAME) + "</strong>"),
|
||||
success: function () {
|
||||
var route = script_root + '/api/v1/teams/' + TEAM_ID;
|
||||
$.delete(route, {}, function (data) {
|
||||
if (data.success) {
|
||||
CTFd.fetch('/api/v1/teams/' + TEAM_ID, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
window.location = script_root + '/admin/teams';
|
||||
}
|
||||
});
|
||||
@@ -36,7 +39,7 @@ $(document).ready(function () {
|
||||
title: "Delete Submission",
|
||||
body: body,
|
||||
success: function () {
|
||||
fetch(script_root + '/api/v1/submissions/' + submission_id, {
|
||||
CTFd.fetch('/api/v1/submissions/' + submission_id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -70,7 +73,7 @@ $(document).ready(function () {
|
||||
title: "Delete Award",
|
||||
body: body,
|
||||
success: function () {
|
||||
fetch(script_root + '/api/v1/awards/' + award_id, {
|
||||
CTFd.fetch('/api/v1/awards/' + award_id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $('#team-info-form').serializeJSON(true);
|
||||
|
||||
fetch(script_root + '/api/v1/teams/' + TEAM_ID, {
|
||||
CTFd.fetch('/api/v1/teams/' + TEAM_ID, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $('#team-info-form').serializeJSON(true);
|
||||
|
||||
fetch(script_root + '/api/v1/teams', {
|
||||
CTFd.fetch('/api/v1/teams', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -4,9 +4,12 @@ $(document).ready(function () {
|
||||
title: "Delete User",
|
||||
body: "Are you sure you want to delete {0}".format("<strong>" + htmlentities(USER_NAME) + "</strong>"),
|
||||
success: function () {
|
||||
var route = script_root + '/api/v1/users/' + USER_ID;
|
||||
$.delete(route, {}, function (data) {
|
||||
if (data.success) {
|
||||
CTFd.fetch('/api/v1/users/' + USER_ID, {
|
||||
method: 'DELETE',
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (response) {
|
||||
if (response.success) {
|
||||
window.location = script_root + '/admin/users';
|
||||
}
|
||||
});
|
||||
@@ -30,7 +33,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $('#user-award-form').serializeJSON(true);
|
||||
params['user_id'] = USER_ID;
|
||||
fetch(script_root + '/api/v1/awards', {
|
||||
CTFd.fetch('/api/v1/awards', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -79,7 +82,7 @@ $(document).ready(function () {
|
||||
title: "Delete Submission",
|
||||
body: body,
|
||||
success: function () {
|
||||
fetch(script_root + '/api/v1/submissions/' + submission_id, {
|
||||
CTFd.fetch('/api/v1/submissions/' + submission_id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -113,7 +116,7 @@ $(document).ready(function () {
|
||||
title: "Delete Award",
|
||||
body: body,
|
||||
success: function () {
|
||||
fetch(script_root + '/api/v1/awards/' + award_id, {
|
||||
CTFd.fetch('/api/v1/awards/' + award_id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -153,7 +156,7 @@ $(document).ready(function () {
|
||||
title: "Mark Correct",
|
||||
body: body,
|
||||
success: function () {
|
||||
fetch(script_root + '/api/v1/submissions', {
|
||||
CTFd.fetch('/api/v1/submissions', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $('#user-info-form').serializeJSON(true);
|
||||
|
||||
fetch(script_root + '/api/v1/users/' + USER_ID, {
|
||||
CTFd.fetch('/api/v1/users/' + USER_ID, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
var params = $('#user-info-form').serializeJSON(true);
|
||||
|
||||
fetch(script_root + '/api/v1/users', {
|
||||
CTFd.fetch('/api/v1/users', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -23,6 +23,10 @@ var CTFd = (function () {
|
||||
}
|
||||
url = this.options.urlRoot + url;
|
||||
|
||||
|
||||
if (options.headers === undefined) {
|
||||
options.headers = {};
|
||||
}
|
||||
options.credentials = 'same-origin';
|
||||
options.headers['Accept'] = 'application/json';
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function hint(id) {
|
||||
return fetch(script_root + '/api/v1/hints/' + id, {
|
||||
return CTFd.fetch('/api/v1/hints/' + id, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -13,7 +13,7 @@ function hint(id) {
|
||||
|
||||
|
||||
function unlock(params){
|
||||
return fetch(script_root + '/api/v1/unlocks', {
|
||||
return CTFd.fetch('/api/v1/unlocks', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -19,7 +19,7 @@ $(function () {
|
||||
$('#results').empty();
|
||||
var params = $('#user-settings-form').serializeJSON(true);
|
||||
|
||||
fetch(script_root + '/api/v1/users/me', {
|
||||
CTFd.fetch('/api/v1/users/me', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -124,7 +124,10 @@ def init_request_processors(app):
|
||||
return
|
||||
if not session.get('nonce'):
|
||||
session['nonce'] = generate_nonce()
|
||||
if request.method == "POST":
|
||||
if request.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'):
|
||||
if request.content_type == 'application/json':
|
||||
if session['nonce'] != request.headers.get('CSRF-Token'):
|
||||
abort(403)
|
||||
if request.content_type != 'application/json':
|
||||
if session['nonce'] != request.form.get('nonce'):
|
||||
abort(403)
|
||||
|
||||
45
tests/api/v1/test_csrf.py
Normal file
45
tests/api/v1/test_csrf.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
from tests.helpers import *
|
||||
|
||||
|
||||
def test_api_csrf_failure():
|
||||
"""Can a user post /api/v1/awards if not admin"""
|
||||
app = create_ctfd()
|
||||
app.test_client_class = FlaskClient
|
||||
with app.app_context():
|
||||
with login_as_user(app, 'admin') as client:
|
||||
r = client.post(
|
||||
'/api/v1/challenges',
|
||||
json={
|
||||
"name": "chal",
|
||||
"category": "cate",
|
||||
"description": "desc",
|
||||
"value": "100",
|
||||
"state": "hidden",
|
||||
"type": "standard"
|
||||
}
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
nonce = sess.get('nonce')
|
||||
|
||||
r = client.post(
|
||||
'/api/v1/challenges',
|
||||
headers={
|
||||
'CSRF-Token': nonce
|
||||
},
|
||||
json={
|
||||
"name": "chal",
|
||||
"category": "cate",
|
||||
"description": "desc",
|
||||
"value": "100",
|
||||
"state": "hidden",
|
||||
"type": "standard"
|
||||
}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
destroy_ctfd(app)
|
||||
@@ -1,3 +1,5 @@
|
||||
from flask.testing import FlaskClient
|
||||
from werkzeug.datastructures import Headers
|
||||
from CTFd import create_app
|
||||
from CTFd.config import TestingConfig
|
||||
from CTFd.models import *
|
||||
@@ -20,6 +22,19 @@ else:
|
||||
FakeRequest = namedtuple('FakeRequest', ['form'])
|
||||
|
||||
|
||||
class CTFdTestClient(FlaskClient):
|
||||
def open(self, *args, **kwargs):
|
||||
if kwargs.get('json') is not None:
|
||||
with self.session_transaction() as sess:
|
||||
api_key_headers = Headers({
|
||||
'CSRF-Token': sess.get('nonce')
|
||||
})
|
||||
headers = kwargs.pop('headers', Headers())
|
||||
headers.extend(api_key_headers)
|
||||
kwargs['headers'] = headers
|
||||
return super(CTFdTestClient, self).open(*args, **kwargs)
|
||||
|
||||
|
||||
def create_ctfd(ctf_name="CTFd", name="admin", email="admin@ctfd.io", password="password", user_mode="users", setup=True, enable_plugins=False, application_root='/'):
|
||||
if enable_plugins:
|
||||
TestingConfig.SAFE_MODE = False
|
||||
@@ -29,6 +44,7 @@ def create_ctfd(ctf_name="CTFd", name="admin", email="admin@ctfd.io", password="
|
||||
TestingConfig.APPLICATION_ROOT = application_root
|
||||
|
||||
app = create_app(TestingConfig)
|
||||
app.test_client_class = CTFdTestClient
|
||||
|
||||
if setup:
|
||||
app = setup_ctfd(app, ctf_name, name, email, password, user_mode)
|
||||
|
||||
Reference in New Issue
Block a user