diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 44d741ed..2094f5e0 100755 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,6 +19,7 @@ - [ ] Package/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed - [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed - [ ] Project-rules-, architecture-, naming- and documentation-drift reviewed (see #57 for details) +- [ ] Codebase readability, naming, hierarchy, class map, frontend structure, and test-suite clarity reviewed (see #109 for details) - [ ] Follow-up tasks captured in WORKLOG - [ ] Updated / aligned translations and user-facing copy diff --git a/config/services.yaml b/config/services.yaml index 44dc979e..5130025d 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -269,6 +269,20 @@ services: $cachePool: '@cache.rate_limiter' $lockFactory: '@lock.factory' + App\Security\AutoBan\AutoBanStore: + arguments: + $cache: '@cache.app' + $lockFactory: '@lock.factory' + + App\Security\AutoBan\AutoBanSignalEvaluator: + arguments: + $environment: '%kernel.environment%' + + App\Security\AutoBan\AutoBanRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + $trustedApiKeys: '@App\Security\AutoBan\TrustedApiKeyAutoBanBypass' + App\Security\RateLimit\RateLimitRequestSubscriber: arguments: $environment: '%kernel.environment%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index c1d07982..a0923a67 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** This document tracks callable entry points (services, commands, controllers, Twig components, Stimulus controllers). Keep it up to date as new classes are added or interfaces change. This document is meant to evolve alongside the codebase—treat it as a living index for developers to quickly discover callables without grepping through the project. @@ -60,9 +60,9 @@ | Service | `App\Core\Asset\AssetRebuildQueueFactory`, `App\Core\Asset\TailwindBuildAction` | Builds the deterministic package-aware asset rebuild queue with package asset sync, translation aggregation, Symfony asset commands, UX Translator warm-cache output, non-blocking UX icon locking, non-blocking Tailwind startup warnings for web-server policy blocks, failing real Tailwind build errors, production compiled-asset cleanup plus AssetMapper compile, and final cache clear. | `dev/manual/frontend-asset-snippets.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `tests/Core/Asset/TailwindBuildActionTest.php` | | Service/commands | `App\Core\Mercure\MercureBinaryManager`, `App\Core\Mercure\MercureRuntime`, `App\Command\MercureInstallCommand`, `App\Command\MercureStartCommand`, `App\Command\MercureStopCommand`, `App\Command\MercureHealthCommand`, `App\Command\MercureCheckCommand` | Provides optional local Mercure hub tooling with a YAML-configured fixed version, fixed OS/architecture asset names for the Caddy-based prebuilt hub, SHA256-pinned release archive downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, Bolt transport storage at `var/mercure/updates.db`, release-provided Caddyfile startup, JWT secrets passed through a protected `var/mercure/mercure.env` file instead of command arguments, non-secret local Caddy/Mercure directives passed through the detached-process environment, relaxed read-only hub reachability diagnostics for `2xx`, `400`, and `401` responses, strict publish-health probes that require a successful authenticated POST, colon-only local listen and configured hub URL normalization for probe URLs, Mercure-fingerprinted public EventSource subscribe health probes, best-effort macOS quarantine release, a `bin/mercure` wrapper, publish self-healing, public-endpoint failure shutdown, read-only diagnostics, PID-first plus exact-binary process detection, OS-aware stop support that waits for the tracked PID and exact-binary fallback processes to disappear, disabled-integration health no-op success, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.php`, `tests/Command/MercureHealthCommandTest.php` | | Service | `App\Core\Output\JsonOutputRenderer` | Shared raw JSON response renderer for `/api/live/**` UI flows, captcha seeds, polling, and future small JSON endpoints. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Output/JsonOutputRendererTest.php` | -| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver` | Shared segment-bound path-scope matchers for raw technical route scopes and request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver`, `App\Core\Routing\IgnorableRequestPathMatcher` | Shared segment-bound path-scope matchers for raw technical route scopes, request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, and static/tooling/well-known request paths that should not spend rate-limit budget, access-statistics rows, active-ban checks, or passive error-status Security signals, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | +| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyCredentialResolver`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication through a shared HMAC-backed credential resolver, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | | API endpoint registry/documentation | `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\PackageApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController` | Aggregates domain-owned endpoint definitions and handlers through service tags, enforces public safe-method registration, supports explicit anonymous read opt-ins and minimum access levels, dispatches exact paths before broad pattern matches, exposes navigable API root/parent resources with access metadata, and generates OpenAPI 3.2 documents with manifest metadata, server entries, shell/domain tag hierarchy, neutral `x-access` operation metadata, shared schemas, error responses, and trace-header documentation. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Controller/ApiFoundationControllerTest.php` | | Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler and Admin ACL feature states, secret-redacted settings API output, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and Admin ACL-gated package lifecycle review/confirmation endpoints that start LiveOperation runs only after the caller is authorized. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | | Content API | `App\Content\Api\ContentApiEndpointProvider`, `App\Content\Api\ContentApiNavigationHandler`, `App\Content\Api\ContentApiPath`, `App\Content\Api\ContentApiItemListQuery`, `App\Content\Api\ContentApiVisibleItemPager`, `App\Content\Api\ContentApiItemReadModel`, `App\Content\Api\ContentApiItemHandler`, `App\Content\Api\ContentApiMutationStubHandler`, `App\Content\Api\SchemaApiEndpointProvider`, `App\Content\Api\SchemaApiReadModel`, `App\Content\Api\SchemaApiHandler` | Provides collision-free content API dynamic resources below `items/`, ACL-aware published content navigation/items/detail paths with child, variant, and revision navigation, query-backed published content collection pagination/filtering/sorting after ACL filtering, deferred non-published status read surfaces, deferred content mutation command stubs, and author-level schema metadata including custom Twig. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiContentSchemaControllerTest.php`, `tests/Controller/ApiContentItemControllerTest.php` | @@ -96,7 +96,7 @@ | Security checker | `App\Security\UserAccountChecker` | Symfony form-login user checker that rejects inactive or deleted `UserAccount` records before authentication can create a session. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php` | | Event subscriber | `App\Security\AppSecretRotationGuard` | Rejects unsupported short runtime `APP_SECRET` values before recovery handling, stores an environment-specific secret fingerprint, baselines first-seen secrets, and on detected rotation revokes active API keys while issuing password-reset links to active owners through the account-link delivery boundary; local Mercure hubs are stopped/refreshed around rotation when possible and marked unavailable if they cannot be safely stopped. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/AppSecretRotationGuardTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserAccountLifecycle`, `App\Security\AdminUserAssignmentOptions`, `App\Security\AdminUserAccountUpdateService`, `App\Security\AdminUserAccountUpdateResult`, `App\Security\AdminUserPasswordResetService`, `App\Security\UserPasswordChangeService`, `App\Security\UserPasswordChangeResult`, `App\Security\UserAccountClosureService`, `App\Security\UserAccountClosureResult`, `App\Security\PasswordPolicy`, `App\Security\PasswordPolicyErrorMapper` | Applies account status changes, records current/last status state markers, revokes active API keys plus pending password-reset/security-review tokens when accounts become inactive or deleted, keeps admin assignment option filtering, account update mutations, deleted-account status changes, admin password-reset creation, authenticated password-change review-token delivery, and self-service account closure outside controllers, enforces the shared account password policy across setup, registration, reset, and profile changes, and maps policy violations to stable user-facing error keys outside controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php`, `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Security/PasswordPolicyTest.php`, `tests/Security/PasswordPolicyErrorMapperTest.php` | -| Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, bounds menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, bounded account-link TTL, profile username-change availability, validated notification recipients, and bounded deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Service | `App\Backend\BackendAccessGuard` | Converts the current Symfony user into an access actor and checks backend area access through the shared ACL resolver. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and optional Admin ACL feature metadata so visible-only actions render disabled while backend execution still rechecks mutability. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | @@ -128,7 +128,7 @@ | Type | Symbol | Purpose | Docs | Tests | |------|--------|---------|------|-------| -| Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | +| Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\ConfigValidationGuard`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, reusable runtime bounds normalization for already-persisted values, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Core/Config/ConfigValidationGuardTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit/signal policy controls, Log Settings database-retention controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | @@ -195,14 +195,15 @@ | Value object | `App\Core\Workflow\WorkflowResult` | Value object for recoverable workflow results with success, invalid, review, blocked, failed states, message-backed issues, messages, and context. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel and the database lookup projection without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | -| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` skips shared static/tooling/well-known paths, refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | -| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, and cached configurable suspicious probe path patterns while avoiding non-existent contact/captcha path assumptions, assigns symbolic action costs without enforcing limits, and records clear passive signals for high-signal probes and unsafe prefetch attempts. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php` | -| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php` | +| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted query, form, and Content-Length-bounded JSON/raw-body attack-pattern payload detection that scores anonymous Admin/Editor/Setup payload probes while avoiding non-existent contact/captcha path assumptions and skipping authenticated Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, blocks sessionless protected browser surfaces such as Admin, Editor, and protected User account routes before firewall access-control responses, rechecks previous-session protected-browser entry/access-denied handling so non-trusted users remain blocked while trusted contexts bypass, overrides later `400`/`401`/`403`/`404`/`429` error responses before passive signal scoring for dynamic public/content views, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication while marking active-banned recovery attempts to suppress auth-failure side effects, returns bare `403` on recovery login failures, and rechecks successful submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c0f8dd68..ec2bd0d0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -75,66 +75,46 @@ - [ ] Audit follow-up: split the remaining large Admin ACL-adjacent controllers and API handlers along route/action boundaries when those domains are touched next; this slice already extracted the new matrix/form construction, while broader splits for `BackendController`, Admin user/ACL/package controllers, package APIs, operation/scheduler APIs, and the pre-existing large user ACL/review API handlers would be safer as a dedicated behavior-stable refactor. - [ ] Editor/Content/Config follow-up: warn non-blockingly when a proposed route or slug would match a configured suspicious probe path, so legitimate content remains possible but accidental high-signal probe namespace collisions are visible before publication. - [ ] Captcha/rate-limit follow-up: add a short-lived opaque 429 recovery context when real captcha challenges are wired, so verified provider-backed solves can reset only the whitelisted/resettable descriptor and subject scope that produced the rendered 429 without exposing bucket IDs, subject keys, IP data, or limiter internals. +- [ ] Aggregation/rate-limit follow-up: evaluate short-lived emergency country/continent traffic-shedding buckets for DDoS-like spikes. Treat this as aggregate rate limiting, not auto-ban or geo-blocking; ignore `n/a` GeoIP, keep thresholds extreme, preserve trusted-user recovery and Owner/API access, and use brief windows such as 5-15 minutes. - [ ] Audit follow-up: decide whether optional branding packages need capabilities beyond `system-template`; package CSS class namespace validation is now enforced for package-owned selectors. - [ ] Evaluate whether the documented minimum memory requirement should become 256M after PHPUnit 13.2/full-suite runs needed a higher CLI memory limit; do not fix this requirement until setup/init/lint/runtime memory behavior has been reviewed across target hosting platforms. ## Branch Logs **Usage:** Keep concise session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Place new entries chronologically under the matching branch/date heading so reviewers can follow the PR context without reading full verification transcripts. Record meaningful committed or completed changes, decisions, blockers, and follow-ups; keep detailed verification in PR notes unless a result materially affects the worklog context. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. -### 2026-06-18 feat-security-rate-enforcement -- Added binding review-fix rules to `AGENTS.md`: review findings must be traced across adjacent and analogous boundaries before changes, fixed at the narrowest central boundary where practical, kept simple/modular/minimally invasive, checked for unreported neighboring edge cases, and covered with regression tests or documented reasoning for inspected-but-unchanged analogous paths. -- Added binding PR-readiness audit rules to `AGENTS.md`: readiness checklist items must be reviewed as evidence-backed audit passes over the branch diff and affected runtime surfaces, including security/privacy, entry points, sessions/secrets/storage, module boundaries, route/API/live scopes, setup/init/CI, cross-platform and disabled-feature behavior, process/env handling, default seeds, translations/copy, drift, documentation, and captured follow-ups. -- Addressed follow-up rate-limit review findings: pre-setup ordinary wizard navigation remains skipped, but the final `POST /setup/review` apply action can now consume the DB-ready/default-backed setup-apply limiter before setup completes, and the scheduler interval intent/bucket is scoped to the exact `/cron/run` route so `/cron/*` misses cannot poison legitimate scheduler triggers. -- Hardened pre-setup HTTP error rendering: all known `4xx`/`5xx` statuses rendered through the shared browser error renderer now return minimal DB-free HTML `no-store` responses with status text and a Request ID resolved through `AccessRequestMetadata` before setup completion, avoiding custom content/error-page rendering while the database may be unavailable; pre-setup rate-limit/probe `400`/`429` responses reuse that same bare renderer path. -- Added the public `HttpErrorRenderer::resolve()` entry point for browser error-page resolution and migrated existing browser error triggers from direct render calls to that single resolver path; API JSON error rendering remains separate through the API responder, and callers can force the minimal bare response for future block surfaces such as auto-ban. -- Addressed follow-up rate-limit review findings: all non-empty `Authorization` API preflights now classify by `Access-Control-Request-Method` for rate-limit buckets so non-Bearer credentialed preflights cannot bypass write/admin budgets, and submitted-account workflow buckets now consume local Visitor/IP guards before account/email subjects so locally blocked clients cannot poison other users' shared login, registration, or password-reset buckets. -- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for request classification, API CORS, and rate-limit enforcer coverage passed with 89 tests and 474 assertions; `php bin/console lint:container --env=test --no-debug`; focused `bin/lint` for changed PHP/Markdown files; full `php bin/phpunit` passed with 1567 tests and 10342 assertions. -- Addressed follow-up rate-limit review findings: account-token workflows now add HMAC-redacted submitted-account token subjects for `/user/invitation/{token}` and `/user/reset-password/{token}`, scheduler JSON rate-limit responses use segment-bound `/cron` matching so browser content such as `/cronjobs` stays HTML, and rate enforcement now pre-checks all planned descriptor/subject consumes before committing the batch so later global bucket rejections do not spend earlier workflow/account buckets. -- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for subject resolution, limiter factory, enforcer, response renderer, request subscriber, and controller enforcement coverage passed with 90 tests and 928 assertions; `php bin/console lint:container --env=test --no-debug`; focused `bin/lint` for changed PHP/Markdown files; `git diff --check`; full `php bin/phpunit` passed with 1578 tests and 10442 assertions. -- Documented the intentional multi-bucket consume trade-off: the pre-check/commit flow prevents repeatable partial spends and account-bucket poisoning without adding cross-bucket transaction/rollback complexity; the residual concurrent-request race is bounded by per-key limiter locks and accepted as non-practical for repeated unrelated account bucket draining. -- Consolidated API effective-method handling into `ApiRequestMethodPolicy` so API CORS, read-only API key gating, abuse intent classification, and read-only Owner rate-limit exceptions share the same path-bound API v1, Authorization-header, credentialed OPTIONS, and `Access-Control-Request-Method` semantics. -- Added shared segment-bound `PathScopeMatcher` routing helper and moved API v1 detection plus rate-limit technical exclusions/JSON response-surface checks onto raw technical path matching so `/api/v10`, `/cronjobs`, `/_wdtfoo`, localized public lookalikes, and similar paths do not inherit protected path behavior by raw prefix accident. -- Moved rate-limit enforcement-stage eligibility and subject-selection policy onto bucket descriptors through `RateLimitSubjectPolicy`, keeping login/auth-failure, recovery-render, API/admin auth-failure, scheduler credential/IP anchoring, submitted-account workflows, and authenticated multipliers centrally declared with the bucket policy instead of duplicated in the stage enum and selector. -- Added HMAC-redacted submitted-account token subjects for `POST /user/security-review/{token}` and route-attributed localized security-review posts, matching the existing invitation/reset token workflow handling so leaked review-token submissions share the intended password-reset limiter bucket across visitors/IPs. -- Added shared `RequestPathResolver` request-segment resolution with gated URL locale-prefix stripping for locale-prefix UI/account scopes; API/Cron/Setup/static technical scopes remain raw prefixless route scopes that still use the resolved request locale, while access-log surface detection, request-intent classification, scheduler credential scoping, and submitted-account workflow subject detection share exact path-part semantics. -- Addressed follow-up rate-limit review findings: localized Cron/API lookalike paths no longer spend scheduler/API buckets or receive scheduler/API JSON responses, adjacent API v1 and scheduler guards now use segment-bound helpers instead of raw `str_starts_with()` prefixes, descriptorless Admin/Editor navigation now falls back to the global website buckets, and suspicious-probe handling runs before package loading with a forced minimal `400 Invalid Request` response while preserving passive probe signal recording. - -### 2026-06-17 feat-security-rate-enforcement -- Started the rate-enforcement slice after `feat-security-admin-acl-enforcement` merged, archived the completed Admin ACL branch notes into `dev/WORKLOG_HISTORY.md`, and refreshed the active worklog for the new branch. -- Updated `composer.lock` after dependency resolution refreshed `guzzlehttp/psr7` to 2.12.0 and `justinrainbow/json-schema` to 6.10.0. -- Recorded rate-enforcement product decisions: exact `/user/login?bypass=1` recovery path, fail-open limiter-storage degradation, one Owner-gated rate-limit mode setting with `off`/`standard`/`strict`/`panic`, and a dedicated rate-limit policy catalogue that keeps bucket budgets/profile scaling separate from semantic action costs. -- Added the rate-limit policy catalogue, profile scaling, Owner-gated Security setting, descriptor-backed Symfony limiter facade, request subscriber, redacted HTML/JSON `429`/probe `400` responses, fail-open diagnostics, Owner ordinary exemption, authenticated-user multiplier, `/api/live/**`/prefetch exclusions, login-success reset, and the dormant verified-provider captcha reset interface. -- Added a test-environment opt-in header for the request subscriber so legacy functional tests that mutate Security settings or share synthetic visitors are not affected by global limiter state; production and development enforcement are unchanged. -- Verification: focused syntax/lint checks for rate-limit, settings, message, translation, response, docs, and worklog files; focused PHPUnit for rate-limit catalogue/enforcer/reset, Security settings registry/form/API/UI, message catalogues, and HTTP enforcement responses; `php bin/console lint:container --env=test --no-debug`; `php bin/console render:route /admin/settings/security --role=owner --env=test --no-debug --include-status`; `bin/lint --diff`; full `php bin/phpunit` passed with 1424 tests and 9326 assertions. -- Hardened review-sensitive rate enforcement details: `/cron/run` is no longer Owner-exempt and now uses explicit scheduler intervals (`standard` 1/minute, `strict` 1/15 minutes, `panic` 1/hour), rate-policy descriptors are generated from user-visible action counts plus unique action-cost multipliers with a single-action profile floor, limiter degradation diagnostics report through the Message layer, and shared rendered HTTP error pages set `no-store` centrally. -- Verification: focused PHPUnit for action-cost catalogue, rate-limit catalogue/enforcer/reset, public error pages, rate-limit response controllers, and message catalogues; full `php bin/phpunit` passed with 1431 tests and 9422 assertions. -- Adjusted suspicious-probe profile scaling so strict/panic extend the probe window while the single-action credit floor prevents Symfony limiter consume failures below the probe action cost. -- Extended `render:route` with request-header input and response-header output, then added CLI route-render coverage proving repeated `/cron/run` renders with Owner context and mutable Owner API key receive scheduler `429` with `Retry-After` and `no-store` instead of bypassing through the Owner exemption. -- Added a production-environment guard to `render:route` so the debug renderer fails closed in `APP_ENV=prod` and remains available only for development/test diagnostics. -- Clarified the scheduler rate-limit policy documentation: `/cron/run` uses an operational pre-auth interval guard, and legitimate scheduler `429` responses in strict/panic modes are not treated as abuse or security signals. -- Addressed first Cloud Review rate-limit findings: auth workflow buckets now charge only unsafe submissions, the limiter runs after routing but before Symfony authentication failures, `/build/**` is excluded with generated assets, active-profile descriptors are used for login/captcha resets, and `/user/login?bypass=1` is wired to the dedicated recovery-login buckets. -- Hardened adjacent route-guard coverage so content routes cannot claim technical/static namespaces such as `/assets/**`, `/build/**`, `/_profiler/**`, `/profiler/**`, and `/_wdt/**` while relying on limiter exclusions. -- Addressed follow-up Cloud Review bypass findings: suspicious probes now run before ordinary `/api/live/**` exclusions, failed login/API credentials charge through `LoginFailureEvent`, ordinary buckets run after Symfony authentication so Owner/API-key subjects and authenticated multipliers are available, login/registration/password-reset buckets include HMAC-redacted submitted-account subjects, invalid API prefixes no longer become primary API limiter subjects, and high-impact Admin upload/download paths are classified before broad package/admin buckets. -- Verification: focused syntax checks, focused PHPUnit coverage for request classification, rate-limit enforcer/reset/controller behavior, render-route cron handling, content route guards, and test seed isolation; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1458 tests and 9679 assertions. -- Addressed additional Cloud Review hardening findings: authentication-failure rate checks now include Admin API mutation/upload/download families, successful login resets the same submitted-account/visitor/IP login keys used by enforcement, persisted Symfony limiter IDs include the active descriptor shape so profile changes do not reuse stale fixed-window state, and consume operations use Symfony's configured lock factory. -- Verification: PHP syntax checks for changed rate-limit classes/tests; focused PHPUnit for limiter factory, reset, controller enforcement, enforcer, and request classification coverage passed with 75 tests and 594 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1462 tests and 9717 assertions. -- Addressed the next Cloud Review rate-limit bypass findings: Bearer-bearing `OPTIONS` requests now classify as API authentication attempts instead of anonymous CORS preflights, recovery-login `GET` renders spend the dedicated recovery bucket while avoiding website buckets, Admin API auth failures add IP anchoring, read-only Owner API-key write denials spend the write/admin bucket before the 403 while read-write Owner keys stay ordinary-exempt, and scheduler intervals key on HMAC-redacted submitted scheduler credentials with IP fallback/secondary anchoring. -- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for request classification, subject resolution, rate-limit enforcer/controller enforcement, scheduler controller, and read-only API method coverage passed with 100 tests and 698 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1473 tests and 9820 assertions. -- Tightened recovery-login accounting so only `GET /user/login?bypass=1` uses the recovery render bucket; unsafe bypass submissions remain normal login attempts, and Panic mode explicitly keeps one recovery render plus the first login submission within budget. -- Narrowed ordinary rate-limit technical path exclusions to exact path segments so generated/static prefixes such as `/_profiler` and `/_wdt` do not accidentally cover similarly named public routes. -- Added the missing `admin.settings.*` source/runtime translations for Security settings fields and options touched by this branch so the Owner-gated Security settings page renders localized Captcha, rate-limit, audit, signal-retention, and probe-pattern controls instead of raw translation keys. -- Verification: PHP syntax checks for changed request-classifier/rate-limit classes and tests; focused PHPUnit for request classification, rate-limit policy/request-subscriber/enforcer/controller behavior, content route guards, and settings coverage passed with 163 tests and 1364 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `php bin/console render:route /admin/settings/security --role=owner --env=test --no-debug --include-status`; `php bin/console render:route '/user/login?bypass=1' --env=test --no-debug --include-status --header 'X-Rate-Limit-Testing: 1'`; full `php bin/phpunit` passed with 1489 tests and 9847 assertions. -- Addressed the latest Cloud Review hardening findings: sensitive recovery-login and Admin export/download/diagnostic `GET` requests now classify before spoofable prefetch forgiveness, derived profile floors now keep two costed ordinary actions available while preserving explicit single-action scheduler/probe interval policies, and suspicious-probe blocking runs before API availability, setup redirect, maintenance, live/API exclusion, and ordinary technical path gates. -- Verification: PHP syntax checks for changed rate-limit/abuse classes and tests; `git diff --check`; focused PHPUnit for request classification, rate-limit policy/request-subscriber/enforcer/reset/factory behavior, and controller enforcement passed with 116 tests and 909 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `php bin/console debug:event-dispatcher kernel.request --env=test --no-debug` confirmed probe priority 900 before response-producing gates; full `php bin/phpunit` passed with 1496 tests and 9941 assertions. -- Documented the future cache panic-mode direction in the frontend delivery/caching draft: Security `panic` is the intended coordination point for a bounded TTL lock that can serve anonymous public traffic from safe cache entries during DDoS-like events while preserving auth, ACL, probe blocking, audit, and Owner/Admin recovery behavior. -- Addressed the CORS preflight rate-limit bypass finding: configured anonymous API CORS preflights still return cheap `204` responses, but `OPTIONS` requests with an actual `Authorization` header are no longer short-circuited by CORS and can reach Bearer authentication failure plus API/Admin rate-limit accounting. -- Verification: PHP syntax checks for changed CORS/rate-limit tests and subscriber; focused PHPUnit for API CORS, request classification, rate-limit enforcer/request subscriber, and controller enforcement passed with 104 tests and 725 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1498 tests and 9960 assertions. -- Addressed the read-only Owner Bearer preflight edge: unsafe `Access-Control-Request-Method` values now count as API write attempts for both read-only API-key method gating and the rate-limit Owner exemption, so read-only Owner keys spend write/admin buckets and receive the same denial shape before repeated attempts become `429`. -- Verification: PHP syntax checks for changed API/rate-limit classes and tests; focused PHPUnit for API read-only method gating, API endpoint access/permission, API CORS, request classification, rate-limit enforcer, and HTTP enforcement passed with 112 tests and 790 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1503 tests and 10005 assertions. -- Addressed the malformed Bearer preflight and signed-in scheduler credential rotation edges: empty/whitespace Bearer API `OPTIONS` requests now classify like the API authenticator's Bearer scheme support and spend the matching API/Admin authentication-failure bucket, while scheduler interval buckets keep IP secondary anchoring even when a user session is present so rotating invalid query credentials cannot bypass `/cron/run` from the same source. -- Verification: PHP syntax checks for changed classifier/rate-limit classes and tests; focused PHPUnit for request classification, API CORS/read-only preflights, abuse subject resolution, rate-limit enforcer/request subscriber/reset/factory behavior, scheduler controller, and HTTP enforcement passed with 138 tests and 862 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1508 tests and 10037 assertions. -- Addressed the next rate-limit review findings and setup safety concern: the early probe hook now prechecks paths with a DB-free default matcher before invoking the full enforcer, pre-setup suspicious probes return a bare generic `400 no-store` without content/error-page DB lookups, ordinary setup wizard traffic is skipped until `APP_SETUP_COMPLETED`, authentication-failure checks no longer apply Owner ordinary exemptions, and only the final review-step setup apply submission is classified into the setup-apply bucket. -- Verification: PHP syntax checks for changed rate-limit/abuse classes and tests; focused PHPUnit for request subscriber, enforcer, classifier, action-cost, HTTP enforcement, and setup redirect behavior passed with 119 tests and 844 assertions; broader Security rate-limit/abuse/API-CORS/read-only/setup focus passed with 169 tests and 1151 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1514 tests and 10065 assertions. +### 2026-06-18 feat-security-auto-ban +- Started the auto-ban preparation branch after `feat-security-rate-enforcement` merged and archived the completed rate-enforcement branch notes into `dev/WORKLOG_HISTORY.md`. +- Recorded the auto-ban response decision: active temporary bans use the shared browser error renderer's forced bare response path with `403 Forbidden`, `Retry-After` when the TTL is known, `Cache-Control: no-store`, the safe Request ID, and the generic bare context `Request blocked due to suspicious activity. retry-after: ` without exposing score, rule, subject, IP, or signal internals. +- Updated the auto-ban plan and Security policy defaults for the score-based implementation: suspicious `400`/`403`/`404`/`429` signals feed a one-hour global score, Visitor ID is primary, stable IP scoring is secondary through a laxer threshold multiplier, active bans use cache-flock TTL state, TTLs escalate `1h`/`3h`/`24h`/`7d`, persistent ban-trigger and reset `security_signal_event` records drive escalation and reset cutoffs, threshold changes affect only future ban decisions, trusted registered users default to level `6`/`MANAGER` and are never auto-banned, setup/database degradation fails open, and the resolver-matched `/user/login?bypass=1` recovery-login render path remains reachable. +- Clarified the first score defaults and enforcement ordering: Visitor threshold `100`, IP threshold `x2`, minimum two qualifying signals, error-hit weight `7`, probe/session-copy weight `100`, failed-auth weight `10`; API keys are trusted-user context rather than auto-ban subjects, and active Visitor/IP bans must resolve before error pages or rate-limit bucket consumption but after trusted-user/API-key context can bypass them. +- Tightened review-readiness decisions: scoreable signals are persisted per evaluated source subject so IP scoring uses indexed subject reads instead of JSON context, one evaluation creates at most one active ban with Visitor preferred over IP, active-ban list rendering uses a cache-backed index while per-subject TTL state remains authoritative, and first-slice auto-ban settings/manual resets are Owner-gated. +- Recorded the auto-ban performance policy: score aggregation is triggered only after a scoreable `security_signal_event` write and reuses that DB path for indexed Visitor/IP lookups; ordinary non-signal requests perform only the active-ban cache check and must not start database score queries. +- Verification: documentation-only preparation slice; focused Markdown lint passed. +- Implemented the first auto-ban slice: scoreable Security signals for suspicious error hits, probes, failed-auth attempts, and session/visitor mismatches now feed Visitor/IP subject scoring only from the signal write path; active bans use cache-backed TTL state with an Admin index, Visitor-before-IP selection, reset cutoffs, retained trigger/reset signal context, and early request enforcement after trusted context resolution. +- Added Owner-gated Security settings for enablement, trusted-user level, and score threshold; Security settings link to a dedicated active-ban list instead of embedding the list in the settings registry, and the link renders disabled when auto-ban is disabled. Disabling auto-ban now stops score evaluation and enforcement while existing TTL states remain until expiry. +- Kept recovery-login bypass matching on the established `/user/login?bypass=1` user-workflow route and RequestPathResolver coverage for locale-prefixed `/user/login` path matching without adding a plural `/users` runtime surface. +- Normalized current-time reads in the auto-ban/session-security path through Symfony Clock and disabled kernel-triggered auto-ban evaluation/enforcement by default in the Symfony `test` environment to prevent broad controller suites from poisoning shared active-ban cache state with intentional error-response tests. +- Replaced throw-based auto-ban payload timestamp parsing with bounded `createFromFormat()` parsing and routed auto-ban storage/evaluation degradation through Security Message-layer diagnostics while preserving fail-open behavior. +- Added configurable hidden Owner alerts for newly decided bans, linked alert actions directly to the active-ban list, added success/error alerts for manual ban release and failed settings saves, and routed alert-delivery degradation through Security Message-layer diagnostics. +- Registered `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing API endpoint registry. Browser and API auto-ban review/reset surfaces use the existing non-configurable `admin.settings.security` ACL gate, so delegated non-Owner admins cannot access the ban list. +- Review-hardened active-ban enforcement so `/api/live/**` remains outside ordinary rate-limit `429` handling but no longer bypasses an already active auto-ban, and added a `request_id`/`reason_code` Security-signal index for the Visitor-over-IP ban dedupe query. +- Verification: `php -l` on changed PHP entry points passed; `php bin/console lint:container` passed; focused AutoBan/API/settings/message PHPUnit groups passed; full `php bin/phpunit` passed with 1631 tests and 10708 assertions; `bin/lint --diff` plus explicit lint for new auto-ban files passed. +- Addressed first Cloud Review round with separate reviewable commits: recovery login submissions can authenticate despite source bans; active-ban index updates are serialized and roll back unindexed active state; failed cache deletes make reset fail; reset success requires reset-signal persistence; concurrent evaluators emit trigger signals/Owner alerts only for newly created bans; detail views are newest-first while retaining history; auto-ban `403` responses do not create passive signals; and auto-ban API endpoints now advertise Owner-level access before the handler ACL gate. +- Addressed second Cloud Review round with separate reviewable commits: index-write rollback now verifies active-state removal and falls back to an expired payload when cache deletion fails; shared ignorable static/tooling/well-known path matching prevents routine missing favicon/robots/touch-icon/discovery requests from creating passive `404` Security signals; login ban bypass now requires the CSRF-backed recovery marker rendered by `GET /user/login?bypass=1`; and auto-ban enablement fails open when config storage is unavailable while setup still seeds completed installations as enabled. +- Addressed third Cloud Review round with separate reviewable commits: active auto-bans override earlier probe responses and set the passive-signal skip marker; malformed scalar request/query fields in the auto-ban inspection path can no longer make active-ban enforcement fail open; session/visitor binding now respects existing auto-ban responses and skip markers; and repeated Owner alerts for the same banned subject use occurrence-specific presentation IDs. +- Added scoreable suspicious payload signals for obvious public/untrusted GET/POST abuse patterns and malformed scalar-only security parameters. The payload matcher records only safe pattern classes and bounded parameter metadata, never raw submitted values, and skips Admin/Editor/Setup/trusted contexts so legitimate code-bearing fields such as schema custom Twig are not treated as probes; active auto-ban `403` requests still suppress new Security signals but remain access-log entries for Request-ID/Visitor/IP audit correlation. +- Enriched active auto-ban detail with country/continent from the latest retained ban-trigger signal's Request ID by reading the matching access-log projection. Security signals still do not duplicate raw IP or per-signal GeoIP values, and missing/expired access-log context falls back to `n/a`. +- Addressed fourth Cloud Review round with separate reviewable commits: active auto-ban checks no longer skip shared ignorable/static paths when those requests reach Symfony, probe candidates mark already-banned sources before suspicious-probe rate-limit consumption, ordinary login submissions are blocked before form authentication unless they carry the recovery marker, and manual reset now releases active state before recording the reset cutoff while restoring active state best-effort if the cutoff cannot be persisted. +- Addressed the fifth Cloud Review reset race by moving reset release plus cutoff recording under the same subject-key lock used for ban creation, so concurrent score evaluation cannot recreate an active ban from pre-reset evidence between cache release and reset-signal persistence. +- Addressed the fifth Cloud Review API-auth ordering issue by adding an early `/api/v1/**` and raw `/cron/run` active-ban guard that blocks invalid/untrusted credentials and credentialed preflights before auth failure/success side effects, while a shared HMAC-backed credential resolver allows active trusted-user-owned API keys, including scheduler `?auth=` when enabled, to bypass the source ban under the trusted-user rule before downstream API/scheduler authorization runs. +- Follow-up audit over prior review fixes found and closed two adjacent edges: auto-ban bare `403` responses now force access-log recording even on otherwise ignorable paths that still reach Symfony, and the pre-auth scheduler trusted-key bypass mirrors the scheduler `?auth=` token length/control-character guard before HMAC/DB lookup. +- Addressed the next Cloud Review round with separate reviewable commits: the auto-ban qualifying floor now counts distinct request IDs instead of scoreable rows, trusted scheduler credentials no longer create passive Visitor/IP source signals when scheduler responses are `403`/`404`, and Security signal retention settings below the maximum auto-ban TTL are rejected instead of allowing active bans to outlive their retained trigger evidence. +- Added a reusable config validation guard for effective runtime bounds so already-persisted `security.signals.retention_days` values are floored to the current maximum auto-ban TTL and capped at the global 30-day retention maximum, while ordinary log-retention settings keep their existing one-day minimum. +- Extended the config validation guard to other small bounded runtime settings that already had form validation: user menu sort order, account-link TTL hours, deleted-user retention days, and the auto-ban score threshold now normalize already-persisted out-of-range values to their effective runtime bounds. +- Addressed the latest Cloud Review round with separate reviewable commits: CSRF-marked recovery login submissions now only pass the post-auth active-ban recheck when authentication established a trusted user context, and valid trusted-user-owned scheduler credentials skip request-phase suspicious payload source scoring for both Bearer and enabled `?auth=` scheduler calls. +- Addressed the next Cloud Review round with separate reviewable commits: trusted auto-ban access level is bounded to valid registered-user levels `USER..OWNER` while keeping the Owner-selectable policy range, request-phase payload signal writes are followed by a second active-ban guard so newly created bans stop before controllers, and payload matching now scans bounded JSON/raw JSON-like body metadata under the same public/untrusted context guards without storing raw submitted values. +- Addressed the next Cloud Review round with separate reviewable commits: active-banned recovery-login failures now return the bare auto-ban `403` and suppress auth-failure signal/rate-limit side effects, suspicious JSON/raw-body scanning now requires bounded `Content-Length` before reading request bodies, and anonymous Admin/Editor/Setup payload probes are scored while authenticated code-bearing application forms remain exempt. +- Addressed the next Cloud Review browser-surface ordering issue: sessionless active-banned sources now receive the bare auto-ban `403` on protected Admin, Editor, and User account routes before firewall access-control responses; previous-session protected-browser requests are rechecked through the security entry/access-denied handler so only trusted user contexts bypass the source ban. A response-phase fallback also overrides later `400`/`401`/`403`/`404`/`429` error responses before passive signal scoring, covering future dynamic public/content views whose routes are not known to the pre-auth prefix guard. The scheduler read-only-key bypass finding was assessed as invalid because auto-ban only decides whether a provided credential belongs to a trusted user; scheduler authorization remains owned by the scheduler authenticator/controller. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index e496d5e2..d04cd201 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -1,7 +1,7 @@ # Developer Worklog History > **Status**: Active -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Preserve compacted branch/PR history moved out of `dev/WORKLOG.md` at branch boundaries. @@ -9,6 +9,11 @@ Move completed branch or PR logs from `dev/WORKLOG.md` into this file when switching branches or after a PR is merged. Keep the active worklog focused on the current branch so reviewers can see the full PR context while older project history stays available. ## Archived Branches +### 2026-06-17 to 2026-06-18 feat-security-rate-enforcement +- Implemented the rate-enforcement slice: descriptor-backed Symfony RateLimiter facade, Owner-gated mode setting (`off`, `standard`, `strict`, `panic`), action-cost-derived policy catalogue, Website/API/Scheduler/Auth/Setup/Probe buckets, fail-open storage diagnostics through the Message layer, authenticated multipliers, Owner ordinary exemption, recovery-login handling, active-profile scoped resets, dormant captcha reset contract, and redacted HTML/JSON `429`/probe responses. +- Hardened the branch through 39 resolved Cloud Review findings: unsafe-only workflow charging, authentication-failure ordering, generated/static exclusions, active-profile resets, recovery buckets, account/token subjects, API/CORS/read-only preflight handling, Owner and scheduler exceptions, exact technical path scopes, probe ordering before package/API/setup/maintenance gates, setup-final-apply safety, multi-bucket pre-check/commit semantics, website fallback for descriptor gaps, and segment-bound API/Cron guards. +- Added shared path helpers, HTTP error-renderer bare/resolve behavior, `render:route` diagnostics hardening, rate-limit Security settings translations, future cache-panic documentation, worklog/class-map/draft updates, PR-readiness/review-fix project rules, and final review notes showing full `bin/phpunit`, `bin/jstest`, and `bin/lint` passed before merge. + ### 2026-06-16 to 2026-06-17 feat-security-admin-acl-enforcement - Implemented the Admin ACL enforcement slice: domain-owned feature registry, denied/visible/mutable states, surface inference from feature keys, seeded Owner-configurable defaults, ACL-group override states, Owner-gated `Settings/ACL` matrix, dynamic active-package settings rows, and feature-matrix caching with explicit invalidation. - Wired Admin ACL feature checks through protected settings fields, Admin navigation/views, package/theme actions, package lifecycle and settings, GeoIP maintenance, operations continuations, scheduler, logs, statistics, users, user reviews, ACL group management, backup/status surfaces, and Admin API handlers while keeping visible-only controls rendered disabled where layout depends on them. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index d4812f1a..39319d7d 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -1,7 +1,7 @@ # Security hardening implementation plan > **Status**: Draft -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Plan the security hardening feature split so each branch can be implemented, reviewed, and merged as a focused production-ready slice. @@ -35,7 +35,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be u - Recognize cross-action abuse. Separate buckets remain useful, but repeated activity across different guarded workflows should also feed a global subject budget and suspicious-signal store. - Add progressive punishment as a first-class concept: observe, throttle, require captcha, temporarily block, and hard-block only when signals justify it. - Support active punishment such as draining a suspicious subject's relevant buckets when clear bot behavior is detected. -- Add temporary auto-bans with TTL for IP, visitor ID, API key, or combined subjects. Authenticated users should receive softer handling where reasonable, and Owner accounts must never be locked out of all recovery paths. +- Add score-based temporary auto-bans with TTL for Visitor-ID and stable client-IP subjects. Trusted registered users are never auto-banned, and Owner accounts must never be locked out of all recovery paths. - Treat GeoIP as operational metadata for logs, statistics, and security review. Missing provider configuration must degrade gracefully. - Include IconCaptcha in the overall security feature cut, but keep its provider implementation in a dedicated branch after the generic captcha contract. - Cover adjacent security surfaces through the abuse/rate policy catalogue instead of local ad hoc checks: setup apply, CORS preflight, high-impact admin operations, package lifecycle, backup/restore, import/export, uploads/archives, diagnostic downloads, and support bundles. @@ -174,11 +174,11 @@ Detailed plan: [auto-ban](security-hardening/auto-ban.md). Scope: -- Add TTL-based ban records for IP, visitor ID, API key, and combined subjects. -- Add ban reasons, expiry, source signals, actor context, and audit entries. -- Apply softer thresholds or bypasses for authenticated users where appropriate. -- Enforce Owner safety so at least one active Owner retains a documented recovery path. -- Provide Admin review and manual unban tools. +- Score retained source-risk Security signals across a one-hour window, primarily by Visitor ID and secondarily by stable client-IP bucket/HMAC with a laxer threshold multiplier. +- Record low-weight Security signals for `400`, `403`, `404`, and `429` outcomes, correlating probe-plus-`400` evidence so one request is not double-counted and excluding login-required `401` by status alone. +- Store active TTL ban state through cache-flock-backed keys plus a cache-backed active-ban index, with escalation derived from retained ban-trigger `security_signal_event` records. +- Add required Config/Settings defaults for auto-ban enablement, trusted-user minimum access level, and score threshold through the settings/default provider so missing database states fail open. +- Add forced bare `403` ban responses, trusted-user/Owner/API-key recovery protection, active-ban review UI, detail pages backed by filtered Security signals, and Owner-gated manual reset. Non-goals: @@ -187,8 +187,8 @@ Non-goals: Acceptance: -- Clear bot/probe behavior can be temporarily blocked without blocking all Owner recovery. -- Operators can understand why a subject is blocked and when the block expires. +- Clear repeated suspicious behavior can be temporarily blocked without blocking trusted users or Owner recovery. +- Operators can understand why a subject is blocked from retained Security signals, see expiry, and reset the active ban immediately. ### `feat-security-captcha-contract` @@ -292,7 +292,7 @@ Acceptance: - Security identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, package-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. Visitor-ID generation may use raw forwarding-header values only as untrusted differentiation entropy and never as Security subject, GeoIP, ban, or signal evidence. - TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. - Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. -- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual unban, mail token delivery, and remember-me token rotation. +- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual reset, mail token delivery, and remember-me token rotation. - Each PR must complete the Security PR-readiness checklist below from the actual branch diff. Do not pre-check items from the template without reviewing the changed public entry points, data flows, browser storage, package boundaries, docs, translations, and verification output for that branch. ## Security PR-readiness checklist @@ -310,21 +310,22 @@ Acceptance: ## Fixed implementation defaults -- Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. -- Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. -- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to active Admin or Owner sessions must not be banned. -- IP-based enforcement is secondary, laxer than Visitor-ID enforcement, and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. +- Auto-ban active state uses cache-flock TTL keys. Admin review, escalation, and reset cutoffs are explained by retained `security_signal_event` records, including ban-trigger and reset signals, rather than a separate durable ban table. +- Auto-ban enforcement applies by default to Visitor-ID and IP source evidence. Trusted registered users at or above the configured trusted-user level, including valid API keys owned by trusted users, are never auto-banned, and Owner accounts must retain recovery access. +- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to trusted active sessions or trusted-user-owned API keys must not be banned. +- IP-based enforcement is secondary and laxer than Visitor-ID enforcement through a fixed `x2` threshold multiplier. Prefer Visitor-ID-backed scoring for continuity, evaluate IP scoring to reduce cookie/header-mutation bypasses, and keep every active ban TTL at or below 7 days. +- Scoreable request signals are persisted per evaluated source subject, normally Visitor ID and IP bucket, so scoring uses indexed subject reads rather than JSON-context filtering. Score aggregation is write-triggered after signal persistence and reuses that DB path; ordinary non-signal requests perform only the cheap active-ban cache check. If both Visitor and IP thresholds cross from one evaluation, create at most one active ban and prefer the Visitor ban. - Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. - Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. - Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. - Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. -- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. -- A recovery login path such as `/user/login?bypass=1` must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. +- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. Auto-ban must correlate the probe signal and its `400` response so one request is not double-counted. +- The `/user/login?bypass=1` recovery login render path, resolved through the shared `RequestPathResolver`, must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, trusted-user, Admin, or Owner policy. - Successful login and verified provider-backed captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - Captcha-based reset or `429` recovery requires an active provider-backed challenge. Provider `none`, missing-provider, or disabled-provider auto-success is never human proof and must not reset limits. -- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. +- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and Owner-gated manual reset. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. - IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. - Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. @@ -333,7 +334,7 @@ Acceptance: ## Remaining calibration points - Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. -- Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. +- Verify the early auto-ban enforcement ordering carefully: active Visitor/IP bans must be resolved before error pages or rate-limit buckets can produce another response, but after authenticated trusted-user and trusted-user-owned API-key context is available to bypass those source bans. - Database-backed lookup projections for message, audit, access, and passive security signals are the preferred Admin/API read model for Security review and abuse correlation. File logs remain the durable raw source and operator fallback. - Define backup/export handling for short-retention security data before enabling any database-backed security projection, so restore and support workflows do not reintroduce expired IP-derived records. diff --git a/dev/draft/0.4.x-FrontendDeliveryCaching.md b/dev/draft/0.4.x-FrontendDeliveryCaching.md index 50cb2389..9c724917 100644 --- a/dev/draft/0.4.x-FrontendDeliveryCaching.md +++ b/dev/draft/0.4.x-FrontendDeliveryCaching.md @@ -1,7 +1,7 @@ # Frontend delivery and caching (Feature Draft) > **Status**: Draft -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Draft for public content delivery, snapshot/cache boundaries, HTTP caching, invalidation, and performance-oriented rendering. @@ -23,6 +23,8 @@ The Security rate-limit `panic` profile is also intended as the future switch po ## Technical Specifications - Define public delivery services separate from editor/admin write services. - Use content route resolver output, theme resolver output, schema rendering, media URLs, and resolver context through a controlled read path. +- Keep future locale-prefix path rewriting limited to safe public navigation links. Delivery may rewrite generated read-only `GET` anchors to active locale-prefixed public paths. For generated internal targets that do not support locale prefixes, delivery may instead append or merge an explicit `language` query parameter, including for mutating form actions, API routes, live endpoints, cron/scheduler endpoints, admin/editor mutators, uploads, downloads, and login/logout targets. The query parameter is a language preference, not a routing fallback; existing query parameters must be preserved, and opaque external or signature-protected URLs must only receive it when the generating component owns the full URL contract. +- Treat path classification as separate from routing. `RequestPathResolver` may classify locale-prefixed paths for enforcement and request intent, but it must not be treated as a Symfony route fallback. Localized unsafe routes need explicit route definitions, route generation rules, and focused regression tests. - Provide cache namespaces for public content rendering, navigation/menu data, resolver outputs, schema rendering metadata, media metadata, and API read output where useful. - Use HTTP caching headers such as `ETag`, `Last-Modified`, and `Cache-Control` where safe. - Use AssetMapper hashed asset URLs for long-lived frontend assets. @@ -43,6 +45,7 @@ The Security rate-limit `panic` profile is also intended as the future switch po - Test rebuild/warmup/prune commands. - Test admin maintenance actions for cache and delivery diagnostics once UI exists. - Test cache panic mode activation, TTL expiry, anonymous cache-hit behavior, cache-miss fallback behavior, and authenticated/Admin/Owner bypass boundaries once implemented. +- Test any future locale-aware delivery rewrite with method-aware route coverage: public `GET` anchors may be localized through a locale prefix, generated internal targets without prefix support may receive a `language` query parameter, and unsafe methods, login-check/logout POST flows, API/live endpoints, admin/editor mutators, and unlocalized routes must not receive path rewrites unless explicitly routed. - Run asset build commands after frontend delivery asset changes. ## Implementation Notes @@ -50,6 +53,7 @@ The Security rate-limit `panic` profile is also intended as the future switch po - **Decision recorded:** Symfony-native cache and HTTP headers are the first delivery foundation; external cache/CDN integrations remain optional. - **Decision recorded:** Publish and lifecycle events must define cache invalidation or rebuild behavior. - **Decision recorded:** The Security `panic` profile is the intended coordination point for a future cache panic mode: a bounded TTL lock may force anonymous public traffic to cache-backed delivery during DDoS-like events while strict rate limits remain active. +- **Decision recorded:** Locale-prefix path rewriting must be method-aware and safe-navigation-only, while `language` query propagation is method-agnostic for generated internal targets that do not support locale prefixes. The recovery login render route stays `/user/login?bypass=1`; future frontend delivery must not infer localized POST or recovery aliases from `RequestPathResolver` classification. - **Open:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache and Admin ACL feature/override/group matrix caches, when the unified caching strategy is implemented. Move them to the shared cache namespace/invalidation model if that produces clearer ownership, diagnostics, or operational controls. - **Open:** Decide whether the first release needs persisted snapshot artifacts or only cache-backed read models. - **Open:** Define default cache TTLs and invalidation namespaces after the first public rendering slice exists. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index bb019cb9..3166f88d 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -63,7 +63,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and anonymous CORS preflight receive no ordinary enforcement cost in this branch; credentialed API preflights are classified by their requested method, while suspicious probes, exact setup review apply, and mutating admin/API workflows receive higher symbolic costs for later limiter branches. - Public-facing unsafe requests that are not classified as a more specific workflow, for example future contact alternatives, comments, forum posts, package-provided public forms, or other user-submitted public content actions, must fall back to the dedicated `website_form`/`FormSubmit` bucket instead of the cheap navigation bucket. - Contact forms, captcha challenges, and package-owned public workflows must not be inferred from invented or path-only slugs such as `/contact` or `/captcha/refresh`. Public content may legitimately use those slugs; later feature branches must opt into special intents through real route names, explicit workflow metadata, or provider-owned `/api/live/**` endpoints. -- `PassiveAbuseSignalSubscriber` records only clear passive signals in this branch, starting with high-signal probe paths and unsafe prefetch attempts. It writes Visitor-ID and IP-bucket HMAC context where available, never raw IP or forwarding-header values, and does not alter the response. +- `PassiveAbuseSignalSubscriber` records only clear passive signals in this branch, starting with high-signal probe paths and unsafe prefetch attempts. `SuspiciousPayloadSignalSubscriber` adds the same source-subject signal shape for obvious GET/POST abuse payloads such as scalar-only security parameter malforming, clear SQL-injection probes, path traversal, sensitive file probes, JNDI lookups, and script-tag probes. Payload scanning is limited to public/untrusted request surfaces and skips Admin, Editor, Setup, and trusted-user contexts because legitimate code/template/content fields may intentionally contain strings such as `', + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata(), $this->tokenStorageWithManager())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItSkipsAdminJsonPayloadsThatMayContainCustomCode(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $content = json_encode([ + 'custom_twig' => '', + ], JSON_THROW_ON_ERROR); + $request = Request::create( + '/admin/content/schemas', + 'POST', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) strlen($content), + 'REMOTE_ADDR' => '203.0.113.10', + ], + content: $content, + ); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata(), $this->tokenStorageWithManager())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItScoresUnauthenticatedApplicationSurfacePayloadProbes(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = $this->subscriber($connection, $visitorIds, new AccessRequestMetadata()); + + foreach (['/admin', '/editor', '/setup'] as $path) { + $request = Request::create($path, 'GET', [ + 'file' => '../../etc/passwd', + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + + $subscriber->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + } + + self::assertSame(6, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame(6, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE reason_code = 'security.signal.suspicious_payload'")); + } + + private function subscriber( + Connection $connection, + VisitorIdGenerator $visitorIds, + AccessRequestMetadata $metadata, + ?TokenStorage $tokenStorage = null, + ): SuspiciousPayloadSignalSubscriber + { + return new SuspiciousPayloadSignalSubscriber( + new SuspiciousRequestPayloadMatcher(), + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, $tokenStorage ?? new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + } + + private function tokenStorageWithManager(): TokenStorage + { + $user = new UserAccount('99999999-0000-7000-8000-000000000201', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $storage = new TokenStorage(); + $storage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + return $storage; + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } +} + +final class SuspiciousPayloadSignalTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php new file mode 100644 index 00000000..36ddf895 --- /dev/null +++ b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php @@ -0,0 +1,96 @@ +match(Request::create('/search', 'GET', [ + 'q' => "x' UNION SELECT password FROM users --", + 'file' => '../../etc/passwd', + ])); + + self::assertIsArray($match); + self::assertContains('sql_union_select', $match['signatures']); + self::assertContains('sensitive_file_probe', $match['signatures']); + self::assertSame('q', $match['parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode($match, JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItDetectsMalformedSecurityParametersButAllowsOrdinaryArrays(): void + { + $matcher = new SuspiciousRequestPayloadMatcher(); + + $malformed = $matcher->match(Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + ])); + $ordinary = $matcher->match(Request::create('/search', 'GET', [ + 'tags' => ['one', 'two'], + ])); + + self::assertIsArray($malformed); + self::assertContains('malformed_parameter', $malformed['signatures']); + self::assertSame('username', $malformed['parameters'][0]['name']); + self::assertNull($ordinary); + } + + public function testItDetectsJsonBodyAttackSignaturesWithoutReturningRawPayloads(): void + { + $content = json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR); + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertIsArray($match); + self::assertContains('sql_union_select', $match['signatures']); + self::assertSame('json', $match['parameters'][0]['source']); + self::assertSame('filter.query', $match['parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItDetectsJsonLikeRawBodyAttackSignatures(): void + { + $content = '{"query":"../../etc/passwd"'; + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertIsArray($match); + self::assertContains('sensitive_file_probe', $match['signatures']); + self::assertSame('raw_body', $match['parameters'][0]['source']); + self::assertSame('body', $match['parameters'][0]['name']); + self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItSkipsOversizedJsonBodiesBeforePayloadScanning(): void + { + $content = '{"query":"../../etc/passwd","padding":"'.str_repeat('x', 9000).'"}'; + + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertNull($match); + } +} diff --git a/tests/Security/AutoBan/AutoBanAdminBrowserTest.php b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php new file mode 100644 index 00000000..d83ea9b7 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php @@ -0,0 +1,144 @@ +connection(); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-detail-history'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + for ($i = 0; $i < 105; ++$i) { + $this->insertSignal($connection, $subject, sprintf('old-%03d', $i), sprintf('2026-06-18 11:%02d:%02d', intdiv($i, 60), $i % 60)); + } + $this->insertSignal($connection, $subject, 'reset', '2026-06-18 12:00:00', AutoBanScoreCatalogue::SIGNAL_RESET); + $this->insertSignal($connection, $subject, 'new-001', '2026-06-18 12:01:00'); + $this->insertSignal($connection, $subject, 'new-002', '2026-06-18 12:02:00'); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertCount(100, $detail['signals']); + self::assertSame('new-002', $detail['signals'][0]['uid']); + self::assertSame('new-001', $detail['signals'][1]['uid']); + self::assertContains('old-104', array_column($detail['signals'], 'uid')); + } + + public function testDetailShowsGeoFromLatestBanTriggerRequest(): void + { + $connection = $this->connection(); + $this->createAccessLogTable($connection); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::IP, 'ip-bucket-detail-geo', true); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + $this->insertSignal($connection, $subject, 'trigger-old', '2026-06-18 12:01:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + $this->insertSignal($connection, $subject, 'trigger-new', '2026-06-18 12:02:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + $connection->insert('access_log_entry', [ + 'uid' => 'access-old', + 'occurred_at' => '2026-06-18 12:01:00', + 'request_id' => 'request-trigger-old', + 'country' => 'DE', + 'continent' => 'EU', + ]); + $connection->insert('access_log_entry', [ + 'uid' => 'access-new', + 'occurred_at' => '2026-06-18 12:02:00', + 'request_id' => 'request-trigger-new', + 'country' => 'NL', + 'continent' => 'EU', + ]); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertSame([ + 'request_id' => 'request-trigger-new', + 'country' => 'NL', + 'continent' => 'EU', + ], $detail['trigger_geo']); + } + + public function testDetailUsesSafeGeoPlaceholdersWhenAccessLogIsUnavailable(): void + { + $connection = $this->connection(); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-detail-no-geo'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + $this->insertSignal($connection, $subject, 'trigger-new', '2026-06-18 12:02:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertSame([ + 'request_id' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $detail['trigger_geo']); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } + + private function createAccessLogTable(Connection $connection): void + { + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL)'); + } + + private function insertSignal( + Connection $connection, + AutoBanSubject $subject, + string $uid, + string $occurredAt, + string $reasonCode = AutoBanScoreCatalogue::SIGNAL_ERROR_HIT, + ): void { + $connection->insert('security_signal_event', [ + 'uid' => $uid, + 'occurred_at' => $occurredAt, + 'expires_at' => '2026-06-25 00:00:00', + 'signal_type' => 'http_error', + 'reason_code' => $reasonCode, + 'severity' => 'NOTICE', + 'confidence' => 40, + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'ip_derived' => 0, + 'request_family' => 'frontend', + 'request_intent' => 'navigation', + 'request_id' => 'request-'.$uid, + 'visitor_id' => $subject->identifier(), + 'path' => '/missing', + 'route' => 'n/a', + 'http_status' => 404, + 'context' => '{}', + ]); + } +} diff --git a/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php new file mode 100644 index 00000000..c0761753 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php @@ -0,0 +1,172 @@ +connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $connection->insert('user_account', [ + 'uid' => 'admin-uid', + 'role' => UserRole::Admin->value, + 'status' => UserAccountStatus::Active->value, + ]); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy(new Config($connection)), $connection, $alerts); + $ban = $this->ban(); + + $notifier->notifyBanTriggered($ban); + + self::assertCount(1, $alerts->userAlerts); + self::assertSame('owner-uid', $alerts->userAlerts[0]['user']); + self::assertSame(UiAlertDelivery::Queue, $alerts->userAlerts[0]['delivery']); + self::assertInstanceOf(UiAlertTranslation::class, $alerts->userAlerts[0]['alert']); + self::assertSame('admin.auto_bans.alerts.triggered', $alerts->userAlerts[0]['alert']->translationKey()); + self::assertSame('hidden', $alerts->userAlerts[0]['presentation']?->mode()); + self::assertStringStartsWith('auto-ban-triggered-'.$ban->key().'-', (string) $alerts->userAlerts[0]['presentation']?->id()); + self::assertSame([ + ['label' => 'Review', 'href' => '/admin/security/auto-bans'], + ], $alerts->userAlerts[0]['presentation']?->actions()); + } + + public function testRepeatBanAlertsUseDifferentPresentationIds(): void + { + $connection = $this->connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy(new Config($connection)), $connection, $alerts); + $ban = $this->ban(); + + $notifier->notifyBanTriggered($ban); + $notifier->notifyBanTriggered($ban); + + self::assertCount(2, $alerts->userAlerts); + self::assertNotSame( + $alerts->userAlerts[0]['presentation']?->id(), + $alerts->userAlerts[1]['presentation']?->id(), + ); + } + + public function testItSkipsOwnerAlertsWhenDeliveryIsDisabled(): void + { + $connection = $this->connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $config = new Config($connection); + $config->set(AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, false, ConfigValueType::Boolean); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy($config), $connection, $alerts); + + $notifier->notifyBanTriggered($this->ban()); + + self::assertSame([], $alerts->userAlerts); + } + + private function ban(): ActiveAutoBan + { + return new ActiveAutoBan( + 'abc123abc123abc123abc123abc123abc123abcd', + 'visitor', + 'visitor-alert', + DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2026-06-18 12:00:00'), + DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2026-06-18 13:00:00'), + 3600, + ); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE user_account (uid VARCHAR(36) PRIMARY KEY NOT NULL, role VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL)'); + + return $connection; + } +} + +final class RecordingAutoBanAlertDispatcher implements UiAlertDispatcherInterface +{ + /** + * @var list + */ + public array $userAlerts = []; + + public function addAlert( + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Direct, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } + + public function addAlertToTopic( + string $topic, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } + + public function addAlertToUser( + UserAccount|UserInterface|string $user, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + $this->userAlerts[] = [ + 'user' => is_string($user) ? $user : $user->getUserIdentifier(), + 'alert' => $alert, + 'delivery' => $delivery, + 'presentation' => $presentation, + ]; + + return true; + } + + public function addAlertToSession( + SessionInterface|string $session, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } +} diff --git a/tests/Security/AutoBan/AutoBanPolicyTest.php b/tests/Security/AutoBan/AutoBanPolicyTest.php new file mode 100644 index 00000000..e23ad260 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanPolicyTest.php @@ -0,0 +1,77 @@ + 'pdo_sqlite', 'memory' => true]), + databaseReadyState: new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-auto-ban-config', 'test'), + defaultProvider: new CoreConfigDefaultProvider(new CoreSettingsRegistry( + new TranslationLanguageCatalog($projectDir), + new SystemPackageMetadataProvider($projectDir), + )), + )); + + self::assertFalse($policy->enabled()); + } + + public function testItUsesPersistedSetupEnabledValueWhenConfigStorageIsAvailable(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + + self::assertTrue((new AutoBanPolicy($config))->enabled()); + } + + public function testItBoundsPersistedScoreThreshold(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::SCORE_THRESHOLD_KEY, 1, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MIN_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); + + $config->set(AutoBanPolicy::SCORE_THRESHOLD_KEY, 10001, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MAX_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); + } + + public function testItBoundsPersistedTrustedAccessLevelToRegisteredUsers(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 0, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MIN_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 10, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MAX_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'invalid', ConfigValueType::String); + + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + } +} diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php new file mode 100644 index 00000000..40950646 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -0,0 +1,641 @@ + '203.0.113.10']); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-ban'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(403, $response->getStatusCode()); + self::assertSame('3600', $response->headers->get('Retry-After')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + self::assertStringContainsString('Request blocked due to suspicious activity.', (string) $response->getContent()); + self::assertStringContainsString('request-ban', (string) $response->getContent()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + self::assertTrue($request->attributes->getBoolean(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE)); + } + + public function testActiveVisitorBanOverridesEarlierProbeResponseAndSkipsPassiveSignals(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/.env', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + $event->setResponse(new Response('', 400)); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertSame('3600', $event->getResponse()?->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testActiveVisitorBanMarksProbeRequestsBeforeRateLimitConsumption(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/.env', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestProbeCandidate($event); + + self::assertFalse($event->hasResponse()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE)); + } + + public function testIgnorablePathsDoNotBypassActiveBansWhenTheyReachSymfony(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/favicon.ico', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testApiRequestsWithoutTrustedBearerDoNotAuthenticateThroughActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid.invalid-secret', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertSame('3600', $event->getResponse()?->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testApiPreflightsDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer valid-owner-key', + 'HTTP_ORIGIN' => 'https://client.example', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSchedulerTriggersWithoutTrustedKeyDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid-scheduler-key', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testProtectedBrowserSurfacesWithoutPreviousSessionAreBlockedBeforeFirewallAccessControl(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + + foreach (['/admin', '/editor/content', '/user/profile'] as $path) { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create($path, server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthBrowserSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode(), $path); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE), $path); + } + } + + public function testProtectedBrowserSurfacesWithPreviousSessionWaitForTrustedAwareGuard(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + $request->cookies->set($session->getName(), 'previous-session-id'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthBrowserSourceBan($event); + + self::assertFalse($event->hasResponse()); + self::assertFalse($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSecurityHandlerBlocksPreviousSessionNonTrustedBrowserAccess(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + $request->cookies->set($session->getName(), 'previous-session-id'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000103', 'member', 'member@example.test', 'hash', role: UserRole::User); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $subscriber = $this->subscriber($visitorIds, $store, $clock, $tokenStorage); + $handler = new HttpErrorSecurityHandler($this->renderer(), $subscriber); + + $response = $handler->handle($request, new AccessDeniedException('Access denied.')); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame('3600', $response->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSecurityHandlerKeepsTrustedBrowserAccessOutsideAutoBanEnforcement(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000104', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $subscriber = $this->subscriber($visitorIds, $store, $clock, $tokenStorage); + + self::assertNull($subscriber->responseForSecurityHandler($request)); + } + + public function testErrorResponsesAreOverriddenBeforePassiveSignalScoring(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + + foreach ([400, 401, 403, 404, 429] as $statusCode) { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/member-only-page', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new ResponseEvent( + new AutoBanRequestTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('error response', $statusCode), + ); + + $this->subscriber($visitorIds, $store, $clock)->onKernelResponseErrorStatus($event); + + self::assertSame(403, $event->getResponse()->getStatusCode(), (string) $statusCode); + self::assertSame('3600', $event->getResponse()->headers->get('Retry-After'), (string) $statusCode); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE), (string) $statusCode); + } + } + + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/de/user/login?bypass=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_locale', 'de'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testEarlyLoginGuardBlocksOrdinaryLoginSubmissionsBeforeAuthentication(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', ['username' => 'owner'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestLogin($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testEarlyLoginGuardKeepsMarkedRecoverySubmissionsReachable(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens)->onKernelRequestLogin($event); + + self::assertFalse($event->hasResponse()); + } + + public function testRecoveryLoginFailuresAfterActiveBanReturnBareForbiddenWithoutSideEffects(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-recovery-failure-ban'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + $subscriber = $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens); + $requestEvent = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequestLogin($requestEvent); + + self::assertFalse($requestEvent->hasResponse()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + self::assertSame($ban->key(), $request->attributes->get(AutoBanRequestSubscriber::RECOVERY_ACTIVE_BAN_KEY_ATTRIBUTE)); + + $failure = new LoginFailureEvent( + new AuthenticationException('Invalid credentials.'), + new AutoBanRequestTestAuthenticator(), + $request, + null, + 'main', + ); + $subscriber->onLoginFailure($failure); + + self::assertSame(403, $failure->getResponse()?->getStatusCode()); + self::assertSame('3600', $failure->getResponse()?->headers->get('Retry-After')); + self::assertStringContainsString('request-recovery-failure-ban', (string) $failure->getResponse()?->getContent()); + } + + public function testRecoveryLoginSubmissionsCanEstablishTrustedContextDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000101', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage, $csrfTokens)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testRecoveryLoginSubmissionsWithoutTrustedContextAreRecheckedAfterAuthentication(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'member', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000102', 'member', 'member@example.test', 'hash', role: UserRole::User); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage, $csrfTokens)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testOrdinaryLoginSubmissionsDoNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', ['username' => 'owner'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + } + + public function testMalformedLoginFieldsDoNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => ['invalid'], + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testMalformedRecoveryQueryDoesNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login?bypass[]=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testLiveEndpointsDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/live/status', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + } + + public function testPostSignalGuardBlocksBansCreatedAfterTheFinalPreSignalGuard(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/search', 'GET', ['q' => 'probe'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $subscriber = $this->subscriber($visitorIds, $store, $clock); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + + $store->ban($subject, 3600); + $subscriber->onKernelRequestAfterSignalWrites($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testTrustedUsersBypassActiveVisitorBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000001', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit(): void + { + $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $autoBanResponse = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::RESPONSE]; + $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $payloadSignals = SuspiciousPayloadSignalSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $passiveSignals = PassiveAbuseSignalSubscriber::getSubscribedEvents()[KernelEvents::RESPONSE]; + + self::assertSame(['onKernelRequestPreAuthSourceBan', 4098], $autoBan[0]); + self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[1]); + self::assertSame(['onKernelRequestLogin', 16], $autoBan[2]); + self::assertSame(['onKernelRequestPreAuthBrowserSourceBan', 9], $autoBan[3]); + self::assertSame(['onKernelRequest', 4], $autoBan[4]); + self::assertSame(['onKernelRequestAfterSignalWrites', 1], $autoBan[5]); + self::assertGreaterThan($rateLimit[0][1], $autoBan[0][1]); + self::assertGreaterThan($rateLimit[0][1], $autoBan[1][1]); + self::assertGreaterThan(8, $autoBan[3][1]); + self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); + self::assertGreaterThan($rateLimit[1][1], $autoBan[3][1]); + self::assertGreaterThan($autoBan[5][1], $payloadSignals[1]); + self::assertLessThan($autoBan[4][1], $payloadSignals[1]); + self::assertSame(['onKernelResponseErrorStatus', -299], $autoBanResponse); + self::assertGreaterThan($passiveSignals[1], $autoBanResponse[1]); + } + + private function subscriber( + VisitorIdGenerator $visitorIds, + AutoBanStore $store, + MockClock $clock, + ?TokenStorage $tokenStorage = null, + ?CsrfTokenManager $csrfTokens = null, + ): AutoBanRequestSubscriber { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + + return new AutoBanRequestSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, $tokenStorage ?? new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new AutoBanPolicy($config), + $store, + $this->renderer(), + new AccessRequestMetadata(), + clock: $clock, + csrfTokens: $csrfTokens, + ); + } + + private function renderer(): HttpErrorRenderer + { + return new HttpErrorRenderer( + new Environment(new ArrayLoader()), + (new \ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(Security::class))->newInstanceWithoutConstructor(), + new SetupCompletionMarker(), + new AccessRequestMetadata(), + sys_get_temp_dir(), + 'test', + ); + } +} + +final class AutoBanRequestTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} + +final class AutoBanRequestTestAuthenticator implements AuthenticatorInterface +{ + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(Request $request): Passport + { + throw new AuthenticationException('Not used by this test.'); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + throw new AuthenticationException('Not used by this test.'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} diff --git a/tests/Security/AutoBan/AutoBanResetServiceTest.php b/tests/Security/AutoBan/AutoBanResetServiceTest.php new file mode 100644 index 00000000..fef8f0d2 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanResetServiceTest.php @@ -0,0 +1,72 @@ +ban($subject, 3600); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), function (ActiveAutoBan $released) use ($store, $subject): bool { + self::assertNull($store->active($subject)); + + return $released->key() !== ''; + }); + + self::assertInstanceOf(ActiveAutoBan::class, $released); + self::assertNull($store->active($subject)); + } + + public function testItRestoresActiveStateWhenResetSignalCannotBeRecorded(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::IP, 'ip-reset-service', true); + $ban = $store->ban($subject, 3600, ['score' => 100]); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), static fn (): bool => false); + + self::assertNull($released); + $restored = $store->active($subject); + self::assertInstanceOf(ActiveAutoBan::class, $restored); + self::assertSame($ban->key(), $restored->key()); + self::assertTrue($restored->context()['restored_after_failed_reset_signal'] ?? false); + } + + public function testItSerializesReleaseAndCutoffAgainstBanCreation(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-reset-race'); + $ban = $store->ban($subject, 3600, ['score' => 100]); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), function () use ($store, $subject): bool { + self::assertNull($store->active($subject)); + self::assertNull($store->createOrReturnActive($subject, 7200, ['score' => 200])); + + return true; + }); + + self::assertInstanceOf(ActiveAutoBan::class, $released); + self::assertNull($store->active($subject)); + } +} diff --git a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php new file mode 100644 index 00000000..eca62345 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php @@ -0,0 +1,248 @@ +stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-1'); + + $this->recordProbe($recorder, $visitor, 'request-1'); + + self::assertNull($store->active($visitor)); + + $this->recordProbe($recorder, $visitor, 'request-2'); + + self::assertNotNull($store->active($visitor)); + } + + public function testQualifyingFloorCountsDistinctRequestsInsteadOfSignalRows(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-same-request'); + + $this->recordPayloadProbe($recorder, $visitor, 'request-1'); + $this->recordError($recorder, $visitor, 'request-1'); + + self::assertNull($store->active($visitor)); + + $this->recordError($recorder, $visitor, 'request-2'); + + self::assertNotNull($store->active($visitor)); + } + + public function testIpSubjectUsesLaxerThresholdMultiplier(): void + { + [$recorder, $store] = $this->stack(); + $ip = new AutoBanSubject(AutoBanSubject::IP, 'ip-bucket-1', true); + + for ($i = 1; $i <= 28; ++$i) { + $this->recordError($recorder, $ip, 'request-'.$i); + } + + self::assertNull($store->active($ip)); + + $this->recordError($recorder, $ip, 'request-29'); + + self::assertNotNull($store->active($ip)); + } + + public function testResetSignalInvalidatesEarlierScoreEvidence(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-reset'); + + $this->recordProbe($recorder, $visitor, 'request-1'); + $this->recordProbe($recorder, $visitor, 'request-2'); + self::assertNotNull($store->active($visitor)); + + $store->reset($visitor->key()); + $recorder->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $visitor->type(), + $visitor->identifier(), + requestId: 'request-reset', + ); + + $this->recordProbe($recorder, $visitor, 'request-3'); + + self::assertNull($store->active($visitor)); + } + + public function testSameEvaluationPrefersVisitorBanOverIpBan(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-shared'); + $ip = new AutoBanSubject(AutoBanSubject::IP, 'ip-shared', true); + + $this->recordProbe($recorder, $visitor, 'request-1'); + $this->recordProbe($recorder, $ip, 'request-1'); + $this->recordProbe($recorder, $visitor, 'request-2'); + $this->recordProbe($recorder, $ip, 'request-2'); + + self::assertNotNull($store->active($visitor)); + self::assertNull($store->active($ip)); + } + + /** + * @return array{0: SecuritySignalRecorder, 1: AutoBanStore, 2: Connection} + */ + private function stack(): array + { + $connection = $this->connection(); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $evaluator = new AutoBanSignalEvaluator( + $connection, + new DatabaseLogRetentionPolicy($connection), + new AutoBanPolicy($config), + new AutoBanScoreCatalogue(), + $store, + clock: $clock, + ); + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: $clock, + autoBanSignals: $evaluator, + ); + + return [$recorder, $store, $connection]; + } + + private function recordProbe(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'probe', + AutoBanScoreCatalogue::SIGNAL_SUSPICIOUS_PROBE, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'WARNING', + confidence: 95, + requestId: $requestId, + visitorId: 'visitor-context', + httpStatus: 400, + ); + } + + private function recordError(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'http_error', + AutoBanScoreCatalogue::SIGNAL_ERROR_HIT, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'NOTICE', + confidence: 40, + requestId: $requestId, + visitorId: 'visitor-context', + httpStatus: 404, + ); + } + + private function recordPayloadProbe(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'payload_probe', + AutoBanScoreCatalogue::SIGNAL_SUSPICIOUS_PAYLOAD, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'WARNING', + confidence: 90, + requestId: $requestId, + visitorId: 'visitor-context', + ); + } + + public function testInvalidActiveBanPayloadFailsOpenWithMessage(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $cache = new ArrayAdapter(); + $messages = new RecordingAutoBanMessageReporter(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), $messages, $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-invalid-payload'); + $item = $cache->getItem('security.auto_ban.active.'.$subject->key()); + $item->set([ + 'key' => $subject->key(), + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'created_at' => 'not-a-date', + 'expires_at' => '2026-06-18 13:00:00', + 'ttl_seconds' => 3600, + ]); + $cache->save($item); + + self::assertNull($store->active($subject)); + self::assertSame(SecurityMessageCode::AUTO_BAN_PAYLOAD_INVALID, $messages->records[0]['message']->code()); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE INDEX idx_security_signal_subject_at ON security_signal_event (subject_type, subject_identifier, occurred_at)'); + + return $connection; + } +} + +final class RecordingAutoBanMessageReporter implements MessageReporterInterface +{ + /** + * @var list}> + */ + public array $records = []; + + public function report(Message $message, array $context = []): Message + { + $this->records[] = ['message' => $message, 'context' => $context]; + + return $message; + } + + public function reportBatch(iterable $records): array + { + $messages = []; + foreach ($records as $record) { + $message = $record['message']; + if (!$message instanceof Message) { + continue; + } + + $messages[] = $this->report($message, $record['context'] ?? []); + } + + return $messages; + } +} diff --git a/tests/Security/AutoBan/AutoBanStoreTest.php b/tests/Security/AutoBan/AutoBanStoreTest.php new file mode 100644 index 00000000..4114810f --- /dev/null +++ b/tests/Security/AutoBan/AutoBanStoreTest.php @@ -0,0 +1,111 @@ +ban($subject, 3600)); + self::assertNull($store->active($subject)); + self::assertSame([], $store->activeBans()); + } + + public function testResetFailsWhenActiveCacheDeleteFails(): void + { + $cache = new DeleteFailingAutoBanCache(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-delete-failure'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + self::assertNull($store->reset($ban->key())); + self::assertNotNull($store->active($subject)); + } + + public function testBanRollbackClearsActiveStateWhenIndexAndDeleteFail(): void + { + $cache = new IndexAndDeleteFailingAutoBanCache(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-index-delete-failure'); + + self::assertNull($store->ban($subject, 3600)); + self::assertNull($store->active($subject)); + self::assertSame([], $store->activeBans()); + } + + public function testCreateOrReturnActiveMarksExistingBanAsNotCreated(): void + { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-existing'); + + $first = $store->createOrReturnActive($subject, 3600); + self::assertNotNull($first); + self::assertTrue($first->created()); + + $second = $store->createOrReturnActive($subject, 3600); + self::assertNotNull($second); + self::assertFalse($second->created()); + self::assertSame($first->ban()->key(), $second->ban()->key()); + } +} + +final class IndexFailingAutoBanCache extends ArrayAdapter +{ + public function save(CacheItemInterface $item): bool + { + if ('security.auto_ban.index.v1' === $item->getKey()) { + return false; + } + + return parent::save($item); + } +} + +final class DeleteFailingAutoBanCache extends ArrayAdapter +{ + public function deleteItem(mixed $key): bool + { + if (is_string($key) && str_starts_with($key, 'security.auto_ban.active.')) { + return false; + } + + return parent::deleteItem($key); + } +} + +final class IndexAndDeleteFailingAutoBanCache extends ArrayAdapter +{ + public function save(CacheItemInterface $item): bool + { + if ('security.auto_ban.index.v1' === $item->getKey()) { + return false; + } + + return parent::save($item); + } + + public function deleteItem(mixed $key): bool + { + if (is_string($key) && str_starts_with($key, 'security.auto_ban.active.')) { + return false; + } + + return parent::deleteItem($key); + } +} diff --git a/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php b/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php new file mode 100644 index 00000000..ca94debe --- /dev/null +++ b/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php @@ -0,0 +1,75 @@ + 'Bearer invalid.invalid', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $event = new LoginFailureEvent( + new AuthenticationException('Invalid credentials.'), + new RateLimitAuthenticationTestAuthenticator(), + $request, + null, + 'api', + ); + + (new RateLimitAuthenticationSubscriber( + (new \ReflectionClass(RateLimitResetService::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(), + 'prod', + ))->onLoginFailure($event); + + self::assertNull($event->getResponse()); + } +} + +final class RateLimitAuthenticationTestAuthenticator implements AuthenticatorInterface +{ + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(Request $request): Passport + { + throw new AuthenticationException('Not used by this test.'); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + throw new AuthenticationException('Not used by this test.'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 48dd417d..408af391 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -99,6 +99,12 @@ public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget() self::assertFalse($result->isAllowed()); self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); + + $localizedRecovery = $this->request('/de/user/login?bypass=1'); + $localizedRecovery->attributes->set('_route', 'user_login'); + $localizedRecovery->attributes->set('_locale', 'de'); + + self::assertFalse($enforcer->check($localizedRecovery, RateLimitEnforcementStage::Ordinary)->isAllowed()); } public function testPanicRecoveryLoginRenderAndSubmitFitBudgets(): void diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index 8e8afbd2..d45b9a66 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -11,6 +11,7 @@ use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; +use App\Core\Routing\IgnorableRequestPathMatcher; use App\Core\Routing\PathScopeMatcher; use App\Core\Statistics\VisitorIdGenerator; use App\Security\Abuse\AbuseRequestInspector; @@ -18,6 +19,7 @@ use App\Security\Abuse\ActionCostCatalogue; use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\RateLimit\RateLimitRequestSubscriber; use App\Security\RateLimit\RateLimitEnforcer; use App\Security\RateLimit\RateLimitLimiterFactory; @@ -84,6 +86,9 @@ public static function excludedPathCases(): iterable yield 'assets sibling' => ['/assets-preview', false]; yield 'build child' => ['/build/app.js', true]; yield 'build sibling' => ['/builder', false]; + yield 'favicon' => ['/favicon.ico', true]; + yield 'touch icon' => ['/apple-touch-icon.png', true]; + yield 'well-known security' => ['/.well-known/security.txt', true]; yield 'profiler root' => ['/_profiler', true]; yield 'profiler child' => ['/_profiler/123', true]; yield 'profiler sibling' => ['/_profilerfoo', false]; @@ -97,6 +102,8 @@ public function testExcludedPathUsesSegmentBoundaries(string $path, bool $exclud $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); $paths->setValue($subscriber, new PathScopeMatcher()); + $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths'); + $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); @@ -107,6 +114,8 @@ public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): v $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); $paths->setValue($subscriber, new PathScopeMatcher()); + $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths'); + $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); $localized = Request::create('/de/api/live/status'); $localized->attributes->set('_locale', 'de'); @@ -149,6 +158,31 @@ public function testProbeHookSkipsFullEnforcerForNonProbePaths(): void self::assertFalse($event->hasResponse()); } + public function testProbeHookSkipsConsumptionWhenActiveAutoBanAlreadyMatched(): void + { + $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(); + $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); + $subscriber = new RateLimitRequestSubscriber( + $enforcer, + $responses, + 'prod', + new SetupCompletionMarker(), + dirname(__DIR__, 3), + new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS), + ); + $request = Request::create('/.env'); + $request->attributes->set(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE, true); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertFalse($event->hasResponse()); + } + public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void { unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); diff --git a/tests/Security/SessionVisitorBindingSubscriberTest.php b/tests/Security/SessionVisitorBindingSubscriberTest.php index fd9087ae..fba12de2 100644 --- a/tests/Security/SessionVisitorBindingSubscriberTest.php +++ b/tests/Security/SessionVisitorBindingSubscriberTest.php @@ -15,6 +15,7 @@ use App\Security\Abuse\ActionCostCatalogue; use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SecuritySignalRecorder; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\SessionVisitorBindingSubscriber; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; @@ -138,6 +139,43 @@ public function testItRecordsSecuritySignalWhenTheBoundVisitorChanges(): void self::assertNotSame('', $context['ip_bucket']); } + public function testItDoesNotOverrideAutoBanResponsesOrRecordSignals(): void + { + $tokenStorage = new TokenStorage(); + $user = $this->user(); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $auditLogger = new RecordingSessionAuditLogger(); + $generator = new VisitorIdGenerator('test-secret'); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $session = new Session(new MockArraySessionStorage()); + $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234'); + $request->setSession($session); + $connection = $this->signalConnection(); + $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + $event->setResponse(new Response('blocked', Response::HTTP_FORBIDDEN)); + + (new SessionVisitorBindingSubscriber( + $tokenStorage, + $generator, + $auditLogger, + new AbuseRequestInspector( + new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ))->onKernelRequest($event); + + self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode()); + self::assertSame('blocked', $event->getResponse()?->getContent()); + self::assertNotNull($tokenStorage->getToken()); + self::assertSame('previousVisitorId1234', $session->get(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID)); + self::assertSame([], $auditLogger->records); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + public function testItKeepsSessionsWhenTheBoundVisitorMatches(): void { $tokenStorage = new TokenStorage(); diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index c73e11fe..357e0904 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -15,6 +15,7 @@ use App\Setup\SetupInput; use App\Scheduler\SchedulerSettings; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanPolicy; use App\Security\UserFlowConfig; use PHPUnit\Framework\TestCase; @@ -38,8 +39,12 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]); self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]); - self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); + self::assertSame(DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays(), $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]); + self::assertTrue($settings[AutoBanPolicy::ENABLED_KEY]); + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $settings[AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY]); + self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $settings[AutoBanPolicy::SCORE_THRESHOLD_KEY]); + self::assertTrue($settings[AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY]); self::assertSame((new AdminFeatureDefaults())->overrides(), $settings[AdminFeatureOverrideStore::CONFIG_KEY]); } @@ -81,6 +86,10 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, SuspiciousProbePathMatcher::PATTERNS_KEY, + AutoBanPolicy::ENABLED_KEY, + AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, + AutoBanPolicy::SCORE_THRESHOLD_KEY, + AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY, \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, MaxMindGeoIpConfig::ENABLED_KEY, diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 13c73baf..f78ea891 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -654,6 +654,31 @@ admin: context: 'Kontext' no_context: 'Es ist kein strukturierter Kontext verfügbar.' raw: 'Rohzeile' + auto_bans: + active: + title: 'Aktive Auto-Bans' + empty: 'Keine aktiven Auto-Bans.' + open: 'Prüfen' + open_list: 'Aktive Auto-Bans öffnen' + disabled: 'Auto-Ban-Enforcement ist deaktiviert. Bestehende TTL-States bleiben bis zum Ablauf erhalten, werden aber nicht durchgesetzt.' + columns: + subject: 'Subjekt' + created_at: 'Erstellt' + expires_at: 'Läuft ab' + ttl: 'TTL-Sekunden' + details: 'Details' + detail: + title: 'Auto-Ban-Detail' + signals: 'Zugehörige Security-Signale' + no_signals: 'Für diesen aktiven Bann sind keine aufbewahrten Signale verfügbar.' + trigger_country: 'Auslöse-Land' + trigger_continent: 'Auslöse-Kontinent' + reset: + submit: 'Auto-Ban zurücksetzen' + saved: 'Auto-Ban zurückgesetzt.' + failed: 'Auto-Ban konnte nicht zurückgesetzt werden.' + alerts: + triggered: 'Auto-Ban für %subject% erstellt.' statistics: title: 'Statistiken' access_title: 'Zugriffsstatistiken' @@ -778,6 +803,18 @@ admin: label: 'Captcha-Vorschau' rate_limit_mode: label: 'Ratenbegrenzung' + auto_ban_enabled: + label: 'Auto-Ban aktivieren' + help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.' + auto_ban_trusted_access_level: + label: 'Trusted-User-Level' + help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.' + auto_ban_score_threshold: + label: 'Auto-Ban-Score-Schwelle' + help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.' + auto_ban_new_ban_owner_alerts: + label: 'Alerts für neue Auto-Bans aktivieren' + help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: @@ -796,6 +833,16 @@ admin: standard: 'Standard' strict: 'Streng' panic: 'Panik' + access_level: + user: 'Benutzer' + moderator: 'Moderator' + author: 'Autor' + publisher: 'Publisher' + curator: 'Kurator' + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' @@ -944,6 +991,18 @@ admin: label: 'Captcha-Vorschau' rate_limit_mode: label: 'Ratenbegrenzung' + auto_ban_enabled: + label: 'Auto-Ban aktivieren' + help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.' + auto_ban_trusted_access_level: + label: 'Trusted-User-Level' + help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.' + auto_ban_score_threshold: + label: 'Auto-Ban-Score-Schwelle' + help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.' + auto_ban_new_ban_owner_alerts: + label: 'Alerts für neue Auto-Bans aktivieren' + help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: @@ -1015,6 +1074,11 @@ admin: standard: 'Standard' strict: 'Streng' panic: 'Panik' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 0f190e2d..5f9a7f45 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -124,6 +124,12 @@ message: request_rejected: 'Die Anfrage konnte nicht akzeptiert werden.' storage_degraded: 'Rate-Limit-Speicher war nicht verfügbar; die Anfrage wurde zugelassen.' reset_degraded: 'Rate-Limit-Reset-Speicher war nicht verfügbar.' + auto_ban: + storage_degraded: 'Auto-Ban-Speicher war nicht verfügbar; Enforcement ist fail-open weitergelaufen.' + evaluation_degraded: 'Auto-Ban-Score-Auswertung war nicht verfügbar; die Anfrage wurde zugelassen.' + payload_invalid: 'Auto-Ban-State enthielt eine ungültige Payload und wurde ignoriert.' + reset_released: 'Auto-Ban wurde gelöst.' + alert_delivery_degraded: 'Auto-Ban-Owner-Alert-Zustellung war nicht verfügbar.' event: hook: invalid: 'Event-Hook "%event%" ist keine gültige öffentliche Hook-Definition.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 2fecffd0..4eea756a 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -654,6 +654,31 @@ admin: context: 'Context' no_context: 'No structured context is available.' raw: 'Raw line' + auto_bans: + active: + title: 'Active auto-bans' + empty: 'No active auto-bans.' + open: 'Review' + open_list: 'Open active auto-bans' + disabled: 'Auto-ban enforcement is disabled. Existing TTL states are retained until expiry but are not enforced.' + columns: + subject: 'Subject' + created_at: 'Created' + expires_at: 'Expires' + ttl: 'TTL seconds' + details: 'Details' + detail: + title: 'Auto-ban detail' + signals: 'Related security signals' + no_signals: 'No retained signals are available for this active ban.' + trigger_country: 'Trigger country' + trigger_continent: 'Trigger continent' + reset: + submit: 'Reset auto-ban' + saved: 'Auto-ban reset.' + failed: 'Auto-ban could not be reset.' + alerts: + triggered: 'Auto-ban created for %subject%.' statistics: title: 'Statistics' access_title: 'Access statistics' @@ -778,6 +803,18 @@ admin: label: 'Captcha preview' rate_limit_mode: label: 'Rate limiting' + auto_ban_enabled: + label: 'Enable auto-ban' + help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.' + auto_ban_trusted_access_level: + label: 'Trusted user level' + help: 'Registered users at or above this access level are never auto-banned.' + auto_ban_score_threshold: + label: 'Auto-ban score threshold' + help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.' + auto_ban_new_ban_owner_alerts: + label: 'Enable alerts for newly decided auto-bans' + help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.' audit_enabled: label: 'Enable audit logging' audit_events: @@ -796,6 +833,16 @@ admin: standard: 'Standard' strict: 'Strict' panic: 'Panic' + access_level: + user: 'User' + moderator: 'Moderator' + author: 'Author' + publisher: 'Publisher' + curator: 'Curator' + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' @@ -944,6 +991,18 @@ admin: label: 'Captcha preview' rate_limit_mode: label: 'Rate limiting' + auto_ban_enabled: + label: 'Enable auto-ban' + help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.' + auto_ban_trusted_access_level: + label: 'Trusted user level' + help: 'Registered users at or above this access level are never auto-banned.' + auto_ban_score_threshold: + label: 'Auto-ban score threshold' + help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.' + auto_ban_new_ban_owner_alerts: + label: 'Enable alerts for newly decided auto-bans' + help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.' audit_enabled: label: 'Enable audit logging' audit_events: @@ -1015,6 +1074,11 @@ admin: standard: 'Standard' strict: 'Strict' panic: 'Panic' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index f2be7e66..ffeb62e1 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -124,6 +124,12 @@ message: request_rejected: 'The request could not be accepted.' storage_degraded: 'Rate-limit storage was unavailable; the request was allowed.' reset_degraded: 'Rate-limit reset storage was unavailable.' + auto_ban: + storage_degraded: 'Auto-ban storage was unavailable; enforcement failed open.' + evaluation_degraded: 'Auto-ban score evaluation was unavailable; the request was allowed.' + payload_invalid: 'Auto-ban state contained an invalid payload and was ignored.' + reset_released: 'Auto-ban released.' + alert_delivery_degraded: 'Auto-ban owner alert delivery was unavailable.' event: hook: invalid: 'Event hook "%event%" is not a valid public hook definition.'