feat(security): add API key IP whitelisting with CIDR support (P1-004)
- P1-002: API key security tests verified (bcrypt, rotation) - P1-003: Webhook signature tests verified (HMAC-SHA256) - P1-004: IP whitelisting with IPv4/IPv6 CIDR support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
919f7e1fc1
commit
49c862b6c1
8 changed files with 866 additions and 22 deletions
51
TODO.md
51
TODO.md
|
|
@ -4,22 +4,24 @@
|
|||
|
||||
### High Priority
|
||||
|
||||
- [ ] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation
|
||||
- [ ] Test API key creation with bcrypt hashing
|
||||
- [ ] Test API key authentication
|
||||
- [ ] Test key rotation with grace period
|
||||
- [ ] Test key revocation
|
||||
- [ ] Test scoped key access
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- [x] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation
|
||||
- [x] Test API key creation with bcrypt hashing
|
||||
- [x] Test API key authentication
|
||||
- [x] Test key rotation with grace period
|
||||
- [x] Test key revocation
|
||||
- [x] Test scoped key access
|
||||
- **Completed:** 29 January 2026
|
||||
- **File:** `src/Api/Tests/Feature/ApiKeySecurityTest.php`
|
||||
|
||||
- [ ] **Test Coverage: Webhook System** - Test delivery and signatures
|
||||
- [ ] Test webhook endpoint registration
|
||||
- [ ] Test HMAC-SHA256 signature generation
|
||||
- [ ] Test signature verification
|
||||
- [ ] Test webhook delivery retry logic
|
||||
- [ ] Test exponential backoff
|
||||
- [ ] Test delivery status tracking
|
||||
- **Estimated effort:** 4-5 hours
|
||||
- [x] **Test Coverage: Webhook System** - Test delivery and signatures
|
||||
- [x] Test webhook endpoint registration
|
||||
- [x] Test HMAC-SHA256 signature generation
|
||||
- [x] Test signature verification
|
||||
- [x] Test webhook delivery retry logic
|
||||
- [x] Test exponential backoff
|
||||
- [x] Test delivery status tracking
|
||||
- **Completed:** 29 January 2026
|
||||
- **File:** `src/Api/Tests/Feature/WebhookDeliveryTest.php`
|
||||
|
||||
- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits
|
||||
- [ ] Test per-tier rate limits
|
||||
|
|
@ -141,12 +143,16 @@
|
|||
|
||||
### High Priority
|
||||
|
||||
- [ ] **Security: API Key IP Whitelisting** - Restrict key usage
|
||||
- [ ] Add allowed_ips column to api_keys
|
||||
- [ ] Validate request IP against whitelist
|
||||
- [ ] Test with IPv4 and IPv6
|
||||
- [ ] Add CIDR notation support
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- [x] **Security: API Key IP Whitelisting** - Restrict key usage
|
||||
- [x] Add allowed_ips column to api_keys
|
||||
- [x] Validate request IP against whitelist
|
||||
- [x] Test with IPv4 and IPv6
|
||||
- [x] Add CIDR notation support
|
||||
- **Completed:** 29 January 2026
|
||||
- **Files:**
|
||||
- `src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php`
|
||||
- `src/Api/Services/IpRestrictionService.php`
|
||||
- `src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php`
|
||||
|
||||
- [ ] **Security: Request Signing** - Prevent replay attacks
|
||||
- [ ] Implement timestamp validation
|
||||
|
|
@ -242,5 +248,8 @@
|
|||
- [x] **Rate Limiting** - Tier-based rate limits with usage alerts
|
||||
- [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc
|
||||
- [x] **Documentation** - Complete API package documentation
|
||||
- [x] **API Key Security Tests** - Comprehensive bcrypt hashing and rotation tests (P1-002)
|
||||
- [x] **Webhook System Signature Tests** - HMAC-SHA256 signature verification tests (P1-003)
|
||||
- [x] **API Key IP Whitelisting** - allowed_ips column with IPv4/IPv6 and CIDR support (P1-004)
|
||||
|
||||
*See `changelog/2026/jan/` for completed features.*
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ class Boot extends ServiceProvider
|
|||
$this->app->singleton(Services\WebhookTemplateService::class);
|
||||
$this->app->singleton(Services\WebhookSecretRotationService::class);
|
||||
|
||||
// Register IP restriction service for API key whitelisting
|
||||
$this->app->singleton(Services\IpRestrictionService::class);
|
||||
|
||||
// Register API Documentation provider
|
||||
$this->app->register(DocumentationServiceProvider::class);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class ApiKeyFactory extends Factory
|
|||
'prefix' => $prefix,
|
||||
'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||
'server_scopes' => null,
|
||||
'allowed_ips' => null,
|
||||
'last_used_at' => null,
|
||||
'expires_at' => null,
|
||||
'grace_period_ends_at' => null,
|
||||
|
|
@ -219,6 +220,18 @@ class ApiKeyFactory extends Factory
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set IP whitelist restrictions.
|
||||
*
|
||||
* @param array<string>|null $ips Array of IP addresses/CIDRs
|
||||
*/
|
||||
public function withAllowedIps(?array $ips): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'allowed_ips' => $ips,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a revoked (soft-deleted) key.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Api\Middleware;
|
||||
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Api\Services\IpRestrictionService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -59,6 +60,16 @@ class AuthenticateApiKey
|
|||
return $this->unauthorized('API key has expired');
|
||||
}
|
||||
|
||||
// Check IP whitelist if restrictions are enabled
|
||||
if ($apiKey->hasIpRestrictions()) {
|
||||
$ipService = app(IpRestrictionService::class);
|
||||
$requestIp = $request->ip();
|
||||
|
||||
if (! $ipService->isIpAllowed($requestIp, $apiKey->getAllowedIps() ?? [])) {
|
||||
return $this->forbidden('IP address not allowed for this API key');
|
||||
}
|
||||
}
|
||||
|
||||
// Check scope if required
|
||||
if ($scope !== null && ! $apiKey->hasScope($scope)) {
|
||||
return $this->forbidden("API key missing required scope: {$scope}");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Adds IP whitelisting support to API keys:
|
||||
* - allowed_ips: JSON array of IP addresses and/or CIDR ranges
|
||||
*
|
||||
* When allowed_ips is null or empty, no IP restrictions apply.
|
||||
* When populated, only requests from whitelisted IPs are accepted.
|
||||
*
|
||||
* Supports both IPv4 and IPv6 addresses and CIDR notation:
|
||||
* - Individual IPs: "192.168.1.1", "::1"
|
||||
* - CIDR ranges: "192.168.0.0/24", "2001:db8::/32"
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('api_keys', function (Blueprint $table) {
|
||||
$table->json('allowed_ips')
|
||||
->nullable()
|
||||
->after('server_scopes')
|
||||
->comment('IP whitelist: null=all IPs allowed, ["192.168.1.0/24"]=specific');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('api_keys', function (Blueprint $table) {
|
||||
$table->dropColumn('allowed_ips');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -64,6 +64,7 @@ class ApiKey extends Model
|
|||
'prefix',
|
||||
'scopes',
|
||||
'server_scopes',
|
||||
'allowed_ips',
|
||||
'last_used_at',
|
||||
'expires_at',
|
||||
'grace_period_ends_at',
|
||||
|
|
@ -73,6 +74,7 @@ class ApiKey extends Model
|
|||
protected $casts = [
|
||||
'scopes' => 'array',
|
||||
'server_scopes' => 'array',
|
||||
'allowed_ips' => 'array',
|
||||
'last_used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'grace_period_ends_at' => 'datetime',
|
||||
|
|
@ -208,9 +210,10 @@ class ApiKey extends Model
|
|||
$this->expires_at
|
||||
);
|
||||
|
||||
// Copy server scopes to new key
|
||||
// Copy server scopes and IP restrictions to new key
|
||||
$result['api_key']->update([
|
||||
'server_scopes' => $this->server_scopes,
|
||||
'allowed_ips' => $this->allowed_ips,
|
||||
'rotated_from_id' => $this->id,
|
||||
]);
|
||||
|
||||
|
|
@ -312,6 +315,57 @@ class ApiKey extends Model
|
|||
return $this->server_scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this key has IP restrictions enabled.
|
||||
*/
|
||||
public function hasIpRestrictions(): bool
|
||||
{
|
||||
return ! empty($this->allowed_ips);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the allowed IPs list (null = all IPs allowed).
|
||||
*
|
||||
* @return array<string>|null
|
||||
*/
|
||||
public function getAllowedIps(): ?array
|
||||
{
|
||||
return $this->allowed_ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the IP whitelist.
|
||||
*
|
||||
* @param array<string>|null $ips Array of IP addresses/CIDRs, or null to allow all
|
||||
*/
|
||||
public function updateAllowedIps(?array $ips): void
|
||||
{
|
||||
$this->update(['allowed_ips' => $ips]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an IP or CIDR to the whitelist.
|
||||
*/
|
||||
public function addAllowedIp(string $ipOrCidr): void
|
||||
{
|
||||
$whitelist = $this->allowed_ips ?? [];
|
||||
|
||||
if (! in_array($ipOrCidr, $whitelist, true)) {
|
||||
$whitelist[] = $ipOrCidr;
|
||||
$this->update(['allowed_ips' => $whitelist]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an IP or CIDR from the whitelist.
|
||||
*/
|
||||
public function removeAllowedIp(string $ipOrCidr): void
|
||||
{
|
||||
$whitelist = $this->allowed_ips ?? [];
|
||||
$whitelist = array_values(array_filter($whitelist, fn ($entry) => $entry !== $ipOrCidr));
|
||||
$this->update(['allowed_ips' => empty($whitelist) ? null : $whitelist]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke this API key.
|
||||
*/
|
||||
|
|
|
|||
308
src/Api/Services/IpRestrictionService.php
Normal file
308
src/Api/Services/IpRestrictionService.php
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Api\Services;
|
||||
|
||||
/**
|
||||
* IP Restriction Service.
|
||||
*
|
||||
* Validates IP addresses against API key whitelists.
|
||||
* Supports individual IPs and CIDR notation for both IPv4 and IPv6.
|
||||
*/
|
||||
class IpRestrictionService
|
||||
{
|
||||
/**
|
||||
* Check if an IP address is in a whitelist.
|
||||
*
|
||||
* Supports:
|
||||
* - Individual IPv4 addresses (192.168.1.1)
|
||||
* - Individual IPv6 addresses (::1, 2001:db8::1)
|
||||
* - CIDR notation for IPv4 (192.168.1.0/24)
|
||||
* - CIDR notation for IPv6 (2001:db8::/32)
|
||||
*
|
||||
* @param array<string> $whitelist
|
||||
*/
|
||||
public function isIpAllowed(string $ip, array $whitelist): bool
|
||||
{
|
||||
$ip = trim($ip);
|
||||
|
||||
// Empty whitelist means no restrictions
|
||||
if (empty($whitelist)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate the request IP is a valid IP address
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($whitelist as $entry) {
|
||||
$entry = trim($entry);
|
||||
|
||||
if (empty($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for CIDR notation
|
||||
if (str_contains($entry, '/')) {
|
||||
if ($this->ipMatchesCidr($ip, $entry)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Exact IP match (normalise both for comparison)
|
||||
if ($this->normaliseIp($ip) === $this->normaliseIp($entry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches a CIDR range.
|
||||
*/
|
||||
public function ipMatchesCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
$parts = explode('/', $cidr, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$range, $prefix] = $parts;
|
||||
$prefix = (int) $prefix;
|
||||
|
||||
// Validate both IPs
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! filter_var($range, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||||
$isRangeIpv6 = filter_var($range, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||||
|
||||
// IP version must match
|
||||
if ($isIpv6 !== $isRangeIpv6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isIpv6) {
|
||||
return $this->ipv6MatchesCidr($ip, $range, $prefix);
|
||||
}
|
||||
|
||||
return $this->ipv4MatchesCidr($ip, $range, $prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv4 address matches a CIDR range.
|
||||
*/
|
||||
protected function ipv4MatchesCidr(string $ip, string $range, int $prefix): bool
|
||||
{
|
||||
// Validate prefix length
|
||||
if ($prefix < 0 || $prefix > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ipLong = ip2long($ip);
|
||||
$rangeLong = ip2long($range);
|
||||
|
||||
if ($ipLong === false || $rangeLong === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the subnet mask
|
||||
$mask = -1 << (32 - $prefix);
|
||||
|
||||
// Apply mask and compare
|
||||
return ($ipLong & $mask) === ($rangeLong & $mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv6 address matches a CIDR range.
|
||||
*/
|
||||
protected function ipv6MatchesCidr(string $ip, string $range, int $prefix): bool
|
||||
{
|
||||
// Validate prefix length
|
||||
if ($prefix < 0 || $prefix > 128) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert to binary representation
|
||||
$ipBin = $this->ipv6ToBinary($ip);
|
||||
$rangeBin = $this->ipv6ToBinary($range);
|
||||
|
||||
if ($ipBin === null || $rangeBin === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare the first $prefix bits
|
||||
$prefixBytes = (int) floor($prefix / 8);
|
||||
$remainingBits = $prefix % 8;
|
||||
|
||||
// Compare full bytes
|
||||
if (substr($ipBin, 0, $prefixBytes) !== substr($rangeBin, 0, $prefixBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare remaining bits if any
|
||||
if ($remainingBits > 0) {
|
||||
$mask = 0xFF << (8 - $remainingBits);
|
||||
$ipByte = ord($ipBin[$prefixBytes]);
|
||||
$rangeByte = ord($rangeBin[$prefixBytes]);
|
||||
|
||||
if (($ipByte & $mask) !== ($rangeByte & $mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IPv6 address to its binary representation.
|
||||
*/
|
||||
protected function ipv6ToBinary(string $ip): ?string
|
||||
{
|
||||
$packed = inet_pton($ip);
|
||||
|
||||
if ($packed === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $packed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise an IP address for comparison.
|
||||
*
|
||||
* - IPv4: No change needed
|
||||
* - IPv6: Expand to full form for consistent comparison
|
||||
*/
|
||||
public function normaliseIp(string $ip): string
|
||||
{
|
||||
$ip = trim($ip);
|
||||
|
||||
// Try to pack and unpack for normalisation
|
||||
$packed = inet_pton($ip);
|
||||
|
||||
if ($packed === false) {
|
||||
return $ip; // Return original if invalid
|
||||
}
|
||||
|
||||
// inet_ntop will return normalised form
|
||||
$normalised = inet_ntop($packed);
|
||||
|
||||
return $normalised !== false ? $normalised : $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an IP address or CIDR notation.
|
||||
*
|
||||
* @return array{valid: bool, error: ?string}
|
||||
*/
|
||||
public function validateEntry(string $entry): array
|
||||
{
|
||||
$entry = trim($entry);
|
||||
|
||||
if (empty($entry)) {
|
||||
return ['valid' => false, 'error' => 'Empty entry'];
|
||||
}
|
||||
|
||||
// Check for CIDR notation
|
||||
if (str_contains($entry, '/')) {
|
||||
return $this->validateCidr($entry);
|
||||
}
|
||||
|
||||
// Validate as plain IP
|
||||
if (! filter_var($entry, FILTER_VALIDATE_IP)) {
|
||||
return ['valid' => false, 'error' => 'Invalid IP address'];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CIDR notation.
|
||||
*
|
||||
* @return array{valid: bool, error: ?string}
|
||||
*/
|
||||
public function validateCidr(string $cidr): array
|
||||
{
|
||||
$parts = explode('/', $cidr, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
return ['valid' => false, 'error' => 'Invalid CIDR notation'];
|
||||
}
|
||||
|
||||
[$ip, $prefix] = $parts;
|
||||
|
||||
// Validate IP portion
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return ['valid' => false, 'error' => 'Invalid IP address in CIDR'];
|
||||
}
|
||||
|
||||
// Validate prefix is numeric
|
||||
if (! is_numeric($prefix)) {
|
||||
return ['valid' => false, 'error' => 'Invalid prefix length'];
|
||||
}
|
||||
|
||||
$prefix = (int) $prefix;
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
||||
|
||||
// Validate prefix range
|
||||
if ($isIpv6) {
|
||||
if ($prefix < 0 || $prefix > 128) {
|
||||
return ['valid' => false, 'error' => 'IPv6 prefix must be between 0 and 128'];
|
||||
}
|
||||
} else {
|
||||
if ($prefix < 0 || $prefix > 32) {
|
||||
return ['valid' => false, 'error' => 'IPv4 prefix must be between 0 and 32'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['valid' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a multi-line string of IPs/CIDRs into an array.
|
||||
*
|
||||
* @return array{entries: array<string>, errors: array<string>}
|
||||
*/
|
||||
public function parseWhitelistInput(string $input): array
|
||||
{
|
||||
$lines = preg_split('/[\r\n,]+/', $input);
|
||||
$entries = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip comments
|
||||
if (str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validation = $this->validateEntry($line);
|
||||
|
||||
if ($validation['valid']) {
|
||||
$entries[] = $line;
|
||||
} else {
|
||||
$errors[] = "{$line}: {$validation['error']}";
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'entries' => $entries,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
403
src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php
Normal file
403
src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Api\Services\IpRestrictionService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->workspace->users()->attach($this->user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$this->ipService = app(IpRestrictionService::class);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IP Restriction Service - IPv4
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('IP Restriction Service - IPv4', function () {
|
||||
it('allows IP when whitelist is empty', function () {
|
||||
expect($this->ipService->isIpAllowed('192.168.1.1', []))->toBeTrue();
|
||||
});
|
||||
|
||||
it('matches exact IPv4 address', function () {
|
||||
$whitelist = ['192.168.1.1', '10.0.0.1'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('192.168.1.2', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches IPv4 CIDR /24 range', function () {
|
||||
$whitelist = ['192.168.1.0/24'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('192.168.1.0', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('192.168.1.255', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('192.168.2.1', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches IPv4 CIDR /16 range', function () {
|
||||
$whitelist = ['10.0.0.0/16'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('10.0.255.255', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('10.1.0.1', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches IPv4 CIDR /32 single host', function () {
|
||||
$whitelist = ['192.168.1.100/32'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('192.168.1.101', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches IPv4 CIDR /8 class A range', function () {
|
||||
$whitelist = ['10.0.0.0/8'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('10.255.255.255', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('11.0.0.1', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects invalid IPv4 addresses', function () {
|
||||
$whitelist = ['192.168.1.0/24'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('invalid', $whitelist))->toBeFalse();
|
||||
expect($this->ipService->isIpAllowed('256.256.256.256', $whitelist))->toBeFalse();
|
||||
expect($this->ipService->isIpAllowed('', $whitelist))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IP Restriction Service - IPv6
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('IP Restriction Service - IPv6', function () {
|
||||
it('matches exact IPv6 address', function () {
|
||||
$whitelist = ['::1', '2001:db8::1'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db8::2', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('normalises IPv6 for comparison', function () {
|
||||
$whitelist = ['2001:db8:0000:0000:0000:0000:0000:0001'];
|
||||
|
||||
// Shortened form should match expanded form
|
||||
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
|
||||
});
|
||||
|
||||
it('matches IPv6 CIDR /64 range', function () {
|
||||
$whitelist = ['2001:db8::/64'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db8::ffff', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db8:0:1::1', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches IPv6 CIDR /32 range', function () {
|
||||
$whitelist = ['2001:db8::/32'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db8:ffff::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches IPv6 loopback', function () {
|
||||
$whitelist = ['::1/128'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('::2', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not match IPv4 against IPv6 CIDR', function () {
|
||||
$whitelist = ['2001:db8::/32'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not match IPv6 against IPv4 CIDR', function () {
|
||||
$whitelist = ['192.168.1.0/24'];
|
||||
|
||||
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IP Restriction Service - Validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('IP Restriction Service - Validation', function () {
|
||||
it('validates correct IPv4 addresses', function () {
|
||||
$result = $this->ipService->validateEntry('192.168.1.1');
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
});
|
||||
|
||||
it('validates correct IPv6 addresses', function () {
|
||||
$result = $this->ipService->validateEntry('2001:db8::1');
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
});
|
||||
|
||||
it('validates correct IPv4 CIDR', function () {
|
||||
$result = $this->ipService->validateEntry('192.168.1.0/24');
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
});
|
||||
|
||||
it('validates correct IPv6 CIDR', function () {
|
||||
$result = $this->ipService->validateEntry('2001:db8::/32');
|
||||
|
||||
expect($result['valid'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid IP addresses', function () {
|
||||
$result = $this->ipService->validateEntry('not-an-ip');
|
||||
|
||||
expect($result['valid'])->toBeFalse();
|
||||
expect($result['error'])->toBe('Invalid IP address');
|
||||
});
|
||||
|
||||
it('rejects invalid CIDR prefix for IPv4', function () {
|
||||
$result = $this->ipService->validateEntry('192.168.1.0/33');
|
||||
|
||||
expect($result['valid'])->toBeFalse();
|
||||
expect($result['error'])->toBe('IPv4 prefix must be between 0 and 32');
|
||||
});
|
||||
|
||||
it('rejects invalid CIDR prefix for IPv6', function () {
|
||||
$result = $this->ipService->validateEntry('2001:db8::/129');
|
||||
|
||||
expect($result['valid'])->toBeFalse();
|
||||
expect($result['error'])->toBe('IPv6 prefix must be between 0 and 128');
|
||||
});
|
||||
|
||||
it('rejects empty entries', function () {
|
||||
$result = $this->ipService->validateEntry('');
|
||||
|
||||
expect($result['valid'])->toBeFalse();
|
||||
expect($result['error'])->toBe('Empty entry');
|
||||
});
|
||||
|
||||
it('parses multi-line whitelist input', function () {
|
||||
$input = "192.168.1.1\n10.0.0.0/8\n# Comment line\n2001:db8::1\ninvalid-ip";
|
||||
|
||||
$result = $this->ipService->parseWhitelistInput($input);
|
||||
|
||||
expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.0/8', '2001:db8::1']);
|
||||
expect($result['errors'])->toHaveCount(1);
|
||||
expect($result['errors'][0])->toContain('invalid-ip');
|
||||
});
|
||||
|
||||
it('handles comma-separated whitelist input', function () {
|
||||
$input = '192.168.1.1, 10.0.0.1, 172.16.0.0/12';
|
||||
|
||||
$result = $this->ipService->parseWhitelistInput($input);
|
||||
|
||||
expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/12']);
|
||||
expect($result['errors'])->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key IP Whitelist Model Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key IP Whitelist Model', function () {
|
||||
it('reports no restrictions when allowed_ips is null', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'No Restrictions Key'
|
||||
);
|
||||
|
||||
expect($result['api_key']->hasIpRestrictions())->toBeFalse();
|
||||
expect($result['api_key']->getAllowedIps())->toBeNull();
|
||||
});
|
||||
|
||||
it('reports no restrictions when allowed_ips is empty', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Empty Whitelist Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => []]);
|
||||
|
||||
expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse();
|
||||
});
|
||||
|
||||
it('reports restrictions when allowed_ips has entries', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Restricted Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => ['192.168.1.0/24']]);
|
||||
|
||||
$key = $result['api_key']->fresh();
|
||||
expect($key->hasIpRestrictions())->toBeTrue();
|
||||
expect($key->getAllowedIps())->toBe(['192.168.1.0/24']);
|
||||
});
|
||||
|
||||
it('updates allowed IPs', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Update IPs Key'
|
||||
);
|
||||
|
||||
$result['api_key']->updateAllowedIps(['10.0.0.0/8', '192.168.1.1']);
|
||||
|
||||
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.0/8', '192.168.1.1']);
|
||||
});
|
||||
|
||||
it('adds IP to whitelist', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Add IP Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
|
||||
|
||||
$result['api_key']->addAllowedIp('10.0.0.1');
|
||||
|
||||
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1', '10.0.0.1']);
|
||||
});
|
||||
|
||||
it('does not add duplicate IPs', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Duplicate IP Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
|
||||
|
||||
$result['api_key']->addAllowedIp('192.168.1.1');
|
||||
|
||||
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1']);
|
||||
});
|
||||
|
||||
it('removes IP from whitelist', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Remove IP Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => ['192.168.1.1', '10.0.0.1']]);
|
||||
|
||||
$result['api_key']->removeAllowedIp('192.168.1.1');
|
||||
|
||||
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.1']);
|
||||
});
|
||||
|
||||
it('sets allowed_ips to null when removing last IP', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Remove Last IP Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
|
||||
|
||||
$result['api_key']->removeAllowedIp('192.168.1.1');
|
||||
|
||||
expect($result['api_key']->fresh()->getAllowedIps())->toBeNull();
|
||||
expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Rotation with IP Whitelist
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Rotation with IP Whitelist', function () {
|
||||
it('preserves IP whitelist during rotation', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'IP Restricted Key'
|
||||
);
|
||||
$result['api_key']->update(['allowed_ips' => ['192.168.1.0/24', '10.0.0.1']]);
|
||||
|
||||
$rotated = $result['api_key']->fresh()->rotate();
|
||||
|
||||
expect($rotated['api_key']->getAllowedIps())->toBe(['192.168.1.0/24', '10.0.0.1']);
|
||||
});
|
||||
|
||||
it('preserves empty IP whitelist during rotation', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'No Restrictions Key'
|
||||
);
|
||||
|
||||
$rotated = $result['api_key']->rotate();
|
||||
|
||||
expect($rotated['api_key']->getAllowedIps())->toBeNull();
|
||||
expect($rotated['api_key']->hasIpRestrictions())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Factory IP Whitelist
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Factory IP Whitelist', function () {
|
||||
it('creates keys with IP whitelist via factory', function () {
|
||||
$key = ApiKey::factory()
|
||||
->for($this->workspace)
|
||||
->for($this->user)
|
||||
->withAllowedIps(['192.168.1.0/24', '::1'])
|
||||
->create();
|
||||
|
||||
expect($key->hasIpRestrictions())->toBeTrue();
|
||||
expect($key->getAllowedIps())->toBe(['192.168.1.0/24', '::1']);
|
||||
});
|
||||
|
||||
it('creates keys without IP restrictions by default', function () {
|
||||
$key = ApiKey::factory()
|
||||
->for($this->workspace)
|
||||
->for($this->user)
|
||||
->create();
|
||||
|
||||
expect($key->hasIpRestrictions())->toBeFalse();
|
||||
expect($key->getAllowedIps())->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mixed IP Versions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Mixed IP Versions in Whitelist', function () {
|
||||
it('handles mixed IPv4 and IPv6 entries', function () {
|
||||
$whitelist = ['192.168.1.0/24', '2001:db8::/32', '10.0.0.1', '::1'];
|
||||
|
||||
// IPv4 matching
|
||||
expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('172.16.0.1', $whitelist))->toBeFalse();
|
||||
|
||||
// IPv6 matching
|
||||
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
|
||||
expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue