check-domain-setup-permission #97

Merged
hsh-michaelhoennig merged 17 commits from check-domain-setup-permission into master 2024-09-10 13:15:03 +02:00
7 changed files with 142 additions and 49 deletions
Showing only changes of commit 003eb29454 - Show all commits

View File

@ -20,6 +20,7 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
HsDomainSetupBookingItemValidator() { HsDomainSetupBookingItemValidator() {
super( super(
stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce() stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
.maxLength(253)
.matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name") .matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name")
.notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name") .notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name")
.required(), .required(),
@ -34,7 +35,7 @@ class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
final var violations = new ArrayList<String>(); final var violations = new ArrayList<String>();
final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class); final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
if (!bookingItem.isLoaded() && if (!bookingItem.isLoaded() &&
domainName.matches("hostsharing.(com|net|org|coop)")) { domainName.matches("hostsharing.(com|net|org|coop|de)")) {
violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName
+ "' is a forbidden Hostsharing domain name"); + "' is a forbidden Hostsharing domain name");
} }

View File

@ -49,16 +49,25 @@ public class Dns {
return Optional.empty(); return Optional.empty();
} }
public static boolean isRegistrarLevel(final String domainName) { public static boolean isRegistrarLevelDomain(final String domainName) {
return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN) return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN)
.anyMatch(p -> p.matcher(domainName).matches()); .anyMatch(p -> p.matcher(domainName).matches());
} }
/**
* @param domainName a fully qualified domain name
* @return true if `domainName` can be registered at a registrar, false if it's a subdomain of such or a registrar-level domain itself
*/
public static boolean isRegistrableDomain(final String domainName) {
return !isRegistrarLevelDomain(domainName) &&
superDomain(domainName).map(Dns::isRegistrarLevelDomain).orElse(false);
}
public static void fakeResultForDomain(final String domainName, final Result fakeResult) { public static void fakeResultForDomain(final String domainName, final Result fakeResult) {
fakeResults.put(domainName, fakeResult); fakeResults.put(domainName, fakeResult);
} }
static void resetFakeResults() { public static void resetFakeResults() {
fakeResults.clear(); fakeResults.clear();
} }

View File

@ -2,7 +2,6 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Supplier; import java.util.function.Supplier;
@ -27,8 +26,12 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
@Override @Override
public List<String> validateEntity(final HsHostingAsset assetEntity) { public List<String> validateEntity(final HsHostingAsset assetEntity) {
final var violations = // new ArrayList<String>();
super.validateEntity(assetEntity);
if (!violations.isEmpty()) {
return violations;
}
final var violations = new ArrayList<String>();
final var domainName = assetEntity.getIdentifier(); final var domainName = assetEntity.getIdentifier();
final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT"); final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT");
final Supplier<String> getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class); final Supplier<String> getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
@ -50,10 +53,8 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
} }
case Dns.Status.NAME_NOT_FOUND: { case Dns.Status.NAME_NOT_FOUND: {
hsh-michaelhoennig marked this conversation as resolved
Review

use getParentAsset == null + Test

use getParentAsset == null + Test
final var superDomain = superDomain(domainName); if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) {
final var verificationRequired = !superDomain.map(Dns::isRegistrarLevel).orElse(false) final var superDomain = superDomain(domainName);
&& 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 expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
final var verificationFoundInSuperDomain = superDomain.flatMap(superDomainName -> findTxtRecord( final var verificationFoundInSuperDomain = superDomain.flatMap(superDomainName -> findTxtRecord(
new Dns(superDomainName).fetchRecordsOfType("TXT"), new Dns(superDomainName).fetchRecordsOfType("TXT"),
@ -77,17 +78,9 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception()); violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception());
break; break;
} }
violations.addAll(super.validateEntity(assetEntity));
return violations; return violations;
} }
private static Optional<String> findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) {
return result.records().stream()
.filter(r -> r.contains(expectedTxtRecordValue))
.findAny();
}
@Override @Override
protected Pattern identifierPattern(final HsHostingAsset assetEntity) { protected Pattern identifierPattern(final HsHostingAsset assetEntity) {

findRecord

findRecord
if (assetEntity.getBookingItem() != null) { if (assetEntity.getBookingItem() != null) {
@ -98,4 +91,16 @@ class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
final var parentDomainName = assetEntity.getParentAsset().getIdentifier(); final var parentDomainName = assetEntity.getParentAsset().getIdentifier();
return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE); return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE);
} }
private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) {
return !Dns.isRegistrableDomain(assetEntity.getIdentifier())
&& assetEntity.getParentAsset() == null;
}
private static Optional<String> findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) {
return result.records().stream()
.filter(r -> r.contains(expectedTxtRecordValue))
.findAny();
}
} }

View File

@ -12,10 +12,12 @@ import java.util.Map;
import static java.util.Map.entry; import static java.util.Map.entry;
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP;
import static org.apache.commons.lang3.StringUtils.right;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class HsDomainSetupBookingItemValidatorUnitTest { class HsDomainSetupBookingItemValidatorUnitTest {
public static final String TOO_LONG_DOMAIN_NAME = "asdfghijklmnopqrstuvwxyz0123456789.".repeat(8) + "example.org";
final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder()
.debitorNumber(12345) .debitorNumber(12345)
.build(); .build();
@ -44,6 +46,42 @@ class HsDomainSetupBookingItemValidatorUnitTest {
assertThat(result).isEmpty(); assertThat(result).isEmpty();
} }
@Test
void acceptsMaximumDomainNameLength() {
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253))
))
.build();
// when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).isEmpty();
}
@Test
void rejectsTooLongTotalName() {
final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
.type(DOMAIN_SETUP)
.project(project)
.caption("Test-Domain")
.resources(Map.ofEntries(
entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254))
))
.build();
// when
final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
// then
assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.domainName' length is expected to be at max 253 but length of 'dfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.example.org' is 254");
}
hsh-michaelhoennig marked this conversation as resolved Outdated

+.de

+.de
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = { @ValueSource(strings = {
"de", "com", "net", "org", "actually-any-top-level-domain", "de", "com", "net", "org", "actually-any-top-level-domain",
@ -81,7 +119,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = { @ValueSource(strings = {
"hostsharing.net", "hostsharing.org", "hostsharing.com", "hostsharing.coop" "hostsharing.net", "hostsharing.org", "hostsharing.com", "hostsharing.coop", "hostsharing.de"
}) })
void rejectHostsharingDomain(final String secondLevelRegistrarDomain) { void rejectHostsharingDomain(final String secondLevelRegistrarDomain) {
// given // given
@ -111,7 +149,7 @@ class HsDomainSetupBookingItemValidatorUnitTest {
// then // then
assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( 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, maxLength=253, required=true, writeOnce=true}",
"{type=string, propertyName=verificationCode, readOnly=true, computed=IN_INIT}"); "{type=string, propertyName=verificationCode, readOnly=true, computed=IN_INIT}");
} }
} }

View File

@ -14,6 +14,7 @@ import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Order;
@ -65,6 +66,11 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
@Autowired @Autowired
JpaAttempt jpaAttempt; JpaAttempt jpaAttempt;
@AfterEach
void cleanup() {
Dns.resetFakeResults();
}
@Nested @Nested
@Order(2) @Order(2)
class ListAssets { class ListAssets {

View File

@ -7,11 +7,23 @@ import static org.assertj.core.api.Assertions.assertThat;
class DnsUnitTest { class DnsUnitTest {
@Test @Test
void isRegistrarLevel() { void isRegistrarLevelDomain() {
assertThat(Dns.isRegistrarLevel("de")).isTrue(); assertThat(Dns.isRegistrarLevelDomain("de")).isTrue();
assertThat(Dns.isRegistrarLevel("example.de")).isFalse(); assertThat(Dns.isRegistrarLevelDomain("example.de")).isFalse();
assertThat(Dns.isRegistrarLevel("co.uk")).isTrue(); assertThat(Dns.isRegistrarLevelDomain("co.uk")).isTrue();
assertThat(Dns.isRegistrarLevel("example.co.uk")).isFalse(); assertThat(Dns.isRegistrarLevelDomain("example.co.uk")).isFalse();
assertThat(Dns.isRegistrarLevelDomain("co.uk.com")).isFalse();
}
@Test
void isRegistrableDomain() {
assertThat(Dns.isRegistrableDomain("de")).isFalse();
assertThat(Dns.isRegistrableDomain("example.de")).isTrue();
assertThat(Dns.isRegistrableDomain("sub.example.de")).isFalse();
assertThat(Dns.isRegistrableDomain("co.uk")).isFalse();
assertThat(Dns.isRegistrableDomain("example.co.uk")).isTrue();
assertThat(Dns.isRegistrableDomain("sub.example.co.uk")).isFalse();
} }
} }

View File

@ -59,30 +59,30 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
//===================================================================================================================== //=====================================================================================================================
enum InvalidDomainNameIdentifier { enum InvalidSubDomainNameIdentifierForExampleOrg {
EMPTY(""), IDENTICAL("example.org"),
TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.de"), TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.example.org"),
DASH_AT_BEGINNING("-example.com"), DASH_AT_BEGINNING("-sub.example.org"),
DOT_AT_BEGINNING(".example.com"), DOT(".example.org"),
DOT_AT_END("example.com."); DOT_AT_BEGINNING(".sub.example.org"),
DOUBLE_DOT("sub..example.com.");
final String domainName; final String domainName;
InvalidDomainNameIdentifier(final String domainName) { InvalidSubDomainNameIdentifierForExampleOrg(final String domainName) {
this.domainName = domainName; this.domainName = domainName;
} }
} }
@ParameterizedTest @ParameterizedTest
@EnumSource(InvalidDomainNameIdentifier.class) @EnumSource(InvalidSubDomainNameIdentifierForExampleOrg.class)
void rejectsInvalidIdentifier(final InvalidDomainNameIdentifier testCase) { void rejectsInvalidIdentifier(final InvalidSubDomainNameIdentifierForExampleOrg testCase) {
// given // given
final var givenEntity = validEntityBuilder(testCase.domainName, final var givenEntity = validEntityBuilder(testCase.domainName)
bib -> bib.resources(new HashMap<>(ofEntries( .bookingItem(null)
entry("domainName", "example.org") .parentAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier("example.org").build())
))).build() .build();
).build(); // fakeValidDnsVerification(givenEntity);
fakeValidDnsVerification(givenEntity);
final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
// when // when
@ -90,26 +90,26 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
// then // then
assertThat(result).contains( assertThat(result).contains(
hsh-michaelhoennig marked this conversation as resolved
Review

test für parentAsset, inkl. gültige Zeichenlänge

test für parentAsset, inkl. gültige Zeichenlänge
"'identifier' expected to match 'example.org', but is '" + testCase.domainName + "'" "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))\\.example\\.org', but is '" + testCase.domainName + "'"
); );
} }
enum ValidDomainNameIdentifier { enum ValidSubDomainNameIdentifier {
SIMPLE("example.org"), SIMPLE("sub.example.org"),
MAX_LENGTH("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz01234568901.de"), MAX_LENGTH("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz01234568901.example.org"),
WITH_DASH("example-test.com"), MIN_LENGTH("x.example.org"),
SUBDOMAIN("test.example.com"); WITH_DASH("example-test.example.org");
final String domainName; final String domainName;
ValidDomainNameIdentifier(final String domainName) { ValidSubDomainNameIdentifier(final String domainName) {
this.domainName = domainName; this.domainName = domainName;
} }
} }
@ParameterizedTest @ParameterizedTest
@EnumSource(ValidDomainNameIdentifier.class) @EnumSource(ValidSubDomainNameIdentifier.class)
void acceptsValidIdentifier(final ValidDomainNameIdentifier testCase) { void acceptsValidIdentifier(final ValidSubDomainNameIdentifier testCase) {
// given // given
final var givenEntity = validEntityBuilder(testCase.domainName).identifier(testCase.domainName).build(); final var givenEntity = validEntityBuilder(testCase.domainName).identifier(testCase.domainName).build();
fakeValidDnsVerification(givenEntity); fakeValidDnsVerification(givenEntity);
@ -317,11 +317,17 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
} }
@Test @Test
void rejectSetupOfUnregisteredSubdomainWithoutDnsVerificationInSuperDomain() { void rejectSetupOfUnregisteredSubdomainWithoutParentAssetAndWithoutDnsVerificationInSuperDomain() {
domainSetupFor("sub.example.org").notRegistered() domainSetupFor("sub.example.org").notRegistered()
.isRejectedWithCauseMissingVerificationIn("example.org"); .isRejectedWithCauseMissingVerificationIn("example.org");
} }
@Test
void acceptSetupOfUnregisteredSubdomainWithParentAssetEvenWithoutDnsVerificationInSuperDomain() {
domainSetupWithParentAssetFor("sub.example.org").notRegistered()
.isAccepted();
}
@Test @Test
void allowSetupOfExistingSubdomainWithValidDnsVerificationInSuperDomain() { void allowSetupOfExistingSubdomainWithValidDnsVerificationInSuperDomain() {
domainSetupFor("sub.example.org").registered() domainSetupFor("sub.example.org").registered()
@ -361,6 +367,14 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
expectedHash = domainAsset.getBookingItem().getDirectValue("verificationCode", String.class); expectedHash = domainAsset.getBookingItem().getDirectValue("verificationCode", String.class);
} }
public DomainSetupBuilder(final HsHostingAssetRealEntity parentAsset, final String domainName) {
domainAsset = validEntityBuilder(domainName)
.bookingItem(null)
.parentAsset(parentAsset)
.build();
expectedHash = null;
}
DomainSetupBuilder notRegistered() { DomainSetupBuilder notRegistered() {
Dns.fakeResultForDomain(domainAsset.getIdentifier(), DOMAIN_NOT_REGISTERED); Dns.fakeResultForDomain(domainAsset.getIdentifier(), DOMAIN_NOT_REGISTERED);
return this; return this;
@ -386,6 +400,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
} }
DomainSetupBuilder withVerificationIn(final String domainName) { DomainSetupBuilder withVerificationIn(final String domainName) {
assertThat(expectedHash).as("no expectedHash available").isNotNull();
Dns.fakeResultForDomain( Dns.fakeResultForDomain(
domainName, domainName,
Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash)); Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash));
@ -393,6 +408,7 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
} }
void isRejectedWithCauseMissingVerificationIn(final String domainName) { void isRejectedWithCauseMissingVerificationIn(final String domainName) {
assertThat(expectedHash).as("no expectedHash available").isNotNull();
assertThat(validate()).containsAnyOf( assertThat(validate()).containsAnyOf(
"[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash
+ "' found for domain name '" + domainName + "' (nor in its super-domain)", + "' found for domain name '" + domainName + "' (nor in its super-domain)",
@ -413,4 +429,10 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
private DomainSetupBuilder domainSetupFor(final String domainName) { private DomainSetupBuilder domainSetupFor(final String domainName) {
return new DomainSetupBuilder(domainName); return new DomainSetupBuilder(domainName);
} }
private DomainSetupBuilder domainSetupWithParentAssetFor(final String domainName) {
return new DomainSetupBuilder(
HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier(Dns.superDomain(domainName).orElseThrow()).build(),
domainName);
}
} }