# 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 (39 fixed, 3 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 `'` through `{{ value }}` and asserting the output is HTML-escaped. ### F64 — `compose.scheduler.yml` references a missing crontab file - **File:** `compose.scheduler.yml:10` (`./docker/scheduler.crontab`) - **Risk:** The bind-mount source does not exist in the repository. Container starts with empty `/etc/crontabs/root` and silently never runs `cleanup-audit` (audit retention silently broken) or `cleanup-expired-manual-blocks`. Failure-open monitoring gap. - **Severity: 1** - **Status:** Fixed by the F22 fix (the scheduler-image rebuild that replaced the runtime `apk add` with a pinned-digest Dockerfile). The bind-mount of `./docker/scheduler.crontab` is gone; `scheduler/scheduler.crontab` is `COPY`ed into the image at build time so the schedule is part of the immutable artifact (`COPY scheduler.crontab /etc/crontabs/root` in `scheduler/Dockerfile`). Operators wanting a different cadence can still bind-mount their own crontab over `/etc/crontabs/root` in compose, but the default no longer depends on a host file at all. Closing F64 for bookkeeping; no new code change required. ### F65 — `SecretScrubbingProcessor` does not match raw JWT shape - **Files:** `api/src/Infrastructure/Logging/SecretScrubbingProcessor.php:42-57`, `ui/src/Logging/SecretScrubbingProcessor.php:22-37` - **Risk:** A short Bearer (< 20 chars) is not redacted by the current regex. Raw `id_token`/`access_token` JWTs logged under alternate keys (e.g. `'jwt' => '...'`) escape the key-name list. Add a JWT regex `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`. - **Severity: 1** - **Status:** Fixed. Both `SecretScrubbingProcessor` value-pattern lists (api and ui) gained two entries: - `\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b` → `eyJ***`. Anchored on `eyJ` because every JWT header is the base64url encoding of a JSON object that starts with `{"…`, which is `eyJ…`. Anchoring eliminates false positives on dotted-quad IPs (`192.168.1.1`), shared-object names (`lib.so.6`), and arbitrary `a.b.c`-shaped prose; the per- segment `{4,}` floor skips pathological short matches. - The Bearer floor `[A-Za-z0-9._\-]{20,}` was lowered to `{8,}` so a `Bearer abc12345` short token (the SEC_REVIEW called out `< 20 char` Bearers slipping through) gets caught. Regression tests: - api: `testRawJwtInValueIsScrubbed`, `testRawJwtInMessageIsScrubbed`, `testShortBearerTokenIsScrubbed`, `testIpAddressDoesNotMatchJwtRegex` (false-positive guard). - ui: `testRawJwtInValueIsScrubbed`, `testShortBearerIsScrubbed`. All existing tests still pass. ### F66 — `APP_SECRET` and `UI_SECRET` declared but unused - **Files:** `.env.example:23, 82`, `api/config/settings.php:35`, `ui/config/settings.php:37` - **Risk:** `UI_SECRET` is never referenced in `ui/src/`. `APP_SECRET` is never used for any signing/HMAC operation. Operators following `.env.example` generate secret material that does nothing — a misleading security signal. Either wire them into ETag/CSRF/session signing or remove them. - **Severity: 1** - **Status:** Fixed by removal. Neither secret was ever used for signing or HMAC (no callsites under `api/src/` or `ui/src/`), and adding fictional uses just to keep the env vars alive would invent ceremony. Deleted from: - `.env.example` — both `APP_SECRET` and `UI_SECRET` lines. - `api/config/settings.php` — `'app_secret'` settings key. - `ui/config/settings.php` — `'ui_secret'` settings key. - `api/src/Application/Admin/ConfigController.php` — the `APP_SECRET` line in the masked-config response, plus the docblock-listed masking rules. - `api/tests/Integration/Support/AppTestCase.php` and `ui/tests/Integration/Support/AppTestCase.php` — test fixture overrides. - `doc/user-manual.md` and `doc/security.md` — operator guidance that pointed at the removed env vars. Operators upgrading from a prior release can leave the lines in their `.env`; both apps now ignore them. F67 (validator-doesn't- check-UI_SECRET) is closed by the same change since `UI_SECRET` no longer exists to validate. ### F67 — UI `Config::validateOrExit` does not check `UI_SECRET` despite docs - **Files:** `ui/src/App/Config.php:20-55`, `.env.example:82` - **Risk:** `.env.example:82` documents `UI_SECRET` as required ("signs session cookies") but the boot validator does not check it. Sessions are unsigned PHP-native files. Misleading documentation. - **Severity: 1** - **Status:** Fixed alongside F66 by removing `UI_SECRET` entirely rather than wiring the signing the docs implied. The docs/ validator mismatch the SEC_REVIEW called out is resolved by deleting the misleading half: `.env.example`, `ui/config/settings.php`, and `doc/user-manual.md` / `doc/security.md` no longer mention `UI_SECRET`. Sessions remain PHP-native files (the storage was never actually signed by anything in the existing code), but nothing in the deploy documentation now claims otherwise. ### F68 — `/api/v1/openapi.yaml` and `/api/docs` are unauthenticated - **Files:** `api/src/App/AppFactory.php:99-101`, `api/docker/Caddyfile:40-44` - **Risk:** The 48 KB OpenAPI spec leaks the full surface of admin endpoints, internal-job endpoints, expected query/body shapes, and error contracts to unauthenticated callers. Defaults to internet-reachable. Aids reconnaissance. Gate behind a flag like `API_DOCS_PUBLIC` or move to an authenticated path. - **Severity: 1** - **Status:** Fixed. New `api_docs_public` setting (read from the `API_DOCS_PUBLIC` env var, default `false`). `AppFactory::build` now only registers `GET /api/docs` and `GET /api/v1/openapi.yaml` when the flag is `true`; with the default, both paths are simply never registered, so Slim returns 404 like any other unmapped path. Operators who want the docs viewer (open APIs, dev environments) opt in by setting `API_DOCS_PUBLIC=true` in the environment. The `.env.example` documents the gate alongside the other api-side settings. Caddyfile's `@docs`-path CSP block is unchanged — it now only takes effect for the 404 responses on the unregistered paths, which is harmless. Regression tests in `api/tests/Integration/Public/DocsControllerTest.php`: `testDocsPageIs404ByDefault` and `testOpenapiSpecIs404ByDefault` exercise the off-state directly; the F58 SRI test (`testDocsPageEmbedsRapiDocWithSriIntegrity`) and the spec-served smoke test now route through a small `enableDocs()` helper that flips the setting and rebuilds the app, mirroring the binding- override pattern `JobsAdminControllerTest` already uses. ### F69 — `ReportController` parses unbounded JSON body before size checks - **Files:** `api/src/Application/Public/ReportController.php:99-113, 184-197`, `api/src/App/AppFactory.php:67` - **Risk:** `jsonBody()` reads `(string) $request->getBody()` and `json_decode`s it without a pre-decoding size check. The 4 KB `METADATA_MAX_BYTES` is checked only after decoding succeeds — the full body is in memory by then. PHP `post_max_size`/`memory_limit` are not configured anywhere in the docker stack. An authenticated reporter (one weak token suffices) can POST a 100 MB body and burn memory; metadata-depth nesting up to PHP's 512 default amplifies. Set `JSON_THROW_ON_ERROR`, cap body size in Caddy, and enforce `php_value memory_limit`. - **Severity: 1** - **Status:** Fixed at the application layer. Two new defences: 1. **`RequestBodySizeLimitMiddleware`** under `api/src/Infrastructure/Http/Middleware/`. Wired globally in `AppFactory::build` AFTER `addBodyParsingMiddleware()` so it runs FIRST on the inbound LIFO stack — meaning Slim's body parser never reads a body that exceeds the 256 KiB cap. Two layers of check: `Content-Length` header (catches the well-behaved client) and `getSize()` fallback (catches a stream that knows its length even if the header lied or was omitted). Returns 413 `payload_too_large` JSON without touching the stream. 2. **`ReportController::jsonBody()` hardening.** The fall- through path that ran when Slim's parser didn't recognise the `Content-Type` now uses `json_decode($raw, true, JSON_DEPTH_CAP=32, JSON_THROW_ON_ERROR)`, with `JsonException` caught and translated to "treat as empty body". 32 levels is plenty for legitimate metadata shapes; PHP's 512 default left a deep-recursion amplifier in place even after the byte cap. Per-endpoint caps (the existing 4 KiB `metadata` limit) layer on top, and Caddy's `request_body { max_size … }` is the outermost bound — operators can configure that per-route in the Caddyfile if they want to fail-closed before PHP is invoked at all. The 256 KiB application-layer cap is generous enough for every legitimate admin payload (largest is a multi-line description, ≤16 KiB) while bounding the worst-case at "no single request can fill memory by itself". Regression tests: - Unit: `api/tests/Unit/Http/RequestBodySizeLimitMiddlewareTest.php` covers under-cap-passes, over-cap-by-Content-Length-rejected, over-cap-by-stream-size-rejected (Content-Length absent), empty-body-passes, and non-numeric-Content-Length-still- caught-by-stream-size. - Integration: `api/tests/Integration/Public/ReportControllerTest.php` `testOversizedRequestBodyIsRejectedWith413` posts a 512 KiB JSON body and asserts 413 + `payload_too_large` envelope; `testDeeplyNestedJsonDoesNotBlowTheStack` posts a 100-deep nested object and asserts no 500/exception leaks. ### F70 — `BlocklistController?format=json` forces sha256-of-50k-entries on every cache miss - **File:** `api/src/Application/Public/BlocklistController.php:80-85, 176-193` - **Risk:** A consumer can request the more expensive JSON renderer on demand. Per-process `BlocklistCache` TTL=30s — every cache miss rebuilds and rehashes. Combined with the per-token-only rate limit, a single consumer at 60 req/s sustains 60 full build-and-hash operations per second per replica. Header size limits cap the `If-None-Match` parse loop but it is still an amplifier. Constrain `format` per consumer (only allow what the consumer typically uses). - **Severity: 1** - **Status:** Fixed by extending `BlocklistCache` to cache the rendered body and its sha256 ETag per `(policy_id, format)`, not just the underlying `Blocklist` domain object. New `BlocklistCache::getRendered(Policy, format, callable $render)` invokes the render callback at most once per cache window per format; subsequent hits return the cached `body` + `etag` verbatim. The render+hash work that previously paid the full sha256-of-N-entries cost on every request now runs once per cache window per replica per format, regardless of request rate. `BlocklistController` switched to the new method and feeds it a small closure for each format; the choice between `renderText` and `renderJson` is the only thing varying per request now. The per-format invalidation contract from `getOrBuild` is preserved via the same `invalidate($policyId)` / `invalidateAll()` surface (the rendered entries live inside the same cache row, so dropping the row drops both the build and every render alongside it). Per-consumer format constraint is still possible as a future tightening but isn't necessary to defeat the F70 amplifier — the cache already does. Regression tests in `api/tests/Integration/Public/BlocklistControllerTest.php`: `testBlocklistRenderIsCachedAcrossRequests` (two sequential JSON requests produce byte-identical bodies and ETags) and `testTextAndJsonRendersBothCachedIndependently` (alternating format requests preserve each format's cache; text and JSON ETags differ because their bodies differ). ### F71 — `BlocklistCache` is not size-bounded - **File:** `api/src/Infrastructure/Reputation/BlocklistCache.php:33, 42-59` - **Risk:** The cache map is keyed by `$policy->id` and grows monotonically with no LRU. Admin-creatable, but unbounded. Add a bounded LRU (e.g. 16 policies cached). - **Severity: 1** - **Status:** Fixed. New `BlocklistCache::MAX_ENTRIES = 16` cap (also configurable via the constructor's new `$maxEntries` argument). Insertion goes through a private `store()` helper that unset-then-assigns the key (which pushes it to the end of PHP's insertion-ordered array) and evicts `array_key_first()` past the cap. Reads through `getOrBuild` / `getRendered` mark the entry as most-recently-used via a private `touch()` helper. Both paths preserve the existing per-policy invalidation contract (`invalidate($policyId)` / `invalidateAll()`). Worst-case memory is now bounded by 16 × the per-policy build cost, regardless of how many policies an admin creates over the worker's lifetime. Two tiny `@internal` accessors — `entryCount()` and `cachedPolicyIds()` — let the regression test inspect cache state without reaching into private fields via reflection. Regression test in `api/tests/Integration/Public/BlocklistControllerTest.php`: `testCacheIsLruBoundedAtSixteenEntries` rebuilds the cache with a 30 s TTL (the test fixture default is 0 s = no caching), creates 18 policies and one consumer per policy, hits `/api/v1/blocklist` for each, then asserts `entryCount() ≤ MAX_ENTRIES`, the two oldest policy IDs are NOT in the cache, and the most-recent one IS. The 10 pre-existing BlocklistController tests still pass. ### F72 — `/ips/{ip:.+}` accepts unbounded path parameter - **Files:** `api/src/App/AppFactory.php:256`, `api/src/Application/Admin/IpsController.php:121-127` - **Risk:** A logged-in Viewer can call `/api/v1/admin/ips/<10MB>`. `rawurldecode` runs on the entire string; `IpAddress::fromString` applies regex. Web-server URL-length limits typically cap this at a few KB but mitigations are environment-specific. Tighten the regex to a strict IP character class. - **Severity: 1** ### F73 — UI `JsonExceptionHandler` `getCode()` type-juggling for HTTP status - **File:** `ui/src/Http/JsonExceptionHandler.php:40-63` - **Risk:** `getCode()` from arbitrary `Throwable` is used as HTTP status if it is in `[400, 600)`. PDOException's `getCode()` is a SQLSTATE *string* (e.g. `'42S02'`) — coerced via comparison. A string like `'400'` would set status 400 and skip prod message-suppression. Cast to int explicitly and reject non-numeric codes. - **Severity: 1** ### F74 — `LoginThrottle` writes `logger->warning` per failure with no log-rate cap - **File:** `ui/src/Auth/LoginThrottle.php:79-93` - **Risk:** A sustained brute-force attack at 100 req/s fills disk via the structured logger. No log-rate cap, no aggregation. Pair with F1 (XFF spoof) and F2 (no per-user bucket) to amplify. - **Severity: 1** --- ## Reviewed and acceptable The following candidate concerns were checked and are not findings in the current code. Listed for completeness so the next reviewer doesn't redo the same work. - **No raw SQL interpolation of user input.** All repositories bind via DBAL named/positional parameters. The only `sprintf` into SQL is for table names where values are hardcoded constants or `?` placeholder counts. - **No `ORDER BY` injection.** Every `ORDER BY` uses constants — no user-controlled sort column anywhere reviewed. - **No `unserialize`, `eval`, `exec`, `shell_exec`, `system`, `passthru`, `proc_open`, `popen`, backticks, or `preg_replace /e` modifier** anywhere in `api/src` or `ui/src` (vendor excluded). - **IP/CIDR parsing** (`IpAddress::fromString`, `Cidr::fromString`) is strict — rejects whitespace, leading zeros, bracketed IPv6, zone identifiers, malformed octets. - **Token format & entropy.** `random_bytes(20)` = 160 bits, base32 encoded. SHA-256 storage at rest. Lookup is by hash — no timing side channel. - **Constant-time comparisons** used where they matter (`hash_equals` in CSRF, internal token, local-admin username). - **OIDC `S256` PKCE** explicitly set; missing `sub` claim rejected. - **Failed-token responses are uniform 401** — no distinction between missing/malformed/unknown/revoked/expired. - **Impersonation header is silently ignored on non-service tokens.** - **Impersonation user id strictly validated** as `^[1-9][0-9]*$`. - **JobRunner orchestrates entirely via DBAL** — no shell-out. - **MmdbVerifier opens MMDB via `MaxMind\Db\Reader`** with node-count thresholds; truncated/empty downloads fail closed. - **UI Guzzle client uses a fixed `API_BASE_URL`** from env — not user-retargetable. - **`MaintenanceController::purge`** loops `'DELETE FROM '.$table` over a hard-coded allowlist. - **Twig auto-escape (HTML)** is the default; no `|raw` over user-controlled data was found. - **Logout is POST-only and CSRF-protected.** - **Session cookie attributes** (`HttpOnly`, `Secure` in prod, `SameSite=Lax`) are correct. - **`PharData::extractTo` rejects path traversal** since PHP 8. --- ## Summary of severity counts | Severity | Count | |----------|-------| | 3 | 5 | | 2 | 27 | | 1 | 42 | | **Total**| **74**| The headline risks are: (a) the brute-force lockout is bypassable trivially via `X-Forwarded-For` spoofing or distributed sources (F1, F2), (b) a leaked or unrotated service token is a single-step total compromise because `/api/v1/auth/upsert-local` mints Admin users with no impersonation/audit/RBAC/rate-limit (F3), and (c) audit integrity rests on a non-transactional best-effort second write that silently no-ops on failure (F4, F5). The combination of these means a single compromised credential plus a deliberately-failed audit insert yields un-traced privilege escalation.