Repository: CTFd/CTFd

Issue:
API Error: config is too long - JSON bool values not sent correctly - 400 bad request on PATCH requests
**Environment**:

- CTFd Version/Commit: 3.3.0 (`ctfd/ctfd:latest` image)
- Operating System: Docker on AWS ECS
- Web Browser and Version: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:89.0) Gecko/20100101 Firefox/89.0

**What happened?**
Send the following request:

```
curl 'https://host/api/v1/configs' -X PATCH -H 'User-Agent: Mozilla/5.0
(Macintosh; Intel Mac OS X 10.14; rv:89.0) Gecko/20100101 Firefox/89.0' -H
'Accept: application/json' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H
'Referer: https://host/admin/config' -H 'Content-Type: application/json' -H
'CSRF-Token: ****************************************************' -H 'Origin:
https://host' -H 'DNT: 1' -H 'Connection: keep-alive' -H 'Cookie:
session=******-******-**************' -H 'Sec-GPC: 1' -H 'TE: Trailers'
--data-raw $'{"successful_registration_email_subject":"Successfully registered
for {ctf_name}","successful_registration_email_body":"You\'ve successfully
    registered for {ctf_name}\041","verification_email_subject":"Confirm your
    account for {ctf_name}","verification_email_body":"Welcome to
    {ctf_name}\041\\r\\n\\r\\nClick the following link to confirm and activate
    your account:\\r\\n{url}\\r\\n\\r\\nIf the link is not clickable, try
    copying and pasting it into your
    browser.","user_creation_email_subject":"Message from
    {ctf_name}","user_creation_email_body":"A new account has been created for
    you for {ctf_name} at {url}. \\r\\n\\r\\nUsername: {name}\\r\\nPassword:
    {password}","password_reset_subject":"Password Reset Request from
    {ctf_name}","password_reset_body":"Did you initiate a password reset on
    {ctf_name}? If you didn\'t initiate this request you can ignore this email.
    \\r\\n\\r\\nClick the following link to reset your
    password:\\r\\n{url}\\r\\n\\r\\nIf the link is not clickable, try copying
    and pasting it into your
    browser.","password_change_alert_subject":"Password Change Confirmation for
    {ctf_name}","password_change_alert_body":"Your password for {ctf_name} has
    been changed.\\r\\n\\r\\nIf you didn\'t request a password change you can
    reset your password here:
    {url}","mailfrom_addr":"","mail_server":"","mail_port":"","mail_username":null,
    "mail_password":null,"mailgun_base_url":"","mailgun_api_key":"",
    "mail_useauth":false,"mail_ssl":false,"mail_tls":false}' 
```

gives a 400 bad request status with the following body

```
{"success": false, "errors": {"value": ["mail_useauth config is too long"]}}
```

while if I change the `false` values in the JSON to `"false"` (with quotes), I get the expected 200 OK status.

**What did you expect to happen?**

```
{"success": true
```

**How to reproduce your issue**
Use Firefox's integrated developer tools to copy the request as cURL and used cURL to send it again.

**Any associated stack traces or error logs**
```
./CTFd/themes/core/assets/js/fetch.js/t.default
https://host/themes/admin/static/js/pages/configs.min.js:1:62180
./CTFd/themes/core/assets/js/api.js/n</e.prototype.request
https://host/themes/admin/static/js/pages/configs.min.js:1:26970
./CTFd/themes/core/assets/js/patch.js/o.API.prototype.patch_config_list
https://host/themes/admin/static/js/pages/configs.min.js:1:65384
v
https://host/themes/admin/static/js/pages/configs.min.js:1:4002
dispatch
https://host/themes/admin/static/js/vendor.bundle.min.js:110:39033
add/h.handle
https://host/themes/admin/static/js/vendor.bundle.min.js:110:37028
```



Discussion:
Same error for me @phretor 

As a workaround - if you comment out the lines in the patch they put in for #1920 which was clearly broken

https://github.com/CTFd/CTFd/pull/1920/files

           response = schema.load({"key": key, "value": value})
            if response.errors:
                # Inject config key into error
                config_key = response.data["key"]
                response.errors["value"][0] = f"{config_key} config is too long"
                return {"success": False, "errors": response.errors}, 400



@oztechmuse I was thinking about why the `Configs` DB model enforces that values are of `db.Text` type.

```
class Configs(db.Model):
    __tablename__ = "config"
    id = db.Column(db.Integer, primary_key=True)
    key = db.Column(db.Text)
    value = db.Column(db.Text)

    def __init__(self, *args, **kwargs):
        super(Configs, self).__init__(**kwargs)
```

The `Configs` schema is then used by marshmallow to validate JSON data:

```
class ConfigSchema(ma.ModelSchema):
    class Meta:
        model = Configs
        include_fk = True
        dump_only = ("id",)

    views = {"admin": ["id", "key", "value"]}
    key = field_for(Configs, "key", required=True)
    value = field_for(
        Configs,
        "value",
        validate=[validate.Length(max=64000, error="Config is too long")],
    )

    def __init__(self, view=None, *args, **kwargs):
        if view:
            if isinstance(view, string_types):
                kwargs["only"] = self.views[view]
            elif isinstance(view, list):
                kwargs["only"] = view

        super(ConfigSchema, self).__init__(*args, **kwargs)
```

And, clearly, an error is raised because `false` is of type `bool` and not `db.Text` (a.k.a., `str` or `bytes` internally).

Any thoughts?
Meanwhile I've tested this patch, which seems to be doing the job:

```diff
diff --git i/CTFd/api/v1/config.py w/CTFd/api/v1/config.py
index a0368ff..7837857 100644
--- i/CTFd/api/v1/config.py
+++ w/CTFd/api/v1/config.py
@@ -115,6 +115,8 @@ class ConfigList(Resource):
         schema = ConfigSchema()
 
         for key, value in req.items():
+            if isinstance(value, bool):
+                value = str(value)
             response = schema.load({"key": key, "value": value})
             if response.errors:
                 # Inject config key into error

```
Agree that our patch would do the job - I did look to see if you could change the value being sent by the checkbox in the form - or even change it in the processing of the form since the value should be a string - nothing obvious there - so what you have suggested seems like the more pragmatic approach. 
Thanks @phretor for pointing out the error and providing your patch code snippet! I missed that edge case of non-string values on my PR. That seems like a great approach to solving the issue. Feel free to make a PR with the patch for review.

Resolving patch:
diff --git a/CTFd/api/v1/config.py b/CTFd/api/v1/config.py
--- a/CTFd/api/v1/config.py
+++ b/CTFd/api/v1/config.py
@@ -89,9 +89,6 @@ def post(self):
         response = schema.load(req)
 
         if response.errors:
-            # Inject config key into error
-            config_key = response.data["key"]
-            response.errors["value"][0] = f"{config_key} config is too long"
             return {"success": False, "errors": response.errors}, 400
 
         db.session.add(response.data)
@@ -117,9 +114,6 @@ def patch(self):
         for key, value in req.items():
             response = schema.load({"key": key, "value": value})
             if response.errors:
-                # Inject config key into error
-                config_key = response.data["key"]
-                response.errors["value"][0] = f"{config_key} config is too long"
                 return {"success": False, "errors": response.errors}, 400
             set_config(key=key, value=value)
 
@@ -169,11 +163,11 @@ def patch(self, config_key):
             schema = ConfigSchema()
             data["key"] = config_key
             response = schema.load(data)
-            db.session.add(response.data)
 
         if response.errors:
-            return response.errors, 400
+            return {"success": False, "errors": response.errors}, 400
 
+        db.session.add(response.data)
         db.session.commit()
 
         response = schema.dump(response.data)
diff --git a/CTFd/schemas/config.py b/CTFd/schemas/config.py
--- a/CTFd/schemas/config.py
+++ b/CTFd/schemas/config.py
@@ -1,10 +1,28 @@
-from marshmallow import validate
+from marshmallow import fields
+from marshmallow.exceptions import ValidationError
 from marshmallow_sqlalchemy import field_for
 
 from CTFd.models import Configs, ma
 from CTFd.utils import string_types
 
 
+class ConfigValueField(fields.Field):
+    """
+    Custom value field for Configs so that we can perform validation of values
+    """
+
+    def _deserialize(self, value, attr, data, **kwargs):
+        if isinstance(value, str):
+            # 65535 bytes is the size of a TEXT column in MySQL
+            # You may be able to exceed this in other databases
+            # but MySQL is our database of record
+            if len(value) > 65535:
+                raise ValidationError(f'{data["key"]} config is too long')
+            return value
+        else:
+            return value
+
+
 class ConfigSchema(ma.ModelSchema):
     class Meta:
         model = Configs
@@ -13,11 +31,7 @@ class Meta:
 
     views = {"admin": ["id", "key", "value"]}
     key = field_for(Configs, "key", required=True)
-    value = field_for(
-        Configs,
-        "value",
-        validate=[validate.Length(max=64000, error="Config is too long")],
-    )
+    value = ConfigValueField(allow_none=True, required=True)
 
     def __init__(self, view=None, *args, **kwargs):
         if view:


Test patch:
diff --git a/tests/api/v1/test_config.py b/tests/api/v1/test_config.py
--- a/tests/api/v1/test_config.py
+++ b/tests/api/v1/test_config.py
@@ -105,21 +105,73 @@ def test_api_config_delete_admin():
     destroy_ctfd(app)
 
 
-def test_long_values():
-    """Can a config value that is bigger than 64,000 be accepted"""
+def test_config_value_types():
+    """Test that we properly receive values according to schema"""
     app = create_ctfd()
     with app.app_context():
         with login_as_user(app, "admin") as admin:
-            long_text = "a" * 65000
+            # Test new configs error out if too long
+            long_text = "a" * 65536
             r = admin.post(
-                "/api/v1/configs", json={"key": "ctf_footer", "value": long_text}
+                "/api/v1/configs", json={"key": "new_ctf_config", "value": long_text}
             )
             data = r.get_json()
-            assert data["errors"]["value"][0] == "ctf_footer config is too long"
+            assert data["errors"]["value"][0] == "new_ctf_config config is too long"
+            assert r.status_code == 400
+            r = admin.post(
+                "/api/v1/configs", json={"key": "new_ctf_config", "value": "test"}
+            )
+            assert r.status_code == 200
+            assert get_config("new_ctf_config") == "test"
 
-            r = admin.patch("/api/v1/configs", json={"ctf_theme": long_text})
+            # Test strings too long error out
+            r = admin.patch("/api/v1/configs", json={"ctf_footer": long_text})
             data = r.get_json()
-            assert data["errors"]["value"][0] == "ctf_theme config is too long"
+            assert data["errors"]["value"][0] == "ctf_footer config is too long"
             assert r.status_code == 400
 
+            # Test regular length strings
+            r = admin.patch(
+                "/api/v1/configs", json={"ctf_footer": "// regular length string"},
+            )
+            assert r.status_code == 200
+            assert get_config("ctf_footer") == "// regular length string"
+
+            # Test booleans can be received
+            r = admin.patch("/api/v1/configs", json={"view_after_ctf": True})
+            assert r.status_code == 200
+            assert bool(get_config("view_after_ctf")) == True
+
+            # Test None can be received
+            assert get_config("mail_username") is None
+            r = admin.patch("/api/v1/configs", json={"mail_username": "testusername"})
+            assert r.status_code == 200
+            assert get_config("mail_username") == "testusername"
+            r = admin.patch("/api/v1/configs", json={"mail_username": None})
+            assert r.status_code == 200
+            assert get_config("mail_username") is None
+
+            # Test integers can be received
+            r = admin.patch("/api/v1/configs", json={"mail_port": 12345})
+            assert r.status_code == 200
+            assert get_config("mail_port") == 12345
+
+            # Test specific config key
+            r = admin.patch(
+                "/api/v1/configs/long_config_test", json={"value": long_text}
+            )
+            data = r.get_json()
+            assert data["errors"]["value"][0] == "long_config_test config is too long"
+            assert r.status_code == 400
+            assert get_config("long_config_test") is None
+            r = admin.patch(
+                "/api/v1/configs/config_test", json={"value": "config_value_test"}
+            )
+            assert r.status_code == 200
+            assert get_config("config_test") == "config_value_test"
+            r = admin.patch(
+                "/api/v1/configs/mail_username", json={"value": "testusername"}
+            )
+            assert r.status_code == 200
+            assert get_config("mail_username") == "testusername"
     destroy_ctfd(app)