From 48caa775dddaeb82c67945e545aa954ab04ee6e6 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 15 Nov 2023 06:54:35 -0500 Subject: [PATCH] digest validation; error messages; test cases --- src/registrar/forms/domain.py | 75 ++++--------------- src/registrar/tests/test_views.py | 118 +++++++++++++++++++++++++++++- src/registrar/utility/errors.py | 27 ++++++- 3 files changed, 155 insertions(+), 65 deletions(-) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 54df80b14..49a1f0f3c 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -8,6 +8,8 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from registrar.utility.errors import ( NameserverError, NameserverErrorCodes as nsErrorCodes, + DsDataError, + DsDataErrorCodes, ) from ..models import Contact, DomainInformation, Domain @@ -232,14 +234,14 @@ class DomainDsdataForm(forms.Form): def validate_hexadecimal(value): if not re.match(r'^[0-9a-fA-F]+$', value): - raise forms.ValidationError('Digest must contain only alphanumeric characters [0-9,a-f].') + raise forms.ValidationError(str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_CHARS))) key_tag = forms.IntegerField( required=True, label="Key tag", validators=[ - MinValueValidator(0, message="Key tag must be less than 65535"), - MaxValueValidator(65535, message="Key tag must be less than 65535"), + MinValueValidator(0, message=str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE))), + MaxValueValidator(65535, message=str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE))), ], error_messages={"required": ("Key tag is required.")}, ) @@ -257,7 +259,7 @@ class DomainDsdataForm(forms.Form): label="Digest type", coerce=int, # need to coerce into int so dsData objects can be compared choices=[(None, "--Select--")] + DIGEST_TYPE_CHOICES, # type: ignore - error_messages={"required": ("Digest Type is required.")}, + error_messages={"required": ("Digest type is required.")}, ) digest = forms.CharField( @@ -267,7 +269,7 @@ class DomainDsdataForm(forms.Form): max_length=64, error_messages={ "required": "Digest is required.", - "max_length": "Digest must be at most 64 characters long.", + "max_length": str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_LENGTH)), }, ) @@ -282,64 +284,15 @@ class DomainDsdataForm(forms.Form): if digest_type == 1 and len(digest) != 40: self.add_error( "digest", - DsDa) - # remove ANY spaces in the server field - server = server.replace(" ", "") - # lowercase the server - server = server.lower() - cleaned_data["server"] = server - ip = cleaned_data.get("ip", None) - # remove ANY spaces in the ip field - ip = ip.replace(" ", "") - domain = cleaned_data.get("domain", "") - - ip_list = self.extract_ip_list(ip) - - # validate if the form has a server or an ip - if (ip and ip_list) or server: - self.validate_nameserver_ip_combo(domain, server, ip_list) - + DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA1), + ) + elif digest_type == 2 and len(digest) != 64: + self.add_error( + "digest", + DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256), + ) return cleaned_data - def clean_empty_strings(self, cleaned_data): - ip = cleaned_data.get("ip", "") - if ip and len(ip.strip()) == 0: - cleaned_data["ip"] = None - - def extract_ip_list(self, ip): - return [ip.strip() for ip in ip.split(",")] if ip else [] - - def validate_nameserver_ip_combo(self, domain, server, ip_list): - try: - Domain.checkHostIPCombo(domain, server, ip_list) - except NameserverError as e: - if e.code == nsErrorCodes.GLUE_RECORD_NOT_ALLOWED: - self.add_error( - "server", - NameserverError( - code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, - nameserver=domain, - ip=ip_list, - ), - ) - elif e.code == nsErrorCodes.MISSING_IP: - self.add_error( - "ip", - NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=domain, ip=ip_list), - ) - elif e.code == nsErrorCodes.MISSING_HOST: - self.add_error( - "server", - NameserverError(code=nsErrorCodes.MISSING_HOST, nameserver=domain, ip=ip_list), - ) - elif e.code == nsErrorCodes.INVALID_HOST: - self.add_error( - "server", - NameserverError(code=nsErrorCodes.INVALID_HOST, nameserver=server, ip=ip_list), - ) - else: - self.add_error("ip", str(e)) - DomainDsdataFormset = formset_factory( DomainDsdataForm, diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 8f8e161a2..763f9a2ac 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -17,6 +17,8 @@ from registrar.utility.errors import ( SecurityEmailErrorCodes, GenericError, GenericErrorCodes, + DsDataError, + DsDataErrorCodes, ) from registrar.models import ( @@ -1878,7 +1880,30 @@ class TestDomainDNSSEC(TestDomainOverview): self.assertContains(page, "The DS Data records for this domain have been updated.") def test_ds_data_form_invalid(self): - """DS Data form errors with invalid data + """DS Data form errors with invalid data (missing required fields) + + Uses self.app WebTest because we need to interact with forms. + """ + add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # all four form fields are required, so will test with each blank + add_data_page.forms[0]["form-0-key_tag"] = "" + add_data_page.forms[0]["form-0-algorithm"] = "" + add_data_page.forms[0]["form-0-digest_type"] = "" + add_data_page.forms[0]["form-0-digest"] = "" + with less_console_noise(): # swallow logged warning message + result = add_data_page.forms[0].submit() + # form submission was a post with an error, response should be a 200 + # error text appears twice, once at the top of the page, once around + # the field. + self.assertContains(result, "Key tag is required", count=2, status_code=200) + self.assertContains(result, "Algorithm is required", count=2, status_code=200) + self.assertContains(result, "Digest type is required", count=2, status_code=200) + self.assertContains(result, "Digest is required", count=2, status_code=200) + + def test_ds_data_form_invalid_keytag(self): + """DS Data form errors with invalid data (key tag too large) Uses self.app WebTest because we need to interact with forms. """ @@ -1887,13 +1912,100 @@ class TestDomainDNSSEC(TestDomainOverview): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) # first two nameservers are required, so if we empty one out we should # get a form error - add_data_page.forms[0]["form-0-key_tag"] = "" + add_data_page.forms[0]["form-0-key_tag"] = "65536" # > 65535 + add_data_page.forms[0]["form-0-algorithm"] = "" + add_data_page.forms[0]["form-0-digest_type"] = "" + add_data_page.forms[0]["form-0-digest"] = "" with less_console_noise(): # swallow logged warning message result = add_data_page.forms[0].submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the field. - self.assertContains(result, "Key tag is required", count=2, status_code=200) + self.assertContains(result, str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE)), count=2, status_code=200) + + def test_ds_data_form_invalid_digest_length(self): + """DS Data form errors with invalid data (digest too long) + + Uses self.app WebTest because we need to interact with forms. + """ + add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # first two nameservers are required, so if we empty one out we should + # get a form error + add_data_page.forms[0]["form-0-key_tag"] = "1234" + add_data_page.forms[0]["form-0-algorithm"] = "3" + add_data_page.forms[0]["form-0-digest_type"] = "1" + add_data_page.forms[0]["form-0-digest"] = "1234567890123456789012345678901234567890123456789012345678901234567890" + with less_console_noise(): # swallow logged warning message + result = add_data_page.forms[0].submit() + # form submission was a post with an error, response should be a 200 + # error text appears twice, once at the top of the page, once around + # the field. + self.assertContains(result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_LENGTH)), count=2, status_code=200) + + def test_ds_data_form_invalid_digest_chars(self): + """DS Data form errors with invalid data (digest contains non hexadecimal chars) + + Uses self.app WebTest because we need to interact with forms. + """ + add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # first two nameservers are required, so if we empty one out we should + # get a form error + add_data_page.forms[0]["form-0-key_tag"] = "1234" + add_data_page.forms[0]["form-0-algorithm"] = "3" + add_data_page.forms[0]["form-0-digest_type"] = "1" + add_data_page.forms[0]["form-0-digest"] = "GG1234" + with less_console_noise(): # swallow logged warning message + result = add_data_page.forms[0].submit() + # form submission was a post with an error, response should be a 200 + # error text appears twice, once at the top of the page, once around + # the field. + self.assertContains(result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_CHARS)), count=2, status_code=200) + + def test_ds_data_form_invalid_digest_sha1(self): + """DS Data form errors with invalid data (digest is invalid sha-1) + + Uses self.app WebTest because we need to interact with forms. + """ + add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # first two nameservers are required, so if we empty one out we should + # get a form error + add_data_page.forms[0]["form-0-key_tag"] = "1234" + add_data_page.forms[0]["form-0-algorithm"] = "3" + add_data_page.forms[0]["form-0-digest_type"] = "1" # SHA-1 + add_data_page.forms[0]["form-0-digest"] = "A123" + with less_console_noise(): # swallow logged warning message + result = add_data_page.forms[0].submit() + # form submission was a post with an error, response should be a 200 + # error text appears twice, once at the top of the page, once around + # the field. + self.assertContains(result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA1)), count=2, status_code=200) + + def test_ds_data_form_invalid_digest_sha256(self): + """DS Data form errors with invalid data (digest is invalid sha-256) + + Uses self.app WebTest because we need to interact with forms. + """ + add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # first two nameservers are required, so if we empty one out we should + # get a form error + add_data_page.forms[0]["form-0-key_tag"] = "1234" + add_data_page.forms[0]["form-0-algorithm"] = "3" + add_data_page.forms[0]["form-0-digest_type"] = "2" # SHA-256 + add_data_page.forms[0]["form-0-digest"] = "GG1234" + with less_console_noise(): # swallow logged warning message + result = add_data_page.forms[0].submit() + # form submission was a post with an error, response should be a 200 + # error text appears twice, once at the top of the page, once around + # the field. + self.assertContains(result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256)), count=2, status_code=200) class TestApplicationStatus(TestWithUser, WebTest): diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 13506e8a0..50d62933c 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -125,9 +125,19 @@ class DsDataErrorCodes(IntEnum): error mapping. Overview of ds data error codes: - 1 BAD_DATA bad data input in ds data + - 2 INVALID_DIGEST_SHA1 invalid digest for digest type SHA-1 + - 3 INVALID_DIGEST_SHA256 invalid digest for digest type SHA-256 + - 4 INVALID_DIGEST_LENGTH invalid digest length exceeds 64 + - 5 INVALID_DIGEST_CHARS invalid chars in digest + - 6 INVALID_KEYTAG_SIZE invalid key tag size > 65535 """ BAD_DATA = 1 + INVALID_DIGEST_SHA1 = 2 + INVALID_DIGEST_SHA256 = 3 + INVALID_DIGEST_LENGTH = 4 + INVALID_DIGEST_CHARS = 5 + INVALID_KEYTAG_SIZE = 6 class DsDataError(Exception): @@ -139,7 +149,22 @@ class DsDataError(Exception): _error_mapping = { DsDataErrorCodes.BAD_DATA: ( "There’s something wrong with the DS data you provided. If you need help email us at help@get.gov." - ) + ), + DsDataErrorCodes.INVALID_DIGEST_SHA1: ( + "SHA-1 digest must be exactly 40 characters." + ), + DsDataErrorCodes.INVALID_DIGEST_SHA256: ( + "SHA-256 digest must be exactly 64 characters." + ), + DsDataErrorCodes.INVALID_DIGEST_LENGTH: ( + "Digest must be at most 64 characters." + ), + DsDataErrorCodes.INVALID_DIGEST_CHARS: ( + "Digest must contain only alphanumeric characters [0-9,a-f]." + ), + DsDataErrorCodes.INVALID_KEYTAG_SIZE: ( + "Key tag must be less than 65535" + ), } def __init__(self, *args, code=None, **kwargs):