From 888e53397d6769bc1be60f8d6919171993dd2f79 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 9 Sep 2024 15:01:47 +0200 Subject: [PATCH] DNS TXT Record Verification against super-domain --- .../hs/hosting/asset/validators/Dns.java | 17 +++- .../HsDomainSetupHostingAssetValidator.java | 82 +++++++++++------- ...ainSetupHostingAssetValidatorUnitTest.java | 84 +++++++++++++++++-- 3 files changed, 139 insertions(+), 44 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java index b99a0fc9..ef3a3525 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java @@ -13,6 +13,7 @@ import java.util.HashMap; import java.util.Hashtable; import java.util.List; import java.util.Map; +import java.util.Optional; import static java.util.Arrays.stream; import static java.util.Collections.emptyList; @@ -39,6 +40,14 @@ public class Dns { public record Result(Status status, List records, NamingException exception) { + public static Optional 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 records = recordEnumeration == null ? emptyList() @@ -52,10 +61,10 @@ public class Dns { public static Result fromException(final NamingException exception) { return switch (exception) { - case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, null, exc); - case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, null, exc); - case InvalidNameException exc -> new Result(Status.INVALID_NAME, null, exc); - case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, null, exc); + case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, emptyList(), exc); + case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, emptyList(), exc); + case InvalidNameException exc -> new Result(Status.INVALID_NAME, emptyList(), exc); + case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, emptyList(), exc); }; } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java index 8d5cf2e4..8a54f566 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java @@ -4,9 +4,11 @@ import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; import java.util.ArrayList; import java.util.List; +import java.util.Optional; 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.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX; class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { @@ -15,45 +17,55 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName"; HsDomainSetupHostingAssetValidator() { - super( DOMAIN_SETUP, + super( + DOMAIN_SETUP, AlarmContact.isOptional(), NO_EXTRA_PROPERTIES); } -// @Override -// public List 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(); -// 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 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(); + // 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 validateEntity(final HsHostingAsset assetEntity) { final var violations = new ArrayList(); - final var result = new Dns(assetEntity.getIdentifier()).fetchRecordsOfType("TXT"); - switch ( result.status() ) { + final var domainName = assetEntity.getIdentifier(); + final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT"); + switch (dnsResult.status()) { case Dns.Status.SUCCESS: final var code = assetEntity.getBookingItem().getDirectValue("verificationCode", String.class); - final var found = result.records().stream().filter(r -> r.contains("Hostsharing-domain-setup-verification-code=" + code)).findAny(); + final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + code; + final var found = findTxtRecord(dnsResult, expectedTxtRecordValue) + .or(() -> superDomain(domainName) + .flatMap(superDomainName -> findTxtRecord( + new Dns(superDomainName).fetchRecordsOfType("TXT"), + expectedTxtRecordValue)) + ); if (found.isEmpty()) { - violations.add("[DNS] no TXT record 'Hostsharing-domain-setup-verification="+code+"' with valid hash found for domain name '" + assetEntity.getIdentifier() + "'"); + violations.add( + "[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + code + "' found for domain name '" + + assetEntity.getIdentifier() + "'"); } break; @@ -65,22 +77,28 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'"); break; - case Dns.Status.SERVICE_UNAVAILABLE: + case Dns.Status.SERVICE_UNAVAILABLE: case Dns.Status.UNKNOWN_FAILURE: - violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + result.exception()); + violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception()); break; } - violations.addAll(super.validateEntity(assetEntity)); return violations; } + private static Optional findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) { + return result.records().stream() + .filter(r -> r.contains(expectedTxtRecordValue)) + .findAny(); + } + @Override protected Pattern identifierPattern(final HsHostingAsset assetEntity) { - if ( assetEntity.getBookingItem() != null ) { - final var bookingItemDomainName = assetEntity.getBookingItem().getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); - return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE|Pattern.LITERAL); + if (assetEntity.getBookingItem() != null) { + final var bookingItemDomainName = assetEntity.getBookingItem() + .getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); + return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL); } final var parentDomainName = assetEntity.getParentAsset().getIdentifier(); return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java index 3ba87810..2829706a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java @@ -76,7 +76,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest { // then assertThat(result).contains( - "'identifier' expected to match 'example.org', but is '"+testCase.domainName+"'" + "'identifier' expected to match 'example.org', but is '" + testCase.domainName + "'" ); } @@ -154,7 +154,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest { } @ParameterizedTest - @ValueSource(strings = {"not-matching-booking-item-domain-name.org", "indirect.subdomain.example.org"}) + @ValueSource(strings = { "not-matching-booking-item-domain-name.org", "indirect.subdomain.example.org" }) void rejectsDomainNameWhichIsNotADirectSubdomainOfParentAsset(final String newDomainName) { // given final var domainSetupHostingAssetEntity = validEntityBuilder() @@ -236,7 +236,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest { } @Test - void allowSetupOfNonExistingSubdomainOfRegistrarLevelDomain() { + void allowSetupOfAvailableRegistrableDomain() { // given final var domainSetupHostingAssetEntity = validEntityBuilder().build(); @@ -254,12 +254,13 @@ class HsDomainSetupHostingAssetValidatorUnitTest { } @Test - void rejectSetupOfExistingDomainWithInvalidDnsVerification() { + void rejectSetupOfExistingRegistrableDomainWithoutValidDnsVerification() { // given final var domainSetupHostingAssetEntity = validEntityBuilder().build(); final var domainName = domainSetupHostingAssetEntity.getIdentifier(); - final var expectedHash = domainSetupHostingAssetEntity.getBookingItem().getDirectValue("verificationCode", String.class); + final var expectedHash = domainSetupHostingAssetEntity.getBookingItem() + .getDirectValue("verificationCode", String.class); Dns.fakeResultForDomain( domainName, Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=SOME-DEFINITELY-WRONG-HASH")); @@ -269,16 +270,18 @@ class HsDomainSetupHostingAssetValidatorUnitTest { final var result = validator.validateEntity(domainSetupHostingAssetEntity); // then - assertThat(result).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + expectedHash + "' with valid hash found for domain name 'example.org'"); + assertThat(result).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + expectedHash + + "' found for domain name 'example.org'"); } @Test - void allowSetupOfExistingDomainWithValidDnsVerification() { + void allowSetupOfExistingRegistrableDomainWithValidDnsVerification() { // given final var domainSetupHostingAssetEntity = validEntityBuilder().build(); final var domainName = domainSetupHostingAssetEntity.getIdentifier(); - final var expectedHash = domainSetupHostingAssetEntity.getBookingItem().getDirectValue("verificationCode", String.class); + final var expectedHash = domainSetupHostingAssetEntity.getBookingItem() + .getDirectValue("verificationCode", String.class); Dns.fakeResultForDomain( domainName, Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); @@ -291,6 +294,71 @@ class HsDomainSetupHostingAssetValidatorUnitTest { assertThat(result).isEmpty(); } + @Test + void allowSetupOfUnregisteredSubdomainWithValidDnsVerificationInSuperDomain() { + + // 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); + Dns.fakeResultForDomain( + "example.org", + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void allowSetupOfExistingSubdomainWithValidDnsVerificationInSuperDomain() { + + // given + final var domainSetupHostingAssetEntity = validEntityBuilder("sub.example.org").build(); + // ... the new subdomain is already registered: + Dns.fakeResultForDomain("sub.example.org", Dns.Result.fromRecords()); + // ... and a valid verification-code in the super-domain: + final var expectedHash = domainSetupHostingAssetEntity.getBookingItem() + .getDirectValue("verificationCode", String.class); + Dns.fakeResultForDomain( + "example.org", + Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); + final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(domainSetupHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectSetupOfExistingSubdomainWithoutDnsVerification() { + + // given + final var domainSetupHostingAssetEntity = validEntityBuilder("sub.example.org").build(); + // ... the new subdomain is already registered: + Dns.fakeResultForDomain("sub.example.org", Dns.Result.fromRecords()); + 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).contains("[DNS] no TXT record 'Hostsharing-domain-setup-verification=" + expectedHash + + "' found for domain name 'sub.example.org'"); + } + private static HsHostingAssetRealEntity createValidParentDomainSetupAsset(final String parentDomainName) { final var bookingItem = HsBookingItemRealEntity.builder() .type(HsBookingItemType.DOMAIN_SETUP)