1
0

check-domain-setup-permission (#97)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/97
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2024-09-10 13:15:03 +02:00
parent 8e02610679
commit a7d586f0f7
13 changed files with 884 additions and 84 deletions

View File

@@ -1,10 +1,59 @@
package net.hostsharing.hsadminng.hs.booking.item.validators;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
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 DOMAIN_NAME_PROPERTY_NAME = "domainName";
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
HsDomainSetupBookingItemValidator() {
super(
// no properties yet. maybe later, the setup code goes here?
stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
.maxLength(253)
.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")
.required(),
stringProperty(VERIFICATION_CODE_PROPERTY_NAME)
.readOnly().initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode)
);
}
@Override
public List<String> validateEntity(final HsBookingItem bookingItem) {
final var violations = new ArrayList<String>();
final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
if (!bookingItem.isLoaded() &&
domainName.matches("hostsharing.(com|net|org|coop|de)")) {
violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName
+ "' is a forbidden Hostsharing domain name");
}
violations.addAll(super.validateEntity(bookingItem));
return violations;
}
private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) {
final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
final var secureRandom = new SecureRandom();
final var sb = new StringBuilder();
for (int i = 0; i < 40; ++i) {
if ( i > 0 && i % 4 == 0 ) {
sb.append("-");
}
sb.append(alphaNumeric.charAt(secureRandom.nextInt(alphaNumeric.length())));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,134 @@
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.mapper.Array;
import org.apache.commons.collections4.EnumerationUtils;
import javax.naming.InvalidNameException;
import javax.naming.NameNotFoundException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.ServiceUnavailableException;
import javax.naming.directory.Attribute;
import javax.naming.directory.InitialDirContext;
import java.util.HashMap;
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 isRegistrarLevelDomain(final String domainName) {
return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN)
.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) {
fakeResults.put(domainName, fakeResult);
}
public static void resetFakeResults() {
fakeResults.clear();
}
public enum Status {
SUCCESS,
NAME_NOT_FOUND,
INVALID_NAME,
SERVICE_UNAVAILABLE,
UNKNOWN_FAILURE
}
public record Result(Status status, List<String> records, NamingException exception) {
public static Result fromRecords(final NamingEnumeration<?> recordEnumeration) {
final List<String> records = recordEnumeration == null
? emptyList()
: EnumerationUtils.toList(recordEnumeration).stream().map(Object::toString).toList();
return new Result(Status.SUCCESS, records, null);
}
public static Result fromRecords(final String... records) {
return new Result(Status.SUCCESS, stream(records).toList(), null);
}
public static Result fromException(final NamingException exception) {
return switch (exception) {
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);
};
}
}
private final String domainName;
public Dns(final String domainName) {
this.domainName = domainName;
}
public Result fetchRecordsOfType(final String recordType) {
if (fakeResults.containsKey(domainName)) {
return fakeResults.get(domainName);
}
try {
final var env = new Hashtable<>();
env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
final Attribute records = new InitialDirContext(env)
.getAttributes(domainName, new String[] { recordType })
.get(recordType);
return Result.fromRecords(records != null ? records.getAll() : null);
} catch (final NamingException exception) {
return Result.fromException(exception);
}
}
public static void main(String[] args) {
final var result = new Dns("example.org").fetchRecordsOfType("TXT");
System.out.println(result);
}
}

View File

@@ -3,55 +3,104 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
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.superDomain;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX;
class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
private final Pattern identifierPattern;
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
HsDomainSetupHostingAssetValidator() {
super( DOMAIN_SETUP,
super(
DOMAIN_SETUP,
AlarmContact.isOptional(),
NO_EXTRA_PROPERTIES);
this.identifierPattern = Pattern.compile(FQDN_REGEX);
}
@Override
public List<String> validateEntity(final HsHostingAsset assetEntity) {
// TODO.impl: for newly created entities, check the permission of setting up a domain
//
// reject, if the domain is any of these:
// hostsharing.com|net|org|coop, // just to be on the safe side
// [^.}+, // 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,
// com.cn, net.cn, org.cn, gov.cn, edu.cn, ac.cn,
// com.br, net.br, org.br, gov.br, edu.br, mil.br, art.br,
// co.in, net.in, org.in, gen.in, firm.in, ind.in,
// com.mx, net.mx, org.mx, gob.mx, edu.mx,
// gov.it, edu.it,
// 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
//
// 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>();
super.validateEntity(assetEntity);
if (!violations.isEmpty()) {
return violations;
}
return super.validateEntity(assetEntity);
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 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 (verificationFound.isEmpty()) {
violations.add(
"[DNS] no TXT record '" + expectedTxtRecordValue +
"' found for domain name '" + domainName + "' (nor in its super-domain)");
}
break;
}
case Dns.Status.NAME_NOT_FOUND: {
if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) {
final var superDomain = superDomain(domainName);
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() + "'");
break;
case Dns.Status.SERVICE_UNAVAILABLE:
case Dns.Status.UNKNOWN_FAILURE:
violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception());
break;
}
return violations;
}
@Override
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
return identifierPattern;
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);
}
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

@@ -1,10 +1,12 @@
package net.hostsharing.hsadminng.hs.validation;
import lombok.AccessLevel;
import lombok.Setter;
import net.hostsharing.hsadminng.mapper.Array;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -15,11 +17,19 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
protected static final String[] KEY_ORDER = Array.join(
ValidatableProperty.KEY_ORDER_HEAD,
Array.of("matchesRegEx", "minLength", "maxLength", "provided"),
Array.of("matchesRegEx", "matchesRegExDescription",
"notMatchesRegEx", "notMatchesRegExDescription",
"minLength", "maxLength",
"provided"),
ValidatableProperty.KEY_ORDER_TAIL,
Array.of("undisclosed"));
private String[] provided;
private Pattern[] matchesRegEx;
private String matchesRegExDescription;
private Pattern[] notMatchesRegEx;
private String notMatchesRegExDescription;
@Setter(AccessLevel.PRIVATE)
private Consumer<String> describedAsConsumer;
private Integer minLength;
private Integer maxLength;
private boolean undisclosed;
@@ -56,10 +66,23 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
public P matchesRegEx(final String... regExPattern) {
this.matchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new);
this.describedAsConsumer = violationMessage -> matchesRegExDescription = violationMessage;
return self();
}
/// predifined values, similar to fixed values in a combobox
public P notMatchesRegEx(final String... regExPattern) {
this.notMatchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new);
this.describedAsConsumer = violationMessage -> notMatchesRegExDescription = violationMessage;
return self();
}
public P describedAs(final String violationMessage) {
describedAsConsumer.accept(violationMessage);
describedAsConsumer = null;
return self();
}
/// predefined values, similar to fixed values in a combobox
public P provided(final String... provided) {
this.provided = provided;
return self();
@@ -78,16 +101,10 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
@Override
protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
super.validate(result, propValue, propProvider);
if (minLength != null && propValue.length()<minLength) {
result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
}
if (maxLength != null && propValue.length()>maxLength) {
result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
}
if (matchesRegEx != null &&
stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) {
result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + " but " + display(propValue) + " does not match" + (matchesRegEx.length>1?" any":""));
}
validateMinLength(result, propValue);
validateMaxLength(result, propValue);
validateMatchesRegEx(result, propValue);
validateNotMatchesRegEx(result, propValue);
}
@Override
@@ -99,4 +116,47 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
protected String simpleTypeName() {
return "string";
}
private void validateMinLength(final List<String> result, final String propValue) {
if (minLength != null && propValue.length()<minLength) {
result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
}
}
private void validateMaxLength(final List<String> result, final String propValue) {
if (maxLength != null && propValue.length()>maxLength) {
result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
}
}
private void validateMatchesRegEx(final List<String> result, final String propValue) {
if (matchesRegEx != null &&
stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) {
if (matchesRegExDescription != null) {
result.add(propertyName + "' = " + display(propValue) + " " + matchesRegExDescription);
} else if (matchesRegEx.length>1) {
result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) +
" but " + display(propValue) + " does not match any");
} else {
result.add(propertyName + "' is expected to match " + Arrays.toString(matchesRegEx) + " but " + display(
propValue) +
" does not match");
}
}
}
private void validateNotMatchesRegEx(final List<String> result, final String propValue) {
if (notMatchesRegEx != null &&
stream(notMatchesRegEx).map(p -> p.matcher(propValue)).anyMatch(Matcher::matches)) {
if (notMatchesRegExDescription != null) {
result.add(propertyName + "' = " + display(propValue) + " " + notMatchesRegExDescription);
} else if (notMatchesRegEx.length>1) {
result.add(propertyName + "' is expected not to match any of " + Arrays.toString(notMatchesRegEx) +
" but " + display(propValue) + " does match at least one");
} else {
result.add(propertyName + "' is expected not to match " + Arrays.toString(notMatchesRegEx) +
" but " + display(propValue) + " does match");
}
}
}
}