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 desecapi import pdns, mixins
 import datetime, uuid
-from django.core.validators import MinValueValidator
+from django.core.validators import MinValueValidator, RegexValidator
 from collections import OrderedDict
 import rest_framework.authtoken.models
 import time, random
@@ -13,6 +13,20 @@ from os import urandom
 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):
     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):
     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')
-    published = models.DateTimeField(null=True)
+    published = models.DateTimeField(null=True, blank=True)
     _dirtyName = False
 
     def setter_name(self, val):
@@ -364,6 +384,7 @@ class Domain(models.Model, mixins.SetterMixin):
     def save(self, *args, **kwargs):
         new = self.pk is None
         self.clean()
+        self.clean_fields()
         super().save(*args, **kwargs)
 
         if new and not self.owner.locked:
@@ -411,20 +432,26 @@ class Donation(models.Model):
         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):
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     updated = models.DateTimeField(null=True)
     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)])
 
     _dirty = False

+ 11 - 5
api/desecapi/serializers.py

@@ -77,14 +77,20 @@ class RRsetSerializer(BulkSerializerMixin, serializers.ModelSerializer):
     subname = serializers.CharField(
         allow_blank=True,
         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(
         allow_blank=False,
         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)
 
@@ -215,7 +221,7 @@ class RRsetSerializer(BulkSerializerMixin, 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:
         model = Domain

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

@@ -546,7 +546,7 @@ class DesecTestCase(MockPDNSTestCase):
     def random_domain_name(cls, suffix=None):
         if not suffix:
             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
     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.conf import settings
+from django.core.exceptions import ValidationError
 from rest_framework import status
 
 from desecapi.exceptions import PdnsException
@@ -23,6 +24,26 @@ class UnauthenticatedDomainTests(DesecTestCase):
 
 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):
         with self.assertPdnsNoRequestsBut(self.request_pdns_zone_retrieve_crypto_keys()):
             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
             ))
 
-    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()
-        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):
         for response in [
@@ -231,6 +234,13 @@ class AuthenticatedRRSetTestCase(DomainOwnerTestCase):
         response = self.client.post_rr_set(self.my_empty_domain.name, **data)
         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):
         for _type in ['AA', 'ASDF']:
             with self.assertPdnsRequests(

+ 1 - 1
docs/domains.rst

@@ -66,7 +66,7 @@ Field details:
     :Access mode: read, write-once (upon domain creation)
 
     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
     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.
     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
     denoted by ``*``; this is allowed only once at the beginning of the name
     (see RFC 4592 for details).  The maximum length is 178.  Further