Compare commits

...

43 Commits

Author SHA1 Message Date
KodeStar 9a9877a0dc Enforce TRUSTED_HOSTS allow-list regardless of APP_ENV
The custom TrustHosts middleware only overrode hosts(), so it inherited the
parent's shouldSpecifyTrustedHosts() gate, which skips enforcement whenever the
app runs in the local environment or under the test runner. Heimdall ships
APP_ENV=local by default (.env.example, copied to .env on install), so the
TRUSTED_HOSTS allow-list a user configures per the .env.example guidance was
never actually applied.

Override shouldSpecifyTrustedHosts() to tie enforcement to configuration
instead of environment: apply the allow-list whenever TRUSTED_HOSTS is set, in
any environment; when it is unset hosts() is empty and enforcement stays off,
preserving the historic no-restriction behaviour. Add handle()-driven tests
covering both the configured and unconfigured cases.
2026-07-08 18:22:49 +01:00
KodeStar 5dcf462542 Merge branch '2.x' into fix/host-header-injection 2026-07-08 16:09:44 +01:00
KodeStar e2215fe42b Merge pull request #1571 from linuxserver/fix/ci-node-select2
Fix CI: pin Node 24 and install frontend deps with npm ci
2026-07-08 16:08:59 +01:00
KodeStar 5247349d33 Fix CI: pin Node 24 and install frontend deps with npm ci
CI ran `yarn && yarn dev` with no committed yarn.lock, so every run resolved
the latest matching versions and dependency drift broke the pipeline in two
independent ways:

- select2 4.1.0 added engines.node ">=24" but the runner used node 22, so
  `yarn install` failed outright on every pull request.
- webpack 5.108 removed lib/SizeFormatHelpers, which laravel-mix 6 still
  requires, so `yarn dev` would have failed the build regardless of node.

Switch the workflow to `npm ci`, which installs the exact, known-good
versions already pinned in package-lock.json (select2 4.0.13, webpack
5.100.1) and is verified to build and lint cleanly. Pin the runner to node
24 via actions/setup-node so the toolchain is explicit rather than tracking
the runner default. Also pin select2 to ~4.0.13 in package.json so a future
`npm install` cannot pull the incompatible 4.1.0 back in.
2026-07-08 16:06:35 +01:00
KodeStar 881533baa5 Harden against host header injection and open redirect
Heimdall trusted the incoming X-Forwarded-Host header for URL generation, so
a spoofed value poisoned the page base href, asset() URLs and redirect
targets - loading assets from and redirecting to an attacker-controlled host
(CVE-2025-50578).

- TrustProxies no longer trusts X-Forwarded-Host; a forged value can no longer
  influence getHost(), url(), asset() or redirects. X-Forwarded-For/Port/Proto
  handling is unchanged.
- Trusted proxies are now configurable via the TRUSTED_PROXIES env var
  (comma-separated CIDRs/IPs, "*" to trust all), defaulting to the previous
  private ranges.
- Added an opt-in TRUSTED_HOSTS allow-list: when set, only the listed hosts are
  served and any other Host header is rejected. Unset keeps the historic
  behaviour of serving arbitrary hosts, so existing installs are unaffected.

Resolves #1451
2026-07-08 14:42:12 +01:00
KodeStar 5907a1f231 Merge pull request #1559 from JoshSalway/queue-safety-2026-04-22
[2.x] Bound retry and unique-lock lifetimes on UpdateApps and ProcessApps
2026-07-08 09:35:47 +01:00
KodeStar df0eba046b Merge pull request #1563 from Nyuwb/patch-1
fix: proxy options in ItemController
2026-07-08 09:34:39 +01:00
Fabien Ehrlich 6a776e30f8 fix: proxy options in ItemController
The correct context is http->proxy :

https://www.php.net/manual/en/context.http.php
2026-05-13 11:35:13 +02:00
Josh Salway cbf099be2c Remove QueueFailedHandlerTest from the shipped suite
Reproduction-style behavior test, not a regression test, so it does
not belong in the main suite. Full test remains in this branch's
commit history at 56c53ab9 for reviewers:

    git checkout 56c53ab9 -- tests/Feature/QueueFailedHandlerTest.php
    vendor/bin/phpunit --filter QueueFailedHandlerTest

Keeps the PR aligned with Heimdall's existing test conventions
(HTTP-feature tests, no facade-mocking unit tests).
2026-04-22 20:16:11 +10:00
Josh Salway 56c53ab9c5 Prove failed() log shape with behavior test
Tests that UpdateApps::failed() and ProcessApps::failed():
 - call Log::error with the 'permanently failed' message
 - include exception_class, exception_message, and file context keys
 - for UpdateApps, still call Cache::lock('updateApps')->forceRelease()

Uses Log::spy() and Mockery to capture facade calls. 2 tests, 4
assertions, green on PHP 8.4.20. See follow-up commit for why this
lands in history but not in the shipped suite.
2026-04-22 20:15:54 +10:00
Josh Salway 98b6d96cd1 Enrich failed() log context with exception class and file:line
Previously logged only the exception message. Adds:
 - exception_class: distinguishes ClientException vs ConnectException
   vs other Guzzle/PHP failure types at a glance
 - file: file path and line where the exception was raised, useful
   for distinguishing 'failed inside Guzzle' from 'failed inside our
   code path'

The exception message itself often contains the GitHub API URL,
which encodes the app identifier. Capturing the specific appid at
the moment of failure would require touching handle() to track the
current iteration; left as a follow-up.
2026-04-22 17:01:18 +10:00
Josh Salway 5be7a65677 Tighten retry shape: $tries=1, $uniqueFor=600, drop $timeout and $backoff
Most failures for these jobs are GitHub API rate-limit responses;
retries inside the same window do not help, so one attempt is enough
and $backoff has nothing to pace.

$timeout would clip the intentionally throttled handle() loop
(sleep(1) per app) below realistic workloads. Heavy users with 60+
apps would lose updates mid-cycle. The original code left $timeout
unset, and `UpdateApps` is dispatched via `dispatchAfterResponse()`
which doesn't go through `queue:work` at all (the queue $timeout
is irrelevant in that path). Letting the operator's worker config
govern is more honest.

$uniqueFor reduced to 600 (10 min) since with $tries=1 +
worker-governed timeout there is no long retry chain to outlive.
Lock self-heals 10 minutes after a crashed worker.
2026-04-22 16:57:49 +10:00
Josh Salway 44be3cb319 Drop tests/Feature/QueueSafetyTest.php
The test only asserted property values and method_exists. The diff
itself shows the property values; the test added zero confidence
beyond reading the diff. Heimdall is an app, not a package; its
existing tests are HTTP feature tests against user behavior, not
class-property assertions. Removing this file keeps the PR aligned
with the existing test conventions.
2026-04-22 16:56:21 +10:00
Josh Salway 243ad00810 Bound retry and unique-lock lifetimes on UpdateApps and ProcessApps
Both jobs implement ShouldBeUnique without a $uniqueFor value, which on
Redis and database drivers produces a lock that never expires. If the
worker is killed mid-fire (OOM, SIGKILL, server crash), the lock
persists and blocks all future dispatches of UpdateApps or ProcessApps
until the cache entry is manually cleared.

Neither job sets $tries, $backoff, or $timeout, so they inherit the
worker command's defaults (1 for queue:work, 0 for vapor:work), which
varies by platform and is brittle.

This change adds:
 - $uniqueFor = 3600   lock expires after 1 hour
 - $tries = 3          hard cap across worker restarts
 - $backoff = [30, 60, 120]  pace retries to reduce GitHub API load
 - $timeout = 60       bound per-attempt wall-clock time
 - failed(Throwable)    log permanent failures (and preserve the
                        existing Cache::lock('updateApps')->forceRelease
                        on UpdateApps)

A new test (tests/Feature/QueueSafetyTest.php) asserts the retry
properties are present.

All existing tests still pass.
2026-04-22 14:55:01 +10:00
KodeStar 7861ae1512 Merge pull request #1524 from KodeStar/remove_dropdown_on_single_provider
Mark stale issues and pull requests / stale (push) Has been cancelled
Remove search provider dropdown when there's only a single provider
2025-11-11 12:05:43 +00:00
Chris Hunt 66dfe95c9f Fix lint issue 2025-11-11 12:02:53 +00:00
Chris Hunt 130661bd34 Remove search provider dropdown when there's only a single provider
Resolves #1509
2025-11-11 12:00:41 +00:00
KodeStar 900fc83e79 Merge pull request #1523 from KodeStar/add_autocomplete_suggestions
Add autocomplete suggestions support
2025-11-11 11:43:55 +00:00
Chris Hunt 4f30332854 Fix lint issues 2025-11-11 11:42:38 +00:00
Chris Hunt 852c231724 Add autocomplete suggestions support and added to bing, duckduckgo, and google 2025-11-11 11:39:06 +00:00
KodeStar 045bdf0deb Merge pull request #1507 from KodeStar/taglist
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix tag list url when tags are treated as tags
2025-09-16 09:53:06 +01:00
Chris Hunt 755c3e59e1 Fix tag list url when tags are treated as tags 2025-09-16 09:50:03 +01:00
KodeStar 32bf1d034f Add password field to fix #1498 2025-09-15 16:49:42 +01:00
Chris Hunt 6d12c547e7 Add password field 2025-09-15 16:42:53 +01:00
KodeStar ae4ce92dab Merge pull request #1503 from KodeStar/background_max_file_size
Add current background maxsize #1501
2025-09-15 16:28:52 +01:00
KodeStar 966279b252 Merge pull request #1499 from webmogul1/patch-1
Update app.php
2025-09-15 16:28:28 +01:00
KodeStar 54cf2b88ca Update application version to 2.7.6 2025-09-15 16:28:00 +01:00
Chris Hunt 31f1ba8192 Add current background maxsize #1501 2025-09-15 16:26:10 +01:00
webmogul1 eadd9d1dd8 Update app.php
Change version to 2.7.5
2025-09-11 14:46:50 -04:00
KodeStar c9ea2cdeb3 Merge pull request #1496 from KodeStar/bugfix/update_proxy_and_items_with_no_password
Update items with no password
2025-09-10 16:15:23 +01:00
Chris Hunt 517f51ba90 Update items with no password 2025-09-10 16:14:14 +01:00
KodeStar 825f67a4a4 Merge pull request #1480 from Nyuwb/patch-1
feat(icon-upload): proxy management
2025-09-10 15:15:48 +01:00
KodeStar 05a552ffcf Merge pull request #1475 from micvog/fix-german-translation
Fixed multiple typos (German translation)
2025-09-10 15:14:55 +01:00
Adam ad4584e548 Merge pull request #1488 from linuxserver/inherit-security-md 2025-08-23 15:22:58 +01:00
Adam 7d93099f2c Delete SECURITY.md
Inherit LSIO standard security.md from https://github.com/linuxserver/.github/blob/main/SECURITY.md
2025-08-23 15:20:18 +01:00
KodeStar 31ca05f74f Merge pull request #1483 from KodeStar/2.x
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix for some enhanced apps not working
2025-08-02 17:50:17 +01:00
Chris Hunt cd95fc3b92 Update search test 2025-08-02 17:43:49 +01:00
Chris Hunt 63e777b338 Redirect to search provider without error fixes #1482 2025-08-02 17:40:37 +01:00
Chris Hunt fd926e983d Fix for some enhanced apps not working 2025-08-02 17:17:40 +01:00
Fabien Ehrlich dce37c1412 feat(icon-upload): proxy management 2025-07-31 16:54:44 +02:00
KodeStar 6b9f61b0e6 Merge pull request #1477 from KodeStar/2.x
Mark stale issues and pull requests / stale (push) Has been cancelled
Escape search queries and add setting value on edit
2025-07-24 19:06:50 +01:00
Chris Hunt d1a96dd752 Escape search queries and add setting value on edit 2025-07-24 19:05:16 +01:00
micvog 31db31d0f7 Fixed multiple typos (German translation) 2025-07-21 17:37:39 +02:00
33 changed files with 7625 additions and 109 deletions
+10
View File
@@ -4,6 +4,16 @@ APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
# Security: Host Header Injection / Open Redirect hardening (CVE-2025-50578).
# TRUSTED_PROXIES: comma-separated CIDRs/IPs of reverse proxies allowed to set
# X-Forwarded-* headers. Defaults to the private ranges below when unset. Use
# "*" to trust all proxies (only behind a trusted network boundary).
#TRUSTED_PROXIES=192.168.0.0/16,172.16.0.0/12,10.0.0.0/8,127.0.0.1
# TRUSTED_HOSTS: comma-separated hostnames Heimdall is allowed to serve. Unset
# means no restriction (default, backward compatible). Set this to your own
# domain to fully prevent host-header injection / open redirects.
#TRUSTED_HOSTS=heimdall.example.com
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
+7 -7
View File
@@ -31,17 +31,17 @@ jobs:
cp .env.example .env
php artisan key:generate
- name: Cache yarn dependencies
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
path: node_modules
key: yarn-${{ hashFiles('yarn.lock') }}
node-version: '24'
cache: 'npm'
- name: Run yarn
run: yarn && yarn dev
- name: Install node modules and build assets
run: npm ci && npm run dev
- name: Run ESLint
run: yarn lint
run: npm run lint
- name: Run tests
run: php artisan test
-14
View File
@@ -1,14 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.3.x | :white_check_mark: |
| < 2.3 | :x: |
## Reporting a Vulnerability
You can report any vulnerabilities on our discord server by DM-ing a team member, or asking a team member to DM you.
https://discord.com/invite/YWrKVTn
+11
View File
@@ -27,6 +27,17 @@ function format_bytes($bytes, bool $is_drive_size = true, string $beforeunit = '
}
}
function parse_size($size) {
$unit = strtolower(substr($size, -1));
$bytes = (int)$size;
switch($unit) {
case 'g': $bytes *= 1024 * 1024 * 1024; break;
case 'm': $bytes *= 1024 * 1024; break;
case 'k': $bytes *= 1024; break;
}
return $bytes;
}
/**
* @param $title
* @param string $separator
+31 -11
View File
@@ -267,9 +267,16 @@ class ItemController extends Controller
],
];
// Proxy management
$httpsProxy = getenv('HTTPS_PROXY');
$httpsProxyLower = getenv('https_proxy');
if ($httpsProxy !== false || $httpsProxyLower !== false) {
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
}
$file = $request->input('icon');
$path_parts = pathinfo($file);
if (!isset($path_parts['extension'])) {
if (!array_key_exists('extension', $path_parts)) {
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
}
$extension = $path_parts['extension'];
@@ -312,7 +319,11 @@ class ItemController extends Controller
$storedConfigObject = json_decode($storedItem->getAttribute('description'));
$configObject = json_decode($config);
$configObject->password = $storedConfigObject->password;
if ($storedConfigObject && property_exists($storedConfigObject, 'password')) {
$configObject->password = $storedConfigObject->password;
} else {
$configObject->password = null;
}
$config = json_encode($configObject);
}
@@ -429,20 +440,31 @@ class ItemController extends Controller
return null;
}
$output['config'] = null;
$output['custom'] = null;
$app = Application::single($appid);
if (!$app) {
return response()->json(['error' => 'Application not found.'], 404);
}
$output = (array)$app;
$appdetails = Application::getApp($appid);
if (!$appdetails) {
return response()->json(['error' => 'Application details not found.'], 404);
}
if ((bool)$app->enhanced === true) {
$item = $itemId ? Item::find($itemId) : Item::where('appid', $appid)->first();
// if(!isset($app->config)) { // class based config
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = $item->description;
// }
if ($item) {
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = $item->description;
} else {
// Ensure the app is installed if not found
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = null;
}
}
$output['colour'] = ($app->tile_background == 'light') ? '#fafbfc' : '#161b1f';
@@ -450,14 +472,12 @@ class ItemController extends Controller
if (strpos($app->icon, '://') !== false) {
$output['iconview'] = $app->icon;
} elseif (strpos($app->icon, 'icons/') !== false) {
// Private apps have the icon locally
$output['iconview'] = URL::to('/') . '/storage/' . $app->icon;
$output['icon'] = str_replace('icons/', '', $output['icon']);
} else {
$output['iconview'] = config('app.appsource') . 'icons/' . $app->icon;
}
return json_encode($output);
}
+104 -5
View File
@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
use App\Search;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Http;
class SearchController extends Controller
{
@@ -18,10 +20,8 @@ class SearchController extends Controller
$requestprovider = $request->input('provider');
$query = $request->input('q');
// Validate the presence and non-emptiness of the query parameter
if (!$query || trim($query) === '') {
abort(400, 'Missing or empty query parameter');
}
// Sanitize the query to prevent XSS
$query = htmlspecialchars($query, ENT_QUOTES, 'UTF-8');
$provider = Search::providerDetails($requestprovider);
@@ -29,6 +29,11 @@ class SearchController extends Controller
abort(404, 'Invalid provider');
}
// If the query is empty, redirect to the provider's base URL
if (!$query || trim($query) === '') {
return redirect($provider->url);
}
if ($provider->type == 'standard') {
return redirect($provider->url.'?'.$provider->query.'='.urlencode($query));
} elseif ($provider->type == 'external') {
@@ -36,5 +41,99 @@ class SearchController extends Controller
return $class->getResults($query, $provider);
}
abort(404, 'Provider type not supported');}
abort(404, 'Provider type not supported');
}
/**
* Get autocomplete suggestions for a search query
*
* @return JsonResponse
*/
public function autocomplete(Request $request)
{
$requestprovider = $request->input('provider');
$query = $request->input('q');
if (!$query || trim($query) === '') {
return response()->json([]);
}
$provider = Search::providerDetails($requestprovider);
if (!$provider || !isset($provider->autocomplete)) {
return response()->json([]);
}
// Replace {query} placeholder with actual query
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
try {
$response = Http::timeout(5)->get($autocompleteUrl);
if ($response->successful()) {
$data = $response->body();
// Parse the response based on provider
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
return response()->json($suggestions);
}
} catch (\Exception $e) {
// Return empty array on error
return response()->json([]);
}
return response()->json([]);
}
/**
* Parse autocomplete response based on provider format
*
* @param string $data
* @param string $providerId
* @return array
*/
private function parseAutocompleteResponse($data, $providerId)
{
$suggestions = [];
switch ($providerId) {
case 'google':
// Google returns XML format
if (strpos($data, '<?xml') === 0) {
$xml = simplexml_load_string($data);
if ($xml && isset($xml->CompleteSuggestion)) {
foreach ($xml->CompleteSuggestion as $suggestion) {
if (isset($suggestion->suggestion['data'])) {
$suggestions[] = (string) $suggestion->suggestion['data'];
}
}
}
}
break;
case 'bing':
case 'ddg':
// Bing and DuckDuckGo return JSON array format
$json = json_decode($data, true);
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
}
break;
default:
// Try to parse as JSON array
$json = json_decode($data, true);
if (is_array($json)) {
if (isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
} else {
$suggestions = $json;
}
}
break;
}
return $suggestions;
}
}
@@ -45,6 +45,7 @@ class SettingsController extends Controller
if (! is_null($setting)) {
return view('settings.edit')->with([
'setting' => $setting,
'value' => $setting->value,
]);
} else {
$route = route('settings.list', []);
+2
View File
@@ -101,6 +101,8 @@ class TagController extends Controller
$data['tag'] = $item->id;
$data['all_apps'] = $item->children;
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
return view('welcome', $data);
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* The allow-list is read from the TRUSTED_HOSTS env var (comma-separated
* hostnames). When it is unset/empty an empty array is returned so that NO
* host restriction is applied, preserving Heimdall's historic behaviour of
* running on arbitrary hosts. When set, only the listed hosts (and their
* subdomains) are accepted; any other Host header is rejected by Symfony
* with a SuspiciousOperationException (HTTP 400).
*
* @return array
*/
public function hosts()
{
$trustedHosts = env('TRUSTED_HOSTS');
if ($trustedHosts === null || trim((string) $trustedHosts) === '') {
return [];
}
$hosts = [];
foreach (explode(',', (string) $trustedHosts) as $host) {
$host = trim($host);
if ($host !== '') {
$hosts[] = '^(.+\.)?'.preg_quote($host).'$';
}
}
return $hosts;
}
/**
* Determine if the application should specify trusted hosts.
*
* The parent implementation skips enforcement whenever the app runs in the
* "local" environment (Heimdall's shipped default, see .env.example) or
* under the test runner, which would leave the TRUSTED_HOSTS allow-list
* silently unenforced for almost every real deployment. Instead we tie
* enforcement directly to configuration: apply the allow-list whenever one
* has actually been provided, in any environment. When TRUSTED_HOSTS is
* unset hosts() is empty and this returns false, preserving the historic
* no-restriction behaviour.
*
* @return bool
*/
protected function shouldSpecifyTrustedHosts()
{
return ! empty($this->hosts());
}
}
+40 -4
View File
@@ -8,16 +8,52 @@ use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
* The default trusted proxies used when the TRUSTED_PROXIES env var is unset.
*
* @var array
*/
protected $proxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'];
protected $defaultProxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'];
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The current proxy header mappings.
*
* @var array
* Note: Request::HEADER_X_FORWARDED_HOST is intentionally NOT trusted to
* prevent Host header injection / open redirects (CVE-2025-50578). A spoofed
* X-Forwarded-Host header must never influence getHost()/url()/asset().
*
* @var int
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
/**
* Create a new middleware instance.
*
* The set of trusted proxies is read from the TRUSTED_PROXIES env var
* (comma-separated CIDRs/IPs). When unset it falls back to the historic
* default list. The special value "*" trusts all proxies.
*
* @return void
*/
public function __construct()
{
$trustedProxies = env('TRUSTED_PROXIES');
if ($trustedProxies === null || trim((string) $trustedProxies) === '') {
$this->proxies = $this->defaultProxies;
} elseif (trim((string) $trustedProxies) === '*') {
$this->proxies = '*';
} else {
$this->proxies = array_values(array_filter(
array_map('trim', explode(',', (string) $trustedProxies)),
fn ($proxy) => $proxy !== ''
));
}
}
}
+22
View File
@@ -14,11 +14,24 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
class ProcessApps implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Most failures here are GitHub rate-limit responses; retries inside the
* same window do not help, so a single attempt is enough.
*/
public int $tries = 1;
/**
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
* does not permanently block future ProcessApps dispatches.
*/
public int $uniqueFor = 600;
/**
* Create a new job instance.
*
@@ -57,4 +70,13 @@ class ProcessApps implements ShouldQueue, ShouldBeUnique
}
}
}
public function failed(Throwable $exception): void
{
Log::error(static::class . ' permanently failed', [
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'file' => $exception->getFile() . ':' . $exception->getLine(),
]);
}
}
+22 -1
View File
@@ -12,11 +12,26 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
class UpdateApps implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Most failures here are GitHub rate-limit responses; retries inside the
* same window do not help, so a single attempt is enough. The throttle
* loop in handle() means the job is intentionally long-running, so we
* leave $timeout unset and let the operator's worker config govern.
*/
public int $tries = 1;
/**
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
* does not permanently block future UpdateApps dispatches.
*/
public int $uniqueFor = 600;
/**
* Create a new job instance.
*
@@ -49,8 +64,14 @@ class UpdateApps implements ShouldQueue, ShouldBeUnique
Cache::lock('updateApps')->forceRelease();
}
public function failed($exception): void
public function failed(Throwable $exception): void
{
Cache::lock('updateApps')->forceRelease();
Log::error(static::class . ' permanently failed', [
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'file' => $exception->getFile() . ':' . $exception->getLine(),
]);
}
}
+21 -6
View File
@@ -111,17 +111,32 @@ abstract class Search
if ((bool) $user_search_provider) {
$name = 'app.options.'.$user_search_provider;
$provider = self::providerDetails($user_search_provider);
$providers = self::providers();
$providerCount = count($providers);
// If there's only one provider, use its key instead of the user's setting
if ($providerCount === 1) {
$user_search_provider = $providers->keys()->first();
}
$output .= '<div class="searchform">';
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
$output .= '<div id="search-container" class="input-container">';
$output .= '<select name="provider">';
foreach (self::providers() as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
// Only show dropdown if there's more than one provider
if ($providerCount > 1) {
$output .= '<select name="provider">';
foreach ($providers as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
}
$output .= '</select>';
} else {
// Hidden input for single provider
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
}
$output .= '</select>';
$output .= '<input type="text" name="q" value="'.(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
$output .= '</div>';
$output .= '</form>';
+21
View File
@@ -21,6 +21,27 @@ class CustomFormBuilder
);
}
public function password($name, $options = [])
{
return new HtmlString(
$this->html->input('password', $name)->attributes($options)
);
}
public function hidden($name, $value = null, $options = [])
{
return new HtmlString(
$this->html->input('hidden', $name, $value)->attributes($options)
);
}
public function checkbox($name, $value = null, $checked = false, $options = [])
{
return new HtmlString(
$this->html->checkbox($name, $value, $checked)->attributes($options)
);
}
public function select($name, $list = [], $selected = null, $options = [])
{
return new HtmlString(
+24 -24
View File
@@ -150,41 +150,41 @@ class Setting extends Model
switch ($this->type) {
case 'image':
$value = '';
if (isset($this->value) && ! empty($this->value)) {
$value .= '<a class="setting-view-image" href="'.
asset('storage/'.$this->value).
'" title="'.
__('app.settings.view').
'" target="_blank"><img src="'.
asset('storage/'.
$this->value).
if (isset($this->value) && !empty($this->value)) {
$value .= '<a class="setting-view-image" href="' .
asset('storage/' . $this->value) .
'" title="' .
__('app.settings.view') .
'" target="_blank"><img src="' .
asset('storage/' .
$this->value) .
'" /></a>';
}
$value .= '<input type="file" name="value" class="form-control" />';
if (isset($this->value) && ! empty($this->value)) {
$value .= '<a class="settinglink" href="'.
route('settings.clear', $this->id).
'" title="'.
__('app.settings.remove').
'">'.
__('app.settings.reset').
if (isset($this->value) && !empty($this->value)) {
$value .= '<a class="settinglink" href="' .
route('settings.clear', $this->id) .
'" title="' .
__('app.settings.remove') .
'">' .
__('app.settings.reset') .
'</a>';
}
break;
case 'boolean':
$checked = false;
if (isset($this->value) && (bool) $this->value === true) {
if (isset($this->value) && (bool)$this->value === true) {
$checked = true;
}
$set_checked = ($checked) ? ' checked="checked"' : '';
$value = '
<input type="hidden" name="value" value="0" />
<label class="switch">
<input type="checkbox" name="value" value="1"'.$set_checked.' />
<input type="checkbox" name="value" value="1"' . $set_checked . ' />
<span class="slider round"></span>
</label>';
break;
case 'select':
$options = json_decode($this->options);
@@ -193,21 +193,21 @@ class Setting extends Model
}
$value = '<select name="value" class="form-control">';
foreach ($options as $key => $opt) {
$value .= '<option value="'.$key.'" '.(($this->value == $key) ? 'selected' : '').'>'.__($opt).'</option>';
$value .= '<option value="' . $key . '" ' . (($this->value == $key) ? 'selected' : '') . '>' . __($opt) . '</option>';
}
$value .= '</select>';
break;
case 'textarea':
$value = '<textarea name="value" class="form-control" cols="44" rows="15"></textarea>';
$value = '<textarea name="value" class="form-control" cols="44" rows="15">' . htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8') . '</textarea>';
break;
default:
$value = '<input type="text" name="value" class="form-control" />';
$value = '<input type="text" name="value" class="form-control" value="' . htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8') . '" />';
break;
}
return $value;
}
public function group(): BelongsTo
{
return $this->belongsTo(\App\SettingGroup::class, 'group_id');
+3
View File
@@ -32,6 +32,9 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->replace(\Illuminate\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class);
$middleware->trustHosts();
$middleware->replace(\Illuminate\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustHosts::class);
$middleware->alias([
'allowed' => \App\Http\Middleware\CheckAllowed::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
+1 -1
View File
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
return [
'version' => '2.7.2',
'version' => '2.7.7',
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),
+5 -5
View File
@@ -20,7 +20,7 @@ return array (
'settings.language' => 'Sprache',
'settings.reset' => 'Zurücksetzen auf Standard',
'settings.remove' => 'Entfernen',
'settings.search' => 'suche',
'settings.search' => 'Suche',
'settings.no_items' => 'Keine Elemente gefunden',
'settings.label' => 'Bezeichnung',
'settings.value' => 'Wert',
@@ -33,7 +33,7 @@ return array (
'options.ddg' => 'DuckDuckGo',
'options.bing' => 'Bing',
'options.qwant' => 'Qwant',
'options.startpage' => 'StartSeite',
'options.startpage' => 'Startseite',
'options.yes' => 'Ja',
'options.no' => 'Nein',
'options.nzbhydra' => 'NZBHydra',
@@ -46,7 +46,7 @@ return array (
'dash.pin_item' => 'Element auf dem Dashboard anheften',
'dash.no_apps' => 'Derzeit gibt es keine angeheftete Anwendungen. :link1 oder :link2',
'dash.link1' => 'Anwendung neu hinzufügen',
'dash.link2' => 'anheften',
'dash.link2' => 'Anheften',
'dash.pinned_items' => 'Angeheftete Elemente',
'apps.app_list' => 'Anwendungsliste',
'apps.view_trash' => 'Ansicht Papierkorb',
@@ -66,7 +66,7 @@ return array (
'apps.add_tag' => 'Tag hinzufügen',
'apps.tag_name' => 'Tag Name',
'apps.tags' => 'Tags',
'apps.override' => 'Fals anders zur Haupt-URL',
'apps.override' => 'Falls anders zur Haupt-URL',
'apps.preview' => 'Vorschau',
'apps.apptype' => 'Anwendungstyp',
'apps.website' => 'Webseite',
@@ -81,7 +81,7 @@ return array (
'user.avatar' => 'Avatar',
'user.email' => 'Email',
'user.password_confirm' => 'Passwort bestätigen',
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in kraft.',
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in Kraft.',
'user.autologin' => 'Anmelden von spezieller URL erlauben. Jeder mit diesem Link kann sich anmelden.',
'url' => 'URL',
'title' => 'Titel',
+1 -1
View File
@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"select2": "^4.0.13",
"select2": "~4.0.13",
"sortablejs": "^1.15.0"
},
"devDependencies": {
+1 -1
View File
@@ -26,7 +26,7 @@
"webpack-cli": "^6.0.1"
},
"dependencies": {
"select2": "^4.0.13",
"select2": "~4.0.13",
"sortablejs": "^1.15.0"
}
}
+2183 -2
View File
File diff suppressed because one or more lines are too long
+4636 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -1,5 +1,4 @@
{
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
}
+101 -1
View File
@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
}
});
// Autocomplete functionality
let autocompleteTimeout = null;
let currentAutocompleteRequest = null;
function hideAutocomplete() {
$("#search-autocomplete").remove();
}
function showAutocomplete(suggestions, inputElement) {
hideAutocomplete();
if (!suggestions || suggestions.length === 0) {
return;
}
const $input = $(inputElement);
const position = $input.position();
const width = $input.outerWidth();
const $autocomplete = $('<div id="search-autocomplete"></div>');
suggestions.forEach((suggestion) => {
const $item = $('<div class="autocomplete-item"></div>')
.text(suggestion)
.on("click", () => {
$input.val(suggestion);
hideAutocomplete();
$input.closest("form").submit();
});
$autocomplete.append($item);
});
$autocomplete.css({
position: "absolute",
top: `${position.top + $input.outerHeight()}px`,
left: `${position.left}px`,
width: `${width}px`,
});
$input.closest("#search-container").append($autocomplete);
}
function fetchAutocomplete(query, provider) {
// Cancel previous request if any
if (currentAutocompleteRequest) {
currentAutocompleteRequest.abort();
}
if (!query || query.trim().length < 2) {
hideAutocomplete();
return;
}
currentAutocompleteRequest = $.ajax({
url: `${base}search/autocomplete`,
method: "GET",
data: {
q: query,
provider,
},
success(data) {
const inputElement = $("#search-container input[name=q]")[0];
showAutocomplete(data, inputElement);
},
error() {
hideAutocomplete();
},
complete() {
currentAutocompleteRequest = null;
},
});
}
$("#search-container")
.on("input", "input[name=q]", function () {
const search = this.value;
const items = $("#sortable").find(".item-container");
if ($("#search-container select[name=provider]").val() === "tiles") {
// Get provider from either select or hidden input
const provider =
$("#search-container select[name=provider]").val() ||
$("#search-container input[name=provider]").val();
if (provider === "tiles") {
hideAutocomplete();
if (search.length > 0) {
items.hide();
items
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
}
} else {
items.show();
// Debounce autocomplete requests
clearTimeout(autocompleteTimeout);
autocompleteTimeout = setTimeout(() => {
fetchAutocomplete(search, provider);
}, 300);
}
})
.on("change", "select[name=provider]", function () {
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
} else {
$("#search-container button").show();
items.show();
hideAutocomplete();
}
});
// Hide autocomplete when clicking outside
$(document).on("click", (e) => {
if (!$(e.target).closest("#search-container").length) {
hideAutocomplete();
}
});
// Hide autocomplete on Escape key
$(document).on("keydown", (e) => {
if (e.key === "Escape") {
hideAutocomplete();
}
});
$("#search-container select[name=provider]").trigger("change");
$("#app")
+46 -1
View File
@@ -926,6 +926,12 @@ div.create {
max-width: 620px;
position: relative;
z-index: 4;
// Reduce width when there's no select dropdown (only has hidden input)
&:has(input[name="provider"][type="hidden"]) {
max-width: 520px;
}
form {
width: 100%;
}
@@ -933,7 +939,6 @@ div.create {
background: white;
border-radius: 5px;
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
overflow: hidden;
position: relative;
display: flex;
@@ -945,6 +950,11 @@ div.create {
width: 100%;
background: transparent;
}
// When there's no select dropdown, round the input's left corners
input[name="q"]:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button {
position: absolute;
right: 0px;
@@ -965,7 +975,42 @@ div.create {
background: #f5f5f5;
border: none;
border-right: 1px solid #ddd;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
// When select exists, remove input's left border radius
select ~ input[name="q"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
#search-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
max-height: 300px;
overflow-y: auto;
.autocomplete-item {
padding: 12px 15px;
cursor: pointer;
font-size: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f5f5f5;
}
}
}
.ui-autocomplete {
+12 -1
View File
@@ -1,7 +1,18 @@
<section class="module-container">
@if($enable_auth_admin_controls)
<header>
<div class="section-title">{{ __($setting->label) }}</div>
<div class="section-title">
{{ __($setting->label) }}
@if($setting->type === 'image')
@php
$max_upload = ini_get('upload_max_filesize');
$max_upload_bytes = parse_size($max_upload);
@endphp
<a class="settinglink" target="_blank" rel="nofollow noreferer" href="https://github.com/linuxserver/Heimdall?tab=readme-ov-file#new-background-image-not-being-set">({{ format_bytes($max_upload_bytes, false) }})</a>
@endif
</div>
<div class="module-actions">
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('app.buttons.save') }}</span></button>
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>
+1
View File
@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
Route::get('/search', [SearchController::class,'index'])->name('search');
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
Route::get('view/{name_view}', function ($name_view) {
return view('SupportedApps::'.$name_view)->render();
+3
View File
@@ -18,6 +18,7 @@ bing:
method: get
target: _blank
query: q
autocomplete: https://api.bing.com/osjson.aspx?query={query}
ddg:
id: ddg
@@ -26,6 +27,7 @@ ddg:
method: get
target: _blank
query: q
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
google:
id: google
@@ -34,6 +36,7 @@ google:
method: get
target: _blank
query: q
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
startpage:
id: startpage
-18
View File
@@ -32,22 +32,4 @@ class SearchTest extends TestCase
$response->assertStatus(404); // Assert that the response status is 404
}
public function test_search_page_without_query_parameter(): void
{
$provider = 'google'; // Example provider
$response = $this->get(route('search', ['provider' => $provider]));
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
}
public function test_search_page_with_empty_query(): void
{
$provider = 'google'; // Example provider
$query = ''; // Empty search term
$response = $this->get(route('search', ['provider' => $provider, 'q' => $query]));
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\TrustHosts;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Tests\TestCase;
class TrustHostsTest extends TestCase
{
/**
* Remove any TRUSTED_HOSTS override and reset Symfony's static trusted host
* state so tests do not leak into one another.
*/
protected function tearDown(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
Request::setTrustedHosts([]);
parent::tearDown();
}
private function setTrustedHostsEnv(string $value): void
{
putenv('TRUSTED_HOSTS='.$value);
$_ENV['TRUSTED_HOSTS'] = $value;
$_SERVER['TRUSTED_HOSTS'] = $value;
}
private function makeMiddleware(): TrustHosts
{
return $this->app->make(TrustHosts::class);
}
public function test_hosts_is_empty_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
$this->assertSame([], $this->makeMiddleware()->hosts());
}
public function test_arbitrary_host_is_accepted_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
// No trusted host patterns configured -> getHost() must not throw.
Request::setTrustedHosts(array_filter($this->makeMiddleware()->hosts()));
$request = Request::create('http://anything.example/', 'GET');
$this->assertSame('anything.example', $request->getHost());
}
public function test_hosts_contains_pattern_matching_configured_host(): void
{
$this->setTrustedHostsEnv('example.com');
$hosts = $this->makeMiddleware()->hosts();
$this->assertNotEmpty($hosts);
$this->assertCount(1, $hosts);
// Symfony wraps each pattern as {pattern}i before matching.
$this->assertSame(1, preg_match('{'.$hosts[0].'}i', 'example.com'));
$this->assertSame(0, preg_match('{'.$hosts[0].'}i', 'evil.com'));
}
public function test_configured_host_is_accepted_and_others_rejected(): void
{
$this->setTrustedHostsEnv('example.com');
Request::setTrustedHosts($this->makeMiddleware()->hosts());
$accepted = Request::create('http://example.com/', 'GET');
$this->assertSame('example.com', $accepted->getHost());
$this->expectException(SuspiciousOperationException::class);
Request::create('http://evil.com/', 'GET')->getHost();
}
public function test_multiple_hosts_can_be_configured(): void
{
$this->setTrustedHostsEnv('example.com, dash.example.org');
$hosts = $this->makeMiddleware()->hosts();
$this->assertCount(2, $hosts);
Request::setTrustedHosts($hosts);
$this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost());
$this->assertSame('dash.example.org', Request::create('http://dash.example.org/', 'GET')->getHost());
}
public function test_custom_trust_hosts_middleware_is_registered_globally(): void
{
$globalMiddleware = $this->app->make(Kernel::class)->getGlobalMiddleware();
$this->assertContains(TrustHosts::class, $globalMiddleware);
$this->assertNotContains(\Illuminate\Http\Middleware\TrustHosts::class, $globalMiddleware);
}
public function test_handle_enforces_trusted_hosts_even_in_local_environment(): void
{
// The app runs as APP_ENV=local under the test runner; the parent
// middleware would skip enforcement entirely. Confirm handle() still
// applies the allow-list once TRUSTED_HOSTS is configured.
$this->setTrustedHostsEnv('example.com');
$request = Request::create('http://example.com/', 'GET');
$reachedNext = false;
$this->makeMiddleware()->handle($request, function ($req) use (&$reachedNext) {
$reachedNext = true;
return $req;
});
$this->assertTrue($reachedNext);
// The configured host is now accepted and any other Host is rejected.
$this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost());
$this->expectException(SuspiciousOperationException::class);
Request::create('http://evil.com/', 'GET')->getHost();
}
public function test_handle_does_not_restrict_hosts_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
$request = Request::create('http://anything.example/', 'GET');
$this->makeMiddleware()->handle($request, fn ($req) => $req);
// No allow-list configured -> arbitrary hosts still accepted.
$this->assertSame('anything.example', Request::create('http://anything.example/', 'GET')->getHost());
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\TrustProxies;
use Illuminate\Http\Request;
use Tests\TestCase;
class TrustProxiesTest extends TestCase
{
/**
* Remove any TRUSTED_PROXIES override and reset Symfony's static trusted
* proxy/host state so tests do not leak into one another.
*/
protected function tearDown(): void
{
putenv('TRUSTED_PROXIES');
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
Request::setTrustedHosts([]);
parent::tearDown();
}
private function setTrustedProxiesEnv(string $value): void
{
putenv('TRUSTED_PROXIES='.$value);
$_ENV['TRUSTED_PROXIES'] = $value;
$_SERVER['TRUSTED_PROXIES'] = $value;
}
private function readProtected(object $object, string $property): mixed
{
return (fn () => $this->{$property})->call($object);
}
public function test_x_forwarded_host_header_is_ignored(): void
{
$request = Request::create('http://localhost/', 'GET');
$request->server->set('REMOTE_ADDR', '10.0.0.1');
$request->headers->set('X-Forwarded-Host', 'evil.com');
$request->server->set('HTTP_X_FORWARDED_HOST', 'evil.com');
(new TrustProxies())->handle($request, fn ($req) => $req);
$this->assertSame('localhost', $request->getHost());
$this->assertNotSame('evil.com', $request->getHost());
}
public function test_headers_bitmask_excludes_forwarded_host(): void
{
$headers = $this->readProtected(new TrustProxies(), 'headers');
$this->assertSame(0, $headers & Request::HEADER_X_FORWARDED_HOST);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_FOR);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PORT);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PROTO);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_AWS_ELB);
}
public function test_default_trusted_proxies_when_env_unset(): void
{
putenv('TRUSTED_PROXIES');
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
$this->assertSame(
['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'],
$proxies
);
}
public function test_trusted_proxies_can_be_configured_via_env(): void
{
$this->setTrustedProxiesEnv('203.0.113.5, 198.51.100.0/24');
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
$this->assertSame(['203.0.113.5', '198.51.100.0/24'], $proxies);
}
public function test_trusted_proxies_supports_wildcard(): void
{
$this->setTrustedProxiesEnv('*');
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
$this->assertSame('*', $proxies);
}
public function test_wildcard_proxy_trusts_calling_ip_for_forwarded_headers(): void
{
$this->setTrustedProxiesEnv('*');
$request = Request::create('http://localhost/', 'GET');
$request->server->set('REMOTE_ADDR', '203.0.113.9');
$request->headers->set('X-Forwarded-Proto', 'https');
$request->server->set('HTTP_X_FORWARDED_PROTO', 'https');
(new TrustProxies())->handle($request, fn ($req) => $req);
// Proto is honored (proxy trusted) but host is still not taken from headers.
$this->assertTrue($request->isSecure());
$this->assertSame('localhost', $request->getHost());
}
}
-1
View File
@@ -12,7 +12,6 @@ const mix = require("laravel-mix");
*/
mix
.js("resources/assets/js/app.js", "public/js/dummy.js")
.babel(
[
"node_modules/sortablejs/Sortable.min.js",