Skip to content

Commit 7b6336c

Browse files
authored
Harden default stream HTTP client (#95)
1 parent 24e1889 commit 7b6336c

3 files changed

Lines changed: 62 additions & 3 deletions

File tree

‎docs/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,8 @@ class MockHttpClient implements HttpClientInterface {
341341
$MediaEmbed = new MediaEmbed([], null, new MockHttpClient());
342342
```
343343

344+
The default `StreamHttpClient` only fetches public `http` and `https` URLs, rejects localhost/private literal IP targets, disables redirects, and caps response size. Inject a custom `HttpClientInterface` implementation if you need a different network policy.
345+
344346
### Provider Loaders
345347

346348
Load providers from different sources using the loader interface:

‎src/Http/StreamHttpClient.php‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,70 @@ class StreamHttpClient implements HttpClientInterface {
1818

1919
/**
2020
* @param int $timeout Request timeout in seconds.
21+
* @param int $maxBytes Maximum response size in bytes.
2122
*/
22-
public function __construct(int $timeout = 5) {
23+
public function __construct(int $timeout = 5, protected int $maxBytes = 1048576) {
2324
$this->timeout = $timeout;
2425
}
2526

2627
/**
2728
* @inheritDoc
2829
*/
2930
public function get(string $url, array $options = []): ?string {
31+
if (!$this->isSafeUrl($url)) {
32+
return null;
33+
}
34+
3035
$timeout = $options['timeout'] ?? $this->timeout;
36+
$maxBytes = $options['max_bytes'] ?? $this->maxBytes;
3137

3238
$context = stream_context_create([
3339
'http' => [
3440
'header' => 'Connection: close',
3541
'timeout' => $timeout,
42+
'follow_location' => 0,
43+
'ignore_errors' => false,
3644
],
3745
]);
3846

39-
$content = @file_get_contents($url, false, $context);
40-
if ($content === false) {
47+
$stream = @fopen($url, 'rb', false, $context);
48+
if ($stream === false) {
49+
return null;
50+
}
51+
52+
$content = stream_get_contents($stream, $maxBytes + 1);
53+
fclose($stream);
54+
if ($content === false || strlen($content) > $maxBytes) {
4155
return null;
4256
}
4357

4458
return $content;
4559
}
4660

61+
/**
62+
* @param string $url URL to validate.
63+
* @return bool
64+
*/
65+
protected function isSafeUrl(string $url): bool {
66+
$parts = parse_url($url);
67+
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
68+
return false;
69+
}
70+
71+
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
72+
return false;
73+
}
74+
75+
$host = strtolower($parts['host']);
76+
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
77+
return false;
78+
}
79+
80+
if (filter_var($host, FILTER_VALIDATE_IP)) {
81+
return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
82+
}
83+
84+
return true;
85+
}
86+
4787
}

‎tests/Http/HttpClientTest.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ public function testStreamHttpClientWithCustomTimeout(): void {
2020
$this->assertInstanceOf(StreamHttpClient::class, $client);
2121
}
2222

23+
public function testStreamHttpClientWithCustomMaxBytes(): void {
24+
$client = new StreamHttpClient(10, 1024);
25+
26+
$this->assertInstanceOf(StreamHttpClient::class, $client);
27+
}
28+
29+
public function testGetReturnsNullForUnsafeUrls(): void {
30+
$client = new StreamHttpClient(1);
31+
32+
$this->assertNull($client->get('file:///tmp/test.txt'));
33+
$this->assertNull($client->get('javascript:alert(1)'));
34+
$this->assertNull($client->get('http://localhost/test'));
35+
$this->assertNull($client->get('http://127.0.0.1/test'));
36+
$this->assertNull($client->get('http://10.0.0.1/test'));
37+
$this->assertNull($client->get('http://169.254.169.254/test'));
38+
}
39+
2340
public function testGetReturnsNullForInvalidUrl(): void {
2441
$client = new StreamHttpClient(1);
2542

0 commit comments

Comments
 (0)