Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
php: [8.2, 8.3]
php: ['8.2', '8.3', '8.4', '8.5']
laravel: ['11.*', '12.*', '13.*']
exclude:
- php: '8.2'
laravel: '13.*'
include:
- laravel: 11.*
testbench: ^9.0
- laravel: 12.*
testbench: ^10.0
- laravel: 13.*
testbench: ^11.0
stability: [prefer-stable]

name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -40,9 +46,17 @@ jobs:
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"

- name: Install dependencies
shell: bash
run: |
composer validate --strict
if [ "${{ matrix.laravel }}" = "11.*" ]; then
composer config --json policy.advisories.ignore-id '{"PKSA-d5tc-s1qs-h781": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13.", "PKSA-m5cs-t1y6-qpcs": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13.", "PKSA-3r5d-mb8f-1qw9": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13.", "PKSA-mdq4-51ck-6kdq": "Laravel 11 compatibility tests only; production apps should use patched Laravel 12 or 13."}'
fi
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.stability }} --prefer-dist --no-interaction

- name: Audit dependencies
run: composer audit

- name: Execute tests
run: vendor/bin/phpunit
run: vendor/bin/phpunit --fail-on-warning --fail-on-risky --fail-on-phpunit-deprecation
38 changes: 31 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to `lockout` will be documented in this file

## [Unreleased]

## [6.1.0] - 2026-10-09

### Added
- Laravel 13 compatibility while retaining Laravel 11 and 12 support.
- IPv6 CIDR matching using Symfony's existing IP utilities.

### Fixed
- Load and publish the bundled maintenance view from the package resources directory.
- Match route whitelist patterns literally except for `*` wildcards.
- Resolve named routes and API middleware before global middleware dispatch.
- Reject malformed CIDR masks instead of crashing or widening whitelist access.
- Make enable/disable commands update the active environment file atomically, preserve file permissions, fail on persistence errors, and clear stale configuration and status caches.

### Changed
- Use patched PHPUnit 11–13 releases and PHPUnit test attributes.
- Test every supported Laravel version with PHP 8.2–8.5 in CI, and audit dependencies.
- Retain Laravel 11 compatibility tests with CI-only exceptions for four known unpatched framework advisories; recommend patched Laravel 12 or 13 for production.

### Compatibility
- Requires PHP 8.2 or higher with Laravel 11 or 12, or PHP 8.3 or higher with Laravel 13; tested through PHP 8.5.
- Laravel 11 has reached the end of security support. Its compatibility tests allow only `PKSA-d5tc-s1qs-h781`, `PKSA-m5cs-t1y6-qpcs`, `PKSA-3r5d-mb8f-1qw9`, and `PKSA-mdq4-51ck-6kdq` in CI. The package does not change application security policy.
- Enable/disable commands now clear configuration and lockout status caches automatically. Rebuild cached configuration if your deployment uses it, and restart long-running workers or Octane processes. `--clear-cache` remains accepted for existing scripts.

## [6.0.0] - 2025-01-XX

### Added
Expand Down Expand Up @@ -61,10 +84,11 @@ All notable changes to `lockout` will be documented in this file

- Initial release

[Unreleased]: https://github.com/rappasoft/laravel-boilerplate/compare/v5.0.0...develop
[5.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v4.0.0...v5.0.0
[4.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v3.0.1...v4.0.0
[3.0.1]: https://github.com/rappasoft/laravel-boilerplate/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v2.0.0...v3.0.0
[2.0.0]: https://github.com/rappasoft/laravel-boilerplate/compare/v1.0.1...v2.0.0
[1.0.1]: https://github.com/rappasoft/laravel-boilerplate/compare/v1.0.0...v1.0.1
[Unreleased]: https://github.com/rappasoft/lockout/compare/v6.1.0...master
[6.1.0]: https://github.com/rappasoft/lockout/compare/v6.0.0...v6.1.0
[6.0.0]: https://github.com/rappasoft/lockout/compare/v5.0.0...v6.0.0
[5.0.0]: https://github.com/rappasoft/lockout/compare/v4.0.0...v5.0.0
[4.0.0]: https://github.com/rappasoft/lockout/compare/v3.0.1...v4.0.0
[3.0.1]: https://github.com/rappasoft/lockout/compare/v3.0...v3.0.1
[3.0.0]: https://github.com/rappasoft/lockout/compare/v2.1...v3.0
[2.0.0]: https://github.com/rappasoft/lockout/compare/v1.0...v2.0
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
],
"require": {
"php": "^8.2",
"illuminate/support": "^11.0 || ^12.0"
"illuminate/support": "^11.0 || ^12.0 || ^13.0"
},
"require-dev": {
"orchestra/testbench": "^9.0 || ^10.0",
"phpunit/phpunit": "^11.0"
"orchestra/testbench": "^9.0 || ^10.0 || ^11.0",
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0"
},
"autoload": {
"psr-4": {
Expand Down
7 changes: 7 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@ composer require rappasoft/lockout
| 8.x | 3.x |
| 9.x | 4.x |
| 10.x | 5.x |
| 11.x | 6.x |
| 12.x | 6.x |
| 13.x | 6.1+ |

Lockout 6 requires PHP 8.2 or higher. Laravel 13 requires PHP 8.3 or higher.

Laravel 11 remains compatible with Lockout, but has reached the end of its security support. For production applications, use a patched Laravel 12 or 13 release. Laravel 11 compatibility tests allow only its known unpatched framework advisories: `PKSA-d5tc-s1qs-h781`, `PKSA-m5cs-t1y6-qpcs`, `PKSA-3r5d-mb8f-1qw9`, and `PKSA-mdq4-51ck-6kdq`. These exceptions are confined to CI and are not part of the package configuration.

2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ APP_READ_ONLY=true
## Requirements

- PHP 8.2+
- Laravel 11.x or 12.x
- Laravel 11.x, 12.x, or 13.x (Laravel 13 requires PHP 8.3+)

## Documentation

Expand Down
14 changes: 12 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Allow specific routes to bypass lockout:

Allow specific IP addresses to bypass lockout completely:

IPv4 and IPv6 addresses and CIDR ranges are supported. Malformed CIDR masks are ignored.

**Via .env:**
```bash
LOCKOUT_IP_WHITELIST=127.0.0.1,192.168.1.100
Expand Down Expand Up @@ -166,6 +168,8 @@ Automatically detect and handle API requests differently:

Whitelist routes by pattern:

`*` matches any path characters. Other characters, including dots, are matched literally.

```php
'route_patterns' => [
'api/*', // All API routes
Expand All @@ -178,6 +182,8 @@ Whitelist routes by pattern:

Whitelist routes by name:

Names are matched against the route for the current request, including when Lockout runs as global middleware.

```php
'route_names' => [
'health.check',
Expand Down Expand Up @@ -234,6 +240,8 @@ php artisan cache:clear

Or when enabling/disabling:

The enable and disable commands automatically clear the lockout status cache. `--clear-cache` remains accepted for existing scripts.

```bash
php artisan lockout:enable --clear-cache
php artisan lockout:disable --clear-cache
Expand Down Expand Up @@ -287,14 +295,16 @@ Disable events:

## Artisan Commands

These commands update the application's active environment file, preserve its file permissions, and clear any cached Laravel configuration. They fail if the environment file is missing or cannot be updated. Run `php artisan config:cache` afterwards if your deployment uses cached configuration, and restart long-running workers or Octane processes so they reload the setting. If your deployment supplies `APP_READ_ONLY` through external environment variables, update that setting through your deployment instead.

### Enable Lockout

```bash
php artisan lockout:enable
```

Options:
- `--clear-cache` - Clear the lockout cache after enabling
- `--clear-cache` - Accepted for existing scripts; the lockout cache is always cleared

### Disable Lockout

Expand All @@ -303,7 +313,7 @@ php artisan lockout:disable
```

Options:
- `--clear-cache` - Clear the lockout cache after disabling
- `--clear-cache` - Accepted for existing scripts; the lockout cache is always cleared

### Check Status

Expand Down
1 change: 0 additions & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
colors="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Test Suite">
Expand Down
34 changes: 25 additions & 9 deletions src/Console/Commands/LockoutDisable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Rappasoft\Lockout\Events\LockoutDisabled;

class LockoutDisable extends Command
Expand All @@ -28,10 +29,16 @@ class LockoutDisable extends Command
public function handle(): int
{
// Update .env file
$envFile = base_path('.env');
$envFile = $this->laravel->environmentFilePath();

if (file_exists($envFile)) {
$envContent = file_get_contents($envFile);
if (! is_file($envFile)) {
$this->error('The application environment file does not exist.');

return Command::FAILURE;
}

try {
$envContent = File::get($envFile);

// Update or add APP_READ_ONLY
if (preg_match('/^APP_READ_ONLY=(.*)$/m', $envContent)) {
Expand All @@ -40,23 +47,32 @@ public function handle(): int
$envContent .= "\nAPP_READ_ONLY=false\n";
}

file_put_contents($envFile, $envContent);
File::replace($envFile, $envContent, fileperms($envFile) & 0777);

if (is_file($this->laravel->getCachedConfigPath())) {
$this->callSilent('config:clear');
if (is_file($this->laravel->getCachedConfigPath())) {
throw new \RuntimeException('The configuration cache could not be cleared.');
}
}
} catch (\Throwable $exception) {
$this->error('Unable to disable lockout: '.$exception->getMessage());

return Command::FAILURE;
}

// Clear cache if requested
Cache::forget(config('lockout.cache_key', 'lockout.status'));
if ($this->option('clear-cache')) {
Cache::forget(config('lockout.cache_key', 'lockout.status'));
$this->info('Lockout cache cleared.');
}

config(['lockout.enabled' => false]);

// Fire event
if (config('lockout.fire_events', true)) {
event(new LockoutDisabled());
}

// Reload config
config(['lockout.enabled' => false]);

$this->info('Application lockout has been disabled.');

return Command::SUCCESS;
Expand Down
34 changes: 25 additions & 9 deletions src/Console/Commands/LockoutEnable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Rappasoft\Lockout\Events\LockoutEnabled;

class LockoutEnable extends Command
Expand All @@ -28,10 +29,16 @@ class LockoutEnable extends Command
public function handle(): int
{
// Update .env file
$envFile = base_path('.env');
$envFile = $this->laravel->environmentFilePath();

if (file_exists($envFile)) {
$envContent = file_get_contents($envFile);
if (! is_file($envFile)) {
$this->error('The application environment file does not exist.');

return Command::FAILURE;
}

try {
$envContent = File::get($envFile);

// Update or add APP_READ_ONLY
if (preg_match('/^APP_READ_ONLY=(.*)$/m', $envContent)) {
Expand All @@ -40,23 +47,32 @@ public function handle(): int
$envContent .= "\nAPP_READ_ONLY=true\n";
}

file_put_contents($envFile, $envContent);
File::replace($envFile, $envContent, fileperms($envFile) & 0777);

if (is_file($this->laravel->getCachedConfigPath())) {
$this->callSilent('config:clear');
if (is_file($this->laravel->getCachedConfigPath())) {
throw new \RuntimeException('The configuration cache could not be cleared.');
}
}
} catch (\Throwable $exception) {
$this->error('Unable to enable lockout: '.$exception->getMessage());

return Command::FAILURE;
}

// Clear cache if requested
Cache::forget(config('lockout.cache_key', 'lockout.status'));
if ($this->option('clear-cache')) {
Cache::forget(config('lockout.cache_key', 'lockout.status'));
$this->info('Lockout cache cleared.');
}

config(['lockout.enabled' => true]);

// Fire event
if (config('lockout.fire_events', true)) {
event(new LockoutEnabled());
}

// Reload config
config(['lockout.enabled' => true]);

$this->info('Application lockout has been enabled.');

return Command::SUCCESS;
Expand Down
21 changes: 6 additions & 15 deletions src/Helpers/IpHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Rappasoft\Lockout\Helpers;

use Symfony\Component\HttpFoundation\IpUtils;

class IpHelper
{
/**
Expand Down Expand Up @@ -45,36 +47,25 @@ public static function isIpBlocked(string $ip, array $blacklist): bool
*/
protected static function ipMatches(string $ip, string $pattern): bool
{
// Exact match
if ($ip === $pattern) {
return true;
}

// CIDR notation support
if (str_contains($pattern, '/')) {
return self::ipInCidr($ip, $pattern);
}

return false;
return IpUtils::checkIp($ip, $pattern);
}

/**
* Check if an IP is within a CIDR range.
*/
protected static function ipInCidr(string $ip, string $cidr): bool
{
[$subnet, $mask] = explode('/', $cidr);

$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);

if ($ipLong === false || $subnetLong === false) {
$mask = explode('/', $cidr, 2)[1];
if (! ctype_digit($mask)) {
return false;
}

$maskLong = -1 << (32 - (int) $mask);

return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
return IpUtils::checkIp($ip, $cidr);
}

/**
Expand Down
Loading
Loading