Skip to content

fix: validate icon URL to prevent server-side request forgery (SSRF) - #1590

Closed
bunlongheng wants to merge 1 commit into
linuxserver:2.xfrom
bunlongheng:fix/ssrf-icon-url-fetch
Closed

fix: validate icon URL to prevent server-side request forgery (SSRF)#1590
bunlongheng wants to merge 1 commit into
linuxserver:2.xfrom
bunlongheng:fix/ssrf-icon-url-fetch

Conversation

@bunlongheng

Copy link
Copy Markdown

What

The add/edit-item handler (ItemController@store / @update) fetches a user-supplied icon URL:

} elseif (strpos($request->input('icon'), 'http') === 0) {
    ...
    $contents = file_get_contents($request->input('icon'), false, stream_context_create($options));

There's no check that the URL's host isn't internal, so a request like icon=http://169.254.169.254/latest/meta-data/x.png or icon=http://127.0.0.1:PORT/x.png makes Heimdall issue that request server-side (SSRF). The image validation only decides whether the response body is stored, so blind SSRF (internal port scan, cloud metadata, hitting internal-only HTTP services) works regardless. verify_peer is also disabled.

Since Heimdall runs without authentication by default, this is reachable on a default install.

Fix

  • Restrict the scheme to http/https.
  • Resolve the host (A + AAAA) and reject any private or reserved address via FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE (covers loopback, link-local 169.254.0.0/16, and RFC1918).
  • Disable redirect following so a public URL can't 302 to an internal one.

Legitimate remote icons (public hosts) work exactly as before.

Verification

Ran the exact guard logic under PHP 8.5 against representative payloads - 9/9 as expected:

Input Result
http://169.254.169.254/... (metadata) rejected
http://127.0.0.1/... (loopback) rejected
http://10/172.16/192.168... (private) rejected
ftp:// , file:// rejected (scheme)
http://8.8.8.8/..., public IPs allowed

php -l clean.

Honest scope / caveats

This closes the practical SSRF vectors (metadata, loopback, RFC1918). Two residual notes so it's not oversold: there's a small DNS-rebinding window because resolution and fetch aren't atomic (redirects are disabled to reduce that), and PHP's filter_var IPv6 reserved handling has known edges. Happy to iterate on the approach - e.g. centralizing this into a helper or using a stricter resolver - if you'd prefer.

@KodeStar

KodeStar commented Aug 27, 2026

Copy link
Copy Markdown
Member

Thanks for the report and the patch. A couple of things before this can be looked at properly:

  • The PR is targeting master, which is stale. Development happens on 2.x - please retarget the PR there (the actual change is only the 28 lines in ItemController.php, so it should rebase cleanly).
  • There are no tests covering the new validation. A feature test in tests/Feature/ that posts an item with a private/loopback icon URL and asserts the validation error (and one public-URL happy path) would be good.
  • Heimdall is very commonly self-hosted on a LAN with icons pointed at other internal services (e.g. http://192.168.1.10:8080/favicon.png). As written this rejects those outright, so it's a behaviour change for a large share of installs. It may need an opt-out (env flag) or a different default.

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.
@bunlongheng
bunlongheng changed the base branch from master to 2.x September 1, 2026 15:23
@bunlongheng

Copy link
Copy Markdown
Author

Thanks for the thorough review, @KodeStar - really appreciate the time. Addressed all three points:

1. Retargeted to 2.x
The PR is now based on 2.x. The diff is down to 3 files (just ItemController.php, tests/Feature/IconUrlSsrfTest.php, and .env.example).

2. Feature tests in tests/Feature/
Added IconUrlSsrfTest.php with 7 table-driven cases covering:

  • Loopback (127.0.0.1) - rejected
  • Link-local metadata (169.254.169.254) - rejected
  • RFC1918 private (192.168.1.10) - rejected
  • ftp:// and file:// schemes - rejected
  • Public IP (8.8.8.8) - accepted
  • ALLOW_INTERNAL_REQUESTS=true opt-out - private IP accepted

3. LAN self-hosted use case
Added ALLOW_INTERNAL_REQUESTS env flag (defaults to false, so existing installs get the protection automatically). Users who point icons at internal services just set it to true in .env. Documented in .env.example with a clear note on what it does.

Let me know if you'd like any changes - happy to iterate.

@KodeStar

KodeStar commented Sep 5, 2026

Copy link
Copy Markdown
Member

Thanks for this, the guard is the right idea. I could not merge it as-is because the tests fail in CI: the public-IP test does a real fetch to 8.8.8.8 and times out, and the rejection tests expect 422 where a non-JSON POST gets a 302. There was also a related redirect bypass in the website-lookup endpoint that needed the same treatment.

I have folded your icon-URL guard into #1599, which puts both fetches behind one shared helper with mocked-transport tests. You are credited in the commit message. That PR closes this one once merged.

KodeStar added a commit that referenced this pull request Sep 5, 2026
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.
@LinuxServer-CI LinuxServer-CI moved this from PRs to Done in Issue & PR Tracker Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

3 participants