1919namespace Tests \Unit \Rules ;
2020
2121use App \Models \Configs ;
22+ use App \Repositories \ConfigManager ;
2223use App \Rules \PhotoUrlRule ;
2324use Tests \AbstractTestCase ;
2425
@@ -31,7 +32,7 @@ class PhotoUrlRuleTest extends AbstractTestCase
3132 public function setUp (): void
3233 {
3334 parent ::setUp ();
34- $ this ->rule = resolve (PhotoUrlRule::class );
35+ $ this ->rule = $ this -> makeRule ( );
3536 $ this ->failCalled = false ;
3637 $ this ->failMessage = '' ;
3738
@@ -41,6 +42,19 @@ public function setUp(): void
4142 Configs::set ('import_via_url_forbidden_localhost ' , '1 ' );
4243 }
4344
45+ /**
46+ * Create a PhotoUrlRule with an optional mock DNS resolver.
47+ *
48+ * @param \Closure|null $dns_get_record
49+ */
50+ private function makeRule (?\Closure $ dns_get_record = null ): PhotoUrlRule
51+ {
52+ return new PhotoUrlRule (
53+ resolve (ConfigManager::class),
54+ $ dns_get_record ?? fn (string $ hostname , int $ type = DNS_A ) => [],
55+ );
56+ }
57+
4458 public function tearDown (): void
4559 {
4660 Configs::set ('import_via_url_require_https ' , '1 ' );
@@ -102,6 +116,10 @@ public function testHttpsRequiredButNotProvided(): void
102116 */
103117 public function testHttpsRequiredAndProvided (): void
104118 {
119+ $ this ->rule = $ this ->makeRule (fn (string $ hostname , int $ type = DNS_A ) => match ($ type ) {
120+ DNS_A => [['ip ' => '93.184.216.34 ' ]],
121+ default => [],
122+ });
105123 $ this ->rule ->validate ('photo_url ' , 'https://example.com ' , fn ($ m ) => $ this ->m ($ m ));
106124 self ::assertFalse ($ this ->failCalled );
107125 self ::assertEquals ('' , $ this ->failMessage );
@@ -134,9 +152,11 @@ public function testForbiddenPort(): void
134152 */
135153 public function testAllowedPort (): void
136154 {
137- $ this ->rule ->validate ('photo_url ' , 'https://example.com:80 ' , function ($ message ) {
138- $ this ->failCalled = true ;
155+ $ this ->rule = $ this ->makeRule (fn (string $ hostname , int $ type = DNS_A ) => match ($ type ) {
156+ DNS_A => [['ip ' => '93.184.216.34 ' ]],
157+ default => [],
139158 });
159+ $ this ->rule ->validate ('photo_url ' , 'https://example.com:80 ' , fn ($ m ) => $ this ->m ($ m ));
140160
141161 self ::assertFalse ($ this ->failCalled );
142162 self ::assertEquals ('' , $ this ->failMessage );
@@ -149,17 +169,51 @@ public function testForbiddenPrivateIp(): void
149169 {
150170 $ this ->rule ->validate ('photo_url ' , 'https://192.168.1.1 ' , fn ($ m ) => $ this ->m ($ m ));
151171 self ::assertTrue ($ this ->failCalled );
152- self ::assertEquals ('photo_url must not be a private IP address. ' , $ this ->failMessage );
172+ self ::assertEquals ('photo_url must not resolve to a private or reserved IP address. ' , $ this ->failMessage );
153173 }
154174
155175 /**
156176 * Test validation when localhost is forbidden and localhost is provided.
157177 */
158178 public function testForbiddenLocalhost (): void
159179 {
180+ // Disable private IP check so we specifically test the localhost check.
181+ Configs::set ('import_via_url_forbidden_local_ip ' , '0 ' );
182+
160183 $ this ->rule ->validate ('photo_url ' , 'https://localhost ' , fn ($ m ) => $ this ->m ($ m ));
161184 self ::assertTrue ($ this ->failCalled );
162- self ::assertEquals ('photo_url must not be localhost. ' , $ this ->failMessage );
185+ self ::assertEquals ('photo_url must not resolve to localhost. ' , $ this ->failMessage );
186+ }
187+
188+ /**
189+ * Test that hostnames resolving to private IPs are blocked (DNS rebinding protection).
190+ */
191+ public function testForbiddenPrivateIpViaHostname (): void
192+ {
193+ $ this ->rule = $ this ->makeRule (fn (string $ hostname , int $ type = DNS_A ) => match ($ type ) {
194+ DNS_A => [['ip ' => '192.168.0.1 ' ]],
195+ default => [],
196+ });
197+ $ this ->rule ->validate ('photo_url ' , 'https://evil.example.com/test.jpg ' , fn ($ m ) => $ this ->m ($ m ));
198+ self ::assertTrue ($ this ->failCalled );
199+ self ::assertEquals ('photo_url must not resolve to a private or reserved IP address. ' , $ this ->failMessage );
200+ }
201+
202+ /**
203+ * Test that hostnames resolving to localhost are blocked.
204+ */
205+ public function testForbiddenLocalhostViaHostname (): void
206+ {
207+ $ this ->rule = $ this ->makeRule (fn (string $ hostname , int $ type = DNS_A ) => match ($ type ) {
208+ DNS_A => [['ip ' => '127.0.0.1 ' ]],
209+ default => [],
210+ });
211+
212+ Configs::set ('import_via_url_forbidden_local_ip ' , '0 ' );
213+
214+ $ this ->rule ->validate ('photo_url ' , 'https://evil.example.com/test.jpg ' , fn ($ m ) => $ this ->m ($ m ));
215+ self ::assertTrue ($ this ->failCalled );
216+ self ::assertEquals ('photo_url must not resolve to localhost. ' , $ this ->failMessage );
163217 }
164218
165219 /**
0 commit comments