no verification necessary directly for direct subdomains of registrar-level-domains if subdomain does not yet exist

This commit is contained in:
Michael Hoennig 2024-09-09 17:09:45 +02:00
parent 888e53397d
commit 17a5aa2ff4
6 changed files with 113 additions and 80 deletions

View File

@ -2,31 +2,18 @@ package net.hostsharing.hsadminng.hs.booking.item.validators;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import net.hostsharing.hsadminng.mapper.Array;
import jakarta.persistence.EntityManager;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS;
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of(
// "[^.]+", // top-level-domains are already rejected by FQDN_REGEX
"(co|org|gov|ac|sch)\\.uk",
"(com|net|org|edu|gov|asn|id)\\.au",
"(co|ne|or|ac|go)\\.jp",
"(com|net|org|gov|edu|ac)\\.cn",
"(com|net|org|gov|edu|mil|art)\\.br",
"(co|net|org|gen|firm|ind)\\.in",
"(com|net|org|gob|edu)\\.mx",
"(gov|edu)\\.it",
"(co|net|org|govt|ac|school|geek|kiwi)\\.nz",
"(co|ne|or|go|re|pe)\\.kr"
);
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.mapper.Array;
import org.apache.commons.collections4.EnumerationUtils;
import javax.naming.InvalidNameException;
@ -14,14 +15,45 @@ import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import static java.util.Arrays.stream;
import static java.util.Collections.emptyList;
public class Dns {
public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of(
"[^.]+", // top-level-domains
"(co|org|gov|ac|sch)\\.uk",
"(com|net|org|edu|gov|asn|id)\\.au",
"(co|ne|or|ac|go)\\.jp",
"(com|net|org|gov|edu|ac)\\.cn",
"(com|net|org|gov|edu|mil|art)\\.br",
"(co|net|org|gen|firm|ind)\\.in",
"(com|net|org|gob|edu)\\.mx",
"(gov|edu)\\.it",
"(co|net|org|govt|ac|school|geek|kiwi)\\.nz",
"(co|ne|or|go|re|pe)\\.kr"
);
public static final Pattern[] REGISTRAR_LEVEL_DOMAIN_PATTERN = stream(REGISTRAR_LEVEL_DOMAINS)
.map(Pattern::compile)
.toArray(Pattern[]::new);
private final static Map<String, Result> fakeResults = new HashMap<>();
public static Optional<String> superDomain(final String domainName) {
final var parts = domainName.split("\\.", 2);
if (parts.length == 2) {
return Optional.of(parts[1]);
}
return Optional.empty();
}
public static boolean isRegistrarLevel(final String domainName) {
return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN)
.anyMatch(p -> p.matcher(domainName).matches());
}
public static void fakeResultForDomain(final String domainName, final Result fakeResult) {
fakeResults.put(domainName, fakeResult);
}
@ -40,13 +72,6 @@ public class Dns {
public record Result(Status status, List<String> records, NamingException exception) {
public static Optional<String> superDomain(final String domainName) {
final var parts = domainName.split("\\.", 2);
if (parts.length == 2) {
return Optional.of(parts[1]);
}
return Optional.empty();
}
public static Result fromRecords(final NamingEnumeration<?> recordEnumeration) {
final List<String> records = recordEnumeration == null

View File

@ -5,10 +5,11 @@ import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.Result.superDomain;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX;
class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
@ -24,54 +25,48 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
NO_EXTRA_PROPERTIES);
}
// @Override
// public List<String> validateEntity(final HsHostingAsset assetEntity) {
// // TODO.impl: for newly created entities, check the permission of setting up a domain
// //
// // allow if
// // - user has Admin/Agent-role for all its sub-domains and the direct parent-Domain which are set up at at Hostsharing
// // - domain has DNS zone with TXT record approval
// // - parent-domain has DNS zone with TXT record approval
// //
// // TXT-Record check:
// // new InitialDirContext().getAttributes("dns:_netblocks.google.com", new String[] { "TXT"}).get("TXT").getAll();
// final var violations = new ArrayList<String>();
// if ( assetEntity.getBookingItem() != null ) {
// final var bookingItemDomainName = assetEntity .getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
// if ( bookingItemDomainName ) {
// violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName + "' is a forbidden Hostsharing domain name");
// }
// }
// violations.addAll(super.validateEntity(assetEntity));
// return violations;
// }
@Override
public List<String> validateEntity(final HsHostingAsset assetEntity) {
final var violations = new ArrayList<String>();
final var domainName = assetEntity.getIdentifier();
final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT");
final Supplier<String> getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
switch (dnsResult.status()) {
case Dns.Status.SUCCESS:
final var code = assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + code;
final var found = findTxtRecord(dnsResult, expectedTxtRecordValue)
case Dns.Status.SUCCESS: {
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue)
.or(() -> superDomain(domainName)
.flatMap(superDomainName -> findTxtRecord(
new Dns(superDomainName).fetchRecordsOfType("TXT"),
expectedTxtRecordValue))
);
if (found.isEmpty()) {
if (verificationFound.isEmpty()) {
violations.add(
"[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + code + "' found for domain name '"
+ assetEntity.getIdentifier() + "'");
"[DNS] no TXT record '" + expectedTxtRecordValue +
"' found for domain name '" + domainName + "'");
}
break;
}
case Dns.Status.NAME_NOT_FOUND:
// no DNS verification necessary / FIXME: at least if the superdomain is at registrar level
case Dns.Status.NAME_NOT_FOUND: {
final var superDomain = superDomain(domainName);
final var verificationRequired = !superDomain.map(Dns::isRegistrarLevel).orElse(false)
&& assetEntity.getBookingItem() != null; // FIXME: or getParentAsset() == nuĺl? or extract method
if (verificationRequired) {
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
final var verificationFoundInSuperDomain = superDomain.flatMap(superDomainName -> findTxtRecord(
new Dns(superDomainName).fetchRecordsOfType("TXT"),
expectedTxtRecordValue));
if (verificationFoundInSuperDomain.isEmpty()) {
violations.add(
"[DNS] no TXT record '" + expectedTxtRecordValue +
"' found for domain name '" + superDomain.orElseThrow() + "'");
}
}
// otherwise no DNS verification to be able to setup DNS for domains to register
break;
}
case Dns.Status.INVALID_NAME:
violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'");

View File

@ -26,14 +26,14 @@ class HsDomainSetupBookingItemValidatorUnitTest {
private EntityManager em;
@Test
void acceptsUnregisteredDomain() {
void acceptsRegisterableDomain() {
// given
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "example.org") // TODO.test: amend once we check registration
entry("domainName", "example.org")
))
.build();
@ -44,27 +44,9 @@ class HsDomainSetupBookingItemValidatorUnitTest {
assertThat(result).isEmpty();
}
@Test
void rejectsTopLevelDomain() {
// given
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", "org")
))
.build();
// when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).containsExactly("'D-12345:Test-Project:Test-Domain.resources.domainName' = 'org' is not a (non-top-level) fully qualified domain name");
}
@ParameterizedTest
@ValueSource(strings = {
"de", "com", "net", "org", "actually-any-top-level-domain",
"co.uk", "org.uk", "gov.uk", "ac.uk", "sch.uk",
"com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
@ -76,7 +58,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
"co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", "school.nz", "geek.nz", "kiwi.nz",
"co.kr", "ne.kr", "or.kr", "go.kr", "re.kr", "pe.kr"
})
void reject2ndLevelRegistrarDomain(final String secondLevelRegistrarDomain) {
void rejectRegistrarLevelDomain(final String secondLevelRegistrarDomain) {
// given
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
@ -91,7 +73,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).containsExactly(
assertThat(result).contains(
"'D-12345:Test-Project:Test-Domain.resources.domainName' = '" +
secondLevelRegistrarDomain +
"' is a forbidden registrar-level domain name");
@ -129,7 +111,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
// then
assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder(
"{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}], matchesRegExDescription=is not a (non-top-level) fully qualified domain name, notMatchesRegEx=[(co|org|gov|ac|sch)\\.uk, (com|net|org|edu|gov|asn|id)\\.au, (co|ne|or|ac|go)\\.jp, (com|net|org|gov|edu|ac)\\.cn, (com|net|org|gov|edu|mil|art)\\.br, (co|net|org|gen|firm|ind)\\.in, (com|net|org|gob|edu)\\.mx, (gov|edu)\\.it, (co|net|org|govt|ac|school|geek|kiwi)\\.nz, (co|ne|or|go|re|pe)\\.kr], notMatchesRegExDescription=is a forbidden registrar-level domain name, required=true, writeOnce=true}",
"{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}], matchesRegExDescription=is not a (non-top-level) fully qualified domain name, notMatchesRegEx=[[^.]+, (co|org|gov|ac|sch)\\.uk, (com|net|org|edu|gov|asn|id)\\.au, (co|ne|or|ac|go)\\.jp, (com|net|org|gov|edu|ac)\\.cn, (com|net|org|gov|edu|mil|art)\\.br, (co|net|org|gen|firm|ind)\\.in, (com|net|org|gob|edu)\\.mx, (gov|edu)\\.it, (co|net|org|govt|ac|school|geek|kiwi)\\.nz, (co|ne|or|go|re|pe)\\.kr], notMatchesRegExDescription=is a forbidden registrar-level domain name, required=true, writeOnce=true}",
"{type=string, propertyName=verificationCode, readOnly=true, computed=IN_INIT}");
}
}

View File

@ -0,0 +1,17 @@
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DnsUnitTest {
@Test
void isRegistrarLevel() {
assertThat(Dns.isRegistrarLevel("de")).isTrue();
assertThat(Dns.isRegistrarLevel("example.de")).isFalse();
assertThat(Dns.isRegistrarLevel("co.uk")).isTrue();
assertThat(Dns.isRegistrarLevel("example.co.uk")).isFalse();
}
}

View File

@ -97,8 +97,12 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
@EnumSource(ValidDomainNameIdentifier.class)
void acceptsValidIdentifier(final ValidDomainNameIdentifier testCase) {
// given
Dns.fakeResultForDomain(testCase.domainName, new Dns.Result(Dns.Status.NAME_NOT_FOUND, null, null));
final var givenEntity = validEntityBuilder(testCase.domainName).identifier(testCase.domainName).build();
final var expectedHash = givenEntity.getBookingItem()
.getDirectValue("verificationCode", String.class);
Dns.fakeResultForDomain(
testCase.domainName,
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash));
final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
// when
@ -270,7 +274,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
final var result = validator.validateEntity(domainSetupHostingAssetEntity);
// then
assertThat(result).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + expectedHash
assertThat(result).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash
+ "' found for domain name 'example.org'");
}
@ -318,6 +322,29 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
assertThat(result).isEmpty();
}
@Test
void RejectSetupOfUnregisteredSubdomainWithoutDnsVerificationInSuperDomain() {
// given
final var domainSetupHostingAssetEntity = validEntityBuilder("sub.example.org").build();
// ... the new subdomain is not yet registered:
Dns.fakeResultForDomain(
"sub.example.org",
Dns.Result.fromException(new NameNotFoundException("domain not registered")));
// ... and a valid verification-code in the super-domain:
final var expectedHash = domainSetupHostingAssetEntity.getBookingItem()
.getDirectValue("verificationCode", String.class);
final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType());
// when
final var result = validator.validateEntity(domainSetupHostingAssetEntity);
// then
assertThat(result).containsExactly(
"[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash
+ "' found for domain name 'example.org'");
}
@Test
void allowSetupOfExistingSubdomainWithValidDnsVerificationInSuperDomain() {
@ -355,7 +382,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
final var result = validator.validateEntity(domainSetupHostingAssetEntity);
// then
assertThat(result).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + expectedHash
assertThat(result).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash
+ "' found for domain name 'sub.example.org'");
}