Просмотр исходного кода

harden dns record validation (#5197)

* harden dns record validation
* Update test/checks.bats
divinity76 2 месяцев назад
Родитель
Сommit
a74babb739
6 измененных файлов с 629 добавлено и 50 удалено
  1. 10 6
      bin/v-add-dns-record
  2. 18 7
      bin/v-change-dns-record
  3. 67 11
      func/domain.sh
  4. 330 0
      func/internal/dns_record_validator.php
  5. 34 18
      func/main.sh
  6. 170 8
      test/checks.bats

+ 10 - 6
bin/v-add-dns-record

@@ -70,12 +70,14 @@ fi
 
 if [ "$rtype" != "CAA" ]; then
 	dvalue=${dvalue//\"/}
-	# Add support for DS key
-	if [ "$rtype" != "DNSKEY" ] && [ "$rtype" != "DS" ] && [ "$rtype" != "TLSA" ]; then
-		if [ "$rtype" != 'SRV' ] && [[ "$dvalue" =~ [\;[:space:]] ]]; then
-			dvalue='"'"$dvalue"'"'
-		fi
-	fi
+	case "$rtype" in
+		SRV | DNSKEY | DS | TLSA | KEY | IPSECKEY) ;;
+		*)
+			if [[ "$dvalue" =~ [\;[:space:]] ]]; then
+				dvalue='"'"$dvalue"'"'
+			fi
+			;;
+	esac
 fi
 
 if [ "$record" = "@" ] && [ "$rtype" = "CNAME" ]; then
@@ -119,6 +121,8 @@ time_n_date=$(date +'%T %F')
 time=$(echo "$time_n_date" | cut -f 1 -d \ )
 date=$(echo "$time_n_date" | cut -f 2 -d \ )
 
+dvalue=${dvalue//\'/%quote%}
+
 # Adding record
 zone="$USER_DATA/dns/$domain.conf"
 dns_rec="ID='$id' RECORD='$record' TYPE='$rtype' PRIORITY='$priority'"

+ 18 - 7
bin/v-change-dns-record

@@ -101,20 +101,28 @@ fi
 if [ "$rtype" != "CAA" ]; then
 	dvalue=${dvalue//\"/}
 
-	if [ "$rtype" != 'SRV' ] && [[ "$dvalue" =~ [\;[:space:]] ]]; then
-		dvalue='"'"$dvalue"'"'
-	fi
+	case "$rtype" in
+		SRV | DNSKEY | DS | TLSA | KEY | IPSECKEY) ;;
+		*)
+			if [[ "$dvalue" =~ [\;[:space:]] ]]; then
+				dvalue='"'"$dvalue"'"'
+			fi
+			;;
+	esac
 fi
 
 #RTYPE wasn't checked make sure to do it now correctly
 is_format_valid 'user' 'domain' 'id' 'record' 'rtype' 'dvalue'
 
 # Additional verifications
-is_dns_fqnd "$TYPE" "$dvalue"
-is_dns_nameserver_valid "$domain" "$TYPE" "$dvalue"
+is_dns_fqnd "$rtype" "$dvalue"
+is_dns_nameserver_valid "$domain" "$rtype" "$dvalue"
+
+current_value_canon=${VALUE//\'/%quote%}
+new_value_canon=${dvalue//\'/%quote%}
 
 if [[ "$RECORD" == "$record" ]] && [[ "$TYPE" == "$rtype" ]] && [[ "$PRIORITY" -eq "$priority" ]] \
-	&& [[ "$VALUE" == "$dvalue" ]] && [[ "$SUSPENDED" == 'no' ]] && [[ "$TTL" -eq "$ttl" ]]; then
+	&& [[ "$current_value_canon" == "$new_value_canon" ]] && [[ "$SUSPENDED" == 'no' ]] && [[ "$TTL" -eq "$ttl" ]]; then
 	echo "No pending changes in DNS entry."
 	exit "$E_EXISTS"
 fi
@@ -124,6 +132,9 @@ time_n_date=$(date +'%T %F')
 time=$(echo "$time_n_date" | cut -f 1 -d \ )
 date=$(echo "$time_n_date" | cut -f 2 -d \ )
 
+log_value="$dvalue"
+dvalue="$new_value_canon"
+
 # Adding record
 dns_rec="ID='$id' RECORD='$record' TYPE='$rtype' PRIORITY='$priority'"
 dns_rec="$dns_rec VALUE='$dvalue' SUSPENDED='no' TIME='$time' DATE='$date'"
@@ -162,7 +173,7 @@ $BIN/v-restart-dns "$restart"
 check_result $? "DNS restart failed" > /dev/null
 
 # Logging
-$BIN/v-log-action "$user" "Info" "DNS" "DNS record value changed (Type: $rtype, Record: $record, Value: $dvalue, Domain: $domain)."
+$BIN/v-log-action "$user" "Info" "DNS" "DNS record value changed (Type: $rtype, Record: $record, Value: $log_value, Domain: $domain)."
 log_event "$OK" "$ARGUMENTS"
 
 exit

+ 67 - 11
func/domain.sh

@@ -485,11 +485,61 @@ is_dns_domain_new() {
 	fi
 }
 
+# Safely parse KEY='value' pairs from dns.conf lines without using eval
+parse_dns_record_line() {
+	local line="$1"
+	local json
+
+	json=$(
+		$HESTIA_PHP -- "$line" << 'EOPHP'
+<?php
+$line = $argv[1];
+$allowed_keys = array('ID','RECORD','TYPE','PRIORITY','VALUE','SUSPENDED','TIME','DATE','TTL');
+$pattern = "/([A-Za-z](?:[A-Za-z0-9_]{0,64}[A-Za-z])?)='([^']*)'/";
+
+$matches = [];
+if (!preg_match_all($pattern, $line, $matches, PREG_SET_ORDER)) {
+	fwrite(STDERR, "no key/value pairs");
+	exit(1);
+}
+
+$remainder = trim(preg_replace($pattern, '', $line));
+if ($remainder !== '') {
+	fwrite(STDERR, "leftover content");
+	exit(1);
+}
+
+$result = [];
+foreach ($matches as $match) {
+	[, $key, $value] = $match;
+	if (!in_array($key, $allowed_keys, true)) {
+		fwrite(STDERR, "invalid key $key");
+		exit(1);
+	}
+	$result[$key] = $value;
+}
+
+echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+EOPHP
+	)
+	check_result $? "invalid dns record format in $USER_DATA/dns/$domain.conf line: $line" $E_INVALID
+
+	RECORD=$(jq -r '.RECORD // empty' <<< "$json")
+	TYPE=$(jq -r '.TYPE // empty' <<< "$json")
+	PRIORITY=$(jq -r '.PRIORITY // empty' <<< "$json")
+	VALUE=$(jq -r '.VALUE // empty' <<< "$json")
+	SUSPENDED=$(jq -r '.SUSPENDED // empty' <<< "$json")
+	TIME=$(jq -r '.TIME // empty' <<< "$json")
+	DATE=$(jq -r '.DATE // empty' <<< "$json")
+	TTL=$(jq -r '.TTL // empty' <<< "$json")
+}
+
 # Update domain zone
 update_domain_zone() {
 	domain_param=$(grep "DOMAIN='$domain'" $USER_DATA/dns.conf)
 	parse_object_kv_list "$domain_param"
 	local zone_ttl="$TTL"
+	local value_for_zone
 	SOA=$(idn2 --quiet "$SOA")
 	if [ -z "$SERIAL" ]; then
 		SERIAL=$(date +'%Y%m%d01')
@@ -519,15 +569,11 @@ update_domain_zone() {
                                             $refresh
                                             1209600
                                             180 )
-" > $zn_conf
-
-	fields='$RECORD\t$TTL\tIN\t$TYPE\t$PRIORITY\t$VALUE'
-	while read line; do
-		unset TTL
-		IFS=$'\n'
-		for key in $(echo $line | sed "s/' /'\n/g"); do
-			eval ${key%%=*}="${key#*=}"
-		done
+" > "$zn_conf"
+
+	while IFS= read -r line; do
+		unset TTL RECORD TYPE PRIORITY VALUE SUSPENDED TIME DATE
+		parse_dns_record_line "$line"
 
 		# inherit zone TTL if record lacks explicit TTL value
 		[ -z "$TTL" ] && TTL="$zone_ttl"
@@ -538,6 +584,8 @@ update_domain_zone() {
 		fi
 
 		if [ "$TYPE" = 'TXT' ]; then
+			# Restore single quotes before chunking so %quote% tokens are not split mid-way
+			VALUE=${VALUE//%quote%/\'}
 			txtlength=${#VALUE}
 			if [ $txtlength -gt 255 ]; then
 				already_chunked=0
@@ -555,9 +603,10 @@ update_domain_zone() {
 		fi
 
 		if [ "$SUSPENDED" != 'yes' ]; then
-			eval echo -e "\"$fields\"" | sed "s/%quote%/'/g" >> $zn_conf
+			value_for_zone=${VALUE//%quote%/\'}
+			printf "%s\t%s\tIN\t%s\t%s\t%s\n" "$RECORD" "$TTL" "$TYPE" "$PRIORITY" "$value_for_zone" >> "$zn_conf"
 		fi
-	done < $USER_DATA/dns/$domain.conf
+	done < "$USER_DATA/dns/$domain.conf"
 }
 
 # Update zone serial
@@ -619,6 +668,13 @@ is_dns_record_critical() {
 is_dns_fqnd() {
 	t=$1
 	r=$2
+	if [ "$t" = 'SRV' ]; then
+		first_field=$(echo "$r" | awk '{print $1}')
+		last_field=$(echo "$r" | awk '{print $NF}')
+		if [ "$first_field" = "." ] || [ "$last_field" = "." ]; then
+			return
+		fi
+	fi
 	fqdn_type=$(echo $t | grep "^NS\|CNAME\|MX\|PTR\|SRV")
 	tree_length=3
 	if [[ $t = 'CNAME' || $t = 'MX' || $t = 'PTR' ]]; then

+ 330 - 0
func/internal/dns_record_validator.php

@@ -0,0 +1,330 @@
+<?php
+
+declare(strict_types=1);
+
+if (!isset($argv[1], $argv[2], $argv[3])) {
+	$error_json = [
+		"valid" => false,
+		"error_message" => "record, rtype, and priority arguments are required",
+	];
+	echo json_encode(
+		$error_json,
+		JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
+	);
+	exit(1);
+}
+
+$record = $argv[1];
+$rtype = $argv[2];
+$priority = $argv[3];
+$known_types = [
+	"A",
+	"AAAA",
+	"NS",
+	"CNAME",
+	"MX",
+	"TXT",
+	"SRV",
+	"DNSKEY",
+	"KEY",
+	"IPSECKEY",
+	"PTR",
+	"SPF",
+	"TLSA",
+	"CAA",
+	"DS",
+];
+$valid = true;
+$error_message = null;
+$cleaned_record = null;
+$new_priority = null;
+
+$validateInt = static function ($value, $min, $max) {
+	return filter_var($value, FILTER_VALIDATE_INT, [
+		"options" => ["min_range" => $min, "max_range" => $max],
+	]);
+};
+$validateDomain = static function ($value) {
+	return filter_var(rtrim($value, "."), FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
+};
+$validateHex = static function ($value) {
+	return preg_match('/^[A-Fa-f0-9]+$/', $value) === 1;
+};
+$validateBase64 = static function ($value) {
+	if ($value === "" || preg_match("/[^A-Za-z0-9+\/=]/", $value)) {
+		return false;
+	}
+	return base64_decode($value, true) !== false;
+};
+$validatePrintableAscii = static function ($value) {
+	return !preg_match('/[^\x20-\x7E]/', $value);
+};
+$validateSrvTarget = static function ($value) use ($validateDomain) {
+	return $value === "." || $validateDomain($value);
+};
+
+if (!in_array($rtype, $known_types, true)) {
+	$valid = false;
+	$error_message = "unknown record type for validation: $rtype";
+} elseif ($rtype === "A") {
+	$valid = filter_var($record, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
+	if (!$valid) {
+		$error_message = "invalid A record format";
+	}
+} elseif ($rtype === "AAAA") {
+	$valid = filter_var($record, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
+	if (!$valid) {
+		$error_message = "invalid AAAA record format";
+	}
+} elseif ($rtype === "NS" || $rtype === "CNAME" || $rtype === "PTR") {
+	$valid = $validateDomain($record);
+	if (!$valid) {
+		$error_message = "invalid $rtype record format";
+	}
+} elseif ($rtype === "MX") {
+	$valid = $validateDomain($record);
+	if (!$valid) {
+		$error_message = "invalid MX record format";
+	} else {
+		if ($priority === "") {
+			$valid = false;
+			$error_message = "MX record priority is required";
+		} else {
+			$valid = $validateInt($priority, 0, 65535);
+			if ($valid === false) {
+				$error_message = "invalid MX record priority format (must be between 0 and 65535)";
+			}
+		}
+	}
+} elseif ($rtype === "SRV") {
+	$parts = preg_split("/\s+/", trim($record));
+	$parts_count = count($parts);
+	$target = null;
+	$port = null;
+	$weight = null;
+	$priority_to_use = $priority;
+
+	if ($parts_count === 4) {
+		[$priority_from_value, $weight, $port, $target] = $parts;
+		$priority_to_use = $priority_from_value;
+	} elseif ($parts_count === 3) {
+		if ($validateSrvTarget($parts[0])) {
+			[$target, $port, $weight] = $parts;
+		} elseif ($validateSrvTarget($parts[2])) {
+			[$weight, $port, $target] = $parts;
+		} else {
+			$valid = false;
+			$error_message = "invalid SRV record format (expected target with port and weight)";
+		}
+	} else {
+		$valid = false;
+		$error_message =
+			"invalid SRV record format (must contain priority weight port target or target port weight)";
+	}
+
+	if ($valid !== false) {
+		$priority_validated = $validateInt($priority_to_use, 0, 65535);
+		$weight_validated = $validateInt($weight, 0, 65535);
+		$port_validated = $validateInt($port, 0, 65535);
+		$target_validated = $validateSrvTarget($target ?? "");
+
+		if ($priority_validated === false) {
+			$valid = false;
+			$error_message = "invalid SRV record priority format (must be between 0 and 65535)";
+		} elseif ($weight_validated === false) {
+			$valid = false;
+			$error_message = "invalid SRV record weight format (must be between 0 and 65535)";
+		} elseif ($port_validated === false) {
+			$valid = false;
+			$error_message = "invalid SRV record port format (must be between 0 and 65535)";
+		} elseif (!$target_validated) {
+			$valid = false;
+			$error_message = "invalid SRV record target format";
+		} else {
+			$new_priority = $priority_validated;
+			$cleaned_record = $weight_validated . " " . $port_validated . " " . $target;
+		}
+	}
+} elseif ($rtype === "TXT" || $rtype === "SPF") {
+	if ($record === "") {
+		$valid = false;
+		$error_message = "$rtype record cannot be empty";
+	} elseif (strlen($record) > 65535) {
+		$valid = false;
+		$error_message = "$rtype record exceeds maximum length";
+	} elseif (!$validatePrintableAscii($record)) {
+		$valid = false;
+		$error_message = "$rtype record contains non-ASCII characters";
+	}
+} elseif ($rtype === "DNSKEY" || $rtype === "KEY") {
+	$parts = preg_split("/\s+/", trim($record));
+	if (count($parts) < 3) {
+		$valid = false;
+		$error_message = "invalid $rtype record format (expected flags protocol algorithm [public-key])";
+	} else {
+		[$flags, $protocol, $algorithm] = array_slice($parts, 0, 3);
+		$public_key = implode(" ", array_slice($parts, 3));
+		$flags_valid = $validateInt($flags, 0, 65535);
+		$protocol_valid = $validateInt($protocol, 0, 255);
+		$algorithm_valid = $validateInt($algorithm, 0, 255);
+		if ($rtype === "DNSKEY" && $protocol !== "3") {
+			$protocol_valid = false;
+		}
+		if ($flags_valid === false || $protocol_valid === false || $algorithm_valid === false) {
+			$valid = false;
+			$error_message = "invalid $rtype numeric fields";
+		} elseif ($rtype === "KEY" && $algorithm === "0") {
+			if ($public_key !== "") {
+				$valid = false;
+				$error_message = "invalid KEY public key for algorithm 0 (must be empty)";
+			}
+		} elseif ($public_key === "" || !$validateBase64($public_key)) {
+			$valid = false;
+			$error_message = "invalid $rtype public key (must be base64)";
+		}
+	}
+} elseif ($rtype === "IPSECKEY") {
+	$parts = preg_split("/\s+/", trim($record));
+	if (count($parts) < 4) {
+		$valid = false;
+		$error_message =
+			"invalid IPSECKEY record format (expected precedence gateway-type algorithm gateway [public-key])";
+	} else {
+		[$precedence, $gateway_type, $algorithm, $gateway] = array_slice($parts, 0, 4);
+		$public_key = implode(" ", array_slice($parts, 4));
+		$precedence_valid = $validateInt($precedence, 0, 255);
+		$gateway_type_valid = $validateInt($gateway_type, 0, 3);
+		$algorithm_valid = $validateInt($algorithm, 0, 255);
+		if (
+			$precedence_valid === false ||
+			$gateway_type_valid === false ||
+			$algorithm_valid === false
+		) {
+			$valid = false;
+			$error_message = "invalid IPSECKEY numeric fields";
+		} else {
+			if ($gateway_type === "0") {
+				if ($gateway !== "." && $gateway !== "") {
+					$valid = false;
+					$error_message = "invalid IPSECKEY gateway for type 0";
+				}
+			} elseif ($gateway_type === "1") {
+				if (!filter_var($gateway, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+					$valid = false;
+					$error_message = "invalid IPSECKEY IPv4 gateway";
+				}
+			} elseif ($gateway_type === "2") {
+				if (!filter_var($gateway, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+					$valid = false;
+					$error_message = "invalid IPSECKEY IPv6 gateway";
+				}
+			} else {
+				if (!$validateDomain($gateway)) {
+					$valid = false;
+					$error_message = "invalid IPSECKEY domain gateway";
+				}
+			}
+			if ($valid !== false) {
+				if ($algorithm === "0") {
+					if ($public_key !== "") {
+						$valid = false;
+						$error_message =
+							"invalid IPSECKEY public key for algorithm 0 (must be empty)";
+					}
+				} elseif ($public_key === "" || !$validateBase64($public_key)) {
+					$valid = false;
+					$error_message = "invalid IPSECKEY public key (must be base64)";
+				}
+			}
+		}
+	}
+} elseif ($rtype === "TLSA") {
+	$parts = preg_split("/\s+/", trim($record));
+	if (count($parts) < 4) {
+		$valid = false;
+		$error_message = "invalid TLSA record format (expected usage selector matching-type data)";
+	} else {
+		[$usage, $selector, $matching_type] = array_slice($parts, 0, 3);
+		$data = implode(" ", array_slice($parts, 3));
+		$usage_valid = $validateInt($usage, 0, 3);
+		$selector_valid = $validateInt($selector, 0, 1);
+		$matching_valid = $validateInt($matching_type, 0, 2);
+		$data_length = strlen($data);
+		if ($usage_valid === false || $selector_valid === false || $matching_valid === false) {
+			$valid = false;
+			$error_message = "invalid TLSA numeric fields";
+		} elseif ($data === "" || !$validateHex($data) || $data_length % 2 !== 0) {
+			$valid = false;
+			$error_message = "invalid TLSA data";
+		} elseif ($matching_valid === 1 && $data_length !== 64) {
+			$valid = false;
+			$error_message = "invalid TLSA data length for matching type 1";
+		} elseif ($matching_valid === 2 && $data_length !== 128) {
+			$valid = false;
+			$error_message = "invalid TLSA data length for matching type 2";
+		}
+	}
+} elseif ($rtype === "CAA") {
+	$parts = preg_split("/\s+/", trim($record), 3);
+	if (count($parts) < 3) {
+		$valid = false;
+		$error_message = "invalid CAA record format (expected flag tag value)";
+	} else {
+		[$flag, $tag, $value] = $parts;
+		$flag_valid = $validateInt($flag, 0, 255);
+		$tag_valid = preg_match('/^[A-Za-z0-9-]{1,63}$/', $tag);
+		if ($flag_valid === false || $tag_valid === 0) {
+			$valid = false;
+			$error_message = "invalid CAA flag or tag";
+		} elseif ($value === "") {
+			$valid = false;
+			$error_message = "invalid CAA value";
+		}
+	}
+} elseif ($rtype === "DS") {
+	$parts = preg_split("/\s+/", trim($record));
+	if (count($parts) < 4) {
+		$valid = false;
+		$error_message = "invalid DS record format (expected keytag algorithm digest-type digest)";
+	} else {
+		[$key_tag, $algorithm, $digest_type] = array_slice($parts, 0, 3);
+		$digest = implode(" ", array_slice($parts, 3));
+		$key_tag_valid = $validateInt($key_tag, 0, 65535);
+		$algorithm_valid = $validateInt($algorithm, 0, 255);
+		$digest_type_valid = $validateInt($digest_type, 0, 255);
+		$digest_lengths = [1 => 40, 2 => 64, 3 => 64, 4 => 96];
+		$digest_length = strlen($digest);
+		if (
+			$key_tag_valid === false ||
+			$algorithm_valid === false ||
+			$digest_type_valid === false
+		) {
+			$valid = false;
+			$error_message = "invalid DS numeric fields";
+		} elseif ($digest === "" || !$validateHex($digest) || $digest_length % 2 !== 0) {
+			$valid = false;
+			$error_message = "invalid DS digest";
+		} elseif (
+			array_key_exists((int) $digest_type, $digest_lengths) &&
+			$digest_length !== $digest_lengths[(int) $digest_type]
+		) {
+			$valid = false;
+			$error_message = "invalid DS digest length for type $digest_type";
+		}
+	}
+} else {
+	$valid = false;
+	$error_message = "validation not implemented for record type: $rtype";
+}
+
+$json = ["valid" => $valid !== false];
+if ($error_message !== null) {
+	$json["error_message"] = $error_message;
+}
+if ($cleaned_record !== null) {
+	$json["cleaned_record"] = $cleaned_record;
+}
+if ($new_priority !== null) {
+	$json["new_priority"] = $new_priority;
+}
+echo json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

+ 34 - 18
func/main.sh

@@ -792,7 +792,7 @@ is_alias_format_valid() {
 # IP format validator
 is_ip_format_valid() {
 	object_name=${2-ip}
-	valid=$($HESTIA_PHP -r '$ip="$argv[1]"; echo (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 0 : 1);' $1)
+	valid=$($HESTIA_PHP -r '$ip=$argv[1]; echo (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 0 : 1);' "$1")
 	if [ "$valid" -ne 0 ]; then
 		check_result "$E_INVALID" "invalid $object_name :: $1"
 	fi
@@ -801,14 +801,14 @@ is_ip_format_valid() {
 # IPv6 format validator
 is_ipv6_format_valid() {
 	object_name=${2-ipv6}
-	valid=$($HESTIA_PHP -r '$ip="$argv[1]"; echo (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 0 : 1);' $1)
+	valid=$($HESTIA_PHP -r '$ip=$argv[1]; echo (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 0 : 1);' "$1")
 	if [ "$valid" -ne 0 ]; then
 		check_result "$E_INVALID" "invalid $object_name :: $1"
 	fi
 }
 
 is_ip46_format_valid() {
-	valid=$($HESTIA_PHP -r '$ip="$argv[1]"; echo (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) ? 0 : 1);' $1)
+	valid=$($HESTIA_PHP -r '$ip=$argv[1]; echo (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) ? 0 : 1);' "$1")
 	if [ "$valid" -ne 0 ]; then
 		check_result "$E_INVALID" "invalid IP format :: $1"
 	fi
@@ -824,7 +824,7 @@ is_ipv4_cidr_format_valid() {
 
 is_ipv6_cidr_format_valid() {
 	object_name=${2-ipv6}
-	valid=$($HESTIA_PHP -r '$cidr="$argv[1]"; list($ip, $netmask) = [...explode("/", $cidr), 128]; echo ((filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && $netmask <= 128) ? 0 : 1);' $1)
+	valid=$($HESTIA_PHP -r '$cidr=$argv[1]; list($ip, $netmask) = [...explode("/", $cidr), 128]; echo ((filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && $netmask <= 128) ? 0 : 1);' "$1")
 	if [ "$valid" -ne 0 ]; then
 		check_result "$E_INVALID" "invalid $object_name :: $1"
 	fi
@@ -832,7 +832,7 @@ is_ipv6_cidr_format_valid() {
 
 is_netmask_format_valid() {
 	object_name=${2-netmask}
-	valid=$($HESTIA_PHP -r '$netmask="$argv[1]"; echo (preg_match("/^(128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)/", $netmask) ? 0 : 1);' $1)
+	valid=$($HESTIA_PHP -r '$netmask=$argv[1]; echo (preg_match("/^(128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)/", $netmask) ? 0 : 1);' "$1")
 	if [ "$valid" -ne 0 ]; then
 		check_result "$E_INVALID" "invalid $object_name :: $1"
 	fi
@@ -960,29 +960,45 @@ is_dbuser_format_valid() {
 
 # DNS record type validator
 is_dns_type_format_valid() {
-	known_dnstype='A,AAAA,NS,CNAME,MX,TXT,SRV,DNSKEY,KEY,IPSECKEY,PTR,SPF,TLSA,CAA,DS'
-	if [ -z "$(echo $known_dnstype | grep -w $1)" ]; then
+	is_valid=$(
+		$HESTIA_PHP -- "$1" << 'EOPHP'
+	<?php
+	$type = $argv[1];
+	$known_types = array("A","AAAA","NS","CNAME","MX","TXT","SRV","DNSKEY",
+	"KEY","IPSECKEY","PTR","SPF","TLSA","CAA","DS");
+	echo in_array($type, $known_types, true) ? "0" : "1";
+EOPHP
+	)
+	if [ "$is_valid" -ne 0 ]; then
 		check_result "$E_INVALID" "invalid dns record type format :: $1"
 	fi
 }
 
 # DNS record validator
 is_dns_record_format_valid() {
-	if [ "$rtype" = 'A' ]; then
-		is_ip_format_valid "$1"
+	is_no_new_line_format "$1"
+
+	json_from_php=$(
+		$HESTIA_PHP "$HESTIA/func/internal/dns_record_validator.php" "$1" "$rtype" "$priority"
+	)
+	check_result $? "dns record validation failed :: $1" "$E_INVALID"
+
+	is_valid=$(jq -er '.valid' <<< "$json_from_php")
+	if [ $? -ne 0 ]; then
+		check_result "$E_INVALID" "dns record validation failed :: $1"
 	fi
-	if [ "$rtype" = 'NS' ]; then
-		is_domain_format_valid "${1::-1}" 'ns_record'
+	if [ "$is_valid" != 'true' ]; then
+		error_message=$(jq -r '.error_message // "invalid dns record format"' <<< "$json_from_php")
+		check_result "$E_INVALID" "$error_message :: $1"
 	fi
-	if [ "$rtype" = 'MX' ]; then
-		is_domain_format_valid "${1::-1}" 'mx_record'
-		is_int_format_valid "$priority" 'priority_record'
+	cleaned_record=$(jq -r '.cleaned_record // empty' <<< "$json_from_php")
+	if [ -n "$cleaned_record" ]; then
+		dvalue="$cleaned_record"
 	fi
-	if [ "$rtype" = 'SRV' ]; then
-		format_no_quotes "$priority" 'priority_record'
+	updated_priority=$(jq -r '.new_priority // empty' <<< "$json_from_php")
+	if [ -n "$updated_priority" ]; then
+		priority="$updated_priority"
 	fi
-
-	is_no_new_line_format "$1"
 }
 
 # Email format validator

+ 170 - 8
test/checks.bats

@@ -167,19 +167,181 @@ r' "key"
 }
 
 @test "is_dns_record_format_valid" {
-    rtype='MX'
-    priority=1;
-    run is_dns_record_format_valid 'mx.hestiacp.com.'
-    assert_success
+	rtype='MX'
+	priority=1;
+	run is_dns_record_format_valid 'mx.hestiacp.com.'
+	assert_success
+}
+
+@test "is_dns_record_format_valid MX missing priority" {
+	rtype='MX'
+	priority=''
+	run is_dns_record_format_valid 'mx.hestiacp.com.'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid SRV 4-field" {
+	rtype='SRV'
+	priority=''
+	run is_dns_record_format_valid '10 20 5060 srv.hestiacp.com.'
+	assert_success
+}
+
+@test "is_dns_record_format_valid SRV invalid priority" {
+	rtype='SRV'
+	priority=''
+	run is_dns_record_format_valid 'abc 20 5060 srv.hestiacp.com.'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid SRV null target" {
+	rtype='SRV'
+	priority=''
+	run is_dns_record_format_valid '0 5 0 .'
+	assert_success
+}
+
+@test "is_dns_record_format_valid TXT newline" {
+	rtype='TXT'
+	priority=''
+	run is_dns_record_format_valid 'foo
+bar'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid TXT single quote" {
+	rtype='TXT'
+	priority=''
+	run is_dns_record_format_valid "foo'bar"
+	assert_success
+}
+
+@test "is_dns_record_format_valid TXT empty" {
+	rtype='TXT'
+	priority=''
+	run is_dns_record_format_valid ''
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid TXT non-ascii" {
+	rtype='TXT'
+	priority=''
+	run is_dns_record_format_valid 'café'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid DNSKEY valid" {
+	rtype='DNSKEY'
+	priority=''
+	run is_dns_record_format_valid '257 3 13 AwEAAc1='
+	assert_success
+}
+
+@test "is_dns_record_format_valid DNSKEY invalid protocol" {
+	rtype='DNSKEY'
+	priority=''
+	run is_dns_record_format_valid '257 1 13 AwEAAc1'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid DS invalid hex" {
+	rtype='DS'
+	priority=''
+	run is_dns_record_format_valid '12345 8 1 ZZZZ'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid TLSA valid" {
+	rtype='TLSA'
+	priority=''
+	run is_dns_record_format_valid '3 1 1 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
+	assert_success
+}
+
+@test "is_dns_record_format_valid CAA missing value" {
+	rtype='CAA'
+	priority=''
+	run is_dns_record_format_valid '0 issue'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid IPSECKEY valid" {
+	rtype='IPSECKEY'
+	priority=''
+	run is_dns_record_format_valid '10 1 2 192.0.2.1 AQIDBA=='
+	assert_success
+}
+
+@test "is_dns_record_format_valid IPSECKEY invalid gateway" {
+	rtype='IPSECKEY'
+	priority=''
+	run is_dns_record_format_valid '10 1 2 not-an-ip AQIDBA=='
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid IPSECKEY algorithm0 with key fails" {
+	rtype='IPSECKEY'
+	priority=''
+	run is_dns_record_format_valid '10 1 0 192.0.2.1 AQIDBA=='
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid TLSA type1 wrong length" {
+	rtype='TLSA'
+	priority=''
+	run is_dns_record_format_valid '3 1 1 0123'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid TLSA matching_type plus sign wrong length" {
+	rtype='TLSA'
+	priority=''
+	run is_dns_record_format_valid '3 1 +1 0123'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid DS type1 wrong length" {
+	rtype='DS'
+	priority=''
+	run is_dns_record_format_valid '12345 8 1 012345'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid DS type2 correct length" {
+	rtype='DS'
+	priority=''
+	run is_dns_record_format_valid '12345 8 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
+	assert_success
+}
+
+@test "is_dns_record_format_valid KEY algorithm0 empty key" {
+	rtype='KEY'
+	priority=''
+	run is_dns_record_format_valid '256 3 0'
+	assert_success
+}
+
+@test "is_dns_record_format_valid KEY algorithm0 with key fails" {
+	rtype='KEY'
+	priority=''
+	run is_dns_record_format_valid '256 3 0 AQID'
+	assert_failure $E_INVALID
+}
+
+@test "is_dns_record_format_valid KEY algorithm1 missing key fails" {
+	rtype='KEY'
+	priority=''
+	run is_dns_record_format_valid '256 3 1'
+	assert_failure $E_INVALID
 }
 
 @test "is_dns_record_format_valid test" {
-    rtype='MX'
-priority=1;
-     run is_dns_record_format_valid 'c
+	rtype='MX'
+	priority=1;
+	run is_dns_record_format_valid 'c
 1eshutdown
 r'
-    assert_failure $E_INVALID
+	assert_failure $E_INVALID
 }
 
 @test "is_alias_format_valid success" {