diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 6cb5b338f..6a9ea2128 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -8,6 +8,10 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from registrar.utility.errors import ( NameserverError, NameserverErrorCodes as nsErrorCodes, + DsDataError, + DsDataErrorCodes, + SecurityEmailError, + SecurityEmailErrorCodes, ) from ..models import Contact, DomainInformation, Domain @@ -16,6 +20,8 @@ from .common import ( DIGEST_TYPE_CHOICES, ) +import re + class DomainAddUserForm(forms.Form): """Form for adding a user to a domain.""" @@ -152,7 +158,13 @@ class ContactForm(forms.ModelForm): class DomainSecurityEmailForm(forms.Form): """Form for adding or editing a security email to a domain.""" - security_email = forms.EmailField(label="Security email", required=False) + security_email = forms.EmailField( + label="Security email", + required=False, + error_messages={ + "invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)), + }, + ) class DomainOrgNameAddressForm(forms.ModelForm): @@ -228,12 +240,22 @@ class DomainDnssecForm(forms.Form): class DomainDsdataForm(forms.Form): """Form for adding or editing DNSSEC DS Data to a domain.""" + def validate_hexadecimal(value): + """ + Tests that string matches all hexadecimal values. + + Raise validation error to display error in form + if invalid characters entered + """ + if not re.match(r"^[0-9a-fA-F]+$", value): + raise forms.ValidationError(str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_CHARS))) + key_tag = forms.IntegerField( required=True, label="Key tag", validators=[ - MinValueValidator(0, message="Value must be between 0 and 65535"), - MaxValueValidator(65535, message="Value must be between 0 and 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.")}, ) @@ -251,15 +273,38 @@ 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( required=True, label="Digest", - error_messages={"required": ("Digest is required.")}, + validators=[validate_hexadecimal], + error_messages={ + "required": "Digest is required.", + }, ) + def clean(self): + # clean is called from clean_forms, which is called from is_valid + # after clean_fields. it is used to determine form level errors. + # is_valid is typically called from view during a post + cleaned_data = super().clean() + digest_type = cleaned_data.get("digest_type", 0) + digest = cleaned_data.get("digest", "") + # validate length of digest depending on digest_type + if digest_type == 1 and len(digest) != 40: + self.add_error( + "digest", + 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 + DomainDsdataFormset = formset_factory( DomainDsdataForm, diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index cb3b784e9..3bd2c0349 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -598,7 +598,7 @@ class Domain(TimeStampedModel, DomainHelper): # if unable to update domain raise error and stop if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: - raise NameserverError(code=nsErrorCodes.UNABLE_TO_UPDATE_DOMAIN) + raise NameserverError(code=nsErrorCodes.BAD_DATA) successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 927628b11..d049675fc 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -114,7 +114,7 @@ aria-describedby="Your DNSSEC records will be deleted from the registry." data-force-action > - {% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to delete all DS records on your domain" modal_description="To fully disable DNSSEC: In addition to deleting your DS records here you’ll also need to delete the DS records at your DNS host. To avoid causing your domain to appear offline you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button=modal_button|safe %} + {% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain" modal_description="To fully disable DNSSEC: In addition to removing your DS records here you’ll also need to delete the DS records at your DNS host. To avoid causing your domain to appear offline you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button=modal_button|safe %} {% endblock %} {# domain_content #} diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 8f8e161a2..936c344f7 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,87 @@ 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_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..420c616cb 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -66,20 +66,18 @@ class NameserverErrorCodes(IntEnum): value but is not a subdomain - 3 INVALID_IP invalid ip address format or invalid version - 4 TOO_MANY_HOSTS more than the max allowed host values - - 5 UNABLE_TO_UPDATE_DOMAIN unable to update the domain - - 6 MISSING_HOST host is missing for a nameserver - - 7 INVALID_HOST host is invalid for a nameserver - - 8 BAD_DATA bad data input for nameserver + - 5 MISSING_HOST host is missing for a nameserver + - 6 INVALID_HOST host is invalid for a nameserver + - 7 BAD_DATA bad data input for nameserver """ MISSING_IP = 1 GLUE_RECORD_NOT_ALLOWED = 2 INVALID_IP = 3 TOO_MANY_HOSTS = 4 - UNABLE_TO_UPDATE_DOMAIN = 5 - MISSING_HOST = 6 - INVALID_HOST = 7 - BAD_DATA = 8 + MISSING_HOST = 5 + INVALID_HOST = 6 + BAD_DATA = 7 class NameserverError(Exception): @@ -93,9 +91,6 @@ class NameserverError(Exception): NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED: ("Name server address does not match domain name"), NameserverErrorCodes.INVALID_IP: ("{}: Enter an IP address in the required format."), NameserverErrorCodes.TOO_MANY_HOSTS: ("Too many hosts provided, you may not have more than 13 nameservers."), - NameserverErrorCodes.UNABLE_TO_UPDATE_DOMAIN: ( - "Unable to update domain, changes were not applied. Check logs as a Registry Error is the likely cause" - ), NameserverErrorCodes.MISSING_HOST: ("Name server must be provided to enter IP address."), NameserverErrorCodes.INVALID_HOST: ("Enter a name server in the required format, like ns1.example.com"), NameserverErrorCodes.BAD_DATA: ( @@ -125,9 +120,17 @@ 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_CHARS invalid chars in digest + - 5 INVALID_KEYTAG_SIZE invalid key tag size > 65535 """ BAD_DATA = 1 + INVALID_DIGEST_SHA1 = 2 + INVALID_DIGEST_SHA256 = 3 + INVALID_DIGEST_CHARS = 4 + INVALID_KEYTAG_SIZE = 5 class DsDataError(Exception): @@ -139,7 +142,11 @@ 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_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): diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index f365c284a..d265f82a9 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -308,7 +308,7 @@ class DomainNameserversView(DomainFormBaseView): except NameserverError as Err: # NamserverErrors *should* be caught in form; if reached here, # there was an uncaught error in submission (through EPP) - messages.error(self.request, NameserverError(code=nsErrorCodes.UNABLE_TO_UPDATE_DOMAIN)) + messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Nameservers error: {Err}") # TODO: registry is not throwing an error when no connection except RegistryError as Err: @@ -449,7 +449,7 @@ class DomainDsDataView(DomainFormBaseView): modal_button = ( '' + 'name="disable-override-click">Remove all DS Data' ) # context to back out of a broken form on all fields delete