diff --git a/app/Config/Hostnames.php b/app/Config/Hostnames.php new file mode 100644 index 000000000000..1b4c7224ee8b --- /dev/null +++ b/app/Config/Hostnames.php @@ -0,0 +1,40 @@ +getUri()->getHost(); + } + + // Handle localhost and IP addresses - they don't have subdomains + if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { + return ''; + } + + $parts = explode('.', $host); + $partCount = count($parts); + + // Need at least 3 parts for a subdomain (subdomain.domain.tld) + // e.g., api.example.com + if ($partCount < 3) { + return ''; + } + + // Check if we have a two-part TLD (e.g., co.uk, com.au) + $lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1]; + + if (in_array($lastTwoParts, Hostnames::TWO_PART_TLDS, true)) { + // For two-part TLD, need at least 4 parts for subdomain + // e.g., api.example.co.uk (4 parts) + if ($partCount < 4) { + return ''; // No subdomain, just domain.co.uk + } + + // Remove the two-part TLD and domain name (last 3 parts) + // e.g., admin.api.example.co.uk -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 3)); + } + + // Standard TLD: Remove TLD and domain (last 2 parts) + // e.g., admin.api.example.com -> admin.api + return implode('.', array_slice($parts, 0, $partCount - 2)); + } +} diff --git a/system/Router/Attributes/Restrict.php b/system/Router/Attributes/Restrict.php index e8738befd31e..36c087e78f9b 100644 --- a/system/Router/Attributes/Restrict.php +++ b/system/Router/Attributes/Restrict.php @@ -42,38 +42,6 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Restrict implements RouteAttributeInterface { - private const TWO_PART_TLDS = [ - 'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk', - 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au', - 'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp', - 'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz', - 'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in', - 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', - 'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg', - 'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za', - 'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr', - 'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th', - 'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my', - 'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx', - 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br', - 'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il', - 'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id', - 'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk', - 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw', - 'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa', - 'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae', - 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr', - 'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke', - 'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng', - 'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk', - 'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg', - 'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy', - 'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk', - 'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd', - 'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar', - 'gob.cl', - ]; - public function __construct( public array|string|null $environment = null, public array|string|null $hostname = null, @@ -145,7 +113,7 @@ private function checkSubdomain(RequestInterface $request): void return; } - $currentSubdomain = $this->getSubdomain($request); + $currentSubdomain = parse_subdomain($request->getUri()->getHost()); $allowedSubdomains = array_map('strtolower', (array) $this->subdomain); // If no subdomain exists but one is required @@ -158,40 +126,4 @@ private function checkSubdomain(RequestInterface $request): void throw new PageNotFoundException('Access denied: subdomain is blocked.'); } } - - private function getSubdomain(RequestInterface $request): string - { - $host = strtolower($request->getUri()->getHost()); - - // Handle localhost and IP addresses - they don't have subdomains - if ($host === 'localhost' || filter_var($host, FILTER_VALIDATE_IP)) { - return ''; - } - - $parts = explode('.', $host); - $partCount = count($parts); - - // Need at least 3 parts for a subdomain (subdomain.domain.tld) - // e.g., api.example.com - if ($partCount < 3) { - return ''; - } - // Check if we have a two-part TLD (e.g., co.uk, com.au) - $lastTwoParts = $parts[$partCount - 2] . '.' . $parts[$partCount - 1]; - if (in_array($lastTwoParts, self::TWO_PART_TLDS, true)) { - // For two-part TLD, need at least 4 parts for subdomain - // e.g., api.example.co.uk (4 parts) - if ($partCount < 4) { - return ''; // No subdomain, just domain.co.uk - } - - // Remove the two-part TLD and domain name (last 3 parts) - // e.g., admin.api.example.co.uk -> admin.api - return implode('.', array_slice($parts, 0, $partCount - 3)); - } - - // Standard TLD: Remove TLD and domain (last 2 parts) - // e.g., admin.api.example.com -> admin.api - return implode('.', array_slice($parts, 0, $partCount - 2)); - } } diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 7379d014cc09..c89de601cb47 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -1637,7 +1637,7 @@ private function checkSubdomains($subdomains): bool } if ($this->currentSubdomain === null) { - $this->currentSubdomain = $this->determineCurrentSubdomain(); + $this->currentSubdomain = parse_subdomain($this->httpHost); } if (! is_array($subdomains)) { @@ -1653,50 +1653,6 @@ private function checkSubdomains($subdomains): bool return in_array($this->currentSubdomain, $subdomains, true); } - /** - * Examines the HTTP_HOST to get the best match for the subdomain. It - * won't be perfect, but should work for our needs. - * - * It's especially not perfect since it's possible to register a domain - * with a period (.) as part of the domain name. - * - * @return false|string the subdomain - */ - private function determineCurrentSubdomain() - { - // We have to ensure that a scheme exists - // on the URL else parse_url will mis-interpret - // 'host' as the 'path'. - $url = $this->httpHost; - if (! str_starts_with($url, 'http')) { - $url = 'http://' . $url; - } - - $parsedUrl = parse_url($url); - - $host = explode('.', $parsedUrl['host']); - - if ($host[0] === 'www') { - unset($host[0]); - } - - // Get rid of any domains, which will be the last - unset($host[count($host) - 1]); - - // Account for .co.uk, .co.nz, etc. domains - if (end($host) === 'co') { - $host = array_slice($host, 0, -1); - } - - // If we only have 1 part left, then we don't have a sub-domain. - if (count($host) === 1) { - // Set it to false so we don't make it back here again. - return false; - } - - return array_shift($host); - } - /** * Reset the routes, so that a test case can provide the * explicit ones needed for it. diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 81ec9770ea2a..01ef146401f8 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -963,4 +963,40 @@ public function testUrlToMissingArgument(): void url_to('loginURL'); } + + #[DataProvider('provideParseSubdomain')] + public function testParseSubdomain(?string $host, string $expected, bool $useRequest = false): void + { + if ($useRequest) { + // create a request whose host will be used when passing null to parse_subdomain + $this->config->baseURL = 'http://sub.example.com/'; + $this->createRequest('http://sub.example.com/'); + + $this->assertSame($expected, parse_subdomain(null)); + + return; + } + + $this->assertSame($expected, parse_subdomain($host)); + } + + /** + * Provides test cases for parsing subdomains. + * + * @return array + */ + public static function provideParseSubdomain(): iterable + { + return [ + 'standard subdomain' => ['api.example.com', 'api', false], + 'multi-level subdomain' => ['admin.api.example.com', 'admin.api', false], + 'no subdomain (domain only)' => ['example.com', '', false], + 'localhost' => ['localhost', '', false], + 'ipv4' => ['127.0.0.1', '', false], + 'ipv6' => ['::1', '', false], + 'two-part tld no subdomain' => ['example.co.uk', '', false], + 'two-part tld with subdomain' => ['api.example.co.uk', 'api', false], + 'null uses request host' => [null, 'sub', true], + ]; + } } diff --git a/user_guide_src/source/helpers/url_helper.rst b/user_guide_src/source/helpers/url_helper.rst index 44e52dd0d8a3..0225e730f9dc 100644 --- a/user_guide_src/source/helpers/url_helper.rst +++ b/user_guide_src/source/helpers/url_helper.rst @@ -361,6 +361,21 @@ The following functions are available: This function works the same as :php:func:`url_title()` but it converts all accented characters automatically. +.. php:function:: parse_subdomain($hostname) + + :param string|null $hostname: The hostname to parse. If null, uses the current request's host. + :returns: The subdomain, or an empty string if none exists. + :rtype: string + + Parses the subdomain from the given host name. + + Here are some examples: + + .. literalinclude:: url_helper/027.php + + You can customize the list of known two-part TLDs by adding them to the + ``Config\Hostnames::TWO_PART_TLDS`` array. + .. php:function:: prep_url([$str = ''[, $secure = false]]) :param string $str: URL string diff --git a/user_guide_src/source/helpers/url_helper/027.php b/user_guide_src/source/helpers/url_helper/027.php new file mode 100644 index 000000000000..aa3a4e3fbbea --- /dev/null +++ b/user_guide_src/source/helpers/url_helper/027.php @@ -0,0 +1,14 @@ +