# 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 (9 fixed, 18 open), 42 sev-1. --- ## 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### F24 — UI CSP allows `script-src 'unsafe-inline' 'unsafe-eval'` - **File:** `ui/docker/Caddyfile:33` - **Risk:** `'unsafe-inline'` permits inline `