Skip to content

Commit 16a43f8

Browse files
committed
Re-check the SSRF guard on every redirect hop and cover icon URLs
The website-lookup endpoint validated only the initial URL, then let Guzzle follow redirects unchecked. A public URL that answered with a Location pointing at an internal address was fetched and its body returned. The add/edit item icon fetch used file_get_contents with no address check at all. Both now go through a shared App\Helpers\SafeUrlFetcher that: - allows only http(s) URLs - resolves the host (A and AAAA) and refuses any private or reserved address, including IPv4-mapped IPv6 forms - pins the checked address and the URL's actual port via CURLOPT_RESOLVE - follows redirects itself, up to five hops, running the same guard on every Location before requesting it ALLOW_INTERNAL_REQUESTS keeps its meaning and is now read through config so config caching works. Tests use Guzzle's MockHandler so nothing touches the network. Reported by Kashish Topiwala. Icon URL guard based on PR #1590 by Bunlong Heng.
1 parent 808cc90 commit 16a43f8

9 files changed

Lines changed: 672 additions & 69 deletions

File tree

‎.env.example‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,9 @@ AUTH_ROLES_HTTP_HEADER="HTTP_REMOTE_GROUPS"
7676
AUTH_ROLES_ADMIN="admin"
7777
AUTH_ROLES_DELIMITER=","
7878

79-
ALLOW_INTERNAL_REQUESTS=false
79+
# Set to true if you host Heimdall on a LAN and need website lookups or icon
80+
# URLs to reach internal addresses (e.g. http://192.168.1.10:8080/favicon.png).
81+
# When false (the default), server-side fetches of user-supplied URLs are
82+
# refused when the host, or any redirect it returns, resolves to a private or
83+
# reserved address (loopback, RFC1918, link-local metadata, etc.).
84+
ALLOW_INTERNAL_REQUESTS=false
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Exceptions;
4+
5+
use RuntimeException;
6+
7+
/**
8+
* Thrown when a server-side fetch is refused by the SSRF guard: the URL uses
9+
* a scheme other than http(s), its host cannot be resolved, or it (or a
10+
* redirect hop) resolves to a private or reserved address.
11+
*/
12+
class BlockedUrlException extends RuntimeException
13+
{
14+
}

‎app/Helpers/SafeUrlFetcher.php‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
<?php
2+
3+
namespace App\Helpers;
4+
5+
use App\Exceptions\BlockedUrlException;
6+
use GuzzleHttp\Client;
7+
use GuzzleHttp\Exception\ConnectException;
8+
use GuzzleHttp\Exception\GuzzleException;
9+
use GuzzleHttp\HandlerStack;
10+
use GuzzleHttp\Psr7\Uri;
11+
use GuzzleHttp\Psr7\UriResolver;
12+
use Illuminate\Support\Facades\Log;
13+
use Psr\Http\Message\ResponseInterface;
14+
15+
/**
16+
* Fetches caller-supplied URLs server-side without letting them reach
17+
* internal services.
18+
*
19+
* Every hop, including each redirect target, goes through the same guard:
20+
* the scheme must be http(s), the host must resolve, and every resolved
21+
* address must be public. The resolved address is pinned via CURLOPT_RESOLVE
22+
* so the connection goes to the address that was checked. Redirects are not
23+
* delegated to Guzzle; they are followed here one at a time so the guard
24+
* runs again for each Location.
25+
*
26+
* Set ALLOW_INTERNAL_REQUESTS=true to disable the address check for installs
27+
* that deliberately point Heimdall at LAN services.
28+
*/
29+
class SafeUrlFetcher
30+
{
31+
public const MAX_REDIRECTS = 5;
32+
33+
private ?HandlerStack $handler;
34+
35+
public function __construct(?HandlerStack $handler = null)
36+
{
37+
$this->handler = $handler;
38+
}
39+
40+
/**
41+
* Fetch $url with GET, following up to MAX_REDIRECTS redirects and
42+
* re-checking each target. Returns null on a transport failure.
43+
*
44+
* @throws BlockedUrlException when the URL or any redirect target is refused
45+
*/
46+
public function fetch(string $url, array $clientOptions = [], array $requestOptions = []): ?ResponseInterface
47+
{
48+
$clientOptions = array_merge([
49+
'http_errors' => false,
50+
'timeout' => 15,
51+
'connect_timeout' => 15,
52+
'verify' => false,
53+
], $clientOptions);
54+
55+
// Redirects are handled below so every hop is checked.
56+
$clientOptions['allow_redirects'] = false;
57+
58+
if ($this->handler !== null) {
59+
$clientOptions['handler'] = $this->handler;
60+
}
61+
62+
$current = $url;
63+
64+
for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
65+
$resolved = $this->assertAllowed($current);
66+
67+
$options = $clientOptions;
68+
if ($resolved['ip'] !== null) {
69+
$options['curl'][CURLOPT_RESOLVE] = [
70+
sprintf('%s:%d:%s', $resolved['host'], $resolved['port'], $resolved['ip']),
71+
];
72+
}
73+
74+
try {
75+
$response = (new Client($options))->request('GET', $current, $requestOptions);
76+
} catch (ConnectException $e) {
77+
Log::warning('Outbound request failed to connect.', ['url' => $current, 'error' => $e->getMessage()]);
78+
return null;
79+
} catch (GuzzleException $e) {
80+
Log::error('Outbound request failed: ' . $e->getMessage(), ['url' => $current]);
81+
return null;
82+
}
83+
84+
$location = $this->redirectTarget($current, $response);
85+
if ($location === null) {
86+
return $response;
87+
}
88+
89+
$current = $location;
90+
}
91+
92+
throw new BlockedUrlException('Too many redirects while fetching ' . $url);
93+
}
94+
95+
/**
96+
* Validate a URL against the guard and resolve its host.
97+
*
98+
* @return array{host: string, port: int, ip: string|null} ip is null when
99+
* internal requests are allowed and pinning is skipped
100+
* @throws BlockedUrlException
101+
*/
102+
public function assertAllowed(string $url): array
103+
{
104+
$parts = parse_url($url);
105+
if ($parts === false) {
106+
throw new BlockedUrlException('URL could not be parsed.');
107+
}
108+
109+
$scheme = strtolower($parts['scheme'] ?? '');
110+
if (!in_array($scheme, ['http', 'https'], true)) {
111+
throw new BlockedUrlException('Only http and https URLs can be fetched.');
112+
}
113+
114+
$host = $parts['host'] ?? '';
115+
if ($host === '') {
116+
throw new BlockedUrlException('URL has no host.');
117+
}
118+
119+
// IPv6 literals arrive bracketed from parse_url.
120+
$host = trim($host, '[]');
121+
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
122+
123+
if (config('app.allow_internal_requests', false)) {
124+
return ['host' => $host, 'port' => $port, 'ip' => null];
125+
}
126+
127+
$ips = $this->resolve($host);
128+
if ($ips === []) {
129+
throw new BlockedUrlException('Host could not be resolved: ' . $host);
130+
}
131+
132+
foreach ($ips as $ip) {
133+
if (!self::isPublicIp($ip)) {
134+
Log::warning('Blocked access to private or reserved IPs.', ['ip' => $ip, 'host' => $host]);
135+
throw new BlockedUrlException('Access to private or reserved IPs is not allowed.');
136+
}
137+
}
138+
139+
return ['host' => $host, 'port' => $port, 'ip' => $ips[0]];
140+
}
141+
142+
/**
143+
* True when the address is neither private (RFC 1918, ULA) nor reserved
144+
* (loopback, link-local, unspecified, ...). IPv4-mapped IPv6 addresses
145+
* are checked as their embedded IPv4 address.
146+
*/
147+
public static function isPublicIp(string $ip): bool
148+
{
149+
if (str_contains($ip, ':')) {
150+
$packed = @inet_pton($ip);
151+
if ($packed === false) {
152+
return false;
153+
}
154+
if (substr($packed, 0, 12) === "\0\0\0\0\0\0\0\0\0\0\xff\xff") {
155+
$ip = inet_ntop(substr($packed, 12));
156+
}
157+
}
158+
159+
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
160+
}
161+
162+
/**
163+
* All A and AAAA addresses for a host, or the host itself when it is
164+
* already an IP literal.
165+
*
166+
* @return string[]
167+
*/
168+
private function resolve(string $host): array
169+
{
170+
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
171+
return [$host];
172+
}
173+
174+
$records = @dns_get_record($host, DNS_A + DNS_AAAA) ?: [];
175+
$ips = [];
176+
foreach ($records as $record) {
177+
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
178+
if ($ip !== null) {
179+
$ips[] = $ip;
180+
}
181+
}
182+
183+
if ($ips === []) {
184+
// dns_get_record can come back empty on hosts resolved only via
185+
// /etc/hosts; fall back to the resolver library.
186+
$fallback = gethostbyname($host);
187+
if ($fallback !== $host) {
188+
$ips[] = $fallback;
189+
}
190+
}
191+
192+
return array_values(array_unique($ips));
193+
}
194+
195+
/**
196+
* Absolute redirect target for a 3xx response, or null when the response
197+
* is not a redirect.
198+
*/
199+
private function redirectTarget(string $current, ResponseInterface $response): ?string
200+
{
201+
if (!in_array($response->getStatusCode(), [301, 302, 303, 307, 308], true)) {
202+
return null;
203+
}
204+
205+
$location = $response->getHeaderLine('Location');
206+
if ($location === '') {
207+
return null;
208+
}
209+
210+
return (string) UriResolver::resolve(new Uri($current), new Uri($location));
211+
}
212+
}

‎app/Http/Controllers/ItemController.php‎

Lines changed: 30 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
namespace App\Http\Controllers;
44

55
use App\Application;
6+
use App\Exceptions\BlockedUrlException;
7+
use App\Helpers\SafeUrlFetcher;
68
use App\Item;
79
use App\Jobs\ProcessApps;
810
use App\User;
9-
use GuzzleHttp\Client;
10-
use GuzzleHttp\Exception\ConnectException;
1111
use GuzzleHttp\Exception\GuzzleException;
12-
use GuzzleHttp\Exception\ServerException;
1312
use Illuminate\Contracts\View\View;
1413
use Illuminate\Database\Eloquent\Collection;
1514
use Illuminate\Http\RedirectResponse;
@@ -264,28 +263,28 @@ public static function storelogic(Request $request, $id = null): Item
264263
'icon' => $path,
265264
]);
266265
} elseif (strpos($request->input('icon'), 'http') === 0) {
267-
$options = [
268-
"ssl" => [
269-
"verify_peer" => false,
270-
"verify_peer_name" => false,
271-
],
272-
];
273-
274-
// Proxy management
275-
$httpsProxy = getenv('HTTPS_PROXY');
276-
$httpsProxyLower = getenv('https_proxy');
277-
if ($httpsProxy !== false || $httpsProxyLower !== false) {
278-
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
279-
}
280-
281266
$file = $request->input('icon');
282267
$path_parts = pathinfo($file);
283268
if (!array_key_exists('extension', $path_parts)) {
284269
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
285270
}
286271
$extension = $path_parts['extension'];
287272

288-
$contents = file_get_contents($request->input('icon'), false, stream_context_create($options));
273+
// The icon is fetched server-side, so it goes through the same SSRF
274+
// guard as website lookups: http(s) only, public addresses only, and
275+
// every redirect hop re-checked. Proxy settings come from the
276+
// HTTP(S)_PROXY environment, which Guzzle reads by default.
277+
try {
278+
$response = app(SafeUrlFetcher::class)->fetch($file);
279+
} catch (BlockedUrlException $e) {
280+
throw ValidationException::withMessages(['file' => 'Icon URL is not allowed: ' . $e->getMessage()]);
281+
}
282+
283+
if ($response === null || $response->getStatusCode() !== 200) {
284+
throw ValidationException::withMessages(['file' => 'Icon could not be downloaded from the given URL.']);
285+
}
286+
287+
$contents = (string) $response->getBody();
289288

290289
if ($extension === 'svg') {
291290
$sanitizer = new Sanitizer();
@@ -520,62 +519,27 @@ public function testConfig(Request $request)
520519
}
521520

522521
/**
522+
* Fetch a caller-supplied URL through the SSRF guard. Every redirect hop
523+
* is re-checked; a blocked URL aborts with 403.
524+
*
523525
* @param $url
524-
* @param array|bool $overridevars
525-
* @throws GuzzleException
526+
* @param array|bool $overridevars Guzzle client options replacing the defaults
526527
*/
527528
public function execute($url, array $attrs = [], $overridevars = false): ?ResponseInterface
528529
{
529-
// Default Guzzle client configuration
530-
$clientOptions = [
531-
'http_errors' => false,
532-
'timeout' => 15,
533-
'connect_timeout' => 15,
534-
'verify' => false, // In production, set this to `true` and manage certs.
535-
];
536-
537-
// If the user provided overrides, use them.
538-
if ($overridevars !== false) {
539-
$clientOptions = $overridevars;
540-
}
541-
542-
// Resolve the hostname to an IP address
543-
$host = parse_url($url, PHP_URL_HOST);
544-
$ip = gethostbyname($host);
545-
546-
// Check if the IP is private or reserved
547-
$allowInternalIps = env('ALLOW_INTERNAL_REQUESTS', false);
548-
if (!$allowInternalIps && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
549-
Log::warning('Blocked access to private or reserved IPs.', ['ip' => $ip, 'host' => $host]);
550-
abort(Response::HTTP_FORBIDDEN, 'Access to private or reserved IPs is not allowed.');
551-
}
552-
553-
// Force Guzzle to use the resolved IP address
554-
$clientOptions['curl'][CURLOPT_RESOLVE] = ["{$host}:80:{$ip}", "{$host}:443:{$ip}"];
555-
556-
$client = new Client($clientOptions);
557-
$method = 'GET';
558-
559530
try {
560-
return $client->request($method, $url, $attrs);
561-
} catch (ConnectException $e) {
562-
Log::warning('SSRF Attempt Blocked: Connection to a private IP was prevented.', [
563-
'url' => $url,
564-
'error' => $e->getMessage()
565-
]);
566-
return null;
567-
} catch (ServerException $e) {
568-
Log::debug($e->getMessage());
569-
} catch (\Exception $e) {
570-
Log::error('General error: ' . $e->getMessage());
531+
return app(SafeUrlFetcher::class)->fetch($url, $overridevars === false ? [] : $overridevars, $attrs);
532+
} catch (BlockedUrlException $e) {
533+
Log::warning('SSRF attempt blocked.', ['url' => $url, 'reason' => $e->getMessage()]);
534+
abort(Response::HTTP_FORBIDDEN, 'Access to private or reserved IPs is not allowed.');
571535
}
572-
573-
return null;
574536
}
575537

576538
/**
577-
* @param $url
578-
* @throws GuzzleException
539+
* Fetch the body of a caller-supplied URL for the add-item form's
540+
* title and icon discovery. Blocked or unreachable URLs return 403.
541+
*
542+
* @param $url base64-encoded URL
579543
*/
580544
public function websitelookup($url): StreamInterface
581545
{
@@ -593,7 +557,7 @@ public function websitelookup($url): StreamInterface
593557
if ($response === null) {
594558
abort(Response::HTTP_FORBIDDEN, 'Access to the requested resource is not allowed or the resource is unavailable.');
595559
}
596-
560+
597561
return $response->getBody();
598562
}
599563

‎readme.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Restart the container and the Enhanced apps should now be able to access your lo
185185

186186
## Allow Internal IP Requests
187187

188-
By default, Heimdall blocks requests to private or reserved IP addresses to mitigate potential security risks such as Server-Side Request Forgery (SSRF). However, you can enable access to internal IPs by setting the `ALLOW_INTERNAL_REQUESTS` environment variable in your `.env` file.
188+
By default, Heimdall blocks requests to private or reserved IP addresses to mitigate potential security risks such as Server-Side Request Forgery (SSRF). This applies to every URL a user supplies that Heimdall fetches server-side: the website lookup used by the add-item form and icon URLs. The check runs on the initial URL and again on every redirect it returns, so a public address cannot bounce the request to an internal one. You can enable access to internal IPs by setting the `ALLOW_INTERNAL_REQUESTS` environment variable in your `.env` file.
189189

190190
### Steps to Enable Internal IP Requests
191191
1. Open your `.env` file located in the root directory of your Heimdall installation.

‎storage/app/supportedapps.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)