From 5fd256e5e6e4a30ca979f9bd4edc8daf55034699 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 23 Sep 2018 13:30:56 +0200 Subject: Add embed validation. --- api/admin.py | 3 +- api/migrations/0008_tag_embed_validator.py | 20 ++++ api/models.py | 5 +- api/tests/test_validators.py | 143 ++++++++++++++++++++++++++ api/validators.py | 155 +++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 api/migrations/0008_tag_embed_validator.py create mode 100644 api/tests/test_validators.py create mode 100644 api/validators.py diff --git a/api/admin.py b/api/admin.py index 54cb33ea..526bea52 100644 --- a/api/admin.py +++ b/api/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from .models import ( DocumentationLink, Member, OffTopicChannelName, Role, - SnakeName + SnakeName, Tag ) @@ -12,3 +12,4 @@ admin.site.register(Member) admin.site.register(OffTopicChannelName) admin.site.register(Role) admin.site.register(SnakeName) +admin.site.register(Tag) diff --git a/api/migrations/0008_tag_embed_validator.py b/api/migrations/0008_tag_embed_validator.py new file mode 100644 index 00000000..4c580294 --- /dev/null +++ b/api/migrations/0008_tag_embed_validator.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.1 on 2018-09-23 10:07 + +import api.validators +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_tag'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='embed', + field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[api.validators.validate_tag_embed]), + ), + ] diff --git a/api/models.py b/api/models.py index 58668c34..e84e28c0 100644 --- a/api/models.py +++ b/api/models.py @@ -4,6 +4,8 @@ from django.contrib.postgres import fields as pgfields from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import models +from .validators import validate_tag_embed + class ModelReprMixin: """ @@ -174,5 +176,6 @@ class Tag(ModelReprMixin, models.Model): primary_key=True ) embed = pgfields.JSONField( - help_text="The actual embed shown by this tag." + help_text="The actual embed shown by this tag.", + validators=(validate_tag_embed,) ) diff --git a/api/tests/test_validators.py b/api/tests/test_validators.py new file mode 100644 index 00000000..ce197dec --- /dev/null +++ b/api/tests/test_validators.py @@ -0,0 +1,143 @@ +from django.core.exceptions import ValidationError +from hypothesis import given, settings +from hypothesis.extra.django import TestCase +from hypothesis.strategies import dictionaries, just, lists, one_of, randoms, text + +from ..validators import validate_tag_embed + + +REQUIRED_KEYS = ( + 'content', 'fields', 'image', 'title', 'video' +) + + +class TagEmbedValidatorTests(TestCase): + @given( + dictionaries( + text().filter(lambda key: key not in REQUIRED_KEYS), + text() + ) + ) + def test_rejects_missing_required_keys(self, embed): + with self.assertRaises(ValidationError): + validate_tag_embed(embed) + + def test_rejects_empty_required_key(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': '' + }) + + @given(lists(randoms())) + def test_rejects_list_as_embed(self, embed): + with self.assertRaises(ValidationError): + validate_tag_embed(embed) + + def test_rejects_required_keys_and_unknown_keys(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "the duck walked up to the lemonade stand", + 'and': "he said to the man running the stand" + }) + + def test_rejects_too_long_title(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': 'a' * 257 + }) + + @given( + dictionaries( + just('fields'), + one_of( + lists(randoms(), min_size=25), + text(min_size=25) + ) + ) + ) + @settings(max_examples=10) + def test_rejects_too_many_fields(self, embed): + with self.assertRaises(ValidationError): + validate_tag_embed(embed) + + def test_rejects_too_long_description(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'description': 'd' * 2049 + }) + + def test_rejects_fields_as_list_of_non_mappings(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': ['abc'] + }) + + def test_rejects_fields_with_unknown_fields(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [ + { + 'what': "is this field" + } + ] + }) + + def test_rejects_fields_with_too_long_name(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [ + { + 'name': "a" * 257 + } + ] + }) + + def test_rejects_footer_as_non_mapping(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'footer': [] + }) + + def test_rejects_footer_with_unknown_fields(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'footer': { + 'duck': "quack" + } + }) + + def test_rejects_footer_with_empty_text(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'footer': { + 'text': "" + } + }) + + def test_rejects_author_as_non_mapping(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': [] + }) + + def test_rejects_author_with_unknown_field(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': { + 'field': "that is unknown" + } + }) + + def test_rejects_author_with_empty_name(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'title': "whatever", + 'author': { + 'name': "" + } + }) diff --git a/api/validators.py b/api/validators.py new file mode 100644 index 00000000..2c4ffe4b --- /dev/null +++ b/api/validators.py @@ -0,0 +1,155 @@ +from collections.abc import Mapping + +from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator, MinLengthValidator + + +def validate_tag_embed_fields(fields): + field_validators = { + 'name': (MaxLengthValidator(limit_value=256),), + 'value': (MaxLengthValidator(limit_value=1024),) + } + + for field in fields: + if not isinstance(field, Mapping): + raise ValidationError("Embed fields must be a mapping.") + + for field_name, value in field.items(): + if field_name not in field_validators: + raise ValidationError(f"Unknown embed field field: {field_name!r}.") + + for validator in field_validators[field_name]: + validator(value) + + +def validate_tag_embed_footer(footer): + field_validators = { + 'text': ( + MinLengthValidator( + limit_value=1, + message="Footer text must not be empty." + ), + MaxLengthValidator(limit_value=2048) + ), + 'icon_url': (), + 'proxy_icon_url': () + } + + if not isinstance(footer, Mapping): + raise ValidationError("Embed footer must be a mapping.") + + for field_name, value in footer.items(): + if field_name not in field_validators: + raise ValidationError(f"Unknown embed footer field: {field_name!r}.") + + for validator in field_validators[field_name]: + validator(value) + + +def validate_tag_embed_author(author): + field_validators = { + 'name': ( + MinLengthValidator( + limit_value=1, + message="Embed author name must not be empty." + ), + MaxLengthValidator(limit_value=256) + ), + 'url': (), + 'icon_url': (), + 'proxy_icon_url': () + } + + if not isinstance(author, Mapping): + raise ValidationError("Embed author must be a mapping.") + + for field_name, value in author.items(): + if field_name not in field_validators: + raise ValidationError(f"Unknown embed author field: {field_name!r}.") + + for validator in field_validators[field_name]: + validator(value) + + +def validate_tag_embed(embed): + """ + Validate a JSON document containing an embed as possible to send + on Discord. This attempts to rebuild the validation used by Discord + as well as possible by checking for various embed limits so we can + ensure that any embed we store here will also be accepted as a + valid embed by the Discord API. + + Using this directly is possible, although not intended - you usually + stick this onto the `validators` keyword argument of model fields. + + Example: + + >>> from django.contrib.postgres import fields as pgfields + >>> from django.db import models + >>> from api.validators import validate_tag_embed + >>> class MyMessage(models.Model): + ... embed = pgfields.JSONField( + ... validators=( + ... validate_tag_embed, + ... ) + ... ) + ... # ... + ... + + Args: + embed (Dict[str, Union[str, List[dict], dict]]): + A dictionary describing the contents of this embed. + See the official documentation for a full reference + of accepted keys by this dictionary: + https://discordapp.com/developers/docs/resources/channel#embed-object + + Raises: + ValidationError: + In case the given embed is deemed invalid, a `ValidationError` + is raised which in turn will allow Django to display errors + as appropriate. + """ + + all_keys = { + 'title', 'type', 'description', 'url', 'timestamp', + 'color', 'footer', 'image', 'thumbnail', 'video', + 'provider', 'author', 'fields' + } + one_required_of = {'content', 'fields', 'image', 'title', 'video'} + field_validators = { + 'title': ( + MinLengthValidator( + limit_value=1, + message="Embed title must not be empty." + ), + MaxLengthValidator(limit_value=256) + ), + 'description': (MaxLengthValidator(limit_value=2048),), + 'fields': ( + MaxLengthValidator(limit_value=25), + validate_tag_embed_fields + ), + 'footer': (validate_tag_embed_footer,), + 'author': (validate_tag_embed_author,) + } + + if not embed: + raise ValidationError("Tag embed must not be empty.") + + elif not isinstance(embed, Mapping): + raise ValidationError("Tag embed must be a mapping.") + + elif not any(field in embed for field in one_required_of): + raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.") + + for required_key in one_required_of: + if required_key in embed and not embed[required_key]: + raise ValidationError(f"Key {required_key!r} must not be empty.") + + for field_name, value in embed.items(): + if field_name not in all_keys: + raise ValidationError(f"Unknown field name: {field_name!r}") + + if field_name in field_validators: + for validator in field_validators[field_name]: + validator(value) -- cgit v1.2.3