From 4880613b67aa34d5c6b6c0961c46d554d9cab65e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:34:44 -0500 Subject: [PATCH] fix: harden url mapping validation --- src/Url/UrlMapping.php | 54 ++++++++++++++++++++- src/Url/UrlMappingCollection.php | 7 +++ tests/Unit/Url/UrlMappingCollectionTest.php | 27 +++++++++-- tests/bootstrap.php | 13 +++++ 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/Url/UrlMapping.php b/src/Url/UrlMapping.php index bb0048a..0bd7e38 100644 --- a/src/Url/UrlMapping.php +++ b/src/Url/UrlMapping.php @@ -14,6 +14,10 @@ final class UrlMapping { throw new \InvalidArgumentException( 'Source and destination URLs are required.' ); } + if ( ! $this->isAbsoluteUrl( $source_url ) || ! $this->isAbsoluteUrl( $destination_url ) ) { + throw new \InvalidArgumentException( 'Source and destination URLs must include a scheme and host.' ); + } + $this->source_url = $source_url; $this->destination_url = $destination_url; } @@ -27,6 +31,54 @@ final class UrlMapping { } private function normalizeUrl( string $url ): string { - return rtrim( trim( $url ), '/' ); + $url = trim( $url ); + $parts = wp_parse_url( $url ); + + if ( ! is_array( $parts ) || ! isset( $parts['path'] ) || '/' !== $parts['path'] ) { + return $url; + } + + unset( $parts['path'] ); + + return $this->buildUrl( $parts ); + } + + private function isAbsoluteUrl( string $url ): bool { + $parts = wp_parse_url( $url ); + + return is_array( $parts ) && ! empty( $parts['scheme'] ) && ! empty( $parts['host'] ); + } + + /** + * @param array $parts URL parts. + */ + private function buildUrl( array $parts ): string { + $url = (string) $parts['scheme'] . '://'; + + if ( isset( $parts['user'] ) ) { + $url .= (string) $parts['user']; + + if ( isset( $parts['pass'] ) ) { + $url .= ':' . (string) $parts['pass']; + } + + $url .= '@'; + } + + $url .= (string) $parts['host']; + + if ( isset( $parts['port'] ) ) { + $url .= ':' . (string) $parts['port']; + } + + if ( isset( $parts['query'] ) ) { + $url .= '?' . (string) $parts['query']; + } + + if ( isset( $parts['fragment'] ) ) { + $url .= '#' . (string) $parts['fragment']; + } + + return $url; } } diff --git a/src/Url/UrlMappingCollection.php b/src/Url/UrlMappingCollection.php index 60093e4..744a480 100644 --- a/src/Url/UrlMappingCollection.php +++ b/src/Url/UrlMappingCollection.php @@ -19,6 +19,13 @@ final class UrlMappingCollection { } $this->mappings = array_values( $mappings ); + + usort( + $this->mappings, + static function ( UrlMapping $left, UrlMapping $right ): int { + return strlen( $right->sourceUrl() ) <=> strlen( $left->sourceUrl() ); + } + ); } /** diff --git a/tests/Unit/Url/UrlMappingCollectionTest.php b/tests/Unit/Url/UrlMappingCollectionTest.php index c56a04d..af5a471 100644 --- a/tests/Unit/Url/UrlMappingCollectionTest.php +++ b/tests/Unit/Url/UrlMappingCollectionTest.php @@ -21,12 +21,29 @@ class UrlMappingCollectionTest extends TestCase { new UrlMapping( '', 'https://staging.example.test' ); } - public function test_it_returns_mappings_in_order(): void { - $first = new UrlMapping( 'https://example.test', 'https://staging.example.test' ); - $second = new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ); - $collection = new UrlMappingCollection( array( $first, $second ) ); + public function test_it_rejects_mapping_urls_without_scheme_and_host(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Source and destination URLs must include a scheme and host.' ); - self::assertSame( array( $first, $second ), $collection->all() ); + new UrlMapping( 'https://', 'https://staging.example.test' ); + } + + public function test_it_preserves_query_and_fragment_trailing_slashes(): void { + $mapping = new UrlMapping( + 'https://example.test/?redirect=/', + 'https://staging.example.test/#/' + ); + + self::assertSame( 'https://example.test?redirect=/', $mapping->sourceUrl() ); + self::assertSame( 'https://staging.example.test#/', $mapping->destinationUrl() ); + } + + public function test_it_returns_mappings_by_longest_source_url_first(): void { + $short = new UrlMapping( 'https://example.test', 'https://staging.example.test' ); + $long = new UrlMapping( 'https://example.test/uploads', 'https://staging.example.test/uploads' ); + $collection = new UrlMappingCollection( array( $short, $long ) ); + + self::assertSame( array( $long, $short ), $collection->all() ); } public function test_it_rejects_non_mapping_items(): void { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index dbea835..88fbd06 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -125,6 +125,19 @@ if ( ! function_exists( 'esc_url_raw' ) ) { } } +if ( ! function_exists( 'wp_parse_url' ) ) { + /** + * Minimal URL parser for unit tests. + * + * @param string $url URL to parse. + * @return array|false + */ + function wp_parse_url( $url ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Test stub for WordPress' wp_parse_url(). + return parse_url( $url ); + } +} + if ( ! function_exists( 'get_option' ) ) { /** * Minimal WordPress option reader for unit tests.