Skip to content

Commit a8f7ec0

Browse files
committed
Enhance redirect handling: preserve HEAD method for 303 responses and reject malicious redirect schemes
1 parent c7fbe00 commit a8f7ec0

File tree

2 files changed

+96
-5
lines changed

2 files changed

+96
-5
lines changed

src/FeedIo/Adapter/Http/Client.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,10 @@ protected function request(
8989
$duration
9090
);
9191
case 303:
92-
// 303 See Other always requires GET
92+
// 303 See Other: change POST/PUT/DELETE to GET, but preserve HEAD
93+
$redirectMethod = $method === 'HEAD' ? 'HEAD' : 'GET';
9394
return $this->handleRedirect(
94-
'GET',
95+
$redirectMethod,
9596
$url,
9697
$psrResponse,
9798
$modifiedSince,
@@ -113,7 +114,7 @@ protected function request(
113114
* @param \Psr\Http\Message\ResponseInterface $psrResponse
114115
* @param DateTime|null $modifiedSince
115116
* @param int $redirectCount
116-
* @param float $duration
117+
* @param float $duration Duration of the redirect request (used for error reporting)
117118
* @return ResponseInterface
118119
* @throws ClientExceptionInterface
119120
*/
@@ -143,11 +144,19 @@ protected function handleRedirect(
143144
* @param string $currentUrl
144145
* @param string $location
145146
* @return string
147+
* @throws ServerErrorException
146148
*/
147149
protected function resolveRedirectUrl(string $currentUrl, string $location): string
148150
{
149-
// If location is already absolute, return it
150-
if (preg_match('/^https?:\/\//i', $location)) {
151+
// Check if location has a scheme (absolute URL or potentially malicious scheme)
152+
if (preg_match('/^([a-z][a-z0-9+.-]*):(?:\/\/)?/i', $location, $matches)) {
153+
$scheme = strtolower($matches[1]);
154+
if (!in_array($scheme, ['http', 'https'], true)) {
155+
throw new ServerErrorException(
156+
new \Nyholm\Psr7\Response(400, [], 'Invalid redirect scheme: ' . $scheme),
157+
0
158+
);
159+
}
151160
return $location;
152161
}
153162

tests/FeedIo/Adapter/Http/ClientTest.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,86 @@ public function testResponseDurationIsTrackedOnError(): void
223223
$this->assertGreaterThanOrEqual(0, $e->getDuration());
224224
}
225225
}
226+
227+
public function test303RedirectPreservesHeadMethod(): void
228+
{
229+
// Directly test that a 303 redirect preserves HEAD method
230+
// We'll use reflection to call the protected request() method with HEAD
231+
232+
$redirectResponse = new PsrResponse(303, ['Location' => 'https://example.com/new-feed.xml']);
233+
$finalResponse = new PsrResponse(200, [], 'content');
234+
235+
$requestCount = 0;
236+
$this->psrClient
237+
->expects($this->exactly(2))
238+
->method('sendRequest')
239+
->willReturnCallback(function (RequestInterface $request) use ($redirectResponse, $finalResponse, &$requestCount) {
240+
$requestCount++;
241+
242+
if ($requestCount === 1) {
243+
// First request: HEAD
244+
$this->assertEquals('HEAD', $request->getMethod());
245+
return $redirectResponse;
246+
}
247+
248+
// Second request: should still be HEAD (not changed to GET for 303)
249+
$this->assertEquals('HEAD', $request->getMethod());
250+
$this->assertEquals('https://example.com/new-feed.xml', (string) $request->getUri());
251+
return $finalResponse;
252+
});
253+
254+
// Use reflection to call protected request() method with HEAD
255+
$reflection = new \ReflectionClass($this->client);
256+
$method = $reflection->getMethod('request');
257+
$method->setAccessible(true);
258+
259+
$response = $method->invoke($this->client, 'HEAD', 'https://example.com/old-feed.xml', null, 0);
260+
261+
$this->assertEquals(200, $response->getStatusCode());
262+
}
263+
264+
/**
265+
* @dataProvider maliciousSchemeProvider
266+
*/
267+
public function testRejectsRedirectsWithMaliciousSchemes(string $maliciousUrl): void
268+
{
269+
$redirectResponse = new PsrResponse(301, ['Location' => $maliciousUrl]);
270+
271+
$this->psrClient
272+
->expects($this->once())
273+
->method('sendRequest')
274+
->willReturn($redirectResponse);
275+
276+
$this->expectException(ServerErrorException::class);
277+
278+
$this->client->getResponse('https://example.com/feed.xml');
279+
}
280+
281+
public static function maliciousSchemeProvider(): array
282+
{
283+
return [
284+
'file scheme' => ['file:///etc/passwd'],
285+
'ftp scheme' => ['ftp://malicious.com/data'],
286+
'javascript scheme' => ['javascript:alert(1)'],
287+
'data scheme' => ['data:text/html,<script>alert(1)</script>'],
288+
'mailto scheme' => ['mailto:[email protected]'],
289+
'tel scheme' => ['tel:+1234567890'],
290+
];
291+
}
292+
293+
public function testAllowsHttpAndHttpsRedirects(): void
294+
{
295+
$httpRedirect = new PsrResponse(301, ['Location' => 'http://example.com/feed.xml']);
296+
$httpsRedirect = new PsrResponse(301, ['Location' => 'https://secure.example.com/feed.xml']);
297+
$finalResponse = new PsrResponse(200, [], 'content');
298+
299+
$this->psrClient
300+
->expects($this->exactly(3))
301+
->method('sendRequest')
302+
->willReturnOnConsecutiveCalls($httpRedirect, $httpsRedirect, $finalResponse);
303+
304+
$response = $this->client->getResponse('https://example.com/feed.xml');
305+
306+
$this->assertEquals(200, $response->getStatusCode());
307+
}
226308
}

0 commit comments

Comments
 (0)