Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,8 @@ AUTH_ROLES_HTTP_HEADER="HTTP_REMOTE_GROUPS"
AUTH_ROLES_ADMIN="admin"
AUTH_ROLES_DELIMITER=","

# Set to true if you host Heimdall on a LAN and need to fetch icons from
# internal addresses (e.g. http://192.168.1.10:8080/favicon.png).
# When false (the default), the SSRF guard blocks icon URLs that resolve to
# private or reserved addresses (loopback, RFC1918, link-local metadata, etc.).
ALLOW_INTERNAL_REQUESTS=false
33 changes: 33 additions & 0 deletions app/Http/Controllers/ItemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,46 @@ public static function storelogic(Request $request, $id = null): Item
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
}

// Do not follow redirects: a public URL must not be able to bounce to an internal one.
$options['http']['follow_location'] = 0;
$options['http']['max_redirects'] = 0;

$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'];

// SSRF guard: this URL is fetched server-side, so restrict it to http(s)
// hosts that resolve to a public address. Without this an attacker can point
// it at internal services or cloud metadata (e.g. http://169.254.169.254/)
// and use Heimdall as a request proxy.
//
// Self-hosters who point icons at internal services (e.g. http://192.168.1.10:8080/favicon.png)
// can opt out by setting ALLOW_INTERNAL_REQUESTS=true in their .env file.
$scheme = strtolower((string) parse_url($file, PHP_URL_SCHEME));
$host = parse_url($file, PHP_URL_HOST);
if (!in_array($scheme, ['http', 'https'], true) || empty($host)) {
throw ValidationException::withMessages(['file' => 'Icon URL must be a valid http(s) URL.']);
}
if (!env('ALLOW_INTERNAL_REQUESTS', false)) {
$resolvedIps = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: array_filter(array_map(
static fn ($record) => $record['ip'] ?? $record['ipv6'] ?? null,
@dns_get_record($host, DNS_A + DNS_AAAA) ?: []
));
if (empty($resolvedIps)) {
throw ValidationException::withMessages(['file' => 'Icon URL host could not be resolved.']);
}
foreach ($resolvedIps as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw ValidationException::withMessages(['file' => 'Icon URL must not resolve to a private or reserved address.']);
}
}
}

$contents = file_get_contents($request->input('icon'), false, stream_context_create($options));

if ($extension === 'svg') {
Expand Down
130 changes: 130 additions & 0 deletions tests/Feature/IconUrlSsrfTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

/**
* Tests for the SSRF guard added to ItemController::store / ::update.
*
* The guard rejects icon URLs whose host resolves to a private or reserved
* address, preventing Heimdall from being used as an internal request proxy.
* Self-hosted installs that point icons at internal services can opt out via
* ALLOW_INTERNAL_REQUESTS=true in their .env.
*/
class IconUrlSsrfTest extends TestCase
{
use RefreshDatabase;

protected function itemPayload(array $overrides = []): array
{
return array_merge([
'pinned' => 1,
'appid' => 'null',
'website' => null,
'title' => 'SSRF Test Item',
'colour' => '#000000',
'url' => 'http://example.com',
'tags' => [0],
], $overrides);
}

// ------------------------------------------------------------------ happy path

public function test_icon_with_public_ip_is_accepted(): void
{
$this->seed();

// 8.8.8.8 is a public address - the guard should allow it.
$response = $this->post('/items', $this->itemPayload([
'icon' => 'http://8.8.8.8/favicon.png',
]));

// Validation passes; the fetch itself may fail (no real server), but we
// only care that the guard does NOT raise a 422 for the icon field.
$response->assertJsonMissingValidationErrors('file');
}

// ------------------------------------------------------------------ blocked by default

public function test_loopback_icon_is_rejected(): void
{
$this->seed();

$response = $this->post('/items', $this->itemPayload([
'icon' => 'http://127.0.0.1/icon.png',
]));

$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}

public function test_link_local_metadata_ip_is_rejected(): void
{
$this->seed();

$response = $this->post('/items', $this->itemPayload([
'icon' => 'http://169.254.169.254/latest/meta-data/icon.png',
]));

$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}

public function test_rfc1918_private_ip_is_rejected(): void
{
$this->seed();

$response = $this->post('/items', $this->itemPayload([
'icon' => 'http://192.168.1.10/favicon.png',
]));

$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}

public function test_non_http_scheme_is_rejected(): void
{
$this->seed();

$response = $this->post('/items', $this->itemPayload([
'icon' => 'ftp://example.com/icon.png',
]));

$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}

public function test_file_scheme_is_rejected(): void
{
$this->seed();

$response = $this->post('/items', $this->itemPayload([
'icon' => 'file:///etc/passwd',
]));

$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}

// ------------------------------------------------------------------ ALLOW_INTERNAL_REQUESTS opt-out

public function test_private_ip_is_allowed_when_opt_out_env_is_set(): void
{
$this->seed();
config(['app.allow_internal_requests' => true]);
putenv('ALLOW_INTERNAL_REQUESTS=true');

$response = $this->post('/items', $this->itemPayload([
'icon' => 'http://192.168.1.10:8080/favicon.png',
]));

// With ALLOW_INTERNAL_REQUESTS=true the guard is bypassed entirely.
// The request may still fail at the fetch stage (no real server), but
// it must NOT be rejected by the SSRF guard itself.
$response->assertJsonMissingValidationErrors('file');

putenv('ALLOW_INTERNAL_REQUESTS=false');
}
}
Loading