From c3de81049b9b6f438fbc5a40a26498c4c3192aea Mon Sep 17 00:00:00 2001 From: Aurélien Bompard Date: Mar 15 2017 16:49:23 +0000 Subject: Create a validator class for POST data --- diff --git a/hubs/tests/test_fedora_hubs_flask_api.py b/hubs/tests/test_fedora_hubs_flask_api.py index 442a4e9..006f99e 100644 --- a/hubs/tests/test_fedora_hubs_flask_api.py +++ b/hubs/tests/test_fedora_hubs_flask_api.py @@ -439,3 +439,42 @@ class HubsAPITest(hubs.tests.APPTest): self.assertEqual( result_data["result"]["hub"]["chat_domain"], app.config["CHAT_NETWORKS"][0]["domain"]) + + def test_hub_config_post_unknown_post_data(self): + # Unknown POST data is silently ignored + user = tests.FakeAuthorization('ralph') + with tests.auth_set(app, user): + url = '/ralph/config' + result = self.app.post(url, data={"non_existant": "dummy"}) + self.assertEqual(result.status_code, 400) + result_data = json.loads(result.get_data(as_text=True)) + self.assertEqual(result_data["status"], "ERROR") + self.assertEqual(result_data["message"], "Invalid value(s)") + self.assertIn("non_existant", result_data["fields"]) + self.assertEqual( + result_data["fields"]["non_existant"], + "Unexpected parameter." + ) + + def test_hub_config_post_invalid_chat_domain(self): + user = tests.FakeAuthorization('ralph') + with tests.auth_set(app, user): + url = '/ralph/config' + result = self.app.post(url, data={"chat_domain": "dummy"}) + self.assertEqual(result.status_code, 400) + result_data = json.loads(result.get_data(as_text=True)) + self.assertEqual(result_data["status"], "ERROR") + self.assertEqual(result_data["message"], "Invalid value(s)") + self.assertIn("chat_domain", result_data["fields"]) + self.assertEqual( + result_data["fields"]["chat_domain"], + "Unsupported chat domain." + ) + + @unittest.skip("Authorization layer not present yet") + def test_hub_config_unauthorized(self): + user = tests.FakeAuthorization('ralph') + with tests.auth_set(app, user): + url = '/decause/config' + result = self.app.post(url, data={"summary": "Defaced!"}) + self.assertEqual(result.status_code, 403) diff --git a/hubs/views/hub.py b/hubs/views/hub.py index 2477056..3534c70 100644 --- a/hubs/views/hub.py +++ b/hubs/views/hub.py @@ -5,7 +5,7 @@ import flask import hubs.models from hubs.app import app, session -from .utils import get_hub, login_required +from .utils import get_hub, login_required, RequestValidator @app.route('/') @@ -155,14 +155,30 @@ def hub_config(name): }, } if flask.request.method == 'POST': - for key in hub.config.__json__(): - # Security: don't save every key in the POST. - if key not in flask.request.form: - continue - setattr(hub.config, key, flask.request.form[key]) - if (not flask.request.form["chat_domain"] - and len(app.config["CHAT_NETWORKS"]) > 0): - hub.config.chat_domain = app.config["CHAT_NETWORKS"][0]["domain"] + # Validate values + def _validate_chat_domain(value): + valid_chat_domains = [ + network["domain"] for network in app.config["CHAT_NETWORKS"] + ] + if not value and len(valid_chat_domains) > 0: + value = valid_chat_domains[0] + if value not in valid_chat_domains: + raise ValueError("Unsupported chat domain.") + return value + validator = RequestValidator(dict( + # Only allow the parameters listed in __json__(). + (key, None) for key in hub.config.__json__().keys() + )) + validator.converters["chat_domain"] = _validate_chat_domain + try: + values = validator(flask.request.form) + except ValueError as e: + config = {"status": "ERROR", "message": "Invalid value(s)"} + config["fields"] = e.args[0] + return flask.jsonify(config), 400 + # Now set the configuration values + for key, value in values.items(): + setattr(hub.config, key, value) try: flask.g.db.commit() except Exception as err: diff --git a/hubs/views/utils.py b/hubs/views/utils.py index 733a32a..a90e468 100644 --- a/hubs/views/utils.py +++ b/hubs/views/utils.py @@ -145,3 +145,81 @@ def get_position(): if position not in ['right', 'left']: flask.abort(400, 'Invalid position provided') return position + + +class RequestValidator(object): + """Validate a request's POST data. + + Instantiate this class with a dictionnary mapping POST data keys (found in + ``flask.request.form``) to a function that will validate and convert them. + + POST data can be validated by calling the instance and passing the data as + an argument. The instance will return a mapping of keys to converted + values if all validations are successful. + + A validation function can raise a :py:exc:`ValueError` if the value is not + valid. In this case, the :py:class:`RequestValidator` instance will collect + the error messages in a dictionary mapping failed request keys to messages, + and raise a :py:exc:`ValueError` with this dictionary. + + If the function is None, the value is returned unchanged. If a posted value + is not present in the conversion mapping the class was instantiated with, + an error will be raised. + + A typical usage of this class is:: + + validator = RequestValidator({"number": int}) + try: + values = validator(flask.request.form) + except ValueError as e: + failed_fields = e.args[0] + return flask.jsonify(failed_fields), 400 + # Do something with the validated values. + for key, value in values.items(): + setattr(model_instance, key, value) + + In this example: + + - if the posted data was ``number=1``, the resulting ``values`` variable + would be ``{"number": 1}``. + - if the posted data was ``number=foobar``, a :py:exc:`ValueError` would be + raised, ``failed_fields`` would be ``{"number": "error message"}``. + - if the posted data was ``foo=bar``, a :py:exc:`ValueError` would be + raised, ``failed_fields`` would be + ``{"foo": "message about unexpected key"}``. + + Args: + converters (dict): a mapping from POST data keys to + conversion functions. + """ + + def __init__(self, converters): + self.converters = converters.copy() + + def __call__(self, request_data): + """Convert the values in the given mapping. + + Args: + request_data (dict): the values to validate and convert, + usually ``flask.request.form``. + + Raises: + ValueError: At least one value is invalid. + """ + values = {} + errors = {} + for key, value in request_data.items(): + if key not in self.converters: + errors[key] = "Unexpected parameter." + continue + if self.converters[key] is None: + # Identity + values[key] = value + continue + try: + values[key] = self.converters[key](value) + except (TypeError, ValueError) as e: + errors[key] = e.args[0] + if errors: + raise ValueError(errors) + return values