Skip to content

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

Open
bunlongheng wants to merge 459 commits into
linuxserver:masterfrom
bunlongheng:fix/ssrf-icon-url-fetch
Open

fix: validate icon URL to prevent server-side request forgery (SSRF)#1590
bunlongheng wants to merge 459 commits into
linuxserver:masterfrom
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.

aptalca and others added 30 commits April 19, 2023 09:22
handle issue-pr close and review submitted actions
Spelling and Grammar, included a few apostrophes for correct plural form or possessive. 

Added Capitalization to proper nouns

Included a single colon for consistency to match the styling of  it's predecessor paragraph
…n-item

add search via select2 for application dropdown
Since PHP 5.4 the short array syntax `[]` may be used instead of `array()`.
PHP 5.5.9 adds the new static `class` property which provides the fully qualified class name. This is preferred over using strings for class names since the `class` property references are checked by PHP.
Laravel 8 adopts the tuple syntax for controller actions. Since the old options array is incompatible with this syntax, Shift converted them to use modern, fluent methods.
In an effort to make upgrading the constantly changing config files
easier, Shift defaulted them and merged your true customizations -
where ENV variables may not be used.
`<env>` tags have a lower precedence than system environment variables making it easier to overwrite PHPUnit configuration values in additional environments, such a CI.

Review this blog post for more details on configuration precedence when testing Laravel: https://jasonmccreary.me/articles/laravel-testing-configuration-precedence/
KodeStar and others added 28 commits July 8, 2026 20:33
ItemController::appload() was declared ': ?string', so its two error
branches that 'return response()->json([...], 404)' had the JsonResponse
coerced through Response::__toString() into a raw HTTP message served as
an HTTP 200 body. Widen the return type to
'\Illuminate\Http\JsonResponse|string|null' so those branches emit
real 404 JSON responses. The method body is unchanged, so the happy path
still returns the same JSON string and the frontend contract is preserved.

Flip the endpoint characterization test to assert the corrected 404.
PHPUnit 12 renamed PHPT -> Phpt (src/Framework/Exception/PhptAssertionFailedError.php
and the src/Runner/Phpt/ directory). Because this repo commits vendor/ and the
macOS dev filesystem is case-insensitive, git kept the old-case paths in the index
while Composer wrote the new case to disk, so the change went undetected locally.
On CI's case-sensitive Linux filesystem the checked-out old-case files don't satisfy
PHPUnit's require of the new-case names and 'php artisan test' fatals before running.
Re-track these 8 files under their correct case.
- itemImport: check response.ok in fetchAppDetails so a genuine 404
  (from the appload return-type fix) is reported as 'Failed to find app
  id' instead of being parsed as a successful import; applied to the
  source and the committed compiled bundle.
- phpunit.xml: point the schema URL at 12.5 to match the installed
  PHPUnit 12.5.x.
- ColorHelpersTest: exercise the get_brightness() non-hex stripping the
  test name promised (interior separators), which the prior assertion
  never covered.
…-upgrade

Upgrade to Laravel 13, bump to v2.8.0, and remediate vulnerabilities
The :memory: overrides in phpunit.xml had been commented out since 2024,
so RefreshDatabase ran migrate:fresh against the real .env database and
wiped it on every local test run. Enable the overrides, let the special
:memory: identifier bypass database_path() resolution and the boot-time
touch(), and add a TestCase guard that aborts the suite unless it is
pointed at in-memory sqlite.

ItemExportTest only ever passed by reading the populated dev database;
seed the root dashboard item it depends on so it passes on a fresh DB.
Tiles could vanish without ever being deleted:

- Editing an item merged the editor's user_id into every save, so
  updating a visible item (e.g. a shared user_id=0 tile) silently
  reassigned ownership and hid it from everyone else. user_id is now
  set on create only, and excluded from update input in both Item and
  Tag controllers since it is mass-assignable.
- Deleting a user left their items orphaned with a dangling user_id,
  invisible to all users forever. The user's items are now hard-deleted
  with the account, and a data migration reassigns already-orphaned
  items to the admin user so previously "lost" tiles reappear.
- The Item global scope's ownership filter had an ungrouped orWhere,
  breaking operator precedence in any query that adds further clauses.

Includes regression coverage for ownership on create/update, user
deletion cleanup, and the orphan-recovery migration.
…-upgrade

Fix to try and mitigate any disappearing tiles
TrueNAS is deprecating the REST API (api/v2.0/) in version 26.04,
requiring migration to JSON-RPC 2.0 over WebSocket.

This commit adds:
- phrity/websocket dependency for WebSocket communication
- TrueNASWebSocketClient helper class that handles:
  - Connection to ws(s)://host/api/current
  - Authentication via auth.login_with_api_key
  - JSON-RPC 2.0 request/response formatting
  - TLS verification toggle
  - Proper connection cleanup

Refs: linuxserver#1530
Adds a new boolean setting in the Advanced settings group that allows
users to globally skip TLS certificate verification for all enhanced
apps. This is useful for users who have self-signed certificates on
their services.

When enabled, the Guzzle HTTP client will set 'verify' => false for
all API requests made by enhanced apps.

Resolves linuxserver/Heimdall-Apps#687
Checkbox-type config options (e.g. ignore_tls) always tested as "1"
regardless of the checkbox state. The Test-button config gatherer used
$(this).val() for every .config-item, and jQuery .val() on a checkbox
returns its value attribute ("1") regardless of checked state. App
config blades pair a hidden input (0) with a checkbox (1) sharing the
same data-config, and the checkbox is last in DOM order, so it always
overwrote the value with "1". Normal form saves were unaffected.

Use the :checked state for checkboxes so unchecking now tests as "0".

Also rebuilds the committed compiled bundle (public/js/app.js) so the
fix takes effect at runtime; the edit is byte-identical to the Laravel
Mix development build output for this block.

Fixes linuxserver/Heimdall-Apps#782
The WebSocket support added for TrueNAS JSON-RPC 2.0 requires the
phrity/websocket library to actually be available at runtime. This repo
commits the vendor/ tree (CI does not run composer install), so the
dependency and composer.lock must be committed for class_exists() checks
in the TrueNAS app to succeed.

- composer require phrity/websocket:^3.6 (resolves to 3.7.3) with lock
  and vendor/ committed
- Fix TrueNASWebSocketClient to catch WebSocket\Exception\Exception
  (phrity/websocket v3 namespace) instead of the non-existent
  WebSocket\ConnectionException from the old textalk/websocket v1/v2 API,
  so connection/call failures are logged and wrapped as intended
…skip

Add global TLS verification skip setting
…t-config

Fix checkbox config values always returning "1" in Test config
…socket-client

Add WebSocket support for TrueNAS JSON-RPC 2.0 API
…pdate

🇩🇪 Update German translation
SVG icons with explicit width/height attributes rendered tiny because
max-width/max-height only caps intrinsic size and never scales up, while
dimensionless SVGs defaulted large and were capped to 60px. Pin .app-icon
to a fixed 60x60 box with object-fit: contain so every icon renders at a
consistent size while preserving aspect ratio.

Fixes linuxserver#1582
…sistent-size

Fix inconsistent SVG tile icon sizes
Heimdall doesn't ship a config/session.php, so the cookie name falls
back to Laravel's framework default:

    Str::snake((string) env('APP_NAME', 'laravel')) . '_session'

Str::snake() only inserts underscores before capital letters; it does
not strip characters like "." or spaces. PHP mangles dots in incoming
cookie/GET/POST variable names to underscores when parsing a request,
so an APP_NAME containing a dot (e.g. "example.com") produces a cookie
the app can never read back: it looks for "example.com_session" but
PHP only ever hands it "example_com_session" in $_COOKIE.

That desyncs the session on every single request, so the CSRF token
embedded in any page never matches the token generated on submit,
producing a 419 Page Expired on every POST. Reproduced this with a
bare curl round-trip (fetch page, extract token+cookie, POST back
immediately) with no browser involved at all, which rules out any
browser-specific cause like cookie caching or extensions - this is a
server-side PHP request-parsing behavior, not a browser quirk.

Root cause and fix were worked out with Claude Code: it decrypted the
session cookie server-side and confirmed the mismatch with a $_COOKIE
probe against config('session.cookie') on a live instance where this
was happening.

This matches the symptoms in several previously-reported, unresolved
419 issues:

- linuxserver#398 - Getting 419 error code when trying to add apps
- linuxserver#443 - 419 "Sorry, your session has expired" when submitting new app
- linuxserver#590 - 419 error when hitting save
- linuxserver#873 - 419 Page Expired doing any POST requests

Setting SESSION_COOKIE explicitly in .env already works around this
today (the framework default respects it), but nothing points users
at that until they've already hit the bug. Adding config/session.php
pins the cookie name to a fixed value by default (still overridable
via SESSION_COOKIE) so it no longer depends on APP_NAME at all, fixing
it out of the box for new and existing installs alike.

App-level config files merge over the framework's defaults key by key
(Illuminate\Foundation\Bootstrap\LoadConfiguration), so this only
needs to override the one key - verified the rest of session.* still
resolves from the framework defaults unchanged.
…-appname-dot

Pin session cookie name, stop deriving it from APP_NAME
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.
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.