Skip to content

Commit

Permalink
Added validation for TXT and SRV record values
Browse files Browse the repository at this point in the history
  • Loading branch information
peteeckel committed Oct 10, 2024
1 parent ff0c702 commit dca0874
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 8 deletions.
2 changes: 1 addition & 1 deletion netbox_dns/models/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ def validate_name(self, new_zone=None):

def validate_value(self):
try:
validate_record_value(self.type, self.value)
validate_record_value(self)
except ValidationError as exc:
raise ValidationError({"value": exc})

Expand Down
2 changes: 1 addition & 1 deletion netbox_dns/models/record_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def validate_name(self):

def validate_value(self):
try:
validate_record_value(self.type, self.value)
validate_record_value(self)
except ValidationError as exc:
raise ValidationError({"value": exc}) from None

Expand Down
124 changes: 124 additions & 0 deletions netbox_dns/tests/record/test_validation.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import re
import textwrap

from django.test import TestCase
from django.core.exceptions import ValidationError

from netbox_dns.models import Zone, Record, NameServer
from netbox_dns.choices import RecordTypeChoices


def split_text_value(value):
raw_value = "".join(re.findall(r'"([^"]+)"', value))
if not raw_value:
raw_value = value

return " ".join(
f'"{part}"' for part in textwrap.wrap(raw_value, 255, drop_whitespace=False)
)


class RecordValidationTestCase(TestCase):
@classmethod
def setUpTestData(cls):
Expand Down Expand Up @@ -637,3 +650,114 @@ def test_invalid_name_in_value(self):
for record in records:
with self.assertRaises(ValidationError):
record.save()

def test_valid_txt_value(self):
zone = self.zones[0]

records = (
Record(
name="name1",
zone=zone,
value="test",
),
Record(
name="name2",
zone=zone,
value=255 * "x",
),
Record(
name="name3",
zone=zone,
value=f"\"{255*'x'}\"",
),
Record(
name="name4",
zone=zone,
value="xn--m-w22scd",
),
)

for record in records:
saved_value = record.value

for record_type in (
RecordTypeChoices.TXT,
RecordTypeChoices.SPF,
):
record.type = record_type
record.save()

self.assertEqual(record.value, saved_value)

def test_valid_txt_long_value(self):
zone = self.zones[0]

records = (
Record(
name="name1",
zone=zone,
value=64 * "test",
),
Record(
name="name2",
zone=zone,
value=f"\"{64*'test '}\"",
),
Record(
name="name3",
zone=zone,
value=f"\"{512*'x'}\"",
),
Record(
name="name4",
zone=zone,
value=512 * "x",
),
Record(
name="name5",
zone=zone,
value=128 * "xn--m-w22scd",
),
)

for record in records:
saved_value = record.value

for record_type in (
RecordTypeChoices.TXT,
RecordTypeChoices.SPF,
):
record.type = record_type
record.save()

self.assertEqual(record.value, split_text_value(saved_value))

def test_invalid_txt_value_charset(self):
zone = self.zones[0]

records = (
Record(
name="name1",
zone=zone,
value="täst",
),
Record(
name="name2",
zone=zone,
value="\000test",
),
Record(
name="name3",
zone=zone,
value='"\U0001F595"',
),
)

for record in records:
for record_type in (
RecordTypeChoices.TXT,
RecordTypeChoices.SPF,
):
record.type = record_type
with self.assertRaises(ValidationError):
record.save()
53 changes: 47 additions & 6 deletions netbox_dns/validators/dns_value.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import dns
import re
import textwrap

from dns import rdata, name as dns_name
from dns.exception import SyntaxError

from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
Expand All @@ -11,11 +14,12 @@
validate_generic_name,
)

MAX_TXT_LENGTH = 255

__all__ = ("validate_record_value",)


def validate_record_value(record_type, value):
def validate_record_value(record):
def _validate_idn(name):
try:
name.to_unicode()
Expand All @@ -26,16 +30,53 @@ def _validate_idn(name):
)
)

def _split_text_value(value):
# +
# Text values longer than 255 characters need to be broken up for TXT and
# SPF records.
# First, in case they had been split into separate strings, reassemble the
# original (long) value, then split it into chunks of a maximum length of
# 255 (preferably at word boundaries), and then build a sequence of partial
# strings enclosed in double quotes and separated by space.
#
# See https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3 for details.
# -
raw_value = "".join(re.findall(r'"([^"]+)"', value))
if not raw_value:
raw_value = value

return " ".join(
f'"{part}"'
for part in textwrap.wrap(raw_value, MAX_TXT_LENGTH, drop_whitespace=False)
)

if record.type in (RecordTypeChoices.TXT, RecordTypeChoices.SPF):
if not (record.value.isascii() and record.value.isprintable()):
raise ValidationError(
_(
"Record value {value} for a type {type} record is not a printable ASCII string."
).format(value=record.value, type=record.type)
)

if len(record.value) <= MAX_TXT_LENGTH:
return

try:
rr = rdata.from_text(RecordClassChoices.IN, record.type, record.value)
except SyntaxError as exc:
if str(exc) == "string too long":
record.value = _split_text_value(record.value)

try:
rr = rdata.from_text(RecordClassChoices.IN, record_type, value)
except dns.exception.SyntaxError as exc:
rr = rdata.from_text(RecordClassChoices.IN, record.type, record.value)
except SyntaxError as exc:
raise ValidationError(
_(
"Record value {value} is not a valid value for a {type} record: {error}."
).format(value=value, type=record_type, error=exc)
).format(value=record.value, type=record.type, error=exc)
)

match record_type:
match record.type:
case RecordTypeChoices.CNAME:
_validate_idn(rr.target)
validate_domain_name(
Expand Down

0 comments on commit dca0874

Please sign in to comment.