Skip to content

Commit 23f70de

Browse files
committed
feat(api): gate NS changes on local suffix
Add allow_local_ns_changes to Domain with defaults and migration Use can_modify_ns_records to permit NS edits for local suffixes Update RRset validation and deletion paths Add RRset tests for allowed local-suffix NS changes Tests: ./run-api-tests-stack.sh (fails: desecapi.tests.test_dyndns12update.MultipleDomainDynDNS12UpdateTest.test_ignore_minimum_ttl)
1 parent 6a4ae93 commit 23f70de

File tree

5 files changed

+136
-2
lines changed

5 files changed

+136
-2
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 5.2.10 on 2026-02-10 00:00
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
def set_allow_local_ns_changes(apps, schema_editor):
8+
Domain = apps.get_model("desecapi", "Domain")
9+
Domain.objects.update(allow_local_ns_changes=True)
10+
11+
local_suffixes = set(settings.LOCAL_PUBLIC_SUFFIXES)
12+
if not local_suffixes:
13+
return
14+
15+
to_update = []
16+
for domain in Domain.objects.only("id", "name").iterator():
17+
parent = domain.name.partition(".")[2] or None
18+
if parent in local_suffixes:
19+
to_update.append(domain.id)
20+
21+
if to_update:
22+
Domain.objects.filter(id__in=to_update).update(allow_local_ns_changes=False)
23+
24+
25+
class Migration(migrations.Migration):
26+
dependencies = [
27+
("desecapi", "0045_rr_unique_record_in_rrset"),
28+
]
29+
30+
operations = [
31+
migrations.AddField(
32+
model_name="domain",
33+
name="allow_local_ns_changes",
34+
field=models.BooleanField(default=True),
35+
),
36+
migrations.RunPython(set_allow_local_ns_changes, migrations.RunPython.noop),
37+
]

api/desecapi/models/domains.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class RenewalState(models.IntegerChoices):
6363
choices=RenewalState.choices, db_index=True, default=RenewalState.IMMORTAL
6464
)
6565
renewal_changed = models.DateTimeField(auto_now_add=True)
66+
allow_local_ns_changes = models.BooleanField(default=True)
6667

6768
_keys = None
6869
objects = DomainManager()
@@ -74,6 +75,7 @@ class Meta:
7475
ordering = ("created",)
7576

7677
def __init__(self, *args, **kwargs):
78+
allow_local_ns_changes_provided = "allow_local_ns_changes" in kwargs
7779
if isinstance(kwargs.get("owner"), AnonymousUser):
7880
kwargs = {**kwargs, "owner": None} # make a copy and override
7981
# Avoid super().__init__(owner=None, ...) to not mess up *values instantiation in django.db.models.Model.from_db
@@ -85,6 +87,12 @@ def __init__(self, *args, **kwargs):
8587
and self.is_locally_registrable
8688
):
8789
self.renewal_state = Domain.RenewalState.FRESH
90+
if (
91+
self.pk is None
92+
and not allow_local_ns_changes_provided
93+
and self.is_locally_registrable
94+
):
95+
self.allow_local_ns_changes = False
8896

8997
@cached_property
9098
def public_suffix(self):
@@ -226,6 +234,10 @@ def touched(self):
226234
def is_locally_registrable(self):
227235
return self.parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES
228236

237+
@property
238+
def can_modify_ns_records(self):
239+
return (not self.is_locally_registrable) or self.allow_local_ns_changes
240+
229241
@property
230242
def _owner_or_none(self):
231243
try:

api/desecapi/serializers/records.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ def validate(self, attrs):
541541
# Deletion using records=[] is allowed, except at the apex
542542
if (
543543
type_ == "NS"
544-
and self.domain.is_locally_registrable
544+
and not self.domain.can_modify_ns_records
545545
and (
546546
attrs.get("records", True)
547547
or not attrs.get("subname", self.instance.subname)

api/desecapi/tests/test_rrsets.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,3 +1824,88 @@ def test_bulk_delete_ns_rrset_nonapex(self):
18241824
response = method(self.my_domain.name, [data])
18251825
self.assertStatus(response, status.HTTP_200_OK)
18261826
self.assertEqual(response.data, [])
1827+
1828+
1829+
class AuthenticatedRRSetLPSNSPermissionTestCase(AuthenticatedRRSetBaseTestCase):
1830+
DYN = True
1831+
1832+
ns_data = {"type": "NS", "records": ["ns.example."], "ttl": 3600}
1833+
1834+
def setUp(self):
1835+
super().setUp()
1836+
for domain in (self.my_domain, self.my_empty_domain):
1837+
domain.allow_local_ns_changes = True
1838+
domain.save(update_fields=["allow_local_ns_changes"])
1839+
1840+
def test_create_ns_rrset_allowed(self):
1841+
for subname in ["", "sub"]:
1842+
data = dict(self.ns_data, subname=subname)
1843+
with self.assertRequests(
1844+
self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
1845+
):
1846+
response = self.client.post_rr_set(
1847+
domain_name=self.my_empty_domain.name, **data
1848+
)
1849+
self.assertStatus(response, status.HTTP_201_CREATED)
1850+
1851+
def test_update_ns_rrset_allowed(self):
1852+
for subname in ["", "sub"]:
1853+
for method in (self.client.patch_rr_set, self.client.put_rr_set):
1854+
create_records = settings.DEFAULT_NS
1855+
update_records = list(self.ns_data["records"])
1856+
rrset = self.my_domain.rrset_set.filter(
1857+
subname=subname, type="NS"
1858+
).first()
1859+
if rrset is None:
1860+
try:
1861+
rrset = self.create_rr_set(
1862+
self.my_domain,
1863+
create_records,
1864+
subname=subname,
1865+
type="NS",
1866+
ttl=3600,
1867+
)
1868+
except IntegrityError:
1869+
rrset = self.my_domain.rrset_set.get(subname=subname, type="NS")
1870+
rrset.save_records(create_records)
1871+
current_records = list(rrset.records.values_list("content", flat=True))
1872+
if set(current_records) == set(update_records):
1873+
update_records = create_records
1874+
update_data = dict(
1875+
self.ns_data, subname=subname, records=update_records
1876+
)
1877+
with self.assertRequests(
1878+
self.requests_desec_rr_sets_update(name=self.my_domain.name)
1879+
):
1880+
response = method(self.my_domain.name, subname, "NS", update_data)
1881+
self.assertStatus(response, status.HTTP_200_OK)
1882+
1883+
def test_delete_ns_rrset_apex_allowed(self):
1884+
data = dict(self.ns_data, records=[], subname="")
1885+
for method in (self.client.patch_rr_set, self.client.put_rr_set):
1886+
if not self.my_domain.rrset_set.filter(subname="", type="NS").exists():
1887+
self.create_rr_set(
1888+
self.my_domain,
1889+
settings.DEFAULT_NS,
1890+
subname="",
1891+
type="NS",
1892+
ttl=3600,
1893+
)
1894+
with self.assertRequests(
1895+
self.requests_desec_rr_sets_update(name=self.my_domain.name)
1896+
):
1897+
response = method(self.my_domain.name, "", "NS", data)
1898+
self.assertStatus(response, status.HTTP_204_NO_CONTENT)
1899+
if not self.my_domain.rrset_set.filter(subname="", type="NS").exists():
1900+
self.create_rr_set(
1901+
self.my_domain,
1902+
settings.DEFAULT_NS,
1903+
subname="",
1904+
type="NS",
1905+
ttl=3600,
1906+
)
1907+
with self.assertRequests(
1908+
self.requests_desec_rr_sets_update(name=self.my_domain.name)
1909+
):
1910+
response = self.client.delete_rr_set(self.my_domain.name, "", "NS")
1911+
self.assertStatus(response, status.HTTP_204_NO_CONTENT)

api/desecapi/views/records.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def update(self, request, *args, **kwargs):
100100

101101
def perform_destroy(self, instance):
102102
# Disallow modification of apex NS RRset for locally registrable domains
103-
if instance.type == "NS" and self.domain.is_locally_registrable:
103+
if instance.type == "NS" and not self.domain.can_modify_ns_records:
104104
if instance.subname == "":
105105
raise ValidationError("Cannot modify NS records for this domain.")
106106
with PDNSChangeTracker():

0 commit comments

Comments
 (0)