Skip to content

Commit d06f3a5

Browse files
authored
Merge pull request #20 from leMaur/feature/max-redirects-guard
Add maxRedirects guard to UrlChecker
2 parents 1c857a3 + c15c39e commit d06f3a5

3 files changed

Lines changed: 43 additions & 5 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ composer require lemaur/php-url-checker
3939
The class `Lemaur\UrlChecker\UrlChecker` provides a static method `check` where accepts the URL to check as first parameter
4040
and the user agent string as a second parameter.
4141

42+
Redirects (`301`, `302`, `307`, `308`) are followed automatically, up to `maxRedirects` hops
43+
(default `5`). Set `maxRedirects: 0` to return the redirect response without following it.
44+
4245
Here you can see how to use it 👇
4346

4447
```php
@@ -49,6 +52,7 @@ $response = UrlChecker::check(
4952
userAgent: 'MyApp/1.0 (UrlChecker)',
5053
connectTimeout: 5,
5154
timeout: 10,
55+
maxRedirects: 5,
5256
);
5357
// \Lemaur\UrlChecker\DataTransferObject\CheckData
5458

src/UrlChecker.php

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616

1717
final class UrlChecker
1818
{
19+
/**
20+
* Default maximum number of redirect hops to follow before giving up
21+
* and returning the last redirect response as-is. Guards against
22+
* redirect loops causing unbounded recursion.
23+
*/
24+
public const DEFAULT_MAX_REDIRECTS = 5;
25+
1926
/**
2027
* @var array<Response>
2128
*/
@@ -29,10 +36,10 @@ public static function fake(array $queue): void
2936
self::$queue = $queue;
3037
}
3138

32-
public static function check(string $url, ?string $userAgent = null, ?int $connectTimeout = null, ?int $timeout = null): CheckData
39+
public static function check(string $url, ?string $userAgent = null, ?int $connectTimeout = null, ?int $timeout = null, ?int $maxRedirects = null): CheckData
3340
{
3441
try {
35-
$response = (new self())->getResponse($url, $userAgent, $connectTimeout, $timeout);
42+
$response = (new self())->getResponse($url, $userAgent, $connectTimeout, $timeout, $maxRedirects ?? self::DEFAULT_MAX_REDIRECTS);
3643

3744
if (!$response instanceof ResponseInterface) {
3845
return new CheckData(
@@ -81,7 +88,7 @@ private function getConfig(): array
8188
/**
8289
* @throws GuzzleException
8390
*/
84-
private function getResponse(string $url, ?string $userAgent = null, ?int $connectTimeout = null, ?int $timeout = null): ?ResponseInterface
91+
private function getResponse(string $url, ?string $userAgent = null, ?int $connectTimeout = null, ?int $timeout = null, int $redirectsLeft = self::DEFAULT_MAX_REDIRECTS): ?ResponseInterface
8592
{
8693
$response = null;
8794

@@ -111,8 +118,14 @@ private function getResponse(string $url, ?string $userAgent = null, ?int $conne
111118
}
112119
}
113120

114-
if (in_array($response?->getStatusCode(), [301, 302, 307, 308], strict: true)) {
115-
return $this->getResponse($response->getHeader('Location')[0] ?? '');
121+
if ($redirectsLeft > 0 && in_array($response?->getStatusCode(), [301, 302, 307, 308], strict: true)) {
122+
return $this->getResponse(
123+
$response->getHeader('Location')[0] ?? '',
124+
$userAgent,
125+
$connectTimeout,
126+
$timeout,
127+
$redirectsLeft - 1,
128+
);
116129
}
117130

118131
return $response;

tests/UnitTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,24 @@
2525
'status code 502' => [502, 'Bad Gateway'],
2626
'status code 503' => [503, 'Service Unavailable'],
2727
]);
28+
29+
it('stops following redirects after the maximum number of hops', function (int $statusCode): void {
30+
// In fake mode every hop re-serves the same queued response, so a redirect
31+
// status code recurses indefinitely unless the maxRedirects guard stops it.
32+
UrlChecker::fake([new Response($statusCode, ['Location' => 'https://foo.bar/loop'])]);
33+
34+
expect(UrlChecker::check('https://foo.bar'))
35+
->statusCode->toBe($statusCode);
36+
})->with([
37+
'moved permanently 301' => [301],
38+
'found 302' => [302],
39+
'temporary redirect 307' => [307],
40+
'permanent redirect 308' => [308],
41+
]);
42+
43+
it('does not follow redirects when maxRedirects is zero', function (): void {
44+
UrlChecker::fake([new Response(301, ['Location' => 'https://foo.bar/elsewhere'])]);
45+
46+
expect(UrlChecker::check('https://foo.bar', maxRedirects: 0))
47+
->statusCode->toBe(301);
48+
});

0 commit comments

Comments
 (0)