# IRDB Security Review > Scope: full source tree at `api/src`, `ui/src`, `api/docker`, `ui/docker`, > `Dockerfile`s, `docker-compose.yml`, `compose.scheduler.yml`, `.env.example`, > Twig templates and Caddy configuration. Vendored dependencies were not audited > (no `composer audit` / `npm audit` was run for this review). > > Severity scale: **1** = low / informational, **2** = medium, **3** = high / > critical. Severity reflects both impact and ease of exploitation in the > documented deployment topology. > > Each finding is referenced as **F** for later citation. > > **Findings rolled up:** 5 sev-3 (5 fixed, 0 open), 27 sev-2 (27 fixed, 0 open), 42 sev-1 (9 fixed, 33 open). --- ## Severity 3 — high / critical ### F1 — Login throttle bypass via spoofed `X-Forwarded-For` - **Files:** `ui/src/Auth/LocalLoginController.php:117-130`, `ui/src/Auth/LoginThrottle.php:131-137` - **Risk:** `extractSourceIp()` reads the first hop of `X-Forwarded-For` without verifying the immediate peer is a configured trusted proxy. The throttle bucket is `(lower(username), source_ip)`, so an attacker rotates the `X-Forwarded-For` value per request — every attempt is a fresh bucket and the 5/10/15-failure ladder never trips. Defeats the documented brute-force protection on the local-admin password. Argon2id slows it but does not prevent it. The UI is also reachable on `:8080` directly per `docker-compose.yml`. - **Severity: 3** - **Status:** Fixed in `466d686`. `extractSourceIp` no longer reads `X-Forwarded-For`; Caddy's `trusted_proxies static private_ranges` is the single trust boundary and PHP consumes only `REMOTE_ADDR`. Regression test in `ui/tests/Integration/Auth/LocalLoginTest.php` (`testRotatingXForwardedForDoesNotEvadePerIpLockout`). ### F2 — Throttle bucket includes IP → IP-rotation defeats the per-user lockout - **File:** `ui/src/Auth/LoginThrottle.php:131-137` - **Risk:** The bucket key is `(username, source_ip)`. Even ignoring F1, there is no bucket that counts attempts against a *username* across IPs. An attacker on a distributed source (or a residential proxy pool) gets unlimited password attempts against the single `LOCAL_ADMIN_USERNAME` account because each new IP is a fresh bucket. - **Severity: 3** - **Status:** Fixed in `466d686`. `LoginThrottle` now evaluates a second per-username bucket alongside the per-(user, ip) one with a 25 / 50 / 100 → 60 s / 300 s / 1800 s ladder; `isLocked()` trips on either bucket and `clear()` resets both on successful login. Regression tests in `ui/tests/Unit/Auth/LoginThrottleTest.php` (`testPerUsernameBucketLocksOutAcrossDistinctIps`, `testPerUsernameLadderProgresses`, `testLockoutSecondsRemainingReturnsLargerOfBuckets`) and `LocalLoginTest::testRotatingRemoteAddrEventuallyHitsPerUsernameLockout`. ### F3 — Service token + `upsertLocal` mints arbitrary Admin users with no audit, RBAC, rate-limit, or impersonation - **Files:** `api/src/App/AppFactory.php:156-169`, `api/src/Application/Auth/AuthController.php:56-77`, `api/src/Infrastructure/Auth/UserRepository.php:119-159` - **Risk:** The `/api/v1/auth/*` route group attaches only `$tokenAuth` — no `RbacMiddleware`, no `$impersonation`, no `$auditContext`, no `$rateLimit`. The controller's only check is "kind == Service". `UserRepository::upsertLocal` *unconditionally* assigns `Role::Admin` regardless of input. Anyone holding the service token (in env, masked-prefix-visible in `/admin/config`, baked into the UI image) can `POST /api/v1/auth/users/upsert-local {"username":"x"}` to create a new Admin user id and then impersonate it via `X-Acting-User-Id` on every other admin endpoint. A leaked service token is a single-step total compromise with no log trail. - **Severity: 3** - **Status:** Fixed in `8a94dff`. `UserRepository::upsertLocal` now looks up the existing local row by `is_local = 1` (not by `display_name`) and updates `display_name` + `last_login_at` on it; it only inserts when zero local rows exist. Migration `20260504100000_add_unique_local_user_index` adds a partial unique index (`WHERE is_local = 1` on SQLite; functional index over a `CASE` expression on MySQL) so a regression in code still fails at the DB. Regression tests in `api/tests/Integration/Auth/AuthEndpointsTest.php` (`testRotatingUsernamesNeverCreatesAdditionalLocalAdmins`, `testDbLayerRejectsSecondLocalAdminInsert`). The unaudited-write half of the finding (no `AuditEmitter` call on user creation / role grant) is tracked separately as F5. ### F4 — Audit emit is non-transactional with state mutation - **Files:** `api/src/Infrastructure/Audit/DbAuditEmitter.php:37-48`; controller pattern e.g. `api/src/Application/Admin/ManualBlocksController.php:138-157`, `ReportersController.php:103-116, 198-211`, `PoliciesController.php:135-140, 241-246, 278-283`, `AllowlistController.php:126-131` - **Risk:** Every admin write performs the mutation first, then calls `audit->emit(...)` *outside* a transaction. `DbAuditEmitter::emit` catches `Throwable`, logs `audit_emit_failed`, and returns void. Any DB error, lock timeout, JSON encoding failure, or process kill between mutation and audit insert leaves the state changed with no audit row. An attacker with admin privileges who can intentionally fail the audit insert (e.g. abusing long `details_json` under MySQL strict mode, or racing audit-table maintenance) mutates state without being audited. Integrity of the audit log depends entirely on a best-effort second write. - **Severity: 3** - **Status:** Fixed in `8d948ae`. `AuditEmitter` now exposes two methods with explicit semantics: `emit()` (best-effort, swallows infra errors — used only by `ReportController` and `BlocklistController`, the high-volume public paths whose emit is toggleable per `app_settings`) and `emitOrThrow()` (strict, propagates). Every admin write site (`ManualBlocksController`, `AllowlistController`, `ReportersController`, `ConsumersController`, `CategoriesController`, `PoliciesController`, `TokensController`, `AppSettingsController`, `MaintenanceController.purge`/`seedDemo`, `JobsAdminController.trigger`, and `AuthController.upsertOidc`/`upsertLocal`) now wraps mutation + `emitOrThrow()` in `Connection::transactional()`, so any audit insert failure rolls back the originating mutation. Cache invalidations move to post-commit. `DbAuditEmitter` switches to `JSON_THROW_ON_ERROR` so payload encoding failures become typed exceptions instead of a silent `false`. Regression tests in `api/tests/Integration/Audit/AuditRollbackTest.php` drop the `audit_log` table to force every emit path to fail and assert the target tables (`manual_blocks`, `reporters`, `allowlist`, `categories`, `users`) see zero rows added. ### F5 — User creation and role assignment emit no audit - **Files:** `api/src/Application/Auth/AuthController.php:29-77` (`upsertOidc`, `upsertLocal`) - **Risk:** `/api/v1/auth/users/upsert-oidc` and `upsert-local` create or update users including assigning roles via OIDC group mapping or the local-admin bootstrap, and emit no `AuditEmitter` calls. Privilege grants and account creation — primary SOC/ISO 27001 events — are invisible in the audit log. Combined with F3, an attacker who compromises the service token can grant themselves Admin without trace. - **Severity: 3** - **Status:** Fixed in `8d948ae` alongside F4. `AuthController` now wraps each upsert in `Connection::transactional()` and emits `user.created` (new account, source `oidc` or `local`, with role and groups context) on the bootstrap path, plus `user.role_changed` when a subsequent OIDC login resolves to a different role. The auth route group now attaches `AuditContextMiddleware` so the rows carry source IP and request id. Regression tests in `api/tests/Integration/Auth/AuthEndpointsTest.php` (`testFirstUpsertLocalEmitsUserCreatedAudit`, `testRotatingUsernamesEmitsOnlyOneUserCreatedAudit`, `testNewOidcLoginEmitsUserCreatedAudit`, `testOidcRoleDriftEmitsRoleChangedAudit`) and `AuditRollbackTest::testUpsertLocalRollsBackWhenAuditInsertFails`. --- ## Severity 2 — medium ### F6 — Throttle store is per-process in memory; lost on worker recycle - **File:** `ui/src/Auth/LoginThrottle.php:38, 43-48, 92` - **Risk:** Counters live only in the FrankenPHP worker process. Worker recycling on memory pressure or fixed request counts wipes counters and gives the attacker fresh attempts. Multi-worker FrankenPHP multiplies allowed attempts by N silently. No persistent (DB / Redis) backing. - **Severity: 2** - **Status:** Fixed in `d119b72`. State moved behind a `ThrottleStore` abstraction; production wires `FileThrottleStore`, a flock-protected JSON file under `sys_get_temp_dir()` (overridable via `LOGIN_THROTTLE_PATH`). All FrankenPHP workers in one container share the same file: counters survive worker recycle and a single counter is incremented across the worker pool. Mutations take an exclusive lock on a sibling `.lock` file and write through a temp file + rename, so readers always see a consistent snapshot. Stale entries (`lockedUntil + 24 h < 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 require sticky-LB mode (SPEC's documented topology). Regression tests in `ui/tests/Unit/Auth/FileThrottleStoreTest.php` (`testFailureRecordedOnOneInstanceIsVisibleToAnother`, `testClearOnOneInstanceIsVisibleToAnother`, `testCorruptFileIsTreatedAsEmpty`, `testStaleEntriesGarbageCollected`, `testWritesGoThroughTempPlusRename`, `testResetUnlinksFile`) and `LocalLoginTest::testFailuresArePersistedToConfiguredFilePath`. ### F7 — Username enumeration via response timing on local login - **File:** `ui/src/Auth/LocalLoginController.php:77-78` - **Risk:** `password_verify` only runs after `usernameOk` evaluates truthy, so the request takes Argon2id time (tens to hundreds of ms) on a username match versus microseconds on a miss. An unauthenticated attacker enumerates the configured `LOCAL_ADMIN_USERNAME` value. Mitigation: always run a dummy `password_verify` against a fixed hash regardless of username match. - **Severity: 2** - **Status:** Fixed in `84238e6`. `LocalLoginController` now precomputes a dummy Argon2id hash once per worker in its constructor and routes `password_verify` to either the configured admin hash (when set) or the dummy hash (when unset). The verify result is ANDed with the username check and the "configured" check, so a username miss still fails closed but consumes Argon2id time. Also closes the related timing channel where an empty `LOCAL_ADMIN_PASSWORD_HASH` previously skipped `password_verify` entirely. Regression tests in `ui/tests/Integration/Auth/LocalLoginTest.php` (`testWrongUsernameTriggersPasswordVerify`, `testUnconfiguredLocalPasswordHashStillRunsPasswordVerify`) assert the failure path takes more than 10 ms — well above any path that would skip an Argon2id compare. ### F8 — `headers_sent()` short-circuit silently skips session regeneration / clear - **Files:** `ui/src/Auth/SessionManager.php:43-53, 67-75, 101-107` - **Risk:** Both `regenerateId()` and `clear()` are gated on `!headers_sent()`. If headers are sent before middleware (warning output, accidental whitespace, error rendering), login does not rotate the session id. An attacker who pre-seeded a victim's `irdb_session` cookie can ride that session post-login (classic session fixation). Should fail-closed instead of silently no-op'ing. - **Severity: 2** - **Status:** Fixed in `f811b25`. `SessionManager` now distinguishes a CLI/test mode (auto-detected from `PHP_SAPI === 'cli'`, overridable via constructor) from HTTP mode. In HTTP, both `regenerateId()` and `clear()` throw `\RuntimeException` when `headers_sent()` is true, surfaced by Slim as a 500 so the operator chases the upstream output bug rather than silently leaving the pre-auth cookie valid. Under CLI (PHPUnit), a manual rotation path resets `session_id()` and preserves `$_SESSION`, matching `session_regenerate_id(true)` semantics for tests. The `headers_sent()` call is also routed through an injectable closure so unit tests can drive the HTTP fail-closed path without a real web server. Regression tests in `ui/tests/Unit/Auth/SessionManagerTest.php` (`testRegenerateIdThrowsInHttpModeWhenHeadersSent`, `testClearThrowsInHttpModeWhenHeadersSent`, `testCliFallbackRotatesIdAndPreservesSession`). ### F9 — OIDC session id not regenerated *before* the handshake starts - **Files:** `ui/src/Auth/OidcController.php:39-47, 89`, `ui/src/Auth/JumbojettOidcAuthenticator.php:33-94` - **Risk:** `state`, `nonce`, and `code_verifier` are stashed in `$_SESSION` during `/login/oidc`. Regeneration only happens after a successful `upsert` (line 89). If an attacker can fixate a session id in a victim's browser before `/login/oidc` is hit (hostile network, sibling subdomain, or the F8 race), the attacker shares the same `$_SESSION` and can later hijack the session. Standard hardening regenerates on `initiate()` *before* redirecting to the IdP. - **Severity: 2** - **Status:** Fixed in `2a57589`. `OidcController::initiate` now calls `SessionManager::regenerateId()` at the top, before delegating to the authenticator that stashes `state`, `nonce`, and the PKCE `code_verifier` in `$_SESSION`. The OIDC handshake state is therefore bound only to a freshly rotated session id; any pre-fixated cookie is invalidated at this moment. The post-callback rotation is unchanged (defeats anything carrying over). Per F8, the rotation now also fail-closes if response headers were already sent, so it cannot silently no-op. Regression test in `ui/tests/Integration/Auth/OidcFlowTest.php` (`testInitiateRotatesSessionIdBeforeAuthenticate`) captures `session_id()` inside a fake authenticator and asserts it differs from the pre-request id. ### F10 — Open redirect via attacker-controllable `next` parameter - **Files:** `ui/src/Auth/SessionManager.php:139-150` (`setNext` / `consumeNext`), used by `ui/src/Auth/OidcController.php:98-100`, `ui/src/Auth/LocalLoginController.php:106-108`, `ui/src/Controllers/AllowlistController.php:126-128`, `ui/src/Controllers/ManualBlocksController.php:168-170` - **Risk:** After a delete action, `next` from the form body is sent verbatim as `Location:` with no validation that the value starts with a single `/` (not `//evil.example.com`) and no scheme allowlist. An authenticated operator/admin tricked into submitting a forged form (or any reflected XSS that auto-submits with the legitimate CSRF token) gets a 303 redirect to an arbitrary host from the trusted IRDB origin — high-quality phishing pivot. The login-flow `consumeNext()` path is currently safe (`AuthRequiredMiddleware` controls the source), but `setNext()` itself is unrestricted. - **Severity: 2** - **Status:** Fixed in `55156c5`. `SessionManager::isSafeRedirectPath()` accepts only same-origin paths (must start with `/`; second char must not be `/` or `\`; no control chars including CR/LF/NUL). `SessionManager::safeNextOrDefault()` returns the candidate iff safe, else a hard-coded literal default. `setNext()` and `consumeNext()` also drop unsafe values silently as defence-in-depth so any future caller is safe even without the helper. `AllowlistController::delete` and `ManualBlocksController::delete` now route the `next` form field through `safeNextOrDefault`, so a malicious value falls back to the resource list rather than redirecting the operator to the attacker URL. Regression tests: `SessionManagerTest` data-provider truth table for `isSafeRedirectPath` plus `testSetNextDropsUnsafeValueSilently`, `testConsumeNextRejectsPreviouslyStoredUnsafeValue`, `testSafeNextOrDefaultUsesDefaultOnUnsafeOrMissing`; integration tests in `CrudPagesTest` (`testManualBlockDeleteRejectsOpenRedirectInNext`, `testManualBlockDeleteHonoursSafeNext`, `testAllowlistDeleteRejectsOpenRedirectInNext`). ### F11 — Service-token impersonation accepts any user id with no allow-list, no active-status gate - **Files:** `api/src/Infrastructure/Http/Middleware/ImpersonationMiddleware.php:38-77`, `api/src/Infrastructure/Auth/UserRepository.php:28-37` - **Risk:** Service-token holders can impersonate any user id by setting `X-Acting-User-Id`. There is no allow-list, no "disabled"/"locked" check (no such column on `users`), and no separate audit signal. Combined with F3, a leaked service token is unconstrained Admin. - **Severity: 2** - **Status:** Fixed. Three layers: 1. **Active-status gate.** Migration `20260504110000_add_disabled_at_to_users` adds nullable `disabled_at` to `users`; `User::isDisabled()` exposes the predicate; `ImpersonationMiddleware` returns `403 user_disabled` for any disabled target (mirroring the unknown-user 403 so attackers cannot use the response to distinguish "missing" from "disabled"). `AuthController::upsertOidc` and `upsertLocal` short-circuit with 403 before recomputing role on a disabled row, so a disabled user cannot drift their role via OIDC group membership while disabled. 2. **Distinct audit signal.** Migration `20260504110001_add_actor_via_to_audit_log` adds `actor_via` (`oidc|local|admin-token|service|reporter|consumer|system`). `ImpersonationMiddleware` threads the resolved user's `is_local` flag onto `AuthenticatedPrincipal`, and `AuditContextMiddleware` derives `actor_via` from it — so audit rows split impersonated-OIDC from impersonated-local without joining `users`. `/admin/audit-log?actor_via=local` surfaces only local-admin actions for review. 3. **Admin user-CRUD.** New `UsersController` exposes `GET /api/v1/admin/users`, `GET /{id}`, and `PATCH /{id}` (body `{disabled: bool}`). PATCH wraps the state change + `user.disabled` / `user.enabled` audit emit in `Connection::transactional()` per F4. Refused with 409 on self-disable (`cannot_disable_self`) or on disabling the local-admin row (`cannot_disable_local_admin`) — the local admin is the documented break-glass path; operators wanting to lock it disable it via `LOCAL_ADMIN_PASSWORD_HASH` in the UI's env. UI page at `/app/users` (admin-only sidebar entry). OIDC and local-login controllers route the upstream `403 user_disabled` to `/no-access` (OIDC) or to a generic "invalid credentials" flash (local — probe-resistant). Regression tests: `api/tests/Integration/Auth/DisabledUserTest.php` covers (a) impersonation 403 on disabled rows, (b) `upsertOidc` rejection without role recompute or audit drift, (c) `upsertLocal` rejection on disabled local admin, (d) `actor_via` derivation for impersonated local vs OIDC vs admin-token paths, (e) admin disable + audit emit + idempotency, (f) self-disable / local-admin-disable 409 guards, (g) `/audit-log?actor_via=local` filter. The remaining "allow-list" framing of F11 is by-design per SPEC §8: any user the UI BFF has upserted is impersonatable. The disabled flag is the operator's lever to revoke that consent. ### F12 — Local-admin lookup matches on `display_name` without uniqueness guarantee - **Files:** `api/src/Infrastructure/Auth/UserRepository.php:50-60, 119-160` - **Risk:** `findLocalByUsername()` matches on `display_name` AND `is_local=1`, with no DB-enforced uniqueness on the pair. `LIMIT 1` silently picks one row. A hostile/compromised IdP that pushes an OIDC user with the same `display_name` (and a later data fix flipping `is_local`) could be matched on local-admin login, binding the local-admin password to the wrong identity. - **Severity: 2** - **Status:** Fixed in `4006743`. The display-name match was already removed by the F3 fix (`8a94dff`) — `findLocal()` looks up by `is_local = 1` and the partial unique index `uniq_users_one_local` enforces "at most one local row" at the DB. F12's residual concern (a row reaching the state `is_local=1 AND subject IS NOT NULL` via direct DB tampering) is now addressed in two layers: 1. **Application:** `UserRepository::findLocal()` adds `AND subject IS NULL` to the query. A row with `is_local=1` but a non-null OIDC subject is structurally not a local-admin row and will not bind the local-admin password — `upsertLocal` then trips the unique-index INSERT, surfacing the tamper as a 500. 2. **DB:** Migration `20260505100000_add_users_local_subject_invariant` enforces the same predicate at the storage layer — MySQL via a `CHECK (NOT (is_local = 1 AND subject IS NOT NULL))` constraint, SQLite via paired `BEFORE INSERT` / `BEFORE UPDATE` triggers that `RAISE(ABORT)` on the violating predicate. So a "data fix" script attempting `UPDATE users SET is_local = 1 WHERE id = …` against an OIDC row fails at the DB before any login can bind. Regression tests in `api/tests/Integration/Auth/AuthEndpointsTest.php` (`testDbLayerRejectsInsertingLocalRowWithNonNullSubject`, `testDbLayerRejectsFlippingIsLocalOnOidcRow`, `testFindLocalIgnoresHijackedRowEvenIfDbConstraintIsBypassed`). ### F13 — Service token rotation leaves the old hash valid indefinitely - **File:** `api/src/Infrastructure/Auth/ServiceTokenBootstrap.php:65-89` - **Risk:** When the bootstrap detects a different service-kind row (rotation in progress), it inserts the new row but does not revoke the old. Both tokens remain valid. With no operator tooling for service-token revocation, rotated tokens may live forever in `api_tokens`. If an old token leaks (config snapshot, image layer) the attacker authenticates indefinitely. - **Severity: 2** - **Status:** Fixed in `40be6c1`. `ServiceTokenBootstrap` now wraps revoke-old + insert-new in `Connection::transactional()` (per F4): when the configured `UI_SERVICE_TOKEN` does not match any current row but one or more service-kind rows are currently active, every active service-kind row is `revoked_at = now()`'d before the new row is inserted. The revokes and the create roll back together if any step fails — there is never an observable window with no service token. New repository method `TokenRepository::findActiveServiceTokens()` enumerates the rows to revoke. The bootstrap also refuses to silently re-enable a hash that is already present-but-revoked (operator must issue a fresh value rather than rolling env back). Each revoke emits a `token.revoked` audit row with `details_json.reason = "rotated_by_bootstrap"` and the create emits a `token.created` row carrying `source: "bootstrap"` and a `rotated_from` array of revoked prefixes — so SOC tooling can attribute the rotation and split automatic from operator-initiated revocations. Both audit rows are attributed to `actor_kind=system` via `AuditContext::system()`. Regression tests in `api/tests/Integration/Auth/ServiceTokenBootstrapTest.php` (`testBootstrapWithDifferentTokenRevokesPreviousAndInsertsNewRow`, `testBootstrapRotationRevokesEveryPreviouslyActiveServiceToken`, `testBootstrapRotationEmitsRevokedAndCreatedAuditRows`, `testBootstrapRefusesToReEnablePreviouslyRevokedToken`). ### F14 — `/api/v1/auth/*` has no rate limit - **File:** `api/src/App/AppFactory.php:156-169` - **Risk:** The auth group has only `$tokenAuth`. `RateLimitMiddleware` is not attached. `getUser/{id}` allows enumeration (F17), and combined with F3 a leaked service token gets unlimited writes. - **Severity: 2** - **Status:** Fixed in `9849779`. The `/api/v1/auth/*` route group now attaches `RateLimitMiddleware` alongside `TokenAuthenticationMiddleware` and `AuditContextMiddleware`. Per-token-id token-bucket — same limiter the public group uses — caps a burst at `API_RATE_LIMIT_PER_SECOND × 2` (default 60/s, capacity 120) per service token. The bucket bails out gracefully when no principal is present (auth failure) so it doesn't stack with `TokenAuthenticationMiddleware`'s 401 path. Caps the enumeration speed of `GET /users/{id}` (a residual exposure tracked by F17), and bounds amplification of any service-token-leak abuse against `upsert-local` / `upsert-oidc`. Regression tests in `api/tests/Integration/Public/RateLimitTest.php` (`testAuthGetUserRouteIsRateLimited`, `testAuthUpsertLocalRouteIsRateLimited`) burst the auth endpoints past capacity under a tight FixedClock+limiter and assert the expected 429 ceiling. ### F15 — `MaintenanceController::seedDemo` requires no confirmation token - **File:** `api/src/Application/Admin/MaintenanceController.php:279-288` - **Risk:** Asymmetric with `purge` which gates on `confirm: "PURGE"`. Any actor with admin role (or a compromised service token via F3) can issue a single POST and load thousands of synthetic reports/IPs/blocks into a production database. The 409 "already_seeded" check is keyed only on a literal demo-named reporter, so after a partial purge the seed will re-fire. Also a cheap repeated-write DoS. - **Severity: 2** - **Status:** Fixed in `5c15fc5`. `MaintenanceController::seedDemo` now requires `confirm: "SEED"` in the request body and returns `400 validation_failed` otherwise — symmetric with `purge`'s `"PURGE"` gate. The check runs before the 409 already-seeded shortcut and before any DB write, so a drive-by POST or repeated cost-imposition burst is rejected without touching `reporters`. The OpenAPI spec (`api/public/openapi.yaml` and `api/openapi.php`) documents the new request body and the 400 response. The UI BFF is updated end-to-end: `AdminClient::seedDemo` sends `confirm: "SEED"` (`ui/src/ApiClient/AdminClient.php`), `SettingsController::seedDemo` requires the user to type `SEED` in the form and surfaces a flash error otherwise (`ui/src/Controllers/SettingsController.php`), and the seed-demo modal mirrors the purge modal's typed-confirm UX in `ui/resources/views/pages/settings/index.twig`. Regression test `testSeedDemoRequiresLiteralConfirmString` in `api/tests/Integration/Admin/MaintenanceControllerTest.php` asserts both no-body and wrong-literal POSTs return 400 and that no `reporters`/`reports` rows landed; the existing `testSeedDemoPopulatesDataAndIsIdempotent` / `testSeedDemoForbiddenForViewer` cases were updated to send the new body. ### F16 — Admin-role API tokens are not bound to a `user_id` → privilege persists after offboarding - **Files:** `api/src/Application/Admin/TokensController.php:142-155, 166-177`, `api/src/Infrastructure/Http/Middleware/TokenAuthenticationMiddleware.php:67`, `api/src/Infrastructure/Http/Middleware/RbacMiddleware.php:51-59` - **Risk:** When an Admin user creates an admin-kind token, the `TokenRecord` carries `role` but no `userId`. If the user issuing the token is later demoted/disabled/removed, the token continues to grant Admin until manually revoked. There is no UI/API to list tokens by issuer. - **Severity: 2** - **Status:** Fixed in `947ab89`. Three layers close the gap: 1. **Schema.** Migration `20260505110000_add_user_id_to_api_tokens` adds nullable `api_tokens.user_id`. On MySQL it carries an FK to `users(id)` with `ON DELETE CASCADE` — a hard-deleted user takes the tokens they issued with them. SET NULL was rejected because reverting to a NULL `user_id` would let the token re-enter the grandfathered legacy path. SQLite cannot add an FK via ALTER TABLE, so deletion-time enforcement on that driver falls back to the application layer (issuer-row lookup returns null → 401). The system has no API-level user-deletion path; both drivers behave identically for the actual offboarding flows (disable + role demote). 2. **Binding.** `TokensController::create` writes the acting user's id into the new column for `kind=admin` tokens only. Reporter / consumer / service tokens stay user-less — they are device credentials, not delegated user privilege. `TokenRecord` carries the new `userId` field; the create response and list response surface `user_id`, the list also denormalises a `user_label` (display name, email, or `user#N`). Admin tokens minted via `bin/console tokens:create` carry NULL and are grandfathered — operators rotate those after deploy if they want strict binding. 3. **Enforcement.** `TokenAuthenticationMiddleware` injects `UserRepository`; for any admin-kind token with non-null `user_id` it loads the issuer and refuses the token (401, same shape as every other auth failure) if the issuer row is missing, has `disabled_at` set, or has a current role that doesn't satisfy the token's bound role (`role.satisfies(token.role)`). NULL `user_id` skips the check, preserving the grandfathered path. ImpersonationMiddleware still validates the impersonated user separately (F11), so a service token that claims to be a disabled user is still 403'd before the role check fires. UI: `/app/tokens` adds an Issuer column showing `user_label` (or `user#N` for deleted issuers, or `—` for legacy / console-issued tokens). OpenAPI yaml + `openapi.php` document the new fields. Regression tests in `api/tests/Integration/Auth/TokenIssuerBindingTest.php`: `testAdminTokenCreatedViaApiIsBoundToActingAdmin` (binding + audit row attribution), `testReporterTokenCreatedViaApiIsNotBoundToUser` (admin-only binding), `testListSurfacesIssuerLabel` (denormalised label), `testBoundAdminTokenAuthenticatesWhileIssuerActive` (happy path), `testBoundAdminTokenIsRejectedAfterIssuerDisabled` (F11 intersect), `testBoundAdminTokenIsRejectedAfterIssuerDemotedBelowTokenRole` (Admin-token-after-Viewer-demote → 401), `testBoundAdminTokenStillAuthenticatesIfIssuerHasMatchingRole` (Viewer token held by Admin issuer still works), `testBoundAdminTokenIsRejectedIfIssuerRowIsGone` (deletion fallback on SQLite), `testLegacyUnboundAdminTokenStillAuthenticates` (grandfathering). ### F17 — `GET /api/v1/auth/users/{id}` enables enumeration of internal user records - **File:** `api/src/Application/Auth/AuthController.php:79-104` - **Risk:** A service-token holder can iterate `/users/1`, `/users/2`, ... and exfiltrate every user's `email`, `display_name`, `role`, `is_local`. No rate limit (F14), no audit (F5), no defensive sleep. - **Severity: 2** - **Status:** Fixed by F14 (rate limit) and read-side audit added in `57ab1ba`. 1. **Rate limit.** F14 already attaches `RateLimitMiddleware` to the `/api/v1/auth/*` route group, so a leaked service token is capped at `API_RATE_LIMIT_PER_SECOND × 2` (default 60/s, capacity 120) per token id — both `getUser/{id}` enumeration and `upsert-{oidc,local}` abuse are bucketed on the same limiter. 2. **Read-side audit.** New `AuditAction::USER_FETCHED` constant (`user.fetched`). `AuthController::getUser` now emits a best-effort audit row on **both** the 200 (`outcome: found`, `target_label = email|display_name`) and 404 (`outcome: not_found`, `target_label = null`) paths. The 400 malformed-id path stays silent — that's a protocol error, not a valid-shape probe. `emit()` (best-effort) is used so a DB hiccup on the audit insert does not 500 a UI session refresh; volume is bounded by the F14 rate limit anyway. This is the only read action recorded in `audit_log` — every other action recorded there is state-changing per SPEC §4. The exception is documented inline at the constant. 3. **No defensive sleep.** Skipped intentionally. The 200/404 bodies differ structurally (`{"user_id": ...}` vs `{"error":"not_found"}`), so existence is trivially detectable from the response — timing is not the leak vector here. Adding `usleep` would only impose latency on legitimate UI session refreshes without strengthening the actual mitigation surface. Regression tests in `api/tests/Integration/Auth/AuthEndpointsTest.php`: `testGetUserFoundEmitsUserFetchedAudit` (200 path emits row with `outcome=found` and email label), `testGetUserNotFoundEmitsUserFetchedAudit` (404 path emits row with `outcome=not_found` and null label), `testGetUserInvalidIdDoesNotEmitAudit` (malformed id stays silent), `testEnumerationProducesOneAuditRowPerProbe` (a 5-id sweep produces exactly 5 `user.fetched` rows — the SOC detection signal). OpenAPI yaml + `openapi.php` document the audit emission and the 404 response. ### F18 — Containers run as root (no `USER` directive) - **Files:** `api/Dockerfile:42-43`, `ui/Dockerfile:46-47` - **Risk:** Neither Dockerfile sets a `USER`. PHP/FrankenPHP/Caddy run as UID 0. Any RCE in PHP code, dependency CVE, or FrankenPHP/Caddy CVE gets root inside the container. No `--chown` on `COPY`. The `irdb-data:/data` volume is owned by root. - **Severity: 2** - **Status:** Fixed in `33179d8`. Both `api/Dockerfile` and `ui/Dockerfile` now create an `app` system user (UID/GID 1000) and switch to it via `USER app` after the last root-required step (`apk`, `install-php-extensions`, `chmod +x`, `mkdir`/`chown`). FrankenPHP/Caddy bind to unprivileged ports (8080/8081) — no `setcap` is needed. Layout choices: - `/app` stays **root-owned and world-readable**. The runtime needs only read access; leaving source root-owned is a partial mitigation for F20 (an attacker landing RCE under `app` cannot overwrite `/app/vendor/**` or `/app/public/index.php` to persist). Tightening the read-only stance further (e.g. mounting `/app` read-only at runtime) is tracked separately by F20. - `/data` is **app-owned** so phinx migrations, the `auth:bootstrap-service-token` console call, and runtime SQLite writes succeed without root. Newly-created Docker named volumes (`irdb-data`) inherit this ownership on first creation. - `/home/app/.config` and `/home/app/.local/share` are pre-created and app-owned; `XDG_CONFIG_HOME` / `XDG_DATA_HOME` point at them so Caddy's autosaved-config and TLS-cache state has somewhere to land without falling back to `/root`. - PHP sessions in the UI continue to use the default `/tmp` save path (world-writable), so no extra mount is required. **Upgrade note for operators.** Existing volumes from pre-F18 deployments were created when `/data` was root-owned; after pulling this image the new uid=1000 process cannot write to them and phinx fails with `attempt to write a readonly database`. Recover with either of: ``` # one-shot chown (preserves data) docker run --rm -u 0 -v irdb_irdb-data:/data alpine chown -R 1000:1000 /data # or, if the SQLite data is disposable docker compose down -v && docker compose up -d ``` Documented in the upgrade section of the deployment notes. Verification: rebuilt both images and confirmed `docker compose exec api id` / `exec ui id` report `uid=1000(app)`, the api healthz returns 200, all 429 integration tests still pass under the new user, and the UI Caddy log shows `serving initial configuration` with TLS storage at `/home/app/.local/share/caddy`. ### F19 — No `.dockerignore` — host artifacts baked into images - **Files:** build context roots `api/`, `ui/`; `COPY . ./` lines `api/Dockerfile:31`, `ui/Dockerfile:37` - **Risk:** No `.dockerignore` ships in either subproject. `tests/`, `db/migrations/`, `bin/console`, `.phpstan.cache/`, `.phpunit.cache/`, `node_modules/` (UI), `composer.lock` are baked into the image. The repo-root `.env` is outside the build context by happenstance — any future developer who drops `.env`, `.env.local`, or a fixture into `api/` or `ui/` will silently bake it into the published image. Test fixtures and `bin/console` are also available to any future LFI / arbitrary-file-read primitive. - **Severity: 2** - **Status:** Fixed in `96eaa10`. `api/.dockerignore` and `ui/.dockerignore` now apply to both build contexts and explicitly exclude: - `.env` / `.env.*` — the central F19 concern. Compose loads `.env` from the repo root (outside both build contexts), so nothing here is needed at runtime; blocking the pattern outright keeps any future stray secret file from shipping in the image. - `tests/` — fixtures and integration scaffolding that doubles as LFI surface area. - Dev-tooling caches and configs: `.phpunit.cache/`, `.phpunit.result.cache`, `.phpstan.cache/`, `.php-cs-fixer.cache`, `.php-cs-fixer.dist.php`, `phpstan.neon`, `phpunit.xml`. - VCS / editor noise: `.git`, `.gitignore`, `.gitattributes`, `.idea/`, `.vscode/`, `*.swp`, `*~`, `.DS_Store`. - `CHANGELOG.md`, `Dockerfile`, `.dockerignore`, `.claude/`. - `vendor/` (both subprojects) and `node_modules/` (ui) — the multi-stage builds install clean copies in the `deps`/`assets` stages and pull them in via `COPY --from=...`. Excluding the host copies also fixes a subtle bug: in `api/Dockerfile:30-31` and `ui/Dockerfile:36-37`, the `COPY --from=deps /app/vendor ./vendor` line is followed immediately by `COPY . ./`, which would have clobbered the deps-stage vendor with whatever the host had (typically a `composer install`-with-dev tree). Things that ARE needed at runtime stay in the context: `src/`, `public/`, `config/`, `docker/`, `composer.json`, `composer.lock`; api also keeps `db/migrations/`, `db/seeds/`, `bin/console`, and `openapi.php`; ui also keeps `resources/` (Twig views are loaded at runtime, and `resources/css|js/` are consumed by the assets stage). The ui `package.json`, `package-lock.json`, `tailwind.config.js`, and `postcss.config.js` are kept because the assets stage references them by name — `.dockerignore` applies to every stage that shares the same context, so excluding them would break `npx tailwindcss` / `npx esbuild`. They are tiny and non-sensitive. `bin/console` (api) is intentionally retained — `entrypoint.sh` invokes `php bin/console auth:bootstrap-service-token` on every api start, and `phinx migrate` plus the seeders run from the `migrate` mode. Removing them would break startup; the LFI-surface concern is mitigated by F18 (image runs as uid 1000, source tree is root-owned and read-only to the runtime user) and tracked further by F20. Verification: rebuilt both images; confirmed the excluded paths (tests, dev caches, `.dockerignore`, `Dockerfile`, `.git`, ui `node_modules`) are absent from `/app/` in the final images and the runtime-required paths (`src`, `public`, `config`, `db/migrations`, `db/seeds`, `bin/console`, `vendor`, `docker`, `openapi.php`, ui `resources/views`, ui `public/assets/{app.css,app.js,logo.svg}`) are present. api phpunit is 429/430 — the lone failure is the timing-sensitive `BlocklistPerfTest::test50kEntriesUnder500Ms` perf-budget assertion (628 ms vs 500 ms budget), unrelated to this change. ui phpunit is 134/134. ### F20 — Application source is writable by the process serving requests - **Files:** `api/Dockerfile:36-38`, `ui/Dockerfile:42` - **Risk:** Combined with F18, any RCE-grade bug allows the attacker to overwrite PHP source files in `/app` (vendor, src, `public/index.php`) and persist via the next request. A `USER` directive plus stricter perms (read-only `/app`, writable only `/data`) blocks this persistence path. - **Severity: 2** - **Status:** Fixed in `1ec9d04`. F18 already made `/app` root-owned so the unprivileged `app` user cannot write to it through standard ownership/perm checks. F20 layers on a kernel-level read-only rootfs at runtime so the same protection holds even against a primitive that bypasses uid checks (e.g. capability escalation, or a future regression that flips ownership). All three services in `docker-compose.yml` now carry: ``` read_only: true tmpfs: - /tmp:uid=1000,gid=1000,mode=1777 - /home/app/.config:uid=1000,gid=1000,mode=0700 - /home/app/.local/share:uid=1000,gid=1000,mode=0700 ``` Writable paths are restricted to: - `/data` (api + migrate) — the existing `irdb-data` named volume; holds the SQLite database, phinxlog table, geoip mmdb files, the bootstrapped service token row, and any other future persistent state. - `/tmp` — PHP scratch and session files. World-writable (`mode=1777`) to match Linux convention; PHP sessions in the ui rely on this default save_path. - `/home/app/.config` and `/home/app/.local/share` — XDG dirs where Caddy/FrankenPHP write their `autosave.json` and (unused-here) TLS storage. Owned by uid=1000 with `mode=0700` so only the runtime user can read them. Verification: brought the stack up clean (`docker compose up -d`) with a synthetic `.env`. Phinx ran all 22 migrations; both api and ui reached `healthy`; both `/healthz` endpoints returned 200. Inside each container, `touch` against `/app`, `/app/src`, `/app/vendor`, `/app/public/index.php` returned `Read-only file system`, while `/tmp`, `/home/app/.config`, `/home/app/.local/share`, and `/data` (api only) accepted writes as uid=1000. Operator note: when adding a new writable path to the runtime (e.g. a cache dir, a queue spool, an upload staging area), declare it as either a named volume or a tmpfs in compose — writes anywhere else now fail with EROFS. F22 (scheduler) is scoped separately; this commit does not change `compose.scheduler.yml`. ### F21 — `getTraceAsString` logged in production may leak plaintext credentials - **Files:** `api/src/Infrastructure/Http/JsonErrorHandler.php:48`, `api/src/Infrastructure/Jobs/JobRunner.php:91` - **Risk:** PHP's stringified backtrace inlines scalar arguments to each frame. An exception thrown from inside `password_verify`, a Guzzle request setter, or any function called with a token/password as an argument writes the *plaintext* secret into the trace. `SecretScrubbingProcessor` matches Argon2/bcrypt hashes and `irdb_*` token shapes but does not match arbitrary plaintext passwords or generic OIDC `client_secret` values, so password-spray and OIDC misconfig errors leak via stdout logs. - **Severity: 2** - **Status:** Fixed. Both call sites now route through `App\Infrastructure\Logging\SafeTrace::format()`, which walks `Throwable::getTrace()` (and the `getPrevious()` chain) and renders one frame per line as `#N file(line): Class::method()` — the `args` element is dropped entirely, so no scalar argument can ever reach a log record regardless of the secret-scrubber's pattern list. `JsonErrorHandler` and `JobRunner` no longer call `getTraceAsString()`. Regression test in `api/tests/Unit/Logging/SafeTraceTest.php` covers single-frame arg suppression, `Caused by` chain walking, and the rendered frame layout. ### F22 — `compose.scheduler.yml` runs `apk add` at every container start - **File:** `compose.scheduler.yml:3-8` - **Risk:** `image: alpine:3` (no digest, no minor pin) plus `apk add --no-cache curl tini` at runtime means each restart pulls whatever Alpine ships that minute. A compromise of the Alpine package mirror (or typosquatting) gets root in the scheduler container, which holds `INTERNAL_JOB_TOKEN` and can call `/internal/jobs/*`. Pin the base image digest and the apk versions or build a real image. - **Severity: 2** - **Status:** Fixed. Replaced the inline `image: alpine:3` + runtime `apk add` with a real build context at `scheduler/`. The new `scheduler/Dockerfile` pins `FROM alpine:3.21@sha256:48b0309c…` and installs `curl=8.14.1-r2`, `tini=0.19.0-r3`, `ca-certificates=20260413-r0` at build time; restarts now reuse the locally-built image with no network fetch. The crontab (`scheduler/scheduler.crontab`) is baked into the image, which also removes the previously dangling `./docker/scheduler.crontab` bind-mount path. The compose service runs `read_only: true` with `no-new-privileges:true` and only `/run` + `/tmp` tmpfs mounts; `cap_drop: [ALL]` was tested and rejected because busybox crond calls `initgroups()` before each fork and dies with "can't set groups" without `CAP_SETGID`. Verified end-to-end: `docker compose -f docker-compose.yml -f compose.scheduler.yml up -d` brings the sidecar up healthy and within one minute the api responds `{"job":"tick","status":"success",...}` to the scheduled curl. ### F23 — `jumbojett/openid-connect-php ^1.x` constraint pins a major with historical CVEs - **File:** `ui/composer.json:19` - **Risk:** The `^1.0` constraint covers a major line that has had multiple advisories (e.g. GHSA-jq3w-9mgf-43m4 / CVE-2024-21489 in v0.x/early-v1; an iss-confusion advisory in v1.x prior to 1.0.2). Given this library is the sole OIDC token-validation entry point, drift here is critical. Tighten to `^1.0.2 || ^2.0` after testing and run `composer audit` regularly. - **Severity: 2** - **Status:** Fixed in `f66ceaf`. `ui/composer.json` now requires `jumbojett/openid-connect-php: "^1.0.2 || ^2.0"`, excluding the pre-1.0.2 versions that carry the iss-confusion advisory while still permitting an upgrade path to a future v2.x line. The `composer.lock` was regenerated against the new constraint (still resolves to v1.0.2, the latest published release) and `ui` regression tests pass unchanged. The "run `composer audit` regularly" half of the recommendation was already in place: `scripts/ci.sh` invokes `composer audit --no-dev` for both `api` and `ui` on every CI run (lines 84-85 and 109-110), so any future advisory against the locked version fails the build. ### F24 — UI CSP allows `script-src 'unsafe-inline' 'unsafe-eval'` - **File:** `ui/docker/Caddyfile:33` - **Risk:** `'unsafe-inline'` permits inline `