Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,9 @@ AUTH_ROLES_HTTP_HEADER="HTTP_REMOTE_GROUPS"
AUTH_ROLES_ADMIN="admin"
AUTH_ROLES_DELIMITER=","

ALLOW_INTERNAL_REQUESTS=false
# Set to true if you host Heimdall on a LAN and need website lookups or icon
# URLs to reach internal addresses (e.g. http://192.168.1.10:8080/favicon.png).
# When false (the default), server-side fetches of user-supplied URLs are
# refused when the host, or any redirect it returns, resolves to a private or
# reserved address (loopback, RFC1918, link-local metadata, etc.).
ALLOW_INTERNAL_REQUESTS=false
14 changes: 14 additions & 0 deletions app/Exceptions/BlockedUrlException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Exceptions;

use RuntimeException;

/**
* Thrown when a server-side fetch is refused by the SSRF guard: the URL uses
* a scheme other than http(s), its host cannot be resolved, or it (or a
* redirect hop) resolves to a private or reserved address.
*/
class BlockedUrlException extends RuntimeException
{
}
212 changes: 212 additions & 0 deletions app/Helpers/SafeUrlFetcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

namespace App\Helpers;

use App\Exceptions\BlockedUrlException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Support\Facades\Log;
use Psr\Http\Message\ResponseInterface;

/**
* Fetches caller-supplied URLs server-side without letting them reach
* internal services.
*
* Every hop, including each redirect target, goes through the same guard:
* the scheme must be http(s), the host must resolve, and every resolved
* address must be public. The resolved address is pinned via CURLOPT_RESOLVE
* so the connection goes to the address that was checked. Redirects are not
* delegated to Guzzle; they are followed here one at a time so the guard
* runs again for each Location.
*
* Set ALLOW_INTERNAL_REQUESTS=true to disable the address check for installs
* that deliberately point Heimdall at LAN services.
*/
class SafeUrlFetcher
{
public const MAX_REDIRECTS = 5;

private ?HandlerStack $handler;

public function __construct(?HandlerStack $handler = null)
{
$this->handler = $handler;
}

/**
* Fetch $url with GET, following up to MAX_REDIRECTS redirects and
* re-checking each target. Returns null on a transport failure.
*
* @throws BlockedUrlException when the URL or any redirect target is refused
*/
public function fetch(string $url, array $clientOptions = [], array $requestOptions = []): ?ResponseInterface
{
$clientOptions = array_merge([
'http_errors' => false,
'timeout' => 15,
'connect_timeout' => 15,
'verify' => false,
], $clientOptions);

// Redirects are handled below so every hop is checked.
$clientOptions['allow_redirects'] = false;

if ($this->handler !== null) {
$clientOptions['handler'] = $this->handler;
}

$current = $url;

for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
$resolved = $this->assertAllowed($current);

$options = $clientOptions;
if ($resolved['ip'] !== null) {
$options['curl'][CURLOPT_RESOLVE] = [
sprintf('%s:%d:%s', $resolved['host'], $resolved['port'], $resolved['ip']),
];
}

try {
$response = (new Client($options))->request('GET', $current, $requestOptions);
} catch (ConnectException $e) {
Log::warning('Outbound request failed to connect.', ['url' => $current, 'error' => $e->getMessage()]);
return null;
} catch (GuzzleException $e) {
Log::error('Outbound request failed: ' . $e->getMessage(), ['url' => $current]);
return null;
}

$location = $this->redirectTarget($current, $response);
if ($location === null) {
return $response;
}

$current = $location;
}

throw new BlockedUrlException('Too many redirects while fetching ' . $url);
}

/**
* Validate a URL against the guard and resolve its host.
*
* @return array{host: string, port: int, ip: string|null} ip is null when
* internal requests are allowed and pinning is skipped
* @throws BlockedUrlException
*/
public function assertAllowed(string $url): array
{
$parts = parse_url($url);
if ($parts === false) {
throw new BlockedUrlException('URL could not be parsed.');
}

$scheme = strtolower($parts['scheme'] ?? '');
if (!in_array($scheme, ['http', 'https'], true)) {
throw new BlockedUrlException('Only http and https URLs can be fetched.');
}

$host = $parts['host'] ?? '';
if ($host === '') {
throw new BlockedUrlException('URL has no host.');
}

// IPv6 literals arrive bracketed from parse_url.
$host = trim($host, '[]');
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));

if (config('app.allow_internal_requests', false)) {
return ['host' => $host, 'port' => $port, 'ip' => null];
}

$ips = $this->resolve($host);
if ($ips === []) {
throw new BlockedUrlException('Host could not be resolved: ' . $host);
}

foreach ($ips as $ip) {
if (!self::isPublicIp($ip)) {
Log::warning('Blocked access to private or reserved IPs.', ['ip' => $ip, 'host' => $host]);
throw new BlockedUrlException('Access to private or reserved IPs is not allowed.');
}
}

return ['host' => $host, 'port' => $port, 'ip' => $ips[0]];
}

/**
* True when the address is neither private (RFC 1918, ULA) nor reserved
* (loopback, link-local, unspecified, ...). IPv4-mapped IPv6 addresses
* are checked as their embedded IPv4 address.
*/
public static function isPublicIp(string $ip): bool
{
if (str_contains($ip, ':')) {
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if (substr($packed, 0, 12) === "\0\0\0\0\0\0\0\0\0\0\xff\xff") {
$ip = inet_ntop(substr($packed, 12));
}
}

return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}

/**
* All A and AAAA addresses for a host, or the host itself when it is
* already an IP literal.
*
* @return string[]
*/
private function resolve(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return [$host];
}

$records = @dns_get_record($host, DNS_A + DNS_AAAA) ?: [];
$ips = [];
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip !== null) {
$ips[] = $ip;
}
}

if ($ips === []) {
// dns_get_record can come back empty on hosts resolved only via
// /etc/hosts; fall back to the resolver library.
$fallback = gethostbyname($host);
if ($fallback !== $host) {
$ips[] = $fallback;
}
}

return array_values(array_unique($ips));
}

/**
* Absolute redirect target for a 3xx response, or null when the response
* is not a redirect.
*/
private function redirectTarget(string $current, ResponseInterface $response): ?string
{
if (!in_array($response->getStatusCode(), [301, 302, 303, 307, 308], true)) {
return null;
}

$location = $response->getHeaderLine('Location');
if ($location === '') {
return null;
}

return (string) UriResolver::resolve(new Uri($current), new Uri($location));
}
}
96 changes: 30 additions & 66 deletions app/Http/Controllers/ItemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
namespace App\Http\Controllers;

use App\Application;
use App\Exceptions\BlockedUrlException;
use App\Helpers\SafeUrlFetcher;
use App\Item;
use App\Jobs\ProcessApps;
use App\User;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
Expand Down Expand Up @@ -264,28 +263,28 @@ public static function storelogic(Request $request, $id = null): Item
'icon' => $path,
]);
} elseif (strpos($request->input('icon'), 'http') === 0) {
$options = [
"ssl" => [
"verify_peer" => false,
"verify_peer_name" => false,
],
];

// Proxy management
$httpsProxy = getenv('HTTPS_PROXY');
$httpsProxyLower = getenv('https_proxy');
if ($httpsProxy !== false || $httpsProxyLower !== false) {
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
}

$file = $request->input('icon');
$path_parts = pathinfo($file);
if (!array_key_exists('extension', $path_parts)) {
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
}
$extension = $path_parts['extension'];

$contents = file_get_contents($request->input('icon'), false, stream_context_create($options));
// The icon is fetched server-side, so it goes through the same SSRF
// guard as website lookups: http(s) only, public addresses only, and
// every redirect hop re-checked. Proxy settings come from the
// HTTP(S)_PROXY environment, which Guzzle reads by default.
try {
$response = app(SafeUrlFetcher::class)->fetch($file);
} catch (BlockedUrlException $e) {
throw ValidationException::withMessages(['file' => 'Icon URL is not allowed: ' . $e->getMessage()]);
}

if ($response === null || $response->getStatusCode() !== 200) {
throw ValidationException::withMessages(['file' => 'Icon could not be downloaded from the given URL.']);
}

$contents = (string) $response->getBody();

if ($extension === 'svg') {
$sanitizer = new Sanitizer();
Expand Down Expand Up @@ -520,62 +519,27 @@ public function testConfig(Request $request)
}

/**
* Fetch a caller-supplied URL through the SSRF guard. Every redirect hop
* is re-checked; a blocked URL aborts with 403.
*
* @param $url
* @param array|bool $overridevars
* @throws GuzzleException
* @param array|bool $overridevars Guzzle client options replacing the defaults
*/
public function execute($url, array $attrs = [], $overridevars = false): ?ResponseInterface
{
// Default Guzzle client configuration
$clientOptions = [
'http_errors' => false,
'timeout' => 15,
'connect_timeout' => 15,
'verify' => false, // In production, set this to `true` and manage certs.
];

// If the user provided overrides, use them.
if ($overridevars !== false) {
$clientOptions = $overridevars;
}

// Resolve the hostname to an IP address
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);

// Check if the IP is private or reserved
$allowInternalIps = env('ALLOW_INTERNAL_REQUESTS', false);
if (!$allowInternalIps && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
Log::warning('Blocked access to private or reserved IPs.', ['ip' => $ip, 'host' => $host]);
abort(Response::HTTP_FORBIDDEN, 'Access to private or reserved IPs is not allowed.');
}

// Force Guzzle to use the resolved IP address
$clientOptions['curl'][CURLOPT_RESOLVE] = ["{$host}:80:{$ip}", "{$host}:443:{$ip}"];

$client = new Client($clientOptions);
$method = 'GET';

try {
return $client->request($method, $url, $attrs);
} catch (ConnectException $e) {
Log::warning('SSRF Attempt Blocked: Connection to a private IP was prevented.', [
'url' => $url,
'error' => $e->getMessage()
]);
return null;
} catch (ServerException $e) {
Log::debug($e->getMessage());
} catch (\Exception $e) {
Log::error('General error: ' . $e->getMessage());
return app(SafeUrlFetcher::class)->fetch($url, $overridevars === false ? [] : $overridevars, $attrs);
} catch (BlockedUrlException $e) {
Log::warning('SSRF attempt blocked.', ['url' => $url, 'reason' => $e->getMessage()]);
abort(Response::HTTP_FORBIDDEN, 'Access to private or reserved IPs is not allowed.');
}

return null;
}

/**
* @param $url
* @throws GuzzleException
* Fetch the body of a caller-supplied URL for the add-item form's
* title and icon discovery. Blocked or unreachable URLs return 403.
*
* @param $url base64-encoded URL
*/
public function websitelookup($url): StreamInterface
{
Expand All @@ -593,7 +557,7 @@ public function websitelookup($url): StreamInterface
if ($response === null) {
abort(Response::HTTP_FORBIDDEN, 'Access to the requested resource is not allowed or the resource is unavailable.');
}

return $response->getBody();
}

Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Restart the container and the Enhanced apps should now be able to access your lo

## Allow Internal IP Requests

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.
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.

### Steps to Enable Internal IP Requests
1. Open your `.env` file located in the root directory of your Heimdall installation.
Expand Down
2 changes: 1 addition & 1 deletion storage/app/supportedapps.json

Large diffs are not rendered by default.

Loading
Loading