From 607a4d7be20c2dfc608c00c2c0d778e52938c7a0 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Wed, 8 Oct 2025 22:35:26 +0200 Subject: [PATCH 1/8] refactor(app): Standardize subdomain detection logic --- app/Config/Hostnames.php | 40 +++++++++++ system/Helpers/url_helper.php | 50 +++++++++++++ system/Router/Attributes/Restrict.php | 70 +------------------ system/Router/RouteCollection.php | 45 +----------- .../system/Helpers/URLHelper/MiscUrlTest.php | 32 +++++++++ user_guide_src/source/helpers/url_helper.rst | 12 ++++ .../source/helpers/url_helper/027.php | 14 ++++ 7 files changed, 150 insertions(+), 113 deletions(-) create mode 100644 app/Config/Hostnames.php create mode 100644 user_guide_src/source/helpers/url_helper/027.php diff --git a/app/Config/Hostnames.php b/app/Config/Hostnames.php new file mode 100644 index 000000000000..c983a2823085 --- /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..9844fe52afbe 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,49 +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 diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 81ec9770ea2a..c262803080ed 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -963,4 +963,36 @@ 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)); + } + + 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..2ce237e0b712 100644 --- a/user_guide_src/source/helpers/url_helper.rst +++ b/user_guide_src/source/helpers/url_helper.rst @@ -361,6 +361,18 @@ 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 + .. 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 @@ + Date: Wed, 8 Oct 2025 17:03:52 -0500 Subject: [PATCH 2/8] Update app/Config/Hostnames.php Co-authored-by: Pooya Parsa --- app/Config/Hostnames.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Config/Hostnames.php b/app/Config/Hostnames.php index c983a2823085..bc2d8392bb09 100644 --- a/app/Config/Hostnames.php +++ b/app/Config/Hostnames.php @@ -36,5 +36,6 @@ class Hostnames 'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar', 'gob.cl', 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl', 'poznan.pl', 'krakow.pl', 'wroclaw.pl', 'gdansk.pl', 'slask.pl', 'warszawa.pl', + 'co.ir', 'ac.ir', 'org.ir', 'id.ir', 'gov.ir', 'sch.ir', 'net.ir', ]; } From 32220e7702055925e6351485460e6fd889c01710 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 9 Oct 2025 16:16:52 -0500 Subject: [PATCH 3/8] Update tests/system/Helpers/URLHelper/MiscUrlTest.php Co-authored-by: Pooya Parsa --- tests/system/Helpers/URLHelper/MiscUrlTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index c262803080ed..0e2a55f14225 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -981,6 +981,11 @@ public function testParseSubdomain(?string $host, string $expected, bool $useReq $this->assertSame($expected, parse_subdomain($host)); } + /** + * Provides test cases for parsing subdomains. + * + * @return array + */ public static function provideParseSubdomain(): iterable { return [ From e969e0eae9abd636fc77e60e515cd0d240bc6174 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 9 Oct 2025 23:19:18 +0200 Subject: [PATCH 4/8] addressing review comments --- app/Config/Hostnames.php | 1 - user_guide_src/source/helpers/url_helper.rst | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Config/Hostnames.php b/app/Config/Hostnames.php index bc2d8392bb09..1b4c7224ee8b 100644 --- a/app/Config/Hostnames.php +++ b/app/Config/Hostnames.php @@ -35,7 +35,6 @@ class Hostnames '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', 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl', - 'poznan.pl', 'krakow.pl', 'wroclaw.pl', 'gdansk.pl', 'slask.pl', 'warszawa.pl', 'co.ir', 'ac.ir', 'org.ir', 'id.ir', 'gov.ir', 'sch.ir', 'net.ir', ]; } diff --git a/user_guide_src/source/helpers/url_helper.rst b/user_guide_src/source/helpers/url_helper.rst index 2ce237e0b712..aef7d48b8589 100644 --- a/user_guide_src/source/helpers/url_helper.rst +++ b/user_guide_src/source/helpers/url_helper.rst @@ -373,6 +373,9 @@ The following functions are available: .. 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 From 3d707799b6cd815541319a9c3766e10c60dd716e Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 9 Oct 2025 23:21:25 +0200 Subject: [PATCH 5/8] cs fix --- .../system/Helpers/URLHelper/MiscUrlTest.php | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 0e2a55f14225..c824f0c7d346 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -964,9 +964,7 @@ public function testUrlToMissingArgument(): void url_to('loginURL'); } - /** - * @dataProvider provideParseSubdomain - */ + #[DataProvider('provideParseSubdomain')] public function testParseSubdomain(?string $host, string $expected, bool $useRequest = false): void { if ($useRequest) { @@ -989,15 +987,15 @@ public function testParseSubdomain(?string $host, string $expected, bool $useReq 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], + '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], + 'null uses request host' => [null, 'sub', true], ]; } } From 46163a697b954eb510d4b415e8bcaff398b9e934 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 9 Oct 2025 23:39:59 +0200 Subject: [PATCH 6/8] cs fix --- tests/system/Helpers/URLHelper/MiscUrlTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index c824f0c7d346..01ef146401f8 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -973,6 +973,7 @@ public function testParseSubdomain(?string $host, string $expected, bool $useReq $this->createRequest('http://sub.example.com/'); $this->assertSame($expected, parse_subdomain(null)); + return; } From acc1acfb86fcc94ccc47791b735a15f63f7baa84 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 9 Oct 2025 23:45:09 +0200 Subject: [PATCH 7/8] cs fix --- system/Router/RouteCollection.php | 1 - 1 file changed, 1 deletion(-) diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 9844fe52afbe..c89de601cb47 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -1653,7 +1653,6 @@ private function checkSubdomains($subdomains): bool return in_array($this->currentSubdomain, $subdomains, true); } - /** * Reset the routes, so that a test case can provide the * explicit ones needed for it. From 22157a7b0319def1f831298cff903a9535079775 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 9 Oct 2025 23:49:23 +0200 Subject: [PATCH 8/8] remove typo in docs ci-skip --- user_guide_src/source/helpers/url_helper.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/helpers/url_helper.rst b/user_guide_src/source/helpers/url_helper.rst index aef7d48b8589..0225e730f9dc 100644 --- a/user_guide_src/source/helpers/url_helper.rst +++ b/user_guide_src/source/helpers/url_helper.rst @@ -361,7 +361,7 @@ 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]) +.. 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.