Browse Source

fix(api): enforce all domain names and subnames to be lowercase

Nils Wisiol 6 years ago
parent
commit
226913cc6d

+ 50 - 0
api/desecapi/migrations/0002_lowercase_domains_and_subnames.py

@@ -0,0 +1,50 @@
+import desecapi.models
+import django.core.validators
+from django.db import migrations, models
+
+
+def lowercase_names(apps, schema_editor):
+    # Domains
+    Domain = apps.get_model('desecapi', 'Domain')
+    domains = list(Domain.objects.all())
+    for domain in domains:
+        domain.name = domain.name.lower()
+    Domain.objects.bulk_update(domains, ['name'], batch_size=500)
+
+    # RRsets
+    RRset = apps.get_model('desecapi', 'RRset')
+    rrsets = list(RRset.objects.all())
+    for rrset in rrsets:
+        rrset.subname = rrset.subname.lower()
+    RRset.objects.bulk_update(rrsets, ['subname'], batch_size=500)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0001_initial_squashed'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='domain',
+            name='name',
+            field=models.CharField(max_length=191, unique=True, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_domain_name', message='Domain name malformed.', regex='^[a-z0-9_.-]+$')]),
+        ),
+        migrations.AlterField(
+            model_name='domain',
+            name='published',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='rrset',
+            name='subname',
+            field=models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message='Subname malformed.', regex='^[*]?[a-z0-9_.-]*$')]),
+        ),
+        migrations.AlterField(
+            model_name='rrset',
+            name='type',
+            field=models.CharField(max_length=10, validators=[desecapi.models.validate_upper, django.core.validators.RegexValidator(code='invalid_type', message='Type malformed.', regex='^[A-Z][A-Z0-9]*$')]),
+        ),
+        migrations.RunPython(lowercase_names, reverse_code=migrations.RunPython.noop),
+    ]

+ 39 - 12
api/desecapi/models.py

@@ -5,7 +5,7 @@ from django.utils import timezone
 from django.core.exceptions import SuspiciousOperation, ValidationError
 from django.core.exceptions import SuspiciousOperation, ValidationError
 from desecapi import pdns, mixins
 from desecapi import pdns, mixins
 import datetime, uuid
 import datetime, uuid
-from django.core.validators import MinValueValidator
+from django.core.validators import MinValueValidator, RegexValidator
 from collections import OrderedDict
 from collections import OrderedDict
 import rest_framework.authtoken.models
 import rest_framework.authtoken.models
 import time, random
 import time, random
@@ -13,6 +13,20 @@ from os import urandom
 from base64 import b64encode
 from base64 import b64encode
 
 
 
 
+def validate_lower(value):
+    if value != value.lower():
+        raise ValidationError('Invalid value (not lowercase): %(value)s',
+                              code='invalid',
+                              params={'value': value})
+
+
+def validate_upper(value):
+    if value != value.upper():
+        raise ValidationError('Invalid value (not uppercase): %(value)s',
+                              code='invalid',
+                              params={'value': value})
+
+
 class MyUserManager(BaseUserManager):
 class MyUserManager(BaseUserManager):
     def create_user(self, email, password=None, registration_remote_ip=None, lock=False, dyn=False):
     def create_user(self, email, password=None, registration_remote_ip=None, lock=False, dyn=False):
         """
         """
@@ -139,9 +153,15 @@ class User(AbstractBaseUser):
 
 
 class Domain(models.Model, mixins.SetterMixin):
 class Domain(models.Model, mixins.SetterMixin):
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
-    name = models.CharField(max_length=191, unique=True)
+    name = models.CharField(max_length=191,
+                            unique=True,
+                            validators=[validate_lower,
+                                        RegexValidator(regex=r'^[a-z0-9_.-]+$',
+                                                       message='Domain name malformed.',
+                                                       code='invalid_domain_name')
+                                        ])
     owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='domains')
     owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='domains')
-    published = models.DateTimeField(null=True)
+    published = models.DateTimeField(null=True, blank=True)
     _dirtyName = False
     _dirtyName = False
 
 
     def setter_name(self, val):
     def setter_name(self, val):
@@ -364,6 +384,7 @@ class Domain(models.Model, mixins.SetterMixin):
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         new = self.pk is None
         new = self.pk is None
         self.clean()
         self.clean()
+        self.clean_fields()
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         if new and not self.owner.locked:
         if new and not self.owner.locked:
@@ -411,20 +432,26 @@ class Donation(models.Model):
         ordering = ('created',)
         ordering = ('created',)
 
 
 
 
-def validate_upper(value):
-    if value != value.upper():
-        raise ValidationError('Invalid value (not uppercase): %(value)s',
-                              code='invalid',
-                              params={'value': value})
-
-
 class RRset(models.Model, mixins.SetterMixin):
 class RRset(models.Model, mixins.SetterMixin):
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
     updated = models.DateTimeField(null=True)
     updated = models.DateTimeField(null=True)
     domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
     domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
-    subname = models.CharField(max_length=178, blank=True)
-    type = models.CharField(max_length=10, validators=[validate_upper])
+    subname = models.CharField(max_length=178,
+                               blank=True,
+                               validators=[validate_lower,
+                                           RegexValidator(regex=r'^[*]?[a-z0-9_.-]*$',
+                                                          message='Subname malformed.',
+                                                          code='invalid_subname')
+                                           ]
+                               )
+    type = models.CharField(max_length=10,
+                            validators=[validate_upper,
+                                        RegexValidator(regex=r'^[A-Z][A-Z0-9]*$',
+                                                       message='Type malformed.',
+                                                       code='invalid_type')
+                                        ]
+                            )
     ttl = models.PositiveIntegerField(validators=[MinValueValidator(1)])
     ttl = models.PositiveIntegerField(validators=[MinValueValidator(1)])
 
 
     _dirty = False
     _dirty = False

+ 11 - 5
api/desecapi/serializers.py

@@ -77,14 +77,20 @@ class RRsetSerializer(BulkSerializerMixin, serializers.ModelSerializer):
     subname = serializers.CharField(
     subname = serializers.CharField(
         allow_blank=True,
         allow_blank=True,
         required=False,
         required=False,
-        validators=[
-            RegexValidator(regex=r'^\*?[a-zA-Z\.\-_0-9]*$', message='Subname malformed.', code='invalid_subname'),
-        ]
+        validators=[RegexValidator(
+            regex=r'^\*?[a-z\.\-_0-9]*$',
+            message='Subname can only use (lowercase) a-z, 0-9, ., -, and _.',
+            code='invalid_subname'
+        )]
     )
     )
     type = RequiredOnPartialUpdateCharField(
     type = RequiredOnPartialUpdateCharField(
         allow_blank=False,
         allow_blank=False,
         required=True,
         required=True,
-        validators=[RegexValidator(regex=r'^[A-Z][A-Z0-9]*$', message='Type malformed.', code='invalid_type')]
+        validators=[RegexValidator(
+            regex=r'^[A-Z][A-Z0-9]*$',
+            message='Type must be uppercase alphanumeric and start with a letter.',
+            code='invalid_type'
+        )]
     )
     )
     records = SlugRRField(many=True)
     records = SlugRRField(many=True)
 
 
@@ -215,7 +221,7 @@ class RRsetSerializer(BulkSerializerMixin, serializers.ModelSerializer):
 
 
 
 
 class DomainSerializer(serializers.ModelSerializer):
 class DomainSerializer(serializers.ModelSerializer):
-    name = serializers.RegexField(regex=r'^[A-Za-z0-9_.-]+$', max_length=191, trim_whitespace=False)
+    name = serializers.RegexField(regex=r'^[a-z0-9_.-]+$', max_length=191, trim_whitespace=False)
 
 
     class Meta:
     class Meta:
         model = Domain
         model = Domain

+ 1 - 1
api/desecapi/tests/base.py

@@ -546,7 +546,7 @@ class DesecTestCase(MockPDNSTestCase):
     def random_domain_name(cls, suffix=None):
     def random_domain_name(cls, suffix=None):
         if not suffix:
         if not suffix:
             suffix = random.choice(cls.PUBLIC_SUFFIXES)
             suffix = random.choice(cls.PUBLIC_SUFFIXES)
-        return random.choice(string.ascii_lowercase) + cls.random_string() + '--test' + '.' + suffix
+        return (random.choice(string.ascii_letters) + cls.random_string() + '--test' + '.' + suffix).lower()
 
 
     @classmethod
     @classmethod
     def create_token(cls, user):
     def create_token(cls, user):

+ 21 - 0
api/desecapi/tests/testdomains.py

@@ -3,6 +3,7 @@ import json
 
 
 from django.core import mail
 from django.core import mail
 from django.conf import settings
 from django.conf import settings
+from django.core.exceptions import ValidationError
 from rest_framework import status
 from rest_framework import status
 
 
 from desecapi.exceptions import PdnsException
 from desecapi.exceptions import PdnsException
@@ -23,6 +24,26 @@ class UnauthenticatedDomainTests(DesecTestCase):
 
 
 class DomainOwnerTestCase1(DomainOwnerTestCase):
 class DomainOwnerTestCase1(DomainOwnerTestCase):
 
 
+    def test_name_validity(self):
+        for name in [
+            'FOO.BAR.com',
+            'tEst.dedyn.io',
+            'ORG',
+            '--BLAH.example.com',
+            '_ASDF.jp',
+        ]:
+            with self.assertRaises(ValidationError):
+                Domain(owner=self.owner, name=name).save()
+        for name in [
+            '_example.com', '_.example.com',
+            '-dedyn.io', '--dedyn.io', '-.dedyn123.io',
+            'foobar.io', 'exam_ple.com',
+        ]:
+            with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name=name)[:-1]  # no serializer, no cryptokeys API call
+            ):
+                Domain(owner=self.owner, name=name).save()
+
     def test_list_domains(self):
     def test_list_domains(self):
         with self.assertPdnsNoRequestsBut(self.request_pdns_zone_retrieve_crypto_keys()):
         with self.assertPdnsNoRequestsBut(self.request_pdns_zone_retrieve_crypto_keys()):
             response = self.client.get(self.reverse('v1:domain-list'))
             response = self.client.get(self.reverse('v1:domain-list'))

+ 15 - 5
api/desecapi/tests/testrrsets.py

@@ -128,12 +128,15 @@ class AuthenticatedRRSetTestCase(DomainOwnerTestCase):
                 kwargs, rr_sets
                 kwargs, rr_sets
             ))
             ))
 
 
-    def test_uniqueness(self):
+    def test_subname_validity(self):
+        for subname in [
+            'aEroport',
+            'AEROPORT',
+            'aéroport'
+        ]:
+            with self.assertRaises(ValidationError):
+                RRset(domain=self.my_domain, subname=subname, ttl=60, type='A').save()
         RRset(domain=self.my_domain, subname='aeroport', ttl=60, type='A').save()
         RRset(domain=self.my_domain, subname='aeroport', ttl=60, type='A').save()
-        with self.assertRaises(ValidationError):
-            RRset(domain=self.my_domain, subname='aeroport', ttl=60, type='A').save()
-        RRset(domain=self.my_domain, subname='AEROPORT', ttl=60, type='A').save()
-        RRset(domain=self.my_domain, subname='aéroport', ttl=100, type='A').save()
 
 
     def test_retrieve_my_rr_sets(self):
     def test_retrieve_my_rr_sets(self):
         for response in [
         for response in [
@@ -231,6 +234,13 @@ class AuthenticatedRRSetTestCase(DomainOwnerTestCase):
         response = self.client.post_rr_set(self.my_empty_domain.name, **data)
         response = self.client.post_rr_set(self.my_empty_domain.name, **data)
         self.assertStatus(response, status.HTTP_409_CONFLICT)
         self.assertStatus(response, status.HTTP_409_CONFLICT)
 
 
+    def test_create_my_rr_sets_upper_case(self):
+        for subname in ['asdF', 'cAse', 'asdf.FOO', '--F', 'ALLCAPS']:
+            data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A', 'subname': subname}
+            response = self.client.post_rr_set(self.my_empty_domain.name, **data)
+            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+            self.assertIn('Subname can only use (lowercase)', str(response.data))
+
     def test_create_my_rr_sets_unknown_type(self):
     def test_create_my_rr_sets_unknown_type(self):
         for _type in ['AA', 'ASDF']:
         for _type in ['AA', 'ASDF']:
             with self.assertPdnsRequests(
             with self.assertPdnsRequests(

+ 1 - 1
docs/domains.rst

@@ -66,7 +66,7 @@ Field details:
     :Access mode: read, write-once (upon domain creation)
     :Access mode: read, write-once (upon domain creation)
 
 
     Domain name.  Restrictions on what is a valid domain name apply on a
     Domain name.  Restrictions on what is a valid domain name apply on a
-    per-user basis.  In general, a domain name consists of alphanumeric
+    per-user basis.  In general, a domain name consists of lowercase alphanumeric
     characters as well as hyphens ``-`` and underscores ``_`` (except at the
     characters as well as hyphens ``-`` and underscores ``_`` (except at the
     beginning of the name).  The maximum length is 191.
     beginning of the name).  The maximum length is 191.
 
 

+ 1 - 1
docs/rrsets.rst

@@ -82,7 +82,7 @@ Field details:
 
 
     Subdomain string which, together with ``domain``, defines the RRset name.
     Subdomain string which, together with ``domain``, defines the RRset name.
     Typical examples are ``www`` or ``_443._tcp``.  In general, a subname
     Typical examples are ``www`` or ``_443._tcp``.  In general, a subname
-    consists of alphanumeric characters as well as hyphens ``-``, underscores
+    consists of lowercase alphanumeric characters as well as hyphens ``-``, underscores
     ``_``, and dots ``.``.  Wildcard name components are
     ``_``, and dots ``.``.  Wildcard name components are
     denoted by ``*``; this is allowed only once at the beginning of the name
     denoted by ``*``; this is allowed only once at the beginning of the name
     (see RFC 4592 for details).  The maximum length is 178.  Further
     (see RFC 4592 for details).  The maximum length is 178.  Further