Fix frontend UI where empty/null requirements could be added (#1824)

* Fix Challenge Requirements interface in Admin Panel to not allow empty/null requirements to be added
* Closes #1809
This commit is contained in:
Kevin Chung
2021-03-16 19:03:55 -04:00
committed by GitHub
parent b74b91774c
commit e5dbd62a66
5 changed files with 79 additions and 4 deletions

View File

@@ -1,9 +1,29 @@
from marshmallow import validate
from marshmallow.exceptions import ValidationError
from marshmallow_sqlalchemy import field_for
from CTFd.models import Challenges, ma
class ChallengeRequirementsValidator(validate.Validator):
default_message = "Error parsing challenge requirements"
def __init__(self, error=None):
self.error = error or self.default_message
def __call__(self, value):
if isinstance(value, dict) is False:
raise ValidationError(self.default_message)
prereqs = value.get("prerequisites", [])
if all(prereqs) is False:
raise ValidationError(
"Challenge requirements cannot have a null prerequisite"
)
return value
class ChallengeSchema(ma.ModelSchema):
class Meta:
model = Challenges
@@ -46,3 +66,7 @@ class ChallengeSchema(ma.ModelSchema):
)
],
)
requirements = field_for(
Challenges, "requirements", validate=[ChallengeRequirementsValidator()],
)

View File

@@ -43,7 +43,12 @@
</select>
</div>
<div class="form-group">
<button class="btn btn-success float-right">Add Prerequisite</button>
<button
class="btn btn-success float-right"
:disabled="!selectedRequirement"
>
Add Prerequisite
</button>
</div>
</form>
</div>
@@ -123,6 +128,10 @@ export default {
? this.requirements.prerequisites
: [];
if (!this.selectedRequirement) {
return;
}
newRequirements.push(this.selectedRequirement);
this.requirements["prerequisites"] = newRequirements;
@@ -144,6 +153,7 @@ export default {
})
.then(data => {
if (data.success) {
this.selectedRequirement = null;
this.loadRequirements();
}
});

View File

@@ -668,7 +668,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
/***/ (function(module, exports, __webpack_require__) {
;
eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\nvar _default = {\n props: {\n challenge_id: Number\n },\n data: function data() {\n return {\n challenges: [],\n requirements: {},\n selectedRequirement: null\n };\n },\n computed: {\n // Get all challenges besides the current one and current prereqs\n otherChallenges: function otherChallenges() {\n var _this = this;\n\n var prerequisites = this.requirements.prerequisites || [];\n return this.challenges.filter(function (challenge) {\n return challenge.id !== _this.$props.challenge_id && !prerequisites.includes(challenge.id);\n });\n }\n },\n methods: {\n loadChallenges: function loadChallenges() {\n var _this2 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges?view=admin\", {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this2.challenges = response.data;\n }\n });\n },\n getChallengeById: function getChallengeById(challenge_id) {\n return this.challenges.find(function (challenge) {\n return challenge.id === challenge_id;\n });\n },\n loadRequirements: function loadRequirements() {\n var _this3 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id, \"/requirements\"), {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this3.requirements = response.data || {};\n }\n });\n },\n addRequirement: function addRequirement() {\n var _this4 = this;\n\n var newRequirements = this.requirements.prerequisites ? this.requirements.prerequisites : [];\n newRequirements.push(this.selectedRequirement);\n this.requirements[\"prerequisites\"] = newRequirements;\n var params = {\n requirements: this.requirements\n };\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\n method: \"PATCH\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (data) {\n if (data.success) {\n _this4.loadRequirements();\n }\n });\n },\n removeRequirement: function removeRequirement(challenge_id) {\n var _this5 = this;\n\n this.requirements.prerequisites = this.requirements.prerequisites.filter(function (val) {\n return val !== challenge_id;\n });\n var params = {\n requirements: this.requirements\n };\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\n method: \"PATCH\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (data) {\n if (data.success) {\n _this5.loadRequirements();\n }\n });\n }\n },\n created: function created() {\n this.loadChallenges();\n this.loadRequirements();\n }\n};\nexports[\"default\"] = _default;\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/requirements/Requirements.vue?./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options");
eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\n//\nvar _default = {\n props: {\n challenge_id: Number\n },\n data: function data() {\n return {\n challenges: [],\n requirements: {},\n selectedRequirement: null\n };\n },\n computed: {\n // Get all challenges besides the current one and current prereqs\n otherChallenges: function otherChallenges() {\n var _this = this;\n\n var prerequisites = this.requirements.prerequisites || [];\n return this.challenges.filter(function (challenge) {\n return challenge.id !== _this.$props.challenge_id && !prerequisites.includes(challenge.id);\n });\n }\n },\n methods: {\n loadChallenges: function loadChallenges() {\n var _this2 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges?view=admin\", {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this2.challenges = response.data;\n }\n });\n },\n getChallengeById: function getChallengeById(challenge_id) {\n return this.challenges.find(function (challenge) {\n return challenge.id === challenge_id;\n });\n },\n loadRequirements: function loadRequirements() {\n var _this3 = this;\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id, \"/requirements\"), {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n }\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n _this3.requirements = response.data || {};\n }\n });\n },\n addRequirement: function addRequirement() {\n var _this4 = this;\n\n var newRequirements = this.requirements.prerequisites ? this.requirements.prerequisites : [];\n\n if (!this.selectedRequirement) {\n return;\n }\n\n newRequirements.push(this.selectedRequirement);\n this.requirements[\"prerequisites\"] = newRequirements;\n var params = {\n requirements: this.requirements\n };\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\n method: \"PATCH\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (data) {\n if (data.success) {\n _this4.selectedRequirement = null;\n\n _this4.loadRequirements();\n }\n });\n },\n removeRequirement: function removeRequirement(challenge_id) {\n var _this5 = this;\n\n this.requirements.prerequisites = this.requirements.prerequisites.filter(function (val) {\n return val !== challenge_id;\n });\n var params = {\n requirements: this.requirements\n };\n\n _CTFd[\"default\"].fetch(\"/api/v1/challenges/\".concat(this.$props.challenge_id), {\n method: \"PATCH\",\n credentials: \"same-origin\",\n headers: {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\"\n },\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (data) {\n if (data.success) {\n _this5.loadRequirements();\n }\n });\n }\n },\n created: function created() {\n this.loadChallenges();\n this.loadRequirements();\n }\n};\nexports[\"default\"] = _default;\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/requirements/Requirements.vue?./node_modules/babel-loader/lib??ref--0!./node_modules/vue-loader/lib??vue-loader-options");
/***/ }),
@@ -847,7 +847,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) *
/***/ (function(module, __webpack_exports__, __webpack_require__) {
;
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"div\", [\n _c(\"table\", { staticClass: \"table table-striped text-center\" }, [\n _vm._m(0),\n _vm._v(\" \"),\n _c(\n \"tbody\",\n { attrs: { id: \"challenge-solves-body\" } },\n _vm._l(_vm.requirements.prerequisites, function(requirement) {\n return _c(\"tr\", { key: requirement }, [\n _c(\"td\", [_vm._v(_vm._s(_vm.getChallengeById(requirement).name))]),\n _vm._v(\" \"),\n _c(\"td\", [\n _c(\"i\", {\n staticClass: \"btn-fa fas fa-times delete-requirement\",\n attrs: { role: \"button\", \"challenge-id\": requirement },\n on: {\n click: function($event) {\n return _vm.removeRequirement(requirement)\n }\n }\n })\n ])\n ])\n }),\n 0\n )\n ]),\n _vm._v(\" \"),\n _c(\n \"form\",\n {\n on: {\n submit: function($event) {\n $event.preventDefault()\n return _vm.addRequirement($event)\n }\n }\n },\n [\n _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\n \"select\",\n {\n directives: [\n {\n name: \"model\",\n rawName: \"v-model\",\n value: _vm.selectedRequirement,\n expression: \"selectedRequirement\"\n }\n ],\n staticClass: \"form-control custom-select\",\n attrs: { name: \"prerequisite\" },\n on: {\n change: function($event) {\n var $$selectedVal = Array.prototype.filter\n .call($event.target.options, function(o) {\n return o.selected\n })\n .map(function(o) {\n var val = \"_value\" in o ? o._value : o.value\n return val\n })\n _vm.selectedRequirement = $event.target.multiple\n ? $$selectedVal\n : $$selectedVal[0]\n }\n }\n },\n [\n _c(\"option\", { attrs: { value: \"\" } }, [_vm._v(\" -- \")]),\n _vm._v(\" \"),\n _vm._l(_vm.otherChallenges, function(challenge) {\n return _c(\n \"option\",\n { key: challenge.id, domProps: { value: challenge.id } },\n [\n _vm._v(\n \"\\n \" + _vm._s(challenge.name) + \"\\n \"\n )\n ]\n )\n })\n ],\n 2\n )\n ]),\n _vm._v(\" \"),\n _vm._m(1)\n ]\n )\n ])\n}\nvar staticRenderFns = [\n function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"thead\", [\n _c(\"tr\", [\n _c(\"td\", [_c(\"b\", [_vm._v(\"Requirement\")])]),\n _vm._v(\" \"),\n _c(\"td\", [_c(\"b\", [_vm._v(\"Settings\")])])\n ])\n ])\n },\n function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\"button\", { staticClass: \"btn btn-success float-right\" }, [\n _vm._v(\"Add Prerequisite\")\n ])\n ])\n }\n]\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/requirements/Requirements.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"render\", function() { return render; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"staticRenderFns\", function() { return staticRenderFns; });\nvar render = function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"div\", [\n _c(\"table\", { staticClass: \"table table-striped text-center\" }, [\n _vm._m(0),\n _vm._v(\" \"),\n _c(\n \"tbody\",\n { attrs: { id: \"challenge-solves-body\" } },\n _vm._l(_vm.requirements.prerequisites, function(requirement) {\n return _c(\"tr\", { key: requirement }, [\n _c(\"td\", [_vm._v(_vm._s(_vm.getChallengeById(requirement).name))]),\n _vm._v(\" \"),\n _c(\"td\", [\n _c(\"i\", {\n staticClass: \"btn-fa fas fa-times delete-requirement\",\n attrs: { role: \"button\", \"challenge-id\": requirement },\n on: {\n click: function($event) {\n return _vm.removeRequirement(requirement)\n }\n }\n })\n ])\n ])\n }),\n 0\n )\n ]),\n _vm._v(\" \"),\n _c(\n \"form\",\n {\n on: {\n submit: function($event) {\n $event.preventDefault()\n return _vm.addRequirement($event)\n }\n }\n },\n [\n _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\n \"select\",\n {\n directives: [\n {\n name: \"model\",\n rawName: \"v-model\",\n value: _vm.selectedRequirement,\n expression: \"selectedRequirement\"\n }\n ],\n staticClass: \"form-control custom-select\",\n attrs: { name: \"prerequisite\" },\n on: {\n change: function($event) {\n var $$selectedVal = Array.prototype.filter\n .call($event.target.options, function(o) {\n return o.selected\n })\n .map(function(o) {\n var val = \"_value\" in o ? o._value : o.value\n return val\n })\n _vm.selectedRequirement = $event.target.multiple\n ? $$selectedVal\n : $$selectedVal[0]\n }\n }\n },\n [\n _c(\"option\", { attrs: { value: \"\" } }, [_vm._v(\" -- \")]),\n _vm._v(\" \"),\n _vm._l(_vm.otherChallenges, function(challenge) {\n return _c(\n \"option\",\n { key: challenge.id, domProps: { value: challenge.id } },\n [\n _vm._v(\n \"\\n \" + _vm._s(challenge.name) + \"\\n \"\n )\n ]\n )\n })\n ],\n 2\n )\n ]),\n _vm._v(\" \"),\n _c(\"div\", { staticClass: \"form-group\" }, [\n _c(\n \"button\",\n {\n staticClass: \"btn btn-success float-right\",\n attrs: { disabled: !_vm.selectedRequirement }\n },\n [_vm._v(\"\\n Add Prerequisite\\n \")]\n )\n ])\n ]\n )\n ])\n}\nvar staticRenderFns = [\n function() {\n var _vm = this\n var _h = _vm.$createElement\n var _c = _vm._self._c || _h\n return _c(\"thead\", [\n _c(\"tr\", [\n _c(\"td\", [_c(\"b\", [_vm._v(\"Requirement\")])]),\n _vm._v(\" \"),\n _c(\"td\", [_c(\"b\", [_vm._v(\"Settings\")])])\n ])\n ])\n }\n]\nrender._withStripped = true\n\n\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/components/requirements/Requirements.vue?./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options");
/***/ }),

File diff suppressed because one or more lines are too long

View File

@@ -397,6 +397,47 @@ def test_hidden_challenge_is_unsolveable():
destroy_ctfd(app)
def test_invalid_requirements_are_rejected():
"""Test that invalid requirements JSON blobs are rejected by the API"""
app = create_ctfd()
with app.app_context():
gen_challenge(app.db)
gen_challenge(app.db)
with login_as_user(app, "admin") as client:
# Test None/null values
r = client.patch(
"/api/v1/challenges/1", json={"requirements": {"prerequisites": [None]}}
)
assert r.status_code == 400
assert r.get_json() == {
"success": False,
"errors": {
"requirements": [
"Challenge requirements cannot have a null prerequisite"
]
},
}
# Test empty strings
r = client.patch(
"/api/v1/challenges/1", json={"requirements": {"prerequisites": [""]}}
)
assert r.status_code == 400
assert r.get_json() == {
"success": False,
"errors": {
"requirements": [
"Challenge requirements cannot have a null prerequisite"
]
},
}
# Test a valid integer
r = client.patch(
"/api/v1/challenges/1", json={"requirements": {"prerequisites": [2]}}
)
assert r.status_code == 200
destroy_ctfd(app)
def test_challenge_with_requirements_is_unsolveable():
"""Test that a challenge with a requirement is unsolveable without first solving the requirement"""
app = create_ctfd()