From 916e6fe8975613d40b9b97c3978931990649cf95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 2 Jun 2023 13:04:15 +0200 Subject: [PATCH 1/4] ignore composer lock --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5601611..166758b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ vendor .phpunit.result.cache .phpunit.cache .vscode/launch.json +composer.lock build From 9ba43921392b627c6ef25a33608f097c0a024578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 2 Jun 2023 14:46:58 +0200 Subject: [PATCH 2/4] bump to laravel 10 --- composer.json | 16 ++++++++-------- phpcs.xml | 7 +++++++ src/Console/SyncFromLDCWeb.php | 6 ++++-- src/Http/Middleware/AdminSecureMiddleware.php | 6 +++--- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index f599007..323f212 100644 --- a/composer.json +++ b/composer.json @@ -10,16 +10,16 @@ } ], "require": { - "php": "~7.3|7.4|~8", - "illuminate/support": "~8|~9", - "laravel/framework": "~8|~9" + "php": ">=8.2", + "illuminate/support": "~10", + "laravel/framework": "~10" }, "require-dev": { - "phpunit/phpunit": "~9.0", - "squizlabs/php_codesniffer": "^3.5", - "orchestra/testbench": "^6.24", - "slevomat/coding-standard": "^7.0", - "phpunit/php-code-coverage": "^9.2" + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3", + "orchestra/testbench": "^8", + "slevomat/coding-standard": "^7", + "phpunit/php-code-coverage": "^9" }, "autoload": { "psr-4": { diff --git a/phpcs.xml b/phpcs.xml index 6f641ba..2e8e8ed 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -15,6 +15,7 @@ + @@ -114,4 +115,10 @@ + + + + */database/migrations/* + + diff --git a/src/Console/SyncFromLDCWeb.php b/src/Console/SyncFromLDCWeb.php index da62c24..121fd7c 100644 --- a/src/Console/SyncFromLDCWeb.php +++ b/src/Console/SyncFromLDCWeb.php @@ -9,6 +9,7 @@ use LDC\WebAdminSecureLaravel\AdminSecureException; /** * Send Report Status To LDC Web. + * * @author Vincent BRIDIER */ class SyncFromLDCWeb extends Command @@ -29,7 +30,7 @@ class SyncFromLDCWeb extends Command /** * The call endpoint - * + * * @var string */ private $endpoint = 'https://ldc-web.ldc.fr/api/ldc-networks'; @@ -38,8 +39,9 @@ class SyncFromLDCWeb extends Command * Execute the console command. * * @return integer + * @throws \LDC\WebAdminSecureLaravel\AdminSecureException If missing LDC_WEB_API_KEY key. */ - public function handle() + public function handle(): int { $this->info('Sync IP Ranges from LDC Web.'); diff --git a/src/Http/Middleware/AdminSecureMiddleware.php b/src/Http/Middleware/AdminSecureMiddleware.php index b399c2b..a131c73 100644 --- a/src/Http/Middleware/AdminSecureMiddleware.php +++ b/src/Http/Middleware/AdminSecureMiddleware.php @@ -11,8 +11,8 @@ class AdminSecureMiddleware /** * Handle an incoming request. * - * @param \Illuminate\Http\Request $request - * @param \Closure $next + * @param \Illuminate\Http\Request $request + * @param \Closure $next * @return mixed */ public function handle(Request $request, Closure $next) @@ -22,7 +22,7 @@ class AdminSecureMiddleware } /** HA PROXY HACK */ - $ip = request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); + $ip = request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); $restrictedIps = Cache::get('ldcweb_ip_ranges'); if (!is_null($restrictedIps) && in_array($ip, $restrictedIps)) { From a3f9712b27a2ee1b54dad137b532ae371e5273d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 2 Jun 2023 16:51:52 +0200 Subject: [PATCH 3/4] Fixes #1 Allows connections from private ip ranges - Add LDC_WEB_API_ENDPOINT config - Add composer dep dxw/cidr --- README.md | 1 + composer.json | 3 +- config/admin-secure.php | 1 + src/AdminSecure.php | 205 ++++++++++++++++++ src/AdminSecureServiceProvider.php | 15 +- src/Console/SyncFromLDCWeb.php | 31 +-- src/{ => Exceptions}/AdminSecureException.php | 2 +- .../AdminSecureInvalidAdressException.php | 7 + src/Http/Middleware/AdminSecureMiddleware.php | 21 +- 9 files changed, 244 insertions(+), 42 deletions(-) create mode 100644 src/AdminSecure.php rename src/{ => Exceptions}/AdminSecureException.php (54%) create mode 100644 src/Exceptions/AdminSecureInvalidAdressException.php diff --git a/README.md b/README.md index 48b858a..5463434 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ composer req ldc/web-admin-secure-laravel:1.0.1 .env.example : ```dotenv +LDC_WEB_API_ENDPOINT="URL" LDC_WEB_API_KEY="MY_KEY" ``` diff --git a/composer.json b/composer.json index 323f212..7286a3e 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "require": { "php": ">=8.2", "illuminate/support": "~10", - "laravel/framework": "~10" + "laravel/framework": "~10", + "dxw/cidr": "^3.1" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/config/admin-secure.php b/config/admin-secure.php index 0c592ad..90c2dfc 100644 --- a/config/admin-secure.php +++ b/config/admin-secure.php @@ -1,5 +1,6 @@ env('LDC_WEB_API_ENDPOINT', 'https://ldc-web.ldc.fr/api/ldc-networks'), 'apiKey' => env('LDC_WEB_API_KEY'), ]; diff --git a/src/AdminSecure.php b/src/AdminSecure.php new file mode 100644 index 0000000..aa2baf1 --- /dev/null +++ b/src/AdminSecure.php @@ -0,0 +1,205 @@ +server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); + $ip = IPAddress::Make($ip); + + if ($ip->isErr()) { + throw new AdminSecureException($ip->getErr()); + } + return $ip->unwrap(); + } + + /** + * Fetch Ip Adress list from LDC Web + * + * @return array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> + * @throws \LDC\WebAdminSecureLaravel\AdminSecureException If Wrong env value. + * @throws \Illuminate\Http\Client\RequestException If calling admin secure fails. + * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber + */ + public static function fetchIpList(): array + { + // phpcs:enable + $endpoint = config('admin-secure.endpoint'); + $key = config('admin-secure.apiKey'); + if (!$endpoint or !\filter_var($endpoint, \FILTER_VALIDATE_URL)) { + throw new AdminSecureException('LDC_WEB_API_ENDPOINT is required in .env to ba a valid url.'); + } + if (!$key) { + throw new AdminSecureException('LDC_WEB_API_KEY is required in .env .'); + } + + $response = Http::get($endpoint, ['token' => $key])->throw(); + $adresses = self::parseAdresses($response); + + Log::info(\sprintf('ADMINSECURE : Fetched %d ip(s) from LDC WEB', \count($adresses))); + + return $adresses; + } + + /** + * Get ip list from cache + * + * @return array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> + */ + public static function getCacheList(): array + { + try { + $ip_list = Cache::get(self::$cache_key, self::fetchIpList()); + self::validateIpList($ip_list); + /** @var array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> $ip_list */ + return $ip_list; + } catch (AdminSecureInvalidAdressException) { + $ip_list = self::fetchIpList(); + self::validateIpList($ip_list); + self::setCacheList($ip_list); + return $ip_list; + } + } + + /** + * Set ip list to cache + * + * @param array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> $ip_list + * @return void + */ + public static function setCacheList(array $ip_list): void + { + self::validateIpList($ip_list); + Cache::put(self::$cache_key, $ip_list); + } + + /** + * Check if ipV4 is private + * + * @param \Dxw\CIDR\IPv4Address $ipv4_address + * @return boolean + */ + private static function ipV4IsPrivate(IPv4Address $ipv4_address): bool + { + return \collect(self::$private_ip_v4_cidr_list) + ->concat([self::$private_ip_v4_loopback_cidr]) + ->map(fn (string $ipv4_cidr) => IPv4Range::Make($ipv4_cidr)->unwrap()) + ->reduce(fn (bool|null $initial, IPv4Range $ipv4_range) => + $initial || $ipv4_range->containsAddress($ipv4_address)); + } + + /** + * Check if ipV6 is private + * + * @param \Dxw\CIDR\IPv6Address $ipv6_address + * @return boolean + */ + private static function ipV6IsPrivate(IPv6Address $ipv6_address): bool + { + return \collect(self::$private_ip_v6_cidr_list) + ->concat([self::$private_ip_v6_loopback_cidr]) + ->map(fn (string $ipv6_cidr) => IPv6Range::Make($ipv6_cidr)->unwrap()) + ->reduce(fn (bool|null $initial, IPv6Range $ipv6_range) => + $initial || $ipv6_range->containsAddress($ipv6_address)); + } + + /** + * Validate ip list + * + * @param array $ip_list + * @return void + * @throws \LDC\WebAdminSecureLaravel\Exceptions\AdminSecureInvalidAdressException If an IP is invalid. + */ + private static function validateIpList(array $ip_list): void + { + \collect($ip_list)->each(function ($ip) { + if (!\is_object($ip) or !\in_array(\get_class($ip), [IPv4Address::class, IPv6Address::class])) { + throw new AdminSecureInvalidAdressException('Invalid adresse ' . \strval(\json_encode($ip))); + } + }); + } + + /** + * Parse adresses + * + * @param \Illuminate\Http\Client\Response $response + * @return array<\Dxw\CIDR\IPAddress> + */ + private static function parseAdresses(Response $response): array + { + return \collect($response->json()) + ->map(fn (string $ip) => IPAddress::Make($ip)->unwrap())->all(); + } +} diff --git a/src/AdminSecureServiceProvider.php b/src/AdminSecureServiceProvider.php index 9d6c7ec..ed8c5c5 100644 --- a/src/AdminSecureServiceProvider.php +++ b/src/AdminSecureServiceProvider.php @@ -3,6 +3,7 @@ namespace LDC\WebAdminSecureLaravel; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; use Illuminate\Routing\Router; use LDC\WebAdminSecureLaravel\Console\SyncFromLDCWeb; @@ -21,22 +22,22 @@ class AdminSecureServiceProvider extends ServiceProvider __DIR__ . '/../config' => config_path(), ], 'config'); - // Register the command if we are using the application via the CLI . - if ($this->app->runningInConsole()) { - $this->commands([ - SyncFromLDCWeb::class, - ]); - } + // * Register the command if we are using the application via the CLI . + $this->commands([SyncFromLDCWeb::class]); + + // * Set filter condition + $this->app->singleton('hasToFilterIp', fn (Application $app) => $app->environment('production')); // * Cron Report everyday $this->app->booted(function () { - if ($this->app->environment('production')) { + if ($this->app->make('hasToFilterIp')) { /** @var \Illuminate\Console\Scheduling\Schedule */ $schedule = $this->app->make(Schedule::class); $schedule->command('ldcweb:admin-secure-sync')->daily(); } }); + /** @var \Illuminate\Routing\Router */ $router = $this->app->make(Router::class); $router->aliasMiddleware('admin-secure', AdminSecureMiddleware::class); } diff --git a/src/Console/SyncFromLDCWeb.php b/src/Console/SyncFromLDCWeb.php index 121fd7c..7a0d7b7 100644 --- a/src/Console/SyncFromLDCWeb.php +++ b/src/Console/SyncFromLDCWeb.php @@ -3,9 +3,7 @@ namespace LDC\WebAdminSecureLaravel\Console; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Cache; -use LDC\WebAdminSecureLaravel\AdminSecureException; +use LDC\WebAdminSecureLaravel\AdminSecure; /** * Send Report Status To LDC Web. @@ -26,37 +24,18 @@ class SyncFromLDCWeb extends Command * * @var string */ - protected $description = 'Sync IP ranges from LDC Web'; - - /** - * The call endpoint - * - * @var string - */ - private $endpoint = 'https://ldc-web.ldc.fr/api/ldc-networks'; + protected $description = 'Sync IP list from LDC Web'; /** * Execute the console command. * * @return integer - * @throws \LDC\WebAdminSecureLaravel\AdminSecureException If missing LDC_WEB_API_KEY key. + * @throws \LDC\WebAdminSecureLaravel\Exceptions\AdminSecureException If missing LDC_WEB_API_KEY key. */ public function handle(): int { - $this->info('Sync IP Ranges from LDC Web.'); - - if (!config('admin-secure.apiKey')) { - throw new AdminSecureException('LDC_WEB_API_KEY is required in .env'); - } - - $response = Http::get($this->endpoint, [ - 'token' => config('admin-secure.apiKey') - ]); - - $response->throw(); - - Cache::put('ldcweb_ip_ranges', \json_decode($response->body())); - + $this->info($this->description); + AdminSecure::setCacheList(AdminSecure::fetchIpList()); $this->info('Done.'); return 0; diff --git a/src/AdminSecureException.php b/src/Exceptions/AdminSecureException.php similarity index 54% rename from src/AdminSecureException.php rename to src/Exceptions/AdminSecureException.php index f333cc7..10a92a1 100644 --- a/src/AdminSecureException.php +++ b/src/Exceptions/AdminSecureException.php @@ -1,6 +1,6 @@ make('hasToFilterIp')) { return $next($request); } - /** HA PROXY HACK */ - $ip = request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); - $restrictedIps = Cache::get('ldcweb_ip_ranges'); + $client_ip = AdminSecure::fetchClientIp(); + $ip_is_allowed = \collect(AdminSecure::fetchIpList()) + ->reduce(fn (bool|null $initial, IPv4Address|IPv6Address $ip_address) => $initial || IP::contains( + $ip_address, + $client_ip + )->unwrap()); - if (!is_null($restrictedIps) && in_array($ip, $restrictedIps)) { + if ($ip_is_allowed or AdminSecure::ipIsPrivate($client_ip)) { return $next($request); } - abort(403); + + abort(403, \trans('Denied IP')); } } From dc6c8371ddd49520f78cf2548a702016827013e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 2 Jun 2023 17:49:33 +0200 Subject: [PATCH 4/4] Fixes #2 Add posibility to config manual CIDR exceptions --- README.md | 9 +++- changelog.md | 6 +++ config/admin-secure.php | 3 ++ src/AdminSecure.php | 46 ++++++++++++++++++- src/Http/Middleware/AdminSecureMiddleware.php | 10 +++- 5 files changed, 70 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5463434..2835451 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,13 @@ composer req ldc/web-admin-secure-laravel:1.0.1 .env.example : ```dotenv -LDC_WEB_API_ENDPOINT="URL" -LDC_WEB_API_KEY="MY_KEY" +# ADMIN SECURE +# - API ENDPOINT (Optional) +LDC_WEB_API_ENDPOINT="https://ldc-web.ldc.fr/api/ldc-networks" +# - API KEY +LDC_WEB_API_KEY="CE8S52Qq7stf5k8frF4T" +# - LIST SEPARATED WITH ',' (Optional) +LDC_WEB_CIDR_MANUAL_ALLOWED_LIST="" ``` README.md : diff --git a/changelog.md b/changelog.md index c23f50e..0fba420 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,10 @@ # Changelog + +## **Version 2.0.0** + *Changed* + - Compatible only with **php 8.2** + - Allow connections from private ip ranges + - Allow setting Cidr list into config ## **Version 1.0.1** *Changed* - Run scheduled only on production envirronement diff --git a/config/admin-secure.php b/config/admin-secure.php index 90c2dfc..0137c15 100644 --- a/config/admin-secure.php +++ b/config/admin-secure.php @@ -3,4 +3,7 @@ return [ 'endpoint' => env('LDC_WEB_API_ENDPOINT', 'https://ldc-web.ldc.fr/api/ldc-networks'), 'apiKey' => env('LDC_WEB_API_KEY'), + 'cidrManualAllowedList' => collect(explode(',', strval(env( + 'LDC_WEB_CIDR_MANUAL_ALLOWED_LIST' + ))))->filter() ]; diff --git a/src/AdminSecure.php b/src/AdminSecure.php index aa2baf1..dfee257 100644 --- a/src/AdminSecure.php +++ b/src/AdminSecure.php @@ -23,6 +23,13 @@ abstract class AdminSecure */ private static string $cache_key = 'ldcweb_ip_list'; + /** + * The cache key where to store config cidr list. + * + * @var string + */ + private static string $cache_config_key = 'ldcweb_ip_cidr_config_list'; + /** * Private ipV4 CIDR list * @@ -80,7 +87,7 @@ abstract class AdminSecure $ip = IPAddress::Make($ip); if ($ip->isErr()) { - throw new AdminSecureException($ip->getErr()); + throw new AdminSecureException(\trans('Client IP not valid ') . $ip->getErr()); } return $ip->unwrap(); } @@ -113,6 +120,43 @@ abstract class AdminSecure return $adresses; } + /** + * Fetch Ip Adress list from LDC Web + * + * @return array<\Dxw\CIDR\IPv4Range|\Dxw\CIDR\IPv6Range> + * @throws \LDC\WebAdminSecureLaravel\AdminSecureException If Wrong env value. + * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber + */ + public static function fetchConfigAllowedCidrList(): array + { + $allowed_cidr_config_list = \collect(config('admin-secure.cidrManualAllowedList'))->filter(); + $allowed_cidr_config_list_hash = \md5($allowed_cidr_config_list->implode(',')); + $allowed_cidr_list = $allowed_cidr_config_list + ->map(function (string $cidr) { + $ip_range = !IPv4Range::Make($cidr)->isErr() ? IPv4Range::Make($cidr) : IPv6Range::Make($cidr); + if ($ip_range->isErr()) { + throw new AdminSecureException(\trans( + 'LDC_WEB_CIDR_MANUAL_ALLOWED_LIST not valid (ipv4 or ipv6 cidr) :cidr', + [ + 'cidr' => $cidr + ] + )); + } + return $ip_range->unwrap(); + })->all(); + + // * Flush cache if hash config hash changed + if ($allowed_cidr_config_list_hash !== \collect(Cache::get(self::$cache_config_key))->get('hash')) { + Cache::flush(self::$cache_config_key); + } + + /** @var array<\Dxw\CIDR\IPv4Range|\Dxw\CIDR\IPv6Range> $allowed_cidr_list */ + return \collect(Cache::rememberForever(self::$cache_config_key, fn () => [ + 'list' => $allowed_cidr_list, + 'hash' => $allowed_cidr_config_list_hash + ]))->get('list'); + } + /** * Get ip list from cache * diff --git a/src/Http/Middleware/AdminSecureMiddleware.php b/src/Http/Middleware/AdminSecureMiddleware.php index 9efbabf..c4f758f 100644 --- a/src/Http/Middleware/AdminSecureMiddleware.php +++ b/src/Http/Middleware/AdminSecureMiddleware.php @@ -5,7 +5,9 @@ namespace LDC\WebAdminSecureLaravel\Http\Middleware; use Closure; use Dxw\CIDR\IP; use Dxw\CIDR\IPv4Address; +use Dxw\CIDR\IPv4Range; use Dxw\CIDR\IPv6Address; +use Dxw\CIDR\IPv6Range; use Illuminate\Http\Request; use LDC\WebAdminSecureLaravel\AdminSecure; @@ -31,7 +33,13 @@ class AdminSecureMiddleware $client_ip )->unwrap()); - if ($ip_is_allowed or AdminSecure::ipIsPrivate($client_ip)) { + $ip_is_allowed_by_config = \collect(AdminSecure::fetchConfigAllowedCidrList()) + ->reduce(fn (bool|null $initial, IPv4Range|IPv6Range $ip_range) => $initial || IP::contains( + $ip_range, + $client_ip + )->unwrap()); + + if ($ip_is_allowed or $ip_is_allowed_by_config or AdminSecure::ipIsPrivate($client_ip)) { return $next($request); }