From 06a5f9c8c901032f7813b7b3310cc98456b6a34e Mon Sep 17 00:00:00 2001 From: Nihaal Shaikh Date: Thu, 15 Oct 2026 09:04:58 +0200 Subject: [PATCH] Fix #2 - Reject IP addresses that Moodle cannot match The custom IP field and the predefined ranges form accepted invalid entries such as 2001:db8::1garbage, 192.168.1.0/24garbage or 192.168.1.0/999. address_in_subnet() silently skips those entries, so a "must not" restriction with a typo let blocked users in. Validation now only accepts formats address_in_subnet() can match: a single address, a subnet (mask 0-32 / 0-128) or a range in the last group with end >= start. IPv6 forms cleanremoteaddr() cannot parse are rejected as well. JS and PHP share the same rules; PHP also checks each entry against address_in_subnet() as a safety net. - Allow spaces after commas in the restriction field - Support IPv6 ranges, e.g. 2001:db8::1-ff - Add PHPUnit tests for helper::is_valid_ipaddress_list() - Bump version to 2026101500 (5.3.2) --- CHANGELOG.md | 9 + classes/form/range_form.php | 49 +---- classes/helper.php | 137 +++++++++++++ lang/en/availability_ipaddress.php | 11 +- tests/helper_test.php | 98 ++++++++++ version.php | 4 +- ...oodle-availability_ipaddress-form-debug.js | 183 +++++++++++++----- .../moodle-availability_ipaddress-form-min.js | 2 +- .../moodle-availability_ipaddress-form.js | 175 ++++++++++++----- yui/src/form/js/form.js | 183 +++++++++++++----- 10 files changed, 653 insertions(+), 198 deletions(-) create mode 100644 tests/helper_test.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3224220..093fd3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 5.3.2 (2026101500) +- Fixed IP address validation being too permissive https://github.com/LdesignMedia/moodle-availability_ipaddress/issues/2 + - Invalid entries like `2001:db8::1garbage` or `192.168.1.0/24garbage` are no longer accepted. Moodle silently ignored them, so a "must not" restriction could let blocked users in. + - Only formats Moodle can match are accepted. Rejected: IPv6 written with an IPv4 part (`::ffff:192.168.1.1`), an IPv6 prefix without mask (`2001:db8::`, use `2001:db8::/32`), masks with leading zeros (`/024`) and some compressed IPv6 forms Moodle can't parse (`1::2:3:4:5:6:7`). + - Predefined IP ranges are now validated with the same rules (subnet mask range, range end). + - Spaces after commas are now allowed in the restriction field, e.g. `192.168.1.1, 10.0.0.1`. + - Added support for IPv6 ranges in the last group, e.g. `2001:db8::1-ff`. + - Note: existing restrictions and predefined ranges are not changed. If one contains an invalid entry, it now shows a validation error the next time it is edited. Entries that were already invalid never matched. + ## 5.3.1 (2026101400) - Solved CI tests https://github.com/LdesignMedia/moodle-availability_ipaddress/issues/20 | Thanks @minhduchoang195 diff --git a/classes/form/range_form.php b/classes/form/range_form.php index d890246..2f4a90b 100644 --- a/classes/form/range_form.php +++ b/classes/form/range_form.php @@ -25,6 +25,8 @@ namespace availability_ipaddress\form; +use availability_ipaddress\helper; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -102,52 +104,11 @@ class range_form extends \moodleform { public function validation($data, $files) { $errors = parent::validation($data, $files); - // Validate IP addresses. - if (!empty($data['ipaddresses'])) { - $ipaddresses = explode(',', $data['ipaddresses']); - foreach ($ipaddresses as $ip) { - $ip = trim($ip); - if (!$this->validate_ip_format($ip)) { - $errors['ipaddresses'] = get_string('error_ipaddress', 'availability_ipaddress'); - break; - } - } + // Validate IP addresses, the field is required so an empty value is invalid as well. + if (!helper::is_valid_ipaddress_list($data['ipaddresses'] ?? '')) { + $errors['ipaddresses'] = get_string('error_ipaddress', 'availability_ipaddress'); } return $errors; } - - /** - * Validate IP address format. - * - * @param string $ip - * - * @return bool - */ - private function validate_ip_format(string $ip): bool { - - // Use the same validation logic as the main plugin. - // This is a simplified version - you might want to use the same regex as in JS. - if (filter_var($ip, FILTER_VALIDATE_IP)) { - return true; - } - - // Check for CIDR notation. - if (strpos($ip, '/') !== false) { - [$addr, $mask] = explode('/', $ip); - if (filter_var($addr, FILTER_VALIDATE_IP) && is_numeric($mask)) { - return true; - } - } - - // Check for IP range. - if (strpos($ip, '-') !== false) { - [$start, $end] = explode('-', $ip); - if (filter_var($start, FILTER_VALIDATE_IP)) { - return true; - } - } - - return false; - } } diff --git a/classes/helper.php b/classes/helper.php index ad16d63..1863816 100644 --- a/classes/helper.php +++ b/classes/helper.php @@ -359,4 +359,141 @@ class helper { return $modified; } + + /** + * Validate a comma separated list of IP addresses. + * + * @param string $ipaddresses Comma separated list of IP addresses. + * + * @return bool True if every entry is valid. + */ + public static function is_valid_ipaddress_list(string $ipaddresses): bool { + foreach (explode(',', $ipaddresses) as $entry) { + if (!self::is_valid_ipaddress_entry(trim($entry))) { + return false; + } + } + + return true; + } + + /** + * Validate a single IP address entry. + * + * Only formats that address_in_subnet() understands are accepted, anything else + * would be silently ignored when the restriction is checked. + * + * - Address: 192.168.1.1 or 2001:db8::1 + * - Subnet: 192.168.1.0/24 or 2001:db8::/32 + * - Range in the last group: 192.168.1.1-255 or 2001:db8::1-ff + * + * @param string $entry A single IP address entry. + * + * @return bool True if the entry is valid. + */ + public static function is_valid_ipaddress_entry(string $entry): bool { + if (strpos($entry, '/') !== false) { + $valid = self::is_valid_subnet($entry); + } else if (strpos($entry, '-') !== false) { + $valid = self::is_valid_range($entry); + } else { + // An address ending in ":" is treated as an incomplete prefix by address_in_subnet(), which fails to parse it. + $valid = self::is_ipv4($entry) || (self::is_ipv6($entry) && substr($entry, -1) !== ':'); + } + + // Safety net: the entry must match its own start address, so address_in_subnet() is able to parse it. + return $valid && address_in_subnet(preg_split('/[\/-]/', $entry)[0], $entry, true); + } + + /** + * Check if a value is a valid IPv4 address. + * + * @param string $value The value to check. + * + * @return bool True if the value is a valid IPv4 address. + */ + private static function is_ipv4(string $value): bool { + return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + } + + /** + * Check if a value is a valid IPv6 address that cleanremoteaddr() can parse. + * + * cleanremoteaddr() doesn't handle embedded IPv4 notation reliably and only expands "::" + * when it replaces at least two groups, or three at the start or end of the address. + * + * @param string $value The value to check. + * + * @return bool True if the value is a valid IPv6 address. + */ + private static function is_ipv6(string $value): bool { + if (strpos($value, '.') !== false || filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return false; + } + + if (strpos($value, '::') === false) { + return true; + } + + $groups = count(array_filter(explode(':', $value), 'strlen')); + if (strpos($value, '::') === 0 || substr($value, -2) === '::') { + return $groups <= 5; + } + + return $groups <= 6; + } + + /** + * Validate a subnet in CIDR notation, e.g. 192.168.1.0/24 or 2001:db8::/32. + * + * @param string $entry The subnet. + * + * @return bool True if the subnet is valid. + */ + private static function is_valid_subnet(string $entry): bool { + $parts = explode('/', $entry); + + // Moodle's is_number() rejects a mask with leading zeros. + if (count($parts) !== 2 || !preg_match('/^(0|[1-9]\d{0,2})$/D', $parts[1])) { + return false; + } + + [$address, $mask] = $parts; + if (self::is_ipv4($address)) { + return (int) $mask <= 32; + } + + return self::is_ipv6($address) && (int) $mask <= 128; + } + + /** + * Validate a range in the last group of the address, e.g. 192.168.1.1-255 or 2001:db8::1-ff. + * + * The end of the range may not be lower than the start, it would never match. + * + * @param string $entry The range. + * + * @return bool True if the range is valid. + */ + private static function is_valid_range(string $entry): bool { + $parts = explode('-', $entry); + if (count($parts) !== 2) { + return false; + } + + [$start, $end] = $parts; + if (self::is_ipv4($start)) { + $startgroup = (int) substr(strrchr($start, '.'), 1); + + return preg_match('/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/D', $end) === 1 && (int) $end >= $startgroup; + } + + if (self::is_ipv6($start)) { + $startgroup = hexdec(substr(strrchr($start, ':'), 1) ?: '0'); + + return preg_match('/^[0-9a-f]{1,4}$/iD', $end) === 1 && hexdec($end) >= $startgroup; + } + + return false; + } } diff --git a/lang/en/availability_ipaddress.php b/lang/en/availability_ipaddress.php index d1ed3c3..7078c52 100644 --- a/lang/en/availability_ipaddress.php +++ b/lang/en/availability_ipaddress.php @@ -56,11 +56,12 @@ $string['ipaddresses_help'] = 'Enter IP addresses separated by commas. Supports $string['ipaddresses_help_help'] = '

Enter one or more IP addresses or ranges, separated by commas.

Examples:

'; +
  • Single IP: 192.168.1.1 or 2001:db8::1
  • +
  • IP range: 192.168.1.1-255 or 2001:db8::1-ff
  • +
  • Subnet: 192.168.1.0/24 or 2001:db8::/32
  • +
  • Multiple: 192.168.1.1, 10.0.0.0/8, 172.16.0.1-255
  • + +

    A range only changes the last part of the address. Use a subnet for an IPv6 network prefix, e.g. 2001:db8::/32 instead of 2001:db8::.

    '; $string['enabled'] = 'Enabled'; $string['sortorder'] = 'Sort order'; $string['existing_ranges'] = 'Existing IP ranges'; diff --git a/tests/helper_test.php b/tests/helper_test.php new file mode 100644 index 0000000..7ec16fa --- /dev/null +++ b/tests/helper_test.php @@ -0,0 +1,98 @@ +. + +namespace availability_ipaddress; + +/** + * Unit tests for the IP address validation in the helper class. + * + * @package availability_ipaddress + * @copyright 2026 LdesignMedia.nl - Luuk Verhoeven + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \availability_ipaddress\helper + */ +final class helper_test extends \basic_testcase { + /** + * Data provider for test_is_valid_ipaddress_list. + * + * @return array + */ + public static function ipaddress_list_provider(): array { + return [ + 'IPv4 address' => ['192.168.1.1', true], + 'IPv4 subnet' => ['192.168.1.0/24', true], + 'IPv4 subnet all addresses' => ['0.0.0.0/0', true], + 'IPv4 range' => ['192.168.1.10-20', true], + 'IPv4 range single address' => ['192.168.1.5-5', true], + 'IPv6 address' => ['2001:db8::1', true], + 'IPv6 loopback' => ['::1', true], + 'IPv6 full address' => ['2001:db8:0:0:0:0:0:1', true], + 'IPv6 compressed in the middle' => ['1:2:3::4:5:6', true], + 'IPv6 compressed at the start' => ['::1:2:3:4:5', true], + 'IPv6 subnet' => ['2001:db8::/32', true], + 'IPv6 subnet all addresses' => ['::/0', true], + 'IPv6 range' => ['2001:db8::1-ff', true], + 'IPv6 range from compressed start' => ['2001:db8::-ffff', true], + 'List' => ['192.168.1.1,10.0.0.0/8,2001:db8::/32', true], + 'List with spaces' => [' 192.168.1.1 , 10.0.0.1 ', true], + 'IPv6 with trailing text' => ['2001:db8::1garbage', false], + 'IPv6 with zone id' => ['fe80::1%eth0', false], + 'IPv6 subnet mask too large' => ['2001:db8::/129', false], + 'IPv6 address ending in ::' => ['2001:db8::', false], + 'IPv6 unspecified address' => ['::', false], + 'IPv6 :: replacing one group' => ['1::2:3:4:5:6:7', false], + 'IPv6 :: at the start replacing two groups' => ['::1:2:3:4:5:6', false], + 'IPv6 subnet :: at the end replacing two groups' => ['2001:db8:1:2:3:4::/112', false], + 'IPv6 with embedded IPv4' => ['::ffff:192.168.1.1', false], + 'IPv6 subnet with embedded IPv4' => ['::ffff:192.168.1.0/120', false], + 'IPv4 subnet with trailing text' => ['192.168.1.0/24garbage', false], + 'IPv4 subnet mask too large' => ['192.168.1.0/33', false], + 'IPv4 subnet mask with leading zero' => ['192.168.1.0/024', false], + 'IPv4 subnet negative mask' => ['192.168.1.0/-5', false], + 'IPv4 subnet exponent mask' => ['192.168.1.0/1e3', false], + 'IPv4 subnet trailing newline' => ["192.168.1.0/24\n", true], + 'IPv4 with non-breaking space' => ["192.168.1.1\u{a0}", false], + 'IPv4 subnet double mask' => ['192.168.1.0/24/8', false], + 'IPv4 range with text' => ['192.168.1.1-garbage', false], + 'IPv4 range end too large' => ['192.168.1.1-256', false], + 'IPv4 range end below start' => ['192.168.1.20-10', false], + 'IPv4 range double dash' => ['192.168.1.1-2-3', false], + 'IPv6 range end below start' => ['2001:db8::ff-1', false], + 'Invalid octet' => ['999.1.1.1', false], + 'Leading zero' => ['192.168.01.1', false], + 'Text' => ['abc', false], + 'Text with subnet' => ['hello/gm', false], + 'Empty' => ['', false], + 'Zero' => ['0', false], + 'Trailing comma' => ['192.168.1.1,', false], + 'List with one invalid entry' => ['192.168.1.1, 2001:db8::1garbage', false], + ]; + } + + /** + * Test validation of a comma separated list of IP addresses. + * + * @dataProvider ipaddress_list_provider + * + * @param string $ipaddresses + * @param bool $expected + * + * @return void + */ + public function test_is_valid_ipaddress_list(string $ipaddresses, bool $expected): void { + $this->assertSame($expected, helper::is_valid_ipaddress_list($ipaddresses)); + } +} diff --git a/version.php b/version.php index 7b34728..b5dccc6 100644 --- a/version.php +++ b/version.php @@ -27,8 +27,8 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'availability_ipaddress'; -$plugin->version = 2026101400; -$plugin->release = '5.3.1'; +$plugin->version = 2026101500; +$plugin->release = '5.3.2'; $plugin->requires = 2016120500; $plugin->maturity = MATURITY_STABLE; $plugin->supported = [400, 503]; diff --git a/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-debug.js b/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-debug.js index ef6cb18..2a27c2f 100644 --- a/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-debug.js +++ b/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-debug.js @@ -15,11 +15,12 @@ YUI.add('moodle-availability_ipaddress-form', function (Y, NAME) { */ M.availability_ipaddress = M.availability_ipaddress || {}; +// A single IPv4 octet (0-255) without leading zeros. +M.availability_ipaddress.octet = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'; + // MIT https://github.com/sindresorhus/ip-regex -// Advanced ip-address regex for validating. -M.availability_ipaddress.v4 = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]' + - '[0-9]|[1-9][0-9]|[0-9])){3}'; -M.availability_ipaddress.v6 = "^((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]" + +// IPv6 address, without anchors so it can be combined. +M.availability_ipaddress.v6 = "((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]" + "\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){5}" + "(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4})" + "{1,2}|:)|(?:[a-fA-F\\d]{1,4}:){4}(?:(:[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]" + @@ -29,7 +30,7 @@ M.availability_ipaddress.v6 = "^((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)| "\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,5}|:)|(?:[a-fA-F\\d]{1,4}:){1}(?:(:[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]" + "|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,6}|:)|(?::" + "((?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}" + - "|(?::[a-fA-F\\d]{1,4}){1,7}|:)))(%[0-9a-zA-Z]{1,})?"; + "|(?::[a-fA-F\\d]{1,4}){1,7}|:)))"; /** * @class M.availability_ipaddress.form @@ -163,60 +164,141 @@ M.availability_ipaddress.form.getNode = function(json) { }; /** - * Validate ipaddresses + * Check if a value is a valid IPv4 address. * - * @param {string[]} ipaddresses + * @param {string} value + * @returns {boolean} + */ +M.availability_ipaddress.isIpv4 = function(value) { + 'use strict'; + var octet = M.availability_ipaddress.octet; + return new RegExp('^' + octet + '(?:\\.' + octet + '){3}$').test(value); +}; + +/** + * Check if a value is a valid IPv6 address that Moodle's cleanremoteaddr() can parse. + * + * cleanremoteaddr() doesn't handle embedded IPv4 notation reliably and only expands "::" + * when it replaces at least two groups, or three at the start or end of the address. + * + * @param {string} value + * @returns {boolean} + */ +M.availability_ipaddress.isIpv6 = function(value) { + 'use strict'; + var groups; + + if (value.indexOf('.') !== -1 || !new RegExp('^' + M.availability_ipaddress.v6 + '$').test(value)) { + return false; + } + if (value.indexOf('::') === -1) { + return true; + } + + groups = value.split(':').filter(function(group) { + return group !== ''; + }).length; + if (value.indexOf('::') === 0 || value.slice(-2) === '::') { + return groups <= 5; + } + return groups <= 6; +}; + +/** + * Trim the same characters as PHP's trim(), which address_in_subnet() uses. + * + * @param {string} value + * @returns {string} + */ +M.availability_ipaddress.trim = function(value) { + 'use strict'; + var chars = ' \t\n\r\x00\x0B', + start = 0, + end = value.length; + + while (start < end && chars.indexOf(value.charAt(start)) !== -1) { + start++; + } + while (end > start && chars.indexOf(value.charAt(end - 1)) !== -1) { + end--; + } + return value.substring(start, end); +}; + +/** + * Validate a single entry. Only formats that Moodle's address_in_subnet() understands are + * accepted, anything else would be silently ignored when the restriction is checked. + * + * - Address: 192.168.1.1 or 2001:db8::1 + * - Subnet: 192.168.1.0/24 or 2001:db8::/32 + * - Range in the last group: 192.168.1.1-255 or 2001:db8::1-ff + * + * @param {string} entry + * @returns {boolean} + */ +M.availability_ipaddress.validateEntry = function(entry) { + 'use strict'; + var parts, mask, lastgroup; + + if (entry.indexOf('/') !== -1) { + parts = entry.split('/'); + // Moodle's is_number() rejects a mask with leading zeros. + if (parts.length !== 2 || !/^(0|[1-9]\d{0,2})$/.test(parts[1])) { + return false; + } + mask = parseInt(parts[1], 10); + if (M.availability_ipaddress.isIpv4(parts[0])) { + return mask <= 32; + } + return M.availability_ipaddress.isIpv6(parts[0]) && mask <= 128; + } + + if (entry.indexOf('-') !== -1) { + parts = entry.split('-'); + if (parts.length !== 2) { + return false; + } + + // The end of the range may not be lower than the start, it would never match. + if (M.availability_ipaddress.isIpv4(parts[0])) { + return new RegExp('^' + M.availability_ipaddress.octet + '$').test(parts[1]) && + parseInt(parts[1], 10) >= parseInt(parts[0].split('.')[3], 10); + } + if (M.availability_ipaddress.isIpv6(parts[0])) { + lastgroup = parts[0].split(':').pop() || '0'; + return /^[a-fA-F\d]{1,4}$/.test(parts[1]) && parseInt(parts[1], 16) >= parseInt(lastgroup, 16); + } + return false; + } + + // An address ending in ":" is treated as an incomplete prefix by address_in_subnet(), which fails to parse it. + return M.availability_ipaddress.isIpv4(entry) || (M.availability_ipaddress.isIpv6(entry) && entry.slice(-1) !== ':'); +}; + +/** + * Validate a comma separated list of ipaddresses. + * + * @param {string} ipaddresses * @returns {boolean} */ M.availability_ipaddress.validateIpaddress = function(ipaddresses) { 'use strict'; - Y.log(ipaddresses); - // Return true for empty string - it's valid to have no custom IPs - if (!ipaddresses || ipaddresses.trim() === '') { + var entries, i; + + // Return true for empty string - it's valid to have no custom IPs. + if (!ipaddresses || M.availability_ipaddress.trim(ipaddresses) === '') { return true; } - ipaddresses = ipaddresses.split(','); - for (var i in ipaddresses) { - // Test normal ip format. - // Strict ipv4 check. - if (new RegExp(/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm) - .test(ipaddresses[i])) { - Y.log('Correct ipv4'); - continue; + entries = ipaddresses.split(','); + for (i = 0; i < entries.length; i++) { + // Whitespace around an entry is allowed, address_in_subnet() trims it as well. + if (!M.availability_ipaddress.validateEntry(M.availability_ipaddress.trim(entries[i]))) { + Y.log('Incorrect ip-address: ' + entries[i]); + return false; } - - var ipv4Regex = new RegExp( - '^(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)' + - '(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}-' + - '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)$', - 'gm' - ); - - if (ipv4Regex.test(ipaddresses[i])) { - Y.log('Correct ipv4 range.'); - continue; - } - - if (new RegExp(M.availability_ipaddress.v6) - .test(ipaddresses[i])) { - Y.log('Correct ipv6'); - continue; - } - - // Test subnet with a regex. - if (new RegExp("^(?:".concat(M.availability_ipaddress.v4 + "\\/(3[0-2]|[12]?[0-9])|(1\\*)", ")|(?:") - .concat(M.availability_ipaddress.v6 + "\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])", ")?\\/gm")) - .test(ipaddresses[i])) { - Y.log('Correct subnet'); - continue; - } - - Y.log('Incorrect ip'); - return false; } - Y.log('Valid ipaddresses'); return true; }; @@ -259,9 +341,8 @@ M.availability_ipaddress.form.fillErrors = function(errors, node) { var value = {}; this.fillValue(value, node); - // Basic ipaddresses checks - only validate if not empty. - if (value.ipaddresses && value.ipaddresses.trim() !== '' && - M.availability_ipaddress.validateIpaddress(value.ipaddresses) === false) { + // Basic ipaddresses checks - an empty value is valid. + if (M.availability_ipaddress.validateIpaddress(value.ipaddresses) === false) { errors.push('availability_ipaddress:error_ipaddress'); } }; diff --git a/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-min.js b/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-min.js index 8aedf57..a30e053 100644 --- a/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-min.js +++ b/yui/build/moodle-availability_ipaddress-form/moodle-availability_ipaddress-form-min.js @@ -1 +1 @@ -YUI.add("moodle-availability_ipaddress-form",function(r,d){M.availability_ipaddress=M.availability_ipaddress||{},M.availability_ipaddress.v4="(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])){3}",M.availability_ipaddress.v6="^((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,2}|:)|(?:[a-fA-F\\d]{1,4}:){4}(?:(:[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,3}|:)|(?:[a-fA-F\\d]{1,4}:){3}(?:(:[a-fA-F\\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,4}|:)|(?:[a-fA-F\\d]{1,4}:){2}(?:(:[a-fA-F\\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,5}|:)|(?:[a-fA-F\\d]{1,4}:){1}(?:(:[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,6}|:)|(?::((?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,7}|:)))(%[0-9a-zA-Z]{1,})?",M.availability_ipaddress.form=r.Object(M.core_availability.plugin),M.availability_ipaddress.form.initInner=function(d){"use strict";this.predefinedRanges=d||[]},M.availability_ipaddress.form.getValue=function(d,a){"use strict";d=a.one("input[name="+d+"]").get("value");return M.availability_ipaddress.validateIpaddress(d),d},M.availability_ipaddress.form.getNode=function(d){"use strict";var a,e,i,s,t="ipaddresses"+M.availability_ipaddress.form.instId,l="predefined"+M.availability_ipaddress.form.instId;if(M.availability_ipaddress.form.instId+=1,a='
    ',this.predefinedRanges&&0')+('")+('")+"
    "+'
    ')+('")}else a=(a+='
    ')+'";return l=r.Node.create("
    "+(a=(a+='')+"
    "+"
    ")+"
    "),d.ipaddresses!==undefined&&l.one("input[name=ipaddresses]").set("value",d.ipaddresses),d.predefined_ranges!==undefined&&this.predefinedRanges&&0',this.predefinedRanges&&0')+('")+('")+""+'
    ')+('")}else a=(a+='
    ')+'";return l=r.Node.create("
    "+(a=(a+='')+"
    "+"
    ")+"
    "),i.ipaddresses!==undefined&&l.one("input[name=ipaddresses]").set("value",i.ipaddresses),i.predefined_ranges!==undefined&&this.predefinedRanges&&0=parseInt(a[0].split(".")[3],10):!!M.availability_ipaddress.isIpv6(a[0])&&(e=a[0].split(":").pop()||"0",/^[a-fA-F\d]{1,4}$/.test(a[1])&&parseInt(a[1],16)>=parseInt(e,16))):M.availability_ipaddress.isIpv4(i)||M.availability_ipaddress.isIpv6(i)&&":"!==i.slice(-1)},M.availability_ipaddress.validateIpaddress=function(i){"use strict";var a,e;if(!i||""===M.availability_ipaddress.trim(i))return!0;for(a=i.split(","),e=0;e start && chars.indexOf(value.charAt(end - 1)) !== -1) { + end--; + } + return value.substring(start, end); +}; + +/** + * Validate a single entry. Only formats that Moodle's address_in_subnet() understands are + * accepted, anything else would be silently ignored when the restriction is checked. + * + * - Address: 192.168.1.1 or 2001:db8::1 + * - Subnet: 192.168.1.0/24 or 2001:db8::/32 + * - Range in the last group: 192.168.1.1-255 or 2001:db8::1-ff + * + * @param {string} entry + * @returns {boolean} + */ +M.availability_ipaddress.validateEntry = function(entry) { + 'use strict'; + var parts, mask, lastgroup; + + if (entry.indexOf('/') !== -1) { + parts = entry.split('/'); + // Moodle's is_number() rejects a mask with leading zeros. + if (parts.length !== 2 || !/^(0|[1-9]\d{0,2})$/.test(parts[1])) { + return false; + } + mask = parseInt(parts[1], 10); + if (M.availability_ipaddress.isIpv4(parts[0])) { + return mask <= 32; + } + return M.availability_ipaddress.isIpv6(parts[0]) && mask <= 128; + } + + if (entry.indexOf('-') !== -1) { + parts = entry.split('-'); + if (parts.length !== 2) { + return false; + } + + // The end of the range may not be lower than the start, it would never match. + if (M.availability_ipaddress.isIpv4(parts[0])) { + return new RegExp('^' + M.availability_ipaddress.octet + '$').test(parts[1]) && + parseInt(parts[1], 10) >= parseInt(parts[0].split('.')[3], 10); + } + if (M.availability_ipaddress.isIpv6(parts[0])) { + lastgroup = parts[0].split(':').pop() || '0'; + return /^[a-fA-F\d]{1,4}$/.test(parts[1]) && parseInt(parts[1], 16) >= parseInt(lastgroup, 16); + } + return false; + } + + // An address ending in ":" is treated as an incomplete prefix by address_in_subnet(), which fails to parse it. + return M.availability_ipaddress.isIpv4(entry) || (M.availability_ipaddress.isIpv6(entry) && entry.slice(-1) !== ':'); +}; + +/** + * Validate a comma separated list of ipaddresses. + * + * @param {string} ipaddresses * @returns {boolean} */ M.availability_ipaddress.validateIpaddress = function(ipaddresses) { 'use strict'; - // Return true for empty string - it's valid to have no custom IPs - if (!ipaddresses || ipaddresses.trim() === '') { + var entries, i; + + // Return true for empty string - it's valid to have no custom IPs. + if (!ipaddresses || M.availability_ipaddress.trim(ipaddresses) === '') { return true; } - ipaddresses = ipaddresses.split(','); - for (var i in ipaddresses) { - // Test normal ip format. - // Strict ipv4 check. - if (new RegExp(/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm) - .test(ipaddresses[i])) { - continue; + entries = ipaddresses.split(','); + for (i = 0; i < entries.length; i++) { + // Whitespace around an entry is allowed, address_in_subnet() trims it as well. + if (!M.availability_ipaddress.validateEntry(M.availability_ipaddress.trim(entries[i]))) { + return false; } - - var ipv4Regex = new RegExp( - '^(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)' + - '(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}-' + - '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)$', - 'gm' - ); - - if (ipv4Regex.test(ipaddresses[i])) { - continue; - } - - if (new RegExp(M.availability_ipaddress.v6) - .test(ipaddresses[i])) { - continue; - } - - // Test subnet with a regex. - if (new RegExp("^(?:".concat(M.availability_ipaddress.v4 + "\\/(3[0-2]|[12]?[0-9])|(1\\*)", ")|(?:") - .concat(M.availability_ipaddress.v6 + "\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])", ")?\\/gm")) - .test(ipaddresses[i])) { - continue; - } - - return false; } return true; @@ -249,9 +337,8 @@ M.availability_ipaddress.form.fillErrors = function(errors, node) { var value = {}; this.fillValue(value, node); - // Basic ipaddresses checks - only validate if not empty. - if (value.ipaddresses && value.ipaddresses.trim() !== '' && - M.availability_ipaddress.validateIpaddress(value.ipaddresses) === false) { + // Basic ipaddresses checks - an empty value is valid. + if (M.availability_ipaddress.validateIpaddress(value.ipaddresses) === false) { errors.push('availability_ipaddress:error_ipaddress'); } }; diff --git a/yui/src/form/js/form.js b/yui/src/form/js/form.js index 7620bfd..1c2d52a 100644 --- a/yui/src/form/js/form.js +++ b/yui/src/form/js/form.js @@ -13,11 +13,12 @@ */ M.availability_ipaddress = M.availability_ipaddress || {}; +// A single IPv4 octet (0-255) without leading zeros. +M.availability_ipaddress.octet = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'; + // MIT https://github.com/sindresorhus/ip-regex -// Advanced ip-address regex for validating. -M.availability_ipaddress.v4 = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]' + - '[0-9]|[1-9][0-9]|[0-9])){3}'; -M.availability_ipaddress.v6 = "^((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]" + +// IPv6 address, without anchors so it can be combined. +M.availability_ipaddress.v6 = "((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]" + "\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){5}" + "(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4})" + "{1,2}|:)|(?:[a-fA-F\\d]{1,4}:){4}(?:(:[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]" + @@ -27,7 +28,7 @@ M.availability_ipaddress.v6 = "^((?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)| "\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,5}|:)|(?:[a-fA-F\\d]{1,4}:){1}(?:(:[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]" + "|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(:[a-fA-F\\d]{1,4}){1,6}|:)|(?::" + "((?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}" + - "|(?::[a-fA-F\\d]{1,4}){1,7}|:)))(%[0-9a-zA-Z]{1,})?"; + "|(?::[a-fA-F\\d]{1,4}){1,7}|:)))"; /** * @class M.availability_ipaddress.form @@ -161,60 +162,141 @@ M.availability_ipaddress.form.getNode = function(json) { }; /** - * Validate ipaddresses + * Check if a value is a valid IPv4 address. * - * @param {string[]} ipaddresses + * @param {string} value + * @returns {boolean} + */ +M.availability_ipaddress.isIpv4 = function(value) { + 'use strict'; + var octet = M.availability_ipaddress.octet; + return new RegExp('^' + octet + '(?:\\.' + octet + '){3}$').test(value); +}; + +/** + * Check if a value is a valid IPv6 address that Moodle's cleanremoteaddr() can parse. + * + * cleanremoteaddr() doesn't handle embedded IPv4 notation reliably and only expands "::" + * when it replaces at least two groups, or three at the start or end of the address. + * + * @param {string} value + * @returns {boolean} + */ +M.availability_ipaddress.isIpv6 = function(value) { + 'use strict'; + var groups; + + if (value.indexOf('.') !== -1 || !new RegExp('^' + M.availability_ipaddress.v6 + '$').test(value)) { + return false; + } + if (value.indexOf('::') === -1) { + return true; + } + + groups = value.split(':').filter(function(group) { + return group !== ''; + }).length; + if (value.indexOf('::') === 0 || value.slice(-2) === '::') { + return groups <= 5; + } + return groups <= 6; +}; + +/** + * Trim the same characters as PHP's trim(), which address_in_subnet() uses. + * + * @param {string} value + * @returns {string} + */ +M.availability_ipaddress.trim = function(value) { + 'use strict'; + var chars = ' \t\n\r\x00\x0B', + start = 0, + end = value.length; + + while (start < end && chars.indexOf(value.charAt(start)) !== -1) { + start++; + } + while (end > start && chars.indexOf(value.charAt(end - 1)) !== -1) { + end--; + } + return value.substring(start, end); +}; + +/** + * Validate a single entry. Only formats that Moodle's address_in_subnet() understands are + * accepted, anything else would be silently ignored when the restriction is checked. + * + * - Address: 192.168.1.1 or 2001:db8::1 + * - Subnet: 192.168.1.0/24 or 2001:db8::/32 + * - Range in the last group: 192.168.1.1-255 or 2001:db8::1-ff + * + * @param {string} entry + * @returns {boolean} + */ +M.availability_ipaddress.validateEntry = function(entry) { + 'use strict'; + var parts, mask, lastgroup; + + if (entry.indexOf('/') !== -1) { + parts = entry.split('/'); + // Moodle's is_number() rejects a mask with leading zeros. + if (parts.length !== 2 || !/^(0|[1-9]\d{0,2})$/.test(parts[1])) { + return false; + } + mask = parseInt(parts[1], 10); + if (M.availability_ipaddress.isIpv4(parts[0])) { + return mask <= 32; + } + return M.availability_ipaddress.isIpv6(parts[0]) && mask <= 128; + } + + if (entry.indexOf('-') !== -1) { + parts = entry.split('-'); + if (parts.length !== 2) { + return false; + } + + // The end of the range may not be lower than the start, it would never match. + if (M.availability_ipaddress.isIpv4(parts[0])) { + return new RegExp('^' + M.availability_ipaddress.octet + '$').test(parts[1]) && + parseInt(parts[1], 10) >= parseInt(parts[0].split('.')[3], 10); + } + if (M.availability_ipaddress.isIpv6(parts[0])) { + lastgroup = parts[0].split(':').pop() || '0'; + return /^[a-fA-F\d]{1,4}$/.test(parts[1]) && parseInt(parts[1], 16) >= parseInt(lastgroup, 16); + } + return false; + } + + // An address ending in ":" is treated as an incomplete prefix by address_in_subnet(), which fails to parse it. + return M.availability_ipaddress.isIpv4(entry) || (M.availability_ipaddress.isIpv6(entry) && entry.slice(-1) !== ':'); +}; + +/** + * Validate a comma separated list of ipaddresses. + * + * @param {string} ipaddresses * @returns {boolean} */ M.availability_ipaddress.validateIpaddress = function(ipaddresses) { 'use strict'; - Y.log(ipaddresses); - // Return true for empty string - it's valid to have no custom IPs - if (!ipaddresses || ipaddresses.trim() === '') { + var entries, i; + + // Return true for empty string - it's valid to have no custom IPs. + if (!ipaddresses || M.availability_ipaddress.trim(ipaddresses) === '') { return true; } - ipaddresses = ipaddresses.split(','); - for (var i in ipaddresses) { - // Test normal ip format. - // Strict ipv4 check. - if (new RegExp(/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm) - .test(ipaddresses[i])) { - Y.log('Correct ipv4'); - continue; + entries = ipaddresses.split(','); + for (i = 0; i < entries.length; i++) { + // Whitespace around an entry is allowed, address_in_subnet() trims it as well. + if (!M.availability_ipaddress.validateEntry(M.availability_ipaddress.trim(entries[i]))) { + Y.log('Incorrect ip-address: ' + entries[i]); + return false; } - - var ipv4Regex = new RegExp( - '^(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)' + - '(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}-' + - '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)$', - 'gm' - ); - - if (ipv4Regex.test(ipaddresses[i])) { - Y.log('Correct ipv4 range.'); - continue; - } - - if (new RegExp(M.availability_ipaddress.v6) - .test(ipaddresses[i])) { - Y.log('Correct ipv6'); - continue; - } - - // Test subnet with a regex. - if (new RegExp("^(?:".concat(M.availability_ipaddress.v4 + "\\/(3[0-2]|[12]?[0-9])|(1\\*)", ")|(?:") - .concat(M.availability_ipaddress.v6 + "\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])", ")?\\/gm")) - .test(ipaddresses[i])) { - Y.log('Correct subnet'); - continue; - } - - Y.log('Incorrect ip'); - return false; } - Y.log('Valid ipaddresses'); return true; }; @@ -257,9 +339,8 @@ M.availability_ipaddress.form.fillErrors = function(errors, node) { var value = {}; this.fillValue(value, node); - // Basic ipaddresses checks - only validate if not empty. - if (value.ipaddresses && value.ipaddresses.trim() !== '' && - M.availability_ipaddress.validateIpaddress(value.ipaddresses) === false) { + // Basic ipaddresses checks - an empty value is valid. + if (M.availability_ipaddress.validateIpaddress(value.ipaddresses) === false) { errors.push('availability_ipaddress:error_ipaddress'); } };