mirror of
https://github.com/linuxserver/Heimdall.git
synced 2026-08-07 15:41:28 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a9877a0dc | |||
| 5dcf462542 | |||
| e2215fe42b | |||
| 5247349d33 | |||
| 881533baa5 | |||
| 5907a1f231 | |||
| df0eba046b | |||
| 6a776e30f8 | |||
| cbf099be2c | |||
| 56c53ab9c5 | |||
| 98b6d96cd1 | |||
| 5be7a65677 | |||
| 44be3cb319 | |||
| 243ad00810 | |||
| 7861ae1512 | |||
| 66dfe95c9f | |||
| 130661bd34 | |||
| 900fc83e79 | |||
| 4f30332854 | |||
| 852c231724 | |||
| 045bdf0deb | |||
| 755c3e59e1 | |||
| 32bf1d034f | |||
| 6d12c547e7 | |||
| ae4ce92dab | |||
| 966279b252 | |||
| 54cf2b88ca | |||
| 31f1ba8192 | |||
| eadd9d1dd8 | |||
| c9ea2cdeb3 | |||
| 517f51ba90 | |||
| 825f67a4a4 | |||
| 05a552ffcf | |||
| ad4584e548 | |||
| 7d93099f2c | |||
| 31ca05f74f | |||
| cd95fc3b92 | |||
| 63e777b338 | |||
| fd926e983d | |||
| dce37c1412 | |||
| 6b9f61b0e6 | |||
| d1a96dd752 | |||
| 31db31d0f7 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"select2": "^4.0.13",
|
||||
"select2": "~4.0.13",
|
||||
"sortablejs": "^1.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"select2": "^4.0.13",
|
||||
"select2": "~4.0.13",
|
||||
"sortablejs": "^1.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2183
-2
File diff suppressed because one or more lines are too long
Vendored
+4636
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Generated
+2
-3
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user