Repository: CTFd/CTFd

Issue:
Empty and null prerequisites can be added from admin UI
**Environment**:

- CTFd Version/Commit: 843546b (tip of master)
- Operating System: Linux
- Web Browser and Version: Firefox

**What happened?**
Adding a prerequisite to a challenge without selecting a valid challenge entry in the drop down results in either `""` (selecting the "--" item in the drop down) or `null` (not selecting anything in the dropdown). This also breaks rendering of any previously added prerequisites when the UI is reloaded.

**What did you expect to happen?**
The backend should reject invalid prerequisites (probably via the `ChallengeSchema`?) and the frontend also shouldn't make it easy to submit them.

**How to reproduce your issue**
1. Create two challenges A and B
2. VIsit the admin UI page to edit B and click on the "Requirements" tab
3. Select A in the dropdown and click "Add Prerequisite"
4. Select the "--" entry in the dropdown and click "Add Prerequisite" again - observe rendering breakage
5. Solve A in either user or teams mode
6. Query `/api/v1/challenges` and observe that B is not present
7. Visit the admin UI again and select nothing in the requirements dropdown (it will remain blank), then click "Add Prerequisite" once more
8. Check the requirements for the B in the DB/from the ORM. They will be `[<ID of A>, "", None]`

**Any associated stack traces or error logs**



Discussion:

Resolving patch:
diff --git a/CTFd/schemas/challenges.py b/CTFd/schemas/challenges.py
--- a/CTFd/schemas/challenges.py
+++ b/CTFd/schemas/challenges.py
@@ -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 Meta:
             )
         ],
     )
+
+    requirements = field_for(
+        Challenges, "requirements", validate=[ChallengeRequirementsValidator()],
+    )


Test patch:
diff --git a/tests/users/test_challenges.py b/tests/users/test_challenges.py
--- a/tests/users/test_challenges.py
+++ b/tests/users/test_challenges.py
@@ -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()