Skip to content

Commit c49d8dd

Browse files
committed
fix: validate icon URL to prevent server-side request forgery
The add/edit-item handler fetches a user-supplied icon URL with file_get_contents and no host validation, so 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. Heimdall runs without authentication by default, so this is reachable on default installs. Restrict the URL to http/https, resolve the host and reject private or reserved addresses, and stop following redirects so a public URL cannot bounce to an internal one.
1 parent 1455f70 commit c49d8dd

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,8 @@ AUTH_ROLES_HTTP_HEADER="HTTP_REMOTE_GROUPS"
7676
AUTH_ROLES_ADMIN="admin"
7777
AUTH_ROLES_DELIMITER=","
7878

79+
# Set to true if you host Heimdall on a LAN and need to fetch icons from
80+
# internal addresses (e.g. http://192.168.1.10:8080/favicon.png).
81+
# When false (the default), the SSRF guard blocks icon URLs that resolve to
82+
# private or reserved addresses (loopback, RFC1918, link-local metadata, etc.).
7983
ALLOW_INTERNAL_REQUESTS=false

‎app/Http/Controllers/ItemController.php‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,46 @@ public static function storelogic(Request $request, $id = null): Item
278278
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
279279
}
280280

281+
// Do not follow redirects: a public URL must not be able to bounce to an internal one.
282+
$options['http']['follow_location'] = 0;
283+
$options['http']['max_redirects'] = 0;
284+
281285
$file = $request->input('icon');
282286
$path_parts = pathinfo($file);
283287
if (!array_key_exists('extension', $path_parts)) {
284288
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
285289
}
286290
$extension = $path_parts['extension'];
287291

292+
// SSRF guard: this URL is fetched server-side, so restrict it to http(s)
293+
// hosts that resolve to a public address. Without this an attacker can point
294+
// it at internal services or cloud metadata (e.g. http://169.254.169.254/)
295+
// and use Heimdall as a request proxy.
296+
//
297+
// Self-hosters who point icons at internal services (e.g. http://192.168.1.10:8080/favicon.png)
298+
// can opt out by setting ALLOW_INTERNAL_REQUESTS=true in their .env file.
299+
$scheme = strtolower((string) parse_url($file, PHP_URL_SCHEME));
300+
$host = parse_url($file, PHP_URL_HOST);
301+
if (!in_array($scheme, ['http', 'https'], true) || empty($host)) {
302+
throw ValidationException::withMessages(['file' => 'Icon URL must be a valid http(s) URL.']);
303+
}
304+
if (!env('ALLOW_INTERNAL_REQUESTS', false)) {
305+
$resolvedIps = filter_var($host, FILTER_VALIDATE_IP)
306+
? [$host]
307+
: array_filter(array_map(
308+
static fn ($record) => $record['ip'] ?? $record['ipv6'] ?? null,
309+
@dns_get_record($host, DNS_A + DNS_AAAA) ?: []
310+
));
311+
if (empty($resolvedIps)) {
312+
throw ValidationException::withMessages(['file' => 'Icon URL host could not be resolved.']);
313+
}
314+
foreach ($resolvedIps as $ip) {
315+
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
316+
throw ValidationException::withMessages(['file' => 'Icon URL must not resolve to a private or reserved address.']);
317+
}
318+
}
319+
}
320+
288321
$contents = file_get_contents($request->input('icon'), false, stream_context_create($options));
289322

290323
if ($extension === 'svg') {

‎tests/Feature/IconUrlSsrfTest.php‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use Illuminate\Foundation\Testing\RefreshDatabase;
6+
use Tests\TestCase;
7+
8+
/**
9+
* Tests for the SSRF guard added to ItemController::store / ::update.
10+
*
11+
* The guard rejects icon URLs whose host resolves to a private or reserved
12+
* address, preventing Heimdall from being used as an internal request proxy.
13+
* Self-hosted installs that point icons at internal services can opt out via
14+
* ALLOW_INTERNAL_REQUESTS=true in their .env.
15+
*/
16+
class IconUrlSsrfTest extends TestCase
17+
{
18+
use RefreshDatabase;
19+
20+
protected function itemPayload(array $overrides = []): array
21+
{
22+
return array_merge([
23+
'pinned' => 1,
24+
'appid' => 'null',
25+
'website' => null,
26+
'title' => 'SSRF Test Item',
27+
'colour' => '#000000',
28+
'url' => 'http://example.com',
29+
'tags' => [0],
30+
], $overrides);
31+
}
32+
33+
// ------------------------------------------------------------------ happy path
34+
35+
public function test_icon_with_public_ip_is_accepted(): void
36+
{
37+
$this->seed();
38+
39+
// 8.8.8.8 is a public address - the guard should allow it.
40+
$response = $this->post('/items', $this->itemPayload([
41+
'icon' => 'http://8.8.8.8/favicon.png',
42+
]));
43+
44+
// Validation passes; the fetch itself may fail (no real server), but we
45+
// only care that the guard does NOT raise a 422 for the icon field.
46+
$response->assertJsonMissingValidationErrors('file');
47+
}
48+
49+
// ------------------------------------------------------------------ blocked by default
50+
51+
public function test_loopback_icon_is_rejected(): void
52+
{
53+
$this->seed();
54+
55+
$response = $this->post('/items', $this->itemPayload([
56+
'icon' => 'http://127.0.0.1/icon.png',
57+
]));
58+
59+
$response->assertStatus(422);
60+
$response->assertJsonValidationErrors('file');
61+
}
62+
63+
public function test_link_local_metadata_ip_is_rejected(): void
64+
{
65+
$this->seed();
66+
67+
$response = $this->post('/items', $this->itemPayload([
68+
'icon' => 'http://169.254.169.254/latest/meta-data/icon.png',
69+
]));
70+
71+
$response->assertStatus(422);
72+
$response->assertJsonValidationErrors('file');
73+
}
74+
75+
public function test_rfc1918_private_ip_is_rejected(): void
76+
{
77+
$this->seed();
78+
79+
$response = $this->post('/items', $this->itemPayload([
80+
'icon' => 'http://192.168.1.10/favicon.png',
81+
]));
82+
83+
$response->assertStatus(422);
84+
$response->assertJsonValidationErrors('file');
85+
}
86+
87+
public function test_non_http_scheme_is_rejected(): void
88+
{
89+
$this->seed();
90+
91+
$response = $this->post('/items', $this->itemPayload([
92+
'icon' => 'ftp://example.com/icon.png',
93+
]));
94+
95+
$response->assertStatus(422);
96+
$response->assertJsonValidationErrors('file');
97+
}
98+
99+
public function test_file_scheme_is_rejected(): void
100+
{
101+
$this->seed();
102+
103+
$response = $this->post('/items', $this->itemPayload([
104+
'icon' => 'file:///etc/passwd',
105+
]));
106+
107+
$response->assertStatus(422);
108+
$response->assertJsonValidationErrors('file');
109+
}
110+
111+
// ------------------------------------------------------------------ ALLOW_INTERNAL_REQUESTS opt-out
112+
113+
public function test_private_ip_is_allowed_when_opt_out_env_is_set(): void
114+
{
115+
$this->seed();
116+
config(['app.allow_internal_requests' => true]);
117+
putenv('ALLOW_INTERNAL_REQUESTS=true');
118+
119+
$response = $this->post('/items', $this->itemPayload([
120+
'icon' => 'http://192.168.1.10:8080/favicon.png',
121+
]));
122+
123+
// With ALLOW_INTERNAL_REQUESTS=true the guard is bypassed entirely.
124+
// The request may still fail at the fetch stage (no real server), but
125+
// it must NOT be rejected by the SSRF guard itself.
126+
$response->assertJsonMissingValidationErrors('file');
127+
128+
putenv('ALLOW_INTERNAL_REQUESTS=false');
129+
}
130+
}

0 commit comments

Comments
 (0)