33namespace Tests \Imaging ;
44
55use GuzzleHttp \ClientInterface ;
6- use GuzzleHttp \Psr7 \Request ;
76use GuzzleHttp \Psr7 \Response ;
8- use GuzzleHttp \Psr7 \Uri ;
97use Mockery ;
108use PHPUnit \Framework \Attributes \Test ;
119use Statamic \Exceptions \InvalidRemoteUrlException ;
@@ -23,48 +21,88 @@ public function setUp(): void
2321 return new RemoteUrlValidator (function ($ host ) {
2422 return match ($ host ) {
2523 'example.com ' => [['ip ' => '93.184.216.34 ' ]],
24+ 'cdn.example.com ' => [['ip ' => '93.184.216.35 ' ]],
2625 default => [],
2726 };
2827 });
2928 });
3029 }
3130
3231 #[Test]
33- public function it_allows_redirects_when_every_hop_is_public ()
32+ public function it_pins_the_connection_to_the_validated_ip ()
3433 {
3534 $ client = Mockery::mock (ClientInterface::class);
36- $ client ->shouldReceive ('get ' )->once ()->andReturnUsing (function ($ url , $ options ) {
37- $ this ->assertEquals ('https://example.com/foo.jpg ' , $ url );
38- $ this ->assertArrayHasKey ('allow_redirects ' , $ options );
39- $ this ->assertArrayHasKey ('on_redirect ' , $ options ['allow_redirects ' ]);
35+ $ client ->shouldReceive ('request ' )->once ()->andReturnUsing (function ($ method , $ url , $ options ) {
36+ $ this ->assertSame ('GET ' , $ method );
37+ $ this ->assertSame ('https://example.com/foo.jpg ' , $ url );
38+ $ this ->assertFalse ($ options ['allow_redirects ' ]);
39+ $ this ->assertSame (['example.com:443:93.184.216.34 ' ], $ options ['curl ' ][CURLOPT_RESOLVE ]);
4040
41- $ options ['allow_redirects ' ]['on_redirect ' ](
42- new Request ('GET ' , 'https://example.com/foo.jpg ' ),
43- new Response (302 , ['Location ' => 'https://example.com/redirected/foo.jpg ' ]),
44- new Uri ('https://example.com/redirected/foo.jpg ' )
45- );
41+ return new Response (200 , [], 'image-bytes ' );
42+ });
43+
44+ $ adapter = new GuzzleAdapter ('https://example.com ' , $ client );
45+
46+ $ this ->assertSame ('image-bytes ' , $ adapter ->read ('foo.jpg ' ));
47+ }
48+
49+ #[Test]
50+ public function it_resolves_the_host_once_and_pins_that_ip ()
51+ {
52+ // DNS rebinding works by returning a public IP for the validation lookup,
53+ // then a private one for the connection's lookup. The fix resolves the host
54+ // a single time and pins that IP to the connection via CURLOPT_RESOLVE, so
55+ // the rebound answer below (127.0.0.1) is never reached. We assert both: the
56+ // pin is the public IP, and the resolver is consulted exactly once.
57+ $ lookups = 0 ;
58+ $ resolver = function () use (&$ lookups ) {
59+ return [['ip ' => ++$ lookups === 1 ? '93.184.216.34 ' : '127.0.0.1 ' ]];
60+ };
61+
62+ $ this ->app ->bind (RemoteUrlValidator::class, fn () => new RemoteUrlValidator ($ resolver ));
63+
64+ $ client = Mockery::mock (ClientInterface::class);
65+ $ client ->shouldReceive ('request ' )->once ()->andReturnUsing (function ($ method , $ url , $ options ) {
66+ $ this ->assertSame (['example.com:443:93.184.216.34 ' ], $ options ['curl ' ][CURLOPT_RESOLVE ]);
4667
4768 return new Response (200 , [], 'image-bytes ' );
4869 });
4970
5071 $ adapter = new GuzzleAdapter ('https://example.com ' , $ client );
5172
73+ $ this ->assertSame ('image-bytes ' , $ adapter ->read ('foo.jpg ' ));
74+ $ this ->assertSame (1 , $ lookups , 'The host should be resolved once; the connection must reuse that result, not re-resolve. ' );
75+ }
76+
77+ #[Test]
78+ public function it_follows_redirects_and_pins_every_hop ()
79+ {
80+ $ client = Mockery::mock (ClientInterface::class);
81+
82+ $ client ->shouldReceive ('request ' )->once ()
83+ ->with ('GET ' , 'https://example.com/foo.jpg ' , Mockery::on (function ($ options ) {
84+ return $ options ['curl ' ][CURLOPT_RESOLVE ] === ['example.com:443:93.184.216.34 ' ];
85+ }))
86+ ->andReturn (new Response (302 , ['Location ' => 'https://cdn.example.com/foo.jpg ' ]));
87+
88+ $ client ->shouldReceive ('request ' )->once ()
89+ ->with ('GET ' , 'https://cdn.example.com/foo.jpg ' , Mockery::on (function ($ options ) {
90+ return $ options ['curl ' ][CURLOPT_RESOLVE ] === ['cdn.example.com:443:93.184.216.35 ' ];
91+ }))
92+ ->andReturn (new Response (200 , [], 'image-bytes ' ));
93+
94+ $ adapter = new GuzzleAdapter ('https://example.com ' , $ client );
95+
5296 $ this ->assertSame ('image-bytes ' , $ adapter ->read ('foo.jpg ' ));
5397 }
5498
5599 #[Test]
56100 public function it_blocks_redirects_to_non_public_destinations ()
57101 {
58102 $ client = Mockery::mock (ClientInterface::class);
59- $ client ->shouldReceive ('get ' )->once ()->andReturnUsing (function ($ url , $ options ) {
60- $ options ['allow_redirects ' ]['on_redirect ' ](
61- new Request ('GET ' , 'https://example.com/foo.jpg ' ),
62- new Response (302 , ['Location ' => 'http://169.254.169.254/latest/meta-data/ ' ]),
63- new Uri ('http://169.254.169.254/latest/meta-data/ ' )
64- );
65-
66- return new Response (200 , [], 'should-not-return ' );
67- });
103+ $ client ->shouldReceive ('request ' )->once ()->andReturn (
104+ new Response (302 , ['Location ' => 'http://169.254.169.254/latest/meta-data/ ' ])
105+ );
68106
69107 $ adapter = new GuzzleAdapter ('https://example.com ' , $ client );
70108
@@ -73,4 +111,41 @@ public function it_blocks_redirects_to_non_public_destinations()
73111
74112 $ adapter ->read ('foo.jpg ' );
75113 }
114+
115+ #[Test]
116+ public function it_refuses_to_fetch_when_curl_is_unavailable ()
117+ {
118+ // Without curl the connection can't be pinned to the validated IP, so rather
119+ // than silently fall back to a rebindable request we refuse to fetch at all.
120+ $ client = Mockery::mock (ClientInterface::class);
121+ $ client ->shouldNotReceive ('request ' );
122+
123+ $ adapter = new class ('https://example.com ' , $ client ) extends GuzzleAdapter
124+ {
125+ protected function supportsConnectionPinning (): bool
126+ {
127+ return false ;
128+ }
129+ };
130+
131+ $ this ->expectException (\RuntimeException::class);
132+ $ this ->expectExceptionMessage ('curl PHP extension is required ' );
133+
134+ $ adapter ->read ('foo.jpg ' );
135+ }
136+
137+ #[Test]
138+ public function it_stops_following_redirects_after_the_limit ()
139+ {
140+ $ client = Mockery::mock (ClientInterface::class);
141+ $ client ->shouldReceive ('request ' )
142+ ->andReturn (new Response (302 , ['Location ' => 'https://example.com/foo.jpg ' ]));
143+
144+ $ adapter = new GuzzleAdapter ('https://example.com ' , $ client );
145+
146+ $ this ->expectException (InvalidRemoteUrlException::class);
147+ $ this ->expectExceptionMessage ('Too many redirects. ' );
148+
149+ $ adapter ->read ('foo.jpg ' );
150+ }
76151}
0 commit comments