# 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, 27 sev-2, 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** ### 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** ### 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** ### 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** ### 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** --- ## 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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** ### 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 `