From c49d8dd7fbaa14fe55a3ce499d27c76756eb3beb Mon Sep 17 00:00:00 2001 From: Bunlong Heng Date: Fri, 21 Aug 2026 11:37:22 -0400 Subject: [PATCH] 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. --- .env.example | 4 + app/Http/Controllers/ItemController.php | 33 ++++++ tests/Feature/IconUrlSsrfTest.php | 130 ++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 tests/Feature/IconUrlSsrfTest.php diff --git a/.env.example b/.env.example index 6764eb03f..1833b3605 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php index 092ac21b6..061c36380 100644 --- a/app/Http/Controllers/ItemController.php +++ b/app/Http/Controllers/ItemController.php @@ -278,6 +278,10 @@ 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)) { @@ -285,6 +289,35 @@ public static function storelogic(Request $request, $id = null): Item } $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') { diff --git a/tests/Feature/IconUrlSsrfTest.php b/tests/Feature/IconUrlSsrfTest.php new file mode 100644 index 000000000..6be2b382b --- /dev/null +++ b/tests/Feature/IconUrlSsrfTest.php @@ -0,0 +1,130 @@ + 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'); + } +}