Przeglądaj źródła

feat(api): block setting A records from abusive domains under dedyn.io

Peter Thomassen 2 lat temu
rodzic
commit
9da9c0cddc

+ 24 - 2
api/desecapi/management/commands/stop-abuse.py

@@ -1,8 +1,9 @@
+import dns.resolver
 from django.core.management import BaseCommand
 from django.core.management import BaseCommand
 from django.db.models import Q
 from django.db.models import Q
 
 
 from api import settings
 from api import settings
-from desecapi.models import RRset, Domain, User
+from desecapi.models import BlockedSubnet, Domain, RR, RRset, User
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 
 
@@ -39,11 +40,32 @@ class Command(BaseCommand):
                 | Q(domain__owner__email__in=options["names"])
                 | Q(domain__owner__email__in=options["names"])
             )
             )
 
 
+            blocked_subnets = []
+            for rr in RR.objects.filter(rrset__in=rrsets.filter(type="A")):
+                if not BlockedSubnet.objects.filter(
+                    subnet__net_contains=rr.content
+                ).exists():
+                    try:
+                        blocked_subnet = BlockedSubnet.from_ip(rr.content)
+                    except dns.resolver.NXDOMAIN:  # for unallocated IP addresses
+                        continue
+                    blocked_subnet.save()
+                    blocked_subnets.append(blocked_subnet)
+
             # Print summary
             # Print summary
             print(
             print(
                 f"Deleting {rrsets.distinct().count()} RRset(s) from {domains.distinct().count()} domain(s); "
                 f"Deleting {rrsets.distinct().count()} RRset(s) from {domains.distinct().count()} domain(s); "
-                f"disabling {users.distinct().count()} associated user account(s)."
+                f"disabling {users.distinct().count()} associated user account(s). {len(blocked_subnets)} subnets:"
             )
             )
+            if blocked_subnets:
+                row_format = "{:>11} {:>18} {:>8} {}"
+                print(row_format.format("ASN", "Subnet", "Country", "Registry"))
+                for bs in blocked_subnets:
+                    print(
+                        row_format.format(
+                            bs.asn, str(bs.subnet), bs.country, bs.registry
+                        )
+                    )
 
 
             # Print details
             # Print details
             for d in domain_names:
             for d in domain_names:

+ 52 - 0
api/desecapi/migrations/0030_blockedsubnet_blockedsubnet_subnet_idx.py

@@ -0,0 +1,52 @@
+# Generated by Django 4.1.3 on 2023-01-27 15:58
+
+import django.contrib.postgres.indexes
+import django.core.validators
+from django.db import migrations, models
+import netfields.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("desecapi", "0029_token_mfa"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="BlockedSubnet",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "asn",
+                    models.PositiveBigIntegerField(
+                        validators=[
+                            django.core.validators.MaxValueValidator(4294967295)
+                        ]
+                    ),
+                ),
+                (
+                    "subnet",
+                    netfields.fields.CidrAddressField(max_length=43, unique=True),
+                ),
+                ("country", models.TextField()),
+                ("registry", models.TextField()),
+                ("allocation_date", models.DateField()),
+            ],
+        ),
+        migrations.AddIndex(
+            model_name="blockedsubnet",
+            index=django.contrib.postgres.indexes.GistIndex(
+                fields=["subnet"], name="subnet_idx", opclasses=("inet_ops",)
+            ),
+        ),
+    ]

+ 1 - 0
api/desecapi/models/__init__.py

@@ -1,3 +1,4 @@
+from .abuse import BlockedSubnet
 from .authenticated_actions import *
 from .authenticated_actions import *
 from .base import validate_domain_name, validate_lower, validate_upper
 from .base import validate_domain_name, validate_lower, validate_upper
 from .captcha import Captcha
 from .captcha import Captcha

+ 40 - 0
api/desecapi/models/abuse.py

@@ -0,0 +1,40 @@
+from datetime import date
+from ipaddress import IPv4Address, IPv4Network
+
+from django.contrib.postgres.indexes import GistIndex
+from django.core.validators import MaxValueValidator
+from django.db import models
+import dns.resolver
+from netfields import CidrAddressField, NetManager
+
+
+class BlockedSubnet(models.Model):
+    created = models.DateTimeField(auto_now_add=True)
+    asn = models.PositiveBigIntegerField(validators=[MaxValueValidator(2**32 - 1)])
+    subnet = CidrAddressField(unique=True)
+    country = models.TextField()
+    registry = models.TextField()
+    allocation_date = models.DateField()
+
+    objects = NetManager()
+
+    class Meta:
+        indexes = (
+            GistIndex(fields=("subnet",), opclasses=("inet_ops",), name="subnet_idx"),
+        )
+
+    @classmethod
+    def from_ip(cls, ip):
+        # Fetch IP metadata provided by Team Cymru, https://www.team-cymru.com/ip-asn-mapping
+        qname = IPv4Address(ip).reverse_pointer.replace(
+            "in-addr.arpa", "origin.asn.cymru.com"
+        )
+        answer = dns.resolver.resolve(qname, "TXT")[0]
+        parts = str(answer).strip('"').split("|")
+        return cls(
+            asn=int(parts[0].strip()),
+            subnet=IPv4Network(parts[1].strip()),
+            country=parts[2].strip(),
+            registry=parts[3].strip(),
+            allocation_date=date.fromisoformat(parts[4].strip()),
+        )

+ 13 - 0
api/desecapi/serializers/records.py

@@ -489,6 +489,18 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             )
             )
         return attrs
         return attrs
 
 
+    def _validate_blocked_content(self, attrs, type_):
+        # Reject IP addresses from blocked IP ranges
+        if type_ == "A" and self.domain.is_locally_registrable:
+            for record in attrs["records"]:
+                if models.BlockedSubnet.objects.filter(
+                    subnet__net_contains=record["content"]
+                ).exists():
+                    raise serializers.ValidationError(
+                        f"IP address {record['content']} not allowed."
+                    )
+        return attrs
+
     def validate(self, attrs):
     def validate(self, attrs):
         if "records" in attrs:
         if "records" in attrs:
             # on the RRsetDetail endpoint, the type is not in attrs
             # on the RRsetDetail endpoint, the type is not in attrs
@@ -496,6 +508,7 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
 
 
             attrs = self._validate_canonical_presentation(attrs, type_)
             attrs = self._validate_canonical_presentation(attrs, type_)
             attrs = self._validate_length(attrs)
             attrs = self._validate_length(attrs)
+            attrs = self._validate_blocked_content(attrs, type_)
 
 
         return attrs
         return attrs
 
 

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

@@ -1278,8 +1278,8 @@ class DomainOwnerTestCase(DesecTestCase, PublicSuffixMockMixin):
         cls.my_domain = cls.my_domains[0]
         cls.my_domain = cls.my_domains[0]
         cls.other_domain = cls.other_domains[0]
         cls.other_domain = cls.other_domains[0]
 
 
-        cls.create_rr_set(cls.my_domain, ["127.0.0.1", "127.0.1.1"], type="A", ttl=123)
-        cls.create_rr_set(cls.other_domain, ["40.1.1.1", "40.2.2.2"], type="A", ttl=456)
+        cls.create_rr_set(cls.my_domain, ["127.0.0.1", "3.2.2.3"], type="A", ttl=123)
+        cls.create_rr_set(cls.other_domain, ["40.1.1.1"], type="A", ttl=456)
 
 
         cls.token = cls.create_token(user=cls.owner)
         cls.token = cls.create_token(user=cls.owner)
 
 

+ 14 - 0
api/desecapi/tests/test_dyndns12update.py

@@ -2,6 +2,7 @@ import random
 
 
 from rest_framework import status
 from rest_framework import status
 
 
+from desecapi.models import BlockedSubnet
 from desecapi.tests.base import DynDomainOwnerTestCase
 from desecapi.tests.base import DynDomainOwnerTestCase
 
 
 
 
@@ -175,6 +176,19 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
             self.assertEqual(response.content, b"nohost")
             self.assertEqual(response.content, b"nohost")
 
 
+    def test_ddclient_dyndns2_v4_blocked(self):
+        # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=3.2.2.3
+        BlockedSubnet.from_ip("3.2.2.3").save()
+        params = {
+            "domain_name": self.my_domain.name,
+            "system": "dyndns",
+            "hostname": self.my_domain.name,
+            "myip": "3.2.2.5",
+        }
+        response = self.client.get(self.reverse("v1:dyndns12update"), params)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn("IP address 3.2.2.5 not allowed.", str(response.data))
+
     def test_ddclient_dyndns2_v6_success(self):
     def test_ddclient_dyndns2_v6_success(self):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
         response = self.assertDynDNS12Update(
         response = self.assertDynDNS12Update(

+ 32 - 1
api/desecapi/tests/test_rrsets.py

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
 from django.core.management import call_command
 from django.core.management import call_command
 from rest_framework import status
 from rest_framework import status
 
 
-from desecapi.models import Domain, RR, RRset
+from desecapi.models import BlockedSubnet, Domain, RR, RRset
 from desecapi.models.records import RR_SET_TYPES_AUTOMATIC, RR_SET_TYPES_UNSUPPORTED
 from desecapi.models.records import RR_SET_TYPES_AUTOMATIC, RR_SET_TYPES_UNSUPPORTED
 from desecapi.tests.base import DesecTestCase, AuthenticatedRRSetBaseTestCase
 from desecapi.tests.base import DesecTestCase, AuthenticatedRRSetBaseTestCase
 
 
@@ -1087,6 +1087,21 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
                     response, "Duplicate", status_code=status.HTTP_400_BAD_REQUEST
                     response, "Duplicate", status_code=status.HTTP_400_BAD_REQUEST
                 )
                 )
 
 
+    def test_create_my_rr_sets_no_ip_block_unless_lps(self):
+        # IP block should not be effective unless domain is under Local Public Suffix
+        BlockedSubnet.from_ip("3.2.2.3").save()
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
+        ):
+            response = self.client.post_rr_set(
+                self.my_empty_domain.name,
+                records=["3.2.2.5"],
+                ttl=3660,
+                subname="blocktest",
+                type="A",
+            )
+            self.assertStatus(response, status.HTTP_201_CREATED)
+
     def test_create_my_rr_sets_txt_splitting(self):
     def test_create_my_rr_sets_txt_splitting(self):
         for t in ["TXT", "SPF"]:
         for t in ["TXT", "SPF"]:
             for l in [200, 255, 256, 300, 400]:
             for l in [200, 255, 256, 300, 400]:
@@ -1521,3 +1536,19 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
                     },
                     },
                 ],
                 ],
             )
             )
+
+
+class AuthenticatedRRSetLPSTestCase(AuthenticatedRRSetBaseTestCase):
+    DYN = True
+
+    def test_create_my_rr_sets_ip_block(self):
+        BlockedSubnet.from_ip("3.2.2.3").save()
+        response = self.client.post_rr_set(
+            self.my_domain.name,
+            records=["3.2.2.5"],
+            ttl=3660,
+            subname="blocktest",
+            type="A",
+        )
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn("IP address 3.2.2.5 not allowed.", str(response.data))

+ 13 - 2
api/desecapi/tests/test_stop_abuse.py

@@ -5,15 +5,19 @@ from desecapi import models
 from desecapi.tests.base import DomainOwnerTestCase
 from desecapi.tests.base import DomainOwnerTestCase
 
 
 
 
+def block_exists(ip):
+    return models.BlockedSubnet.objects.filter(subnet__net_contains=ip).exists()
+
+
 class StopAbuseCommandTest(DomainOwnerTestCase):
 class StopAbuseCommandTest(DomainOwnerTestCase):
     @classmethod
     @classmethod
     def setUpTestDataWithPdns(cls):
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
         super().setUpTestDataWithPdns()
         cls.create_rr_set(
         cls.create_rr_set(
-            cls.my_domains[1], ["127.0.0.1", "127.0.1.1"], type="A", ttl=123
+            cls.my_domains[1], ["127.0.0.1", "4.2.2.4"], type="A", ttl=123
         )
         )
         cls.create_rr_set(
         cls.create_rr_set(
-            cls.other_domains[1], ["40.1.1.1", "40.2.2.2"], type="A", ttl=456
+            cls.other_domains[1], ["40.1.1.1", "127.0.0.2"], type="A", ttl=456
         )
         )
         for d in cls.my_domains + cls.other_domains:
         for d in cls.my_domains + cls.other_domains:
             cls.create_rr_set(d, ["ns1.example.", "ns2.example."], type="NS", ttl=456)
             cls.create_rr_set(d, ["ns1.example.", "ns2.example."], type="NS", ttl=456)
@@ -46,6 +50,9 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             ),
             ),
             set(settings.DEFAULT_NS),
             set(settings.DEFAULT_NS),
         )
         )
+        self.assertTrue(block_exists("3.2.2.3"))
+        self.assertFalse(block_exists("40.1.1.1"))
+        self.assertFalse(block_exists("127.0.0.1"))
 
 
     def test_remove_rrsets_by_email(self):
     def test_remove_rrsets_by_email(self):
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(
@@ -64,6 +71,10 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             ),
             ),
             set(settings.DEFAULT_NS),
             set(settings.DEFAULT_NS),
         )
         )
+        self.assertTrue(block_exists("3.2.2.3"))
+        self.assertTrue(block_exists("4.2.2.4"))
+        self.assertFalse(block_exists("40.1.1.1"))
+        self.assertFalse(block_exists("127.0.0.1"))
 
 
     def test_disable_user_by_domain_name(self):
     def test_disable_user_by_domain_name(self):
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(