Преглед на файлове

fix: persist login throttle state to a file shared by FrankenPHP workers (SEC_REVIEW F6)

Worker recycling on memory pressure or fixed request counts wiped the
per-process LoginThrottle counters and gave attackers fresh attempts;
multi-worker FrankenPHP also let counters drift across N workers.

Introduce a ThrottleStore abstraction with two implementations: an
in-memory store (kept as the unit-test default) and a flock-protected
JSON file under sys_get_temp_dir() that all workers in one container
share. Mutations take an exclusive lock on a sibling .lock file and
write via temp file + rename, so readers always see a consistent
snapshot. Stale entries (lockedUntil + 24h < now) are GC'd opportunistically.

The file lives on the container's ephemeral writable layer, so a
container restart still clears it -- preserving the documented
operator-unlock path. Multi-replica deployments still need sticky-LB
mode (SPEC's documented topology).

Regression coverage:
- ui/tests/Unit/Auth/FileThrottleStoreTest.php exercises cross-instance
  persistence (worker recycle), corrupt-file recovery, GC, atomic
  rename, and reset semantics.
- ui/tests/Integration/Auth/LocalLoginTest::testFailuresArePersistedToConfiguredFilePath
  asserts production wiring writes through the configured path and the
  live LoginThrottle reads the same store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa преди 5 дни
родител
ревизия
d119b72dfe

+ 6 - 0
ui/config/settings.php

@@ -57,6 +57,12 @@ return [
     'session_idle_seconds' => (int) (getenv('SESSION_IDLE_SECONDS') ?: 28800),
     'session_absolute_seconds' => (int) (getenv('SESSION_ABSOLUTE_SECONDS') ?: 86400),
 
+    // Local-admin login throttle: file-backed JSON store on the container's
+    // writable layer. Persists across FrankenPHP worker recycles and is
+    // shared between workers; cleared by container restart (operator unlock
+    // path). Override only if /tmp is unsuitable for the deployment.
+    'login_throttle_path' => getenv('LOGIN_THROTTLE_PATH') ?: (sys_get_temp_dir() . '/irdb_login_throttle.json'),
+
     // GeoIP — only the provider name. The UI uses it to pick the right
     // attribution string for the IP-detail enrichment panel. The api
     // owns the actual provider config; this is a display-only mirror.

+ 15 - 1
ui/src/App/Container.php

@@ -8,6 +8,7 @@ use App\ApiClient\AdminClient;
 use App\ApiClient\ApiClient;
 use App\ApiClient\ApiHealth;
 use App\ApiClient\AuthClient;
+use App\Auth\FileThrottleStore;
 use App\Auth\JumbojettOidcAuthenticator;
 use App\Auth\LocalLoginController;
 use App\Auth\LoginThrottle;
@@ -15,6 +16,7 @@ use App\Auth\LogoutController;
 use App\Auth\OidcAuthenticator;
 use App\Auth\OidcController;
 use App\Auth\SessionManager;
+use App\Auth\ThrottleStore;
 use App\Controllers\AllowlistController;
 use App\Controllers\AuditController;
 use App\Controllers\CategoriesController;
@@ -85,6 +87,8 @@ final class Container
             'settings.local_admin_password_hash' => (string) ($settings['local_admin_password_hash'] ?? ''),
             'settings.session_idle' => (int) ($settings['session_idle_seconds'] ?? 28800),
             'settings.session_absolute' => (int) ($settings['session_absolute_seconds'] ?? 86400),
+            'settings.login_throttle_path' => (string) ($settings['login_throttle_path']
+                ?? sys_get_temp_dir() . '/irdb_login_throttle.json'),
             'settings.geoip_provider' => strtolower((string) ($settings['geoip_provider'] ?? 'dbip')),
             'settings.ui_locale' => trim((string) ($settings['ui_locale'] ?? '')),
 
@@ -238,11 +242,21 @@ final class Container
             SearchController::class => autowire(),
             SettingsController::class => autowire(),
 
+            ThrottleStore::class => factory(static function (ContainerInterface $c): ThrottleStore {
+                /** @var LoggerInterface $logger */
+                $logger = $c->get(LoggerInterface::class);
+                $path = (string) $c->get('settings.login_throttle_path');
+
+                return new FileThrottleStore($path, $logger);
+            }),
+
             LoginThrottle::class => factory(static function (ContainerInterface $c): LoginThrottle {
                 /** @var LoggerInterface $logger */
                 $logger = $c->get(LoggerInterface::class);
+                /** @var ThrottleStore $store */
+                $store = $c->get(ThrottleStore::class);
 
-                return new LoginThrottle($logger);
+                return new LoginThrottle($logger, null, $store);
             }),
 
             LocalLoginController::class => factory(static function (ContainerInterface $c): LocalLoginController {

+ 185 - 0
ui/src/Auth/FileThrottleStore.php

@@ -0,0 +1,185 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Auth;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * File-backed {@see ThrottleStore}. Persists the per-IP and per-username
+ * lockout counters to a JSON file on the container's writable layer
+ * (e.g. `/tmp/irdb_login_throttle.json`).
+ *
+ * **Why this exists (SEC_REVIEW F6):** the previous in-memory store lived
+ * inside the FrankenPHP worker process. Worker recycling — on memory
+ * pressure, fixed request counts, or a crash — wiped counters and gave
+ * the attacker fresh attempts; multi-worker FrankenPHP also let counters
+ * drift across N parallel workers. Persisting to a file shared by all
+ * workers in the container fixes both.
+ *
+ * **Concurrency:** all mutations take an exclusive `flock()` on a
+ * sibling lock file (`<path>.lock`) to serialise read-modify-write across
+ * workers. Writes go to a random temp file in the same directory and are
+ * `rename()`d into place under the lock — readers therefore see either
+ * the previous or the next snapshot, never a torn file.
+ *
+ * **Lifetime:** the file lives on the container's ephemeral writable
+ * layer (no shared volume), so a container restart still clears it —
+ * the documented operator-unlock path.
+ *
+ * **Multi-replica caveat:** each replica keeps its own file. If the
+ * `ui` service is scaled, run the LB in sticky mode (the SPEC's
+ * documented topology); see {@see LoginThrottle} for details.
+ *
+ * **GC:** entries whose `lockedUntil` has been past for more than
+ * {@see self::STALE_LOCK_SECONDS} are dropped at the next mutation. This
+ * bounds disk growth from one-shot username/IP combinations without
+ * shortening the documented in-window lockout ladder.
+ */
+final class FileThrottleStore implements ThrottleStore
+{
+    /**
+     * Drop lock entries whose `lockedUntil` is older than `now - 24h`.
+     * Longer than the steepest 30-minute ladder step, so it never
+     * shortens an active lockout.
+     */
+    private const STALE_LOCK_SECONDS = 86400;
+
+    /** @var \Closure(): int */
+    private \Closure $timeFn;
+
+    public function __construct(
+        private readonly string $filePath,
+        private readonly LoggerInterface $logger,
+        ?\Closure $timeFn = null,
+    ) {
+        $this->timeFn = $timeFn ?? static fn (): int => time();
+    }
+
+    public function load(): array
+    {
+        $raw = @file_get_contents($this->filePath);
+        if ($raw === false || $raw === '') {
+            return ['ip' => [], 'username' => []];
+        }
+
+        return $this->decode($raw);
+    }
+
+    public function mutate(callable $fn): void
+    {
+        $dir = dirname($this->filePath);
+        if (!is_dir($dir) && !@mkdir($dir, 0o700, true) && !is_dir($dir)) {
+            throw new \RuntimeException('throttle store directory unavailable: ' . $dir);
+        }
+
+        $lockPath = $this->filePath . '.lock';
+        $lockFh = @fopen($lockPath, 'c');
+        if ($lockFh === false) {
+            throw new \RuntimeException('throttle store lock open failed: ' . $lockPath);
+        }
+        try {
+            if (!flock($lockFh, LOCK_EX)) {
+                throw new \RuntimeException('throttle store lock acquisition failed');
+            }
+            try {
+                $current = $this->load();
+                $next = $fn($current);
+                $next = [
+                    'ip' => $this->gc($next['ip']),
+                    'username' => $this->gc($next['username']),
+                ];
+                $payload = json_encode($next, JSON_THROW_ON_ERROR);
+                $tmp = $this->filePath . '.tmp.' . bin2hex(random_bytes(4));
+                if (file_put_contents($tmp, $payload) === false) {
+                    throw new \RuntimeException('throttle store temp write failed: ' . $tmp);
+                }
+                if (!@rename($tmp, $this->filePath)) {
+                    @unlink($tmp);
+                    throw new \RuntimeException('throttle store rename failed: ' . $this->filePath);
+                }
+            } finally {
+                flock($lockFh, LOCK_UN);
+            }
+        } finally {
+            fclose($lockFh);
+        }
+    }
+
+    public function reset(): void
+    {
+        @unlink($this->filePath);
+        @unlink($this->filePath . '.lock');
+    }
+
+    /**
+     * @return array{
+     *     ip: array<string, array{count: int, lockedUntil: int}>,
+     *     username: array<string, array{count: int, lockedUntil: int}>
+     * }
+     */
+    private function decode(string $raw): array
+    {
+        try {
+            /** @var mixed $decoded */
+            $decoded = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
+        } catch (\JsonException $e) {
+            $this->logger->warning('throttle store decode failed; resetting', ['error' => $e->getMessage()]);
+
+            return ['ip' => [], 'username' => []];
+        }
+        if (!is_array($decoded)) {
+            return ['ip' => [], 'username' => []];
+        }
+
+        return [
+            'ip' => $this->normaliseBucket($decoded['ip'] ?? null),
+            'username' => $this->normaliseBucket($decoded['username'] ?? null),
+        ];
+    }
+
+    /**
+     * @param array<string, array{count: int, lockedUntil: int}> $bucket
+     *
+     * @return array<string, array{count: int, lockedUntil: int}>
+     */
+    private function gc(array $bucket): array
+    {
+        $now = ($this->timeFn)();
+        $out = [];
+        foreach ($bucket as $key => $entry) {
+            if ($entry['lockedUntil'] > 0 && ($entry['lockedUntil'] + self::STALE_LOCK_SECONDS) < $now) {
+                continue;
+            }
+            $out[$key] = $entry;
+        }
+
+        return $out;
+    }
+
+    /**
+     * @return array<string, array{count: int, lockedUntil: int}>
+     */
+    private function normaliseBucket(mixed $raw): array
+    {
+        if (!is_array($raw)) {
+            return [];
+        }
+        $out = [];
+        foreach ($raw as $key => $entry) {
+            if (
+                is_string($key)
+                && is_array($entry)
+                && isset($entry['count'], $entry['lockedUntil'])
+            ) {
+                $out[$key] = [
+                    'count' => (int) $entry['count'],
+                    'lockedUntil' => (int) $entry['lockedUntil'],
+                ];
+            }
+        }
+
+        return $out;
+    }
+}

+ 36 - 0
ui/src/Auth/InMemoryThrottleStore.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Auth;
+
+/**
+ * Process-local {@see ThrottleStore}. State is held in a private array;
+ * lost when the worker (or process) exits. Used by unit tests and as the
+ * default for environments that don't need cross-worker persistence.
+ */
+final class InMemoryThrottleStore implements ThrottleStore
+{
+    /**
+     * @var array{
+     *     ip: array<string, array{count: int, lockedUntil: int}>,
+     *     username: array<string, array{count: int, lockedUntil: int}>
+     * }
+     */
+    private array $state = ['ip' => [], 'username' => []];
+
+    public function load(): array
+    {
+        return $this->state;
+    }
+
+    public function mutate(callable $fn): void
+    {
+        $this->state = $fn($this->state);
+    }
+
+    public function reset(): void
+    {
+        $this->state = ['ip' => [], 'username' => []];
+    }
+}

+ 91 - 55
ui/src/Auth/LoginThrottle.php

@@ -27,10 +27,13 @@ use Psr\Log\LoggerInterface;
  * message reflects the worst-case wait. `recordFailure()` increments both,
  * `clear()` (called on success) resets both.
  *
- * Storage is a per-process in-memory array; the singleton is constructed
- * once per FrankenPHP worker and lives for the worker's lifetime. A
- * container restart clears all entries — this is intentional and
- * documented as the operator's "unlock the admin" path.
+ * Storage is delegated to a {@see ThrottleStore}. In production we use
+ * {@see FileThrottleStore}, which serialises read-modify-write across all
+ * FrankenPHP workers via `flock()` on a sibling lock file. That fixes
+ * SEC_REVIEW F6: a worker recycle no longer wipes counters and parallel
+ * workers no longer multiply the allowed attempts. The file lives on the
+ * container's writable layer (no shared volume), so a container restart
+ * still clears it — the documented "operator unlocks the admin" path.
  *
  * Multi-replica caveat: each replica has its own counters. A determined
  * attacker who can reach multiple replicas through a non-sticky LB would
@@ -40,15 +43,7 @@ use Psr\Log\LoggerInterface;
  */
 final class LoginThrottle
 {
-    /**
-     * @var array<string, array{count: int, lockedUntil: int}>
-     */
-    private array $entries = [];
-
-    /**
-     * @var array<string, array{count: int, lockedUntil: int}>
-     */
-    private array $usernameEntries = [];
+    private readonly ThrottleStore $store;
 
     /** @var \Closure(): int */
     private \Closure $timeFn;
@@ -56,8 +51,10 @@ final class LoginThrottle
     public function __construct(
         private readonly LoggerInterface $logger,
         ?\Closure $timeFn = null,
+        ?ThrottleStore $store = null,
     ) {
         $this->timeFn = $timeFn ?? static fn (): int => time();
+        $this->store = $store ?? new InMemoryThrottleStore();
     }
 
     public function isLocked(string $username, string $ip): bool
@@ -68,8 +65,9 @@ final class LoginThrottle
     public function lockoutSecondsRemaining(string $username, string $ip): int
     {
         $now = ($this->timeFn)();
-        $perIp = $this->remaining($this->entries, self::ipKey($username, $ip), $now);
-        $perUser = $this->remaining($this->usernameEntries, self::userKey($username), $now);
+        $state = $this->store->load();
+        $perIp = $this->remaining($state['ip'], self::ipKey($username, $ip), $now);
+        $perUser = $this->remaining($state['username'], self::userKey($username), $now);
 
         return max($perIp, $perUser);
     }
@@ -77,51 +75,90 @@ final class LoginThrottle
     public function recordFailure(string $username, string $ip): void
     {
         $now = ($this->timeFn)();
-
         $ipKey = self::ipKey($username, $ip);
-        $ipEntry = $this->entries[$ipKey] ?? ['count' => 0, 'lockedUntil' => 0];
-        $ipEntry['count']++;
-        $ipLock = self::ipLockoutSecondsForAttempt($ipEntry['count']);
-        if ($ipLock > 0) {
-            $ipEntry['lockedUntil'] = $now + $ipLock;
-            $this->logger->error('local login lockout triggered', [
-                'bucket' => 'ip',
-                'username' => $username,
-                'source_ip' => $ip,
-                'failure_count' => $ipEntry['count'],
-                'lock_seconds' => $ipLock,
-            ]);
-        } else {
-            $this->logger->warning('local login failure', [
-                'bucket' => 'ip',
-                'username' => $username,
-                'source_ip' => $ip,
-                'failure_count' => $ipEntry['count'],
-            ]);
-        }
-        $this->entries[$ipKey] = $ipEntry;
-
         $userKey = self::userKey($username);
-        $userEntry = $this->usernameEntries[$userKey] ?? ['count' => 0, 'lockedUntil' => 0];
-        $userEntry['count']++;
-        $userLock = self::usernameLockoutSecondsForAttempt($userEntry['count']);
-        if ($userLock > 0) {
-            $userEntry['lockedUntil'] = $now + $userLock;
-            $this->logger->error('local login lockout triggered', [
-                'bucket' => 'username',
-                'username' => $username,
-                'source_ip' => $ip,
-                'failure_count' => $userEntry['count'],
-                'lock_seconds' => $userLock,
-            ]);
+
+        /** @var list<array{level: string, message: string, context: array<string, mixed>}> $logEvents */
+        $logEvents = [];
+
+        $this->store->mutate(static function (array $state) use (
+            $now,
+            $ipKey,
+            $userKey,
+            $username,
+            $ip,
+            &$logEvents,
+        ): array {
+            $ipEntry = $state['ip'][$ipKey] ?? ['count' => 0, 'lockedUntil' => 0];
+            $ipEntry['count']++;
+            $ipLock = self::ipLockoutSecondsForAttempt($ipEntry['count']);
+            if ($ipLock > 0) {
+                $ipEntry['lockedUntil'] = $now + $ipLock;
+                $logEvents[] = [
+                    'level' => 'error',
+                    'message' => 'local login lockout triggered',
+                    'context' => [
+                        'bucket' => 'ip',
+                        'username' => $username,
+                        'source_ip' => $ip,
+                        'failure_count' => $ipEntry['count'],
+                        'lock_seconds' => $ipLock,
+                    ],
+                ];
+            } else {
+                $logEvents[] = [
+                    'level' => 'warning',
+                    'message' => 'local login failure',
+                    'context' => [
+                        'bucket' => 'ip',
+                        'username' => $username,
+                        'source_ip' => $ip,
+                        'failure_count' => $ipEntry['count'],
+                    ],
+                ];
+            }
+            $state['ip'][$ipKey] = $ipEntry;
+
+            $userEntry = $state['username'][$userKey] ?? ['count' => 0, 'lockedUntil' => 0];
+            $userEntry['count']++;
+            $userLock = self::usernameLockoutSecondsForAttempt($userEntry['count']);
+            if ($userLock > 0) {
+                $userEntry['lockedUntil'] = $now + $userLock;
+                $logEvents[] = [
+                    'level' => 'error',
+                    'message' => 'local login lockout triggered',
+                    'context' => [
+                        'bucket' => 'username',
+                        'username' => $username,
+                        'source_ip' => $ip,
+                        'failure_count' => $userEntry['count'],
+                        'lock_seconds' => $userLock,
+                    ],
+                ];
+            }
+            $state['username'][$userKey] = $userEntry;
+
+            return $state;
+        });
+
+        foreach ($logEvents as $ev) {
+            if ($ev['level'] === 'error') {
+                $this->logger->error($ev['message'], $ev['context']);
+            } else {
+                $this->logger->warning($ev['message'], $ev['context']);
+            }
         }
-        $this->usernameEntries[$userKey] = $userEntry;
     }
 
     public function clear(string $username, string $ip): void
     {
-        unset($this->entries[self::ipKey($username, $ip)]);
-        unset($this->usernameEntries[self::userKey($username)]);
+        $ipKey = self::ipKey($username, $ip);
+        $userKey = self::userKey($username);
+        $this->store->mutate(static function (array $state) use ($ipKey, $userKey): array {
+            unset($state['ip'][$ipKey], $state['username'][$userKey]);
+
+            return $state;
+        });
     }
 
     /**
@@ -130,8 +167,7 @@ final class LoginThrottle
      */
     public function reset(): void
     {
-        $this->entries = [];
-        $this->usernameEntries = [];
+        $this->store->reset();
     }
 
     /**

+ 50 - 0
ui/src/Auth/ThrottleStore.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Auth;
+
+/**
+ * Storage abstraction for {@see LoginThrottle} bucket state.
+ *
+ * State is two named buckets — `ip` (per-(username, ip) tuple) and
+ * `username` (cross-IP per-username) — each an opaque key→entry map.
+ * `mutate()` is an atomic read-modify-write; `load()` returns a snapshot
+ * for read-only paths.
+ *
+ * Two implementations exist:
+ *   - {@see InMemoryThrottleStore} — single-process, used by unit tests
+ *     and as the documented "no-persistence" fallback.
+ *   - {@see FileThrottleStore} — flock-protected JSON file on the
+ *     container's writable layer. Survives FrankenPHP worker recycling
+ *     and is shared across all workers in one container, but is cleared
+ *     by container restart (the documented operator-unlock path; see
+ *     SEC_REVIEW F6).
+ */
+interface ThrottleStore
+{
+    /**
+     * @return array{
+     *     ip: array<string, array{count: int, lockedUntil: int}>,
+     *     username: array<string, array{count: int, lockedUntil: int}>
+     * }
+     */
+    public function load(): array;
+
+    /**
+     * Atomic read-modify-write. The callable receives the current state and
+     * returns the next state. Implementations MUST serialise concurrent
+     * mutations and MUST NOT make partial writes visible to other readers.
+     *
+     * @param callable(array{
+     *     ip: array<string, array{count: int, lockedUntil: int}>,
+     *     username: array<string, array{count: int, lockedUntil: int}>
+     * }): array{
+     *     ip: array<string, array{count: int, lockedUntil: int}>,
+     *     username: array<string, array{count: int, lockedUntil: int}>
+     * } $fn
+     */
+    public function mutate(callable $fn): void;
+
+    public function reset(): void;
+}

+ 38 - 0
ui/tests/Integration/Auth/LocalLoginTest.php

@@ -207,6 +207,44 @@ final class LocalLoginTest extends AppTestCase
         self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']);
     }
 
+    public function testFailuresArePersistedToConfiguredFilePath(): void
+    {
+        // SEC_REVIEW F6: confirm production wiring writes throttle state
+        // to the on-disk path (so a worker recycle cannot wipe it). Drives
+        // a failure through the full Slim stack, then asserts the file
+        // exists and decodes to the expected per-IP/per-username buckets.
+        // The cross-instance persistence guarantee itself is covered in
+        // FileThrottleStoreTest.
+        $this->request('GET', '/login');
+        $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
+
+        $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
+        $this->request('POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded');
+
+        /** @var \DI\Container $c */
+        $c = $this->container;
+        $path = (string) $c->get('settings.login_throttle_path');
+        self::assertFileExists($path);
+        $decoded = json_decode((string) file_get_contents($path), true);
+        self::assertIsArray($decoded);
+        self::assertArrayHasKey('ip', $decoded);
+        self::assertArrayHasKey('username', $decoded);
+        self::assertArrayHasKey('admin', $decoded['username']);
+        self::assertSame(1, $decoded['username']['admin']['count']);
+
+        // And the live LoginThrottle (built via the container) sees that
+        // count too — i.e., it shares the same store.
+        /** @var LoginThrottle $live */
+        $live = $c->get(LoginThrottle::class);
+        self::assertFalse($live->isLocked('admin', '0.0.0.0'));
+        // Top up to the per-IP threshold — uses the SAME file the prior
+        // request wrote to.
+        for ($i = 0; $i < 4; ++$i) {
+            $live->recordFailure('admin', '');
+        }
+        self::assertTrue($live->isLocked('admin', ''));
+    }
+
     public function testApiDownDuringUpsertFlashesError(): void
     {
         $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(

+ 15 - 0
ui/tests/Integration/Support/AppTestCase.php

@@ -35,6 +35,17 @@ abstract class AppTestCase extends TestCase
     protected MockHandler $mock;
     /** @var list<array{request: \Psr\Http\Message\RequestInterface, response: \Psr\Http\Message\ResponseInterface, error: mixed, options: array<string, mixed>}> */
     protected array $apiHistory = [];
+    private ?string $throttlePath = null;
+
+    protected function tearDown(): void
+    {
+        parent::tearDown();
+        if ($this->throttlePath !== null) {
+            @unlink($this->throttlePath);
+            @unlink($this->throttlePath . '.lock');
+            $this->throttlePath = null;
+        }
+    }
 
     /**
      * @param array<string, mixed> $overrides
@@ -44,6 +55,9 @@ abstract class AppTestCase extends TestCase
         // Reset the global session bag between tests.
         $_SESSION = [];
 
+        $this->throttlePath = sys_get_temp_dir() . '/irdb_login_throttle_test_'
+            . bin2hex(random_bytes(8)) . '.json';
+
         $defaults = [
             'app_env' => 'development',
             'log_level' => \Monolog\Level::Warning,
@@ -62,6 +76,7 @@ abstract class AppTestCase extends TestCase
             'local_admin_password_hash' => password_hash('test1234', PASSWORD_ARGON2ID),
             'session_idle_seconds' => 28800,
             'session_absolute_seconds' => 86400,
+            'login_throttle_path' => $this->throttlePath,
         ];
         $settings = array_replace($defaults, $overrides);
 

+ 162 - 0
ui/tests/Unit/Auth/FileThrottleStoreTest.php

@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Auth\FileThrottleStore;
+use App\Auth\LoginThrottle;
+use Monolog\Handler\NullHandler;
+use Monolog\Logger;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * SEC_REVIEW F6 regression: persisting the throttle counters to a file
+ * shared by all FrankenPHP workers must survive a worker recycle (a fresh
+ * `LoginThrottle` instance over the same path) and serialise updates from
+ * concurrent workers.
+ */
+final class FileThrottleStoreTest extends TestCase
+{
+    private string $path;
+
+    protected function setUp(): void
+    {
+        $this->path = sys_get_temp_dir() . '/irdb_throttle_test_' . bin2hex(random_bytes(8)) . '.json';
+    }
+
+    protected function tearDown(): void
+    {
+        @unlink($this->path);
+        @unlink($this->path . '.lock');
+    }
+
+    public function testFailureRecordedOnOneInstanceIsVisibleToAnother(): void
+    {
+        $a = $this->throttle();
+        for ($i = 0; $i < 4; ++$i) {
+            $a->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertFalse($a->isLocked('admin', '10.0.0.1'));
+
+        // Simulates a FrankenPHP worker recycle: throw away the old
+        // singleton, build a new LoginThrottle pointing at the same file.
+        $b = $this->throttle();
+        self::assertFalse($b->isLocked('admin', '10.0.0.1'));
+
+        // The 5th failure recorded by the second instance trips the lockout
+        // because it accumulates against the persisted count from the first.
+        $b->recordFailure('admin', '10.0.0.1');
+        self::assertTrue($b->isLocked('admin', '10.0.0.1'));
+
+        // And the first instance, on its next read, sees the lockout too.
+        self::assertTrue($a->isLocked('admin', '10.0.0.1'));
+    }
+
+    public function testClearOnOneInstanceIsVisibleToAnother(): void
+    {
+        $a = $this->throttle();
+        for ($i = 0; $i < 5; ++$i) {
+            $a->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertTrue($a->isLocked('admin', '10.0.0.1'));
+
+        $b = $this->throttle();
+        $b->clear('admin', '10.0.0.1');
+
+        self::assertFalse($a->isLocked('admin', '10.0.0.1'));
+    }
+
+    public function testMissingFileLoadsAsEmpty(): void
+    {
+        // No prior writes — load() must return both empty buckets without
+        // creating the file.
+        $store = new FileThrottleStore($this->path, $this->logger());
+        $state = $store->load();
+
+        self::assertSame(['ip' => [], 'username' => []], $state);
+        self::assertFileDoesNotExist($this->path);
+    }
+
+    public function testCorruptFileIsTreatedAsEmpty(): void
+    {
+        file_put_contents($this->path, '{not json');
+
+        $t = $this->throttle();
+        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
+        // And we can still write afterwards (the next mutate replaces the
+        // corrupt content via temp + rename).
+        $t->recordFailure('admin', '10.0.0.1');
+        self::assertGreaterThan(0, filesize($this->path));
+    }
+
+    public function testStaleEntriesGarbageCollected(): void
+    {
+        $now = 1_000_000;
+        $store = new FileThrottleStore($this->path, $this->logger(), function () use (&$now): int {
+            return $now;
+        });
+        $throttle = new LoginThrottle($this->logger(), function () use (&$now): int {
+            return $now;
+        }, $store);
+
+        // Trip a per-IP lockout (5 failures).
+        for ($i = 0; $i < 5; ++$i) {
+            $throttle->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertTrue($throttle->isLocked('admin', '10.0.0.1'));
+
+        // Advance well past the stale-lock GC window. The next mutation
+        // (any recordFailure) must drop the dormant per-IP entry from disk.
+        $now += 86400 + 3600;
+        $throttle->recordFailure('other', '10.0.0.99');
+
+        $raw = (string) file_get_contents($this->path);
+        $decoded = json_decode($raw, true);
+        self::assertIsArray($decoded);
+        self::assertArrayNotHasKey('admin|10.0.0.1', $decoded['ip']);
+        self::assertArrayHasKey('other|10.0.0.99', $decoded['ip']);
+    }
+
+    public function testWritesGoThroughTempPlusRename(): void
+    {
+        // Sanity: after a mutation, no `.tmp.*` siblings should leak in
+        // the directory. Confirms the rename path completes cleanly.
+        $t = $this->throttle();
+        $t->recordFailure('admin', '10.0.0.1');
+
+        $dir = dirname($this->path);
+        $base = basename($this->path);
+        $leftovers = glob($dir . '/' . $base . '.tmp.*');
+        self::assertSame([], $leftovers !== false ? $leftovers : []);
+    }
+
+    public function testResetUnlinksFile(): void
+    {
+        $t = $this->throttle();
+        $t->recordFailure('admin', '10.0.0.1');
+        self::assertFileExists($this->path);
+
+        $t->reset();
+        self::assertFileDoesNotExist($this->path);
+        self::assertFileDoesNotExist($this->path . '.lock');
+    }
+
+    private function throttle(): LoginThrottle
+    {
+        return new LoginThrottle(
+            $this->logger(),
+            null,
+            new FileThrottleStore($this->path, $this->logger()),
+        );
+    }
+
+    private function logger(): LoggerInterface
+    {
+        $l = new Logger('test');
+        $l->pushHandler(new NullHandler());
+
+        return $l;
+    }
+}