diff --git a/.manifest b/.manifest index 9039cbdf..ea9f26d8 100644 --- a/.manifest +++ b/.manifest @@ -3,8 +3,8 @@ # APP_CHANNEL defines the target branch inside the specified repository. ##> aavion/studio manifest ### -APP_VERSION=0.2.4 -APP_DATE=2026-06-14 +APP_VERSION=0.2.5 +APP_DATE=2026-06-17 APP_NAME=Studio APP_AUTHOR=Dominik Letica APP_DESCRIPTION=Symfony 8.1 based content-management system for structured project websites. diff --git a/AGENTS.md b/AGENTS.md index c1e7aa5f..efd5edf0 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,21 @@ - Translation changes must keep matching source catalogue files and keys synchronized across all locale directories under `translations/languages/`; runtime `translations/messages.*.yaml` files are generated from those sources. - Refactors before the first public `1.0.0` release may remove obsolete code instead of keeping compatibility shims, but callers, tests, docs, and class map entries must be updated immediately. - If a requested narrow change exposes unrelated drift, fix it only when it blocks the task; otherwise record the follow-up in `dev/WORKLOG.md`. -- When addressing review findings, trace adjacent and analogous code paths that share the same policy, transition, or boundary, and apply or explicitly rule out the same fix there to avoid one-path-only hardening. + +### Review Finding Fixes +- Before applying a fix for a review finding, trace the affected boundary from source to sink and inspect adjacent, related, and analogous code paths that share the same classifier, subscriber, guard, resolver, route family, subject selection, response behavior, storage boundary, or policy decision. +- Prefer fixing the narrowest central boundary that covers all affected paths. Apply a path-local fix only when evidence shows the issue is truly path-specific. +- Keep review fixes simple, modular, and minimally invasive. Do not broaden them into unrelated refactors, compatibility shims, or speculative redesigns. +- While tracing the affected boundary, actively look for additional unreported edge cases, including bypasses, abuse paths, privacy leaks, performance regressions, setup/pre-auth behavior, disabled-feature fallbacks, response redaction, and cache/storage failure behavior. +- Fix small in-scope adjacent issues directly when they share the same boundary and risk profile. Record larger or behavior-changing follow-ups in `dev/WORKLOG.md` instead of hiding them inside the review fix. +- Add or update regression coverage for the reported finding and any adjacent paths changed by the fix. When an analogous path is inspected and intentionally not changed, make that reasoning clear in the worklog, final notes, or PR response where useful. + +### PR Readiness Audits +- Before marking a branch, pull request, or feature slice ready for review, run the PR-readiness checklist as a real audit pass over the branch diff and the affected runtime surfaces. Do not treat checklist items as passive boxes to tick. +- The audit must explicitly review security/privacy considerations; public entry points; authentication, authorization, sessions, secrets, browser storage, and response redaction; package/module boundaries; access levels; route/API/live endpoint scopes; naming and collision risks; setup/init/CI behavior; cross-platform behavior; disabled-feature fallbacks; process and environment handling; default seed coverage for implemented config keys; translations and user-facing copy; project-rule, architecture, naming, documentation, and performance drift; and captured follow-up tasks. +- Use evidence from code inspection, focused tests, render checks, linting, documentation diffs, class map/worklog updates, and seed/default coverage as appropriate for the changed surface. If a checklist item is not applicable, record why instead of silently skipping it. +- Fix small readiness issues directly when they are in scope and low risk. Record larger, behavior-changing, or separate-domain issues in `dev/WORKLOG.md` with a clear next action. +- PR notes must summarize the readiness audit outcome, including verification commands, skipped checks or proof gaps, documentation/worklog/classmap status, translation status, security/privacy considerations, and remaining follow-ups. ## Build and Verification Commands - `bin/init` initializes the repository, refreshes dependencies and assets, locks referenced Symfony UX icons locally when possible, and is the preferred recovery path for broken or incomplete `vendor/` packages because it removes an existing `vendor/` tree before Composer runs. @@ -183,6 +197,8 @@ ## Review Mode - In code review, lead with findings ordered by severity and include file and line references. +- Review-fix implementation must follow the Review Finding Fixes rules under Change Expectations before applying code changes. +- PR-readiness sign-off must follow the PR Readiness Audits rules under Change Expectations instead of only copying checklist items. - Verify worklog, documentation, tests, class map, translations, screenshots, security notes, and PR checklist items when they are relevant to the reviewed change. - Check drift between code and feature drafts in `dev/draft/`; update it only when asked to make changes, otherwise report the drift. - Review translation coverage with `bin/lint ` when user-facing copy changed. diff --git a/README.md b/README.md index afcffd1a..1d8a6a9b 100755 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Studio -> **Version**: 0.2.4 +> **Version**: 0.2.5 > **Status**: Active development -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-17 > **Owner**: Dominik Letica > **Purpose:** A Symfony-based CMS foundation for structured, extensible project websites. diff --git a/composer.lock b/composer.lock index eb3b553d..5adce6c6 100755 --- a/composer.lock +++ b/composer.lock @@ -1536,16 +1536,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.1", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1" + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/640e2897bbee822dbc8af761d49e1a29b1f2a6b1", - "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9b38012e7b54f594707e6db52c684dc0a74b3a43", + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43", "shasum": "" }, "require": { @@ -1635,7 +1635,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.1" + "source": "https://github.com/guzzle/psr7/tree/2.12.0" }, "funding": [ { @@ -1651,7 +1651,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T21:50:12+00:00" + "time": "2026-06-16T21:50:11+00:00" }, { "name": "intervention/image", @@ -1739,16 +1739,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.9.0", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -1758,7 +1758,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -1808,9 +1808,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2026-06-05T14:05:24+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "lcobucci/jwt", diff --git a/config/services.yaml b/config/services.yaml index cac21fd7..44dc979e 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -179,6 +179,10 @@ services: arguments: $sessionFactory: '@session.factory' + App\Command\RenderRouteCommand: + arguments: + $environment: '%kernel.environment%' + App\Setup\SetupRedirectSubscriber: arguments: $projectDir: '%kernel.project_dir%' @@ -260,6 +264,20 @@ services: arguments: $providers: !tagged_iterator { tag: system.acl_group_reference_provider } + App\Security\RateLimit\RateLimitLimiterFactory: + arguments: + $cachePool: '@cache.rate_limiter' + $lockFactory: '@lock.factory' + + App\Security\RateLimit\RateLimitRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + $projectDir: '%kernel.project_dir%' + + App\Security\RateLimit\RateLimitAuthenticationSubscriber: + arguments: + $environment: '%kernel.environment%' + App\Localization\TranslationLanguageCatalog: arguments: $projectDir: '%kernel.project_dir%' @@ -552,6 +570,8 @@ services: App\View\Http\HttpErrorRenderer: arguments: + $projectDir: '%kernel.project_dir%' + $environment: '%kernel.environment%' $debug: '%kernel.debug%' App\View\Http\HttpErrorSubscriber: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 7c1ec2dc..c1d07982 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-16 +> **Updated**: 2026-06-17 > **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,8 +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/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\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, request-scoped authenticated or anonymous API context, read-only method gating, 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/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\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 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` | @@ -107,7 +108,7 @@ | Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController`, `App\Controller\AdminUserController`, `App\Controller\AdminAclGroupController`, `App\Controller\AdminUserReviewController`, `App\Controller\AdminUserInvitationController` | Own focused Admin package install/detail/lifecycle, Operations maintenance/detail/continuation, user management, ACL-group, invitation, and review routes with thematic Admin ACL feature enforcement before mutation or sensitive reveal; visible-only states keep expected UI controls rendered disabled where the workflow layout depends on them. | `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`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | | Service | `App\Core\Id\UuidFactory`, `App\Core\Operation\Live\LiveOperationHttpResponder` | Generates Symfony UID-backed UUIDv7 identifiers for controller-created records and renders LiveLog operation start responses with follow-up status URLs. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Service | `App\Core\Console\ConsoleResultRenderer` | Renders workflow result issues/messages, JSON payloads, and status/WorkflowResult exit codes for console commands. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Console/ConsoleResultRendererTest.php` | -| Command/service | `App\Command\RenderRouteCommand`, `App\Debug\RouteRenderer`, `App\Debug\RouteRenderOptions`, `App\Debug\RouteRenderResult` | Provides project-wide CLI route rendering through `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, setup-completion, browser-route auth token, and API debug context support. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Command/RenderRouteCommandTest.php` | +| Command/service | `App\Command\RenderRouteCommand`, `App\Debug\RouteRenderer`, `App\Debug\RouteRenderOptions`, `App\Debug\RouteRenderResult` | Provides development/test-only CLI route rendering through `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, request headers, response-header output, setup-completion, browser-route auth token, and API debug context support. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Command/RenderRouteCommandTest.php` | | Event subscriber | `App\Backend\BackendNavigationSubscriber` | Adds registered backend view route-target navigation entries through the shared navigation hook so packages can later contribute menu items through the same boundary. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Navigation/NavigationBuilderTest.php` | | Registry | `App\View\Injection\ViewInjectionRegistry`, `App\View\Injection\StaticViewInjection`, `App\View\Injection\ConfigurableStaticViewInjectionSet`, `App\View\Injection\ConfigurableStaticViewInjectionRoute`, `App\View\Injection\DynamicViewInjection`, `App\View\Injection\DynamicViewInjectionFilter`, `App\View\Injection\ViewSurface`, `App\View\Injection\DynamicViewInjectionSlot` | Collects package-facing static route/menu injections, package-setting-backed configurable static route sets, and content-aware dynamic slot or variant-route injections for the `public`, `admin`, and `editor` surfaces. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/DemoControllerTest.php` | | Event payload | `App\View\Injection\Event\StaticViewInjectionRegistryEvent`, `App\View\Injection\Event\DynamicViewInjectionRegistryEvent` | Public mutable hooks that let packages add static and dynamic view injections before route, menu, slot, or variant resolution. | `dev/draft/0.2.x-EventHooksBuses.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Event/PublicEventHookRegistryTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php` | @@ -199,7 +200,8 @@ | 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 without exposing raw IPs or API secrets, 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 `/api/live/**`, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, Admin API mutations before generic API writes, prefetch, CORS preflight, scheduler, setup apply, admin mutations before broad public reset/password keywords, route-backed user/account intents, 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/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` | | 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` | @@ -262,7 +264,7 @@ | Enum | `App\Content\Routing\ContentRedirectTargetType` | Enum for redirect target kinds such as internal route and external URL. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRedirectResolverTest.php` | | Value object | `App\Content\Routing\ContentRoutePath` | Value object for normalized content paths with optional trailing generic variant marker extraction. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Read/PublishedContentResolverTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentRedirectTest.php` | | Value object | `App\Content\Routing\ContentSlug` | Value object for strict lowercase ASCII content slug validation. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentSlugTest.php` | -| Service | `App\Content\Routing\ContentRouteGuard` | Guard for reserved public route prefixes such as `admin`, `editor`, `setup`, `cron`, `system`, and `user`, plus normalized content paths. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRouteGuardTest.php` | +| Service | `App\Content\Routing\ContentRouteGuard` | Guard for reserved public route prefixes including app, API, generated asset, profiler/toolbar, media, package, and system namespaces, plus normalized content paths. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRouteGuardTest.php` | | Value object | `App\Content\Routing\ContentSystemRoute` | Defines the root parent sentinel plus the internal-only `/system/...` content namespace and virtual parent marker. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Entity/ContentItemTest.php`, `tests/Content/Read/PublishedContentResolverTest.php` | | Value object | `App\Content\Schema\ContentSchemaField` | Constants for reserved required base field identifiers that every content schema must define. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Content/Schema/ContentSchemaFieldTest.php` | | Enum | `App\Content\Schema\ContentSchemaSource` | Enum for schema sources such as preset, custom, and module. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Entity/ContentSchemaTest.php` | @@ -323,7 +325,7 @@ | Routes `user_api_keys`, `user_api_key_reveal`, `user_api_key_revoke` | `App\Controller\UserApiKeyController` | Authenticated user API-key routes for generation, revocation, and password-confirmed reveal of encrypted key material. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/UserApiKeyControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_register`, `user_invitation_accept` | `App\Controller\UserRegistrationController` | Public registration and invitation routes for disabled/admin-approval/auto-approval registration, existing-account notices, optional default registration groups, deleted-account reactivation with token role/group reset, and invitation/registration token acceptance through a Security-owned mutation service. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | | Routes `user_reset_password`, `user_password_reset_token`, `user_security_review` | `App\Controller\UserPasswordRecoveryController` | Public password recovery and security-review routes for non-enumerating reset requests, reset completion, password-change review links, and password-change dispute locking. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | -| Routes `backend_setup_index`, `backend_setup_step` | `App\Controller\SetupController` | DB-free web setup wizard adapter for language selection, preflight checks, site/settings input, driver-aware database details, first OWNER account data, review, setup runner execution, live-operation setup apply dispatch, result rendering, setup-completion locking, locale application, and setup-state cleanup after successful completion. Step transitions, state persistence, and database test execution live in setup services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | +| Routes `backend_setup_index`, `backend_setup_step` | `App\Controller\SetupController` | DB-free web setup wizard adapter whose pre-completion ordinary wizard navigation is skipped by rate-limit enforcement except static suspicious-probe matching, while the final `POST /setup/review` apply submission may consume the setup-apply limiter before the controller handles language selection, preflight checks, site/settings input, driver-aware database details, first OWNER account data, review, setup runner execution, live-operation setup apply dispatch, result rendering, setup-completion locking, locale application, and setup-state cleanup after successful completion. Step transitions, state persistence, and database test execution live in setup services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | | Routes `backend_admin_index`, `backend_admin_route`, `backend_admin_log_detail`, `backend_editor_*` | `App\Controller\BackendController` | Native backend/editor dispatcher for area route resolution, area access checks, route messages, backend navigation context, generated Admin Settings submissions, and Admin Log detail rendering. Focused package and operation routes live in dedicated Admin controllers. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | | Routes `backend_admin_package_*`, `backend_admin_operation_*` | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Focused Admin package install/detail/lifecycle routes plus Admin Operations maintenance, detail, and review-continuation routes that require both mutable operations access and mutable target-domain access before starting continuation work. | `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` | | Routes `api_live_operation_status`, `api_live_operation_continue` | `App\Controller\LiveOperationController` | Public but token-protected JSON endpoints for live ActionLog operation state and provider-declared review continuations below the reserved `/api/live/**` internal API branch. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | @@ -370,7 +372,7 @@ | Macro registry templates | `templates/macros/**/*.html.twig` | Namespaced native Twig macro templates and aggregator entrypoint. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Twig/ViewTwigExtensionTest.php` | | Package template path resolver | `App\View\Template\PackageTemplatePathResolver`, `App\View\Template\PackageTemplatePathConfigurator` | Builds and registers deterministic Twig namespace path order for active packages through the central package gate, including `@frontend`, `@backend`, `@root`, `@provider`, root override protection through `system-template`, provider fallback ordering, and Console registration before UX icon locking or AssetMapper cache warming scans templates. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Template/PackageTemplatePathResolverTest.php`, `tests/View/Template/PackageTemplatePathConfiguratorTest.php` | | Provider templates | `templates/provider/{captcha,editor}/*.html.twig`, `templates/frontend/partials/forms/fields/captcha.html.twig`, `templates/backend/editor/fields/richtext.html.twig` | Native provider fallbacks and area stubs for optional captcha and editor-provider rendering through `@provider`, with CodeMirror as the base editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/View/Template/PackageTemplatePathConfiguratorTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | -| HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Renders recoverable HTTP errors through system content, frontend error templates, default error fallback, or anonymous-login `401` response. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | +| HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Provides the browser error-page resolver entry point through `HttpErrorRenderer::resolve()`, rendering recoverable HTTP errors through system content, frontend error templates, default error fallback, anonymous-login `401` response, or forced/minimal bare HTML, with central `no-store` cache headers for rendered error pages and DB-free minimal HTML responses for all known `4xx`/`5xx` statuses before setup completion. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/View/Http/HttpErrorRendererTest.php`, `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | | Frontend error pages | `templates/frontend/error-pages/*.html.twig` | Native frontend-scoped fallback templates for HTTP error pages including lightweight `429` and `503`. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | N/A | | Live polling controllers | `assets/js/live/live_poll.js`, `assets/controllers/live_poll_controller.js`, `assets/controllers/operation_overlay_controller.js` | Provides a reusable live JSON polling primitive and Stimulus controller for `/api/live/**` endpoints with automatic polling, immediate `has_more` page draining when cursors advance, optional recoverable-error retry for fallback channels, `next_poll_ms: 0` manual-mode support, and one-shot `live-poll#poll`/`live-poll#refresh` actions while operation forms surface progress through notification-center runner alerts, keep the triggering button disabled while running, automatically run the OK/redirect/reload action shortly after successful operations unless the ActionLog overlay is opened, remap warning or failed triggers to details, and open the ActionLog overlay only on demand for details, continuation, retry, close controls, non-terminal hide controls that keep polling alive, and reusable running-alert detail actions that can reopen the overlay after it was hidden. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/controller_foundation.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs`, `tests/Controller/LiveOperationControllerTest.php` | | UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `App\View\Alert\WorkflowResultAlertSelector`, `App\View\Alert\UiAlertInbox`, `App\View\Alert\UiAlertDelivery`, `App\View\Alert\UiAlertPresentation`, `App\Command\UiAlertInboxCleanupCommand`, `App\Controller\LiveAlertController`, `assets/controllers/alert_stack_controller.js`, `assets/controllers/ui_alert_stream_controller.js`, `assets/controllers/ui_alert_poll_controller.js`, `assets/js/alerts/*.js` | Renders server-created, translated, client-created, Mercure-pushed, or polling-delivered UI alerts through one `addAlert()` interface with explicit `Direct`, robust `Queue`, and volatile low-level `Push` delivery modes, success-preserving workflow-result alert selection, DB-backed user/session topic inbox that only accepts system-owned UI-alert URN topics and stores bounded HMAC topic keys with setup-completion gating, canonical UID topics from account entities, account UIDs, or case-preserved resolvable usernames, portable append success reporting without sequence-specific insert IDs, paginated catch-up cursors, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, private-subscription authorization cookies for rendered alert stream topics, paginated inbox catch-up drains before stream connection, on stream open/reconnect, and during polling fallback, existing session-cookie topics, transient-failure retries, session-scoped sessionStorage-backed notification center with badge counts, visible no-JavaScript server-rendered alerts, silent stored-alert hydration that does not hide fresh server-rendered flashes, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, sanitized quiet text/link actions, presentation modes, and optional titles/actions/loading state. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/alert_payload.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/controller_foundation.test.mjs`, `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/View/Alert/UiAlertDispatcherTest.php`, `tests/View/Alert/UiAlertInboxTest.php`, `tests/View/Alert/WorkflowResultAlertSelectorTest.php`, `tests/View/Alert/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index dd0281a4..c0f8dd68 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-16 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -70,32 +70,71 @@ - [ ] Audit follow-up: implement remember-me with Symfony-style persistent server-side tokens, visitor binding, explicit revocation, token rotation, and audit signals in the Security branch. - [ ] Audit follow-up: replace the debug account-link mail/message-log delivery stub with the real Mailer delivery contract and a dedicated Mail Message/API catalogue. - [ ] Security follow-up: define and test production HTTP security-header policy, including CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and documented route exceptions. +- [ ] Frontend-delivery follow-up: change custom system error-page rendering so `/system/error-pages/{status}` resolves a content entity for the inner error-page body/fieldset, then lets the status-specific error template decide the full page chrome. The current renderer sends custom error entities through the normal frontend content entity template, which is too rigid for lightweight `400`/`429` responses versus full `404` pages. - [ ] Security/Admin ACL follow-up: add explicit Owner/configurable ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions across Admin UI, Admin API, Operations, and service boundaries. - [ ] 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. - [ ] 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-16 feat-security-admin-acl-enforcement -- Started the Admin ACL enforcement slice from `feat-security-admin-acl-enforcement`, reviewed the branch implementation plan and Security ACL draft, and archived the completed Abuse Foundation worklog into `dev/WORKLOG_HISTORY.md` for a clean branch basis. -- Product direction recorded for the implementation baseline: feature/action permissions are grouped by surface (`admin`, `editor`, `frontend`); Admin ACL granularity delegates selected denied/visible/mutable permissions through seeded Owner-controlled overrides; explicit ACL-group states can grant or restrict relative to role/default state after the relevant surface gate is satisfied; non-configurable rules remain visible read-only for transparency. -- Added a lightweight domain-provider Admin ACL registry with denied/visible/mutable states, Admin/Editor/Frontend surfaces inferred from key prefixes, seeded configurable defaults under `acl.admin.features`, Owner override persistence, explicit ACL-group override states, and an Owner-gated `Settings/ACL` matrix. -- Wired `access_feature` metadata into protected settings fields, Admin backend views/navigation, GeoIP update and maintenance backend action rendering/execution, package/theme UI actions, package install/lifecycle controllers, dynamic package settings pages, and package lifecycle Admin API review/confirmation so Live Operations remain generic while sensitive callers enforce the thematic feature key. -- Registered the initial configurable Admin-surface features for security/logging/statistics/API/scheduler/package settings, logs, packages/themes, operations, maintenance actions, scheduler operations, users, user ACLs, and user reviews; backup/restore, package self-update, security settings, and support rows remain non-configurable transparency rows where required. -- Package settings ACL rows are registered dynamically for active packages with settings. Inactive package rows stay hidden without losing stored overrides, and package purge removes the matching `admin.settings.packages.{package_slug}` override from `acl.admin.features`. -- Cached Admin ACL registry definitions, configured overrides, and ACL-group availability with the same short-TTL Symfony cache shape used by suspicious-probe patterns, plus explicit invalidation on matrix saves, ACL-group changes, and package lifecycle/registry changes; the cache draft records that this must be re-evaluated once the unified cache strategy exists. -- Added Admin ACL enforcement to direct user, ACL-group, invitation/review, operations, scheduler, logs, statistics, theme, backup, and Admin API entry points so visible-only features keep read/review models available but reject confirmed mutations. Existing buttons remain rendered disabled where the UI layout expects them. -- Treated Audit and Security Signal log sources as sensitive read surfaces that require mutable `admin.logs` access; visible-only log access keeps normal log sources available and filters sensitive sources from browser/API source lists. -- Extended `Settings/ACL` audit context with redacted old/new changed-feature summaries while keeping internal helper keys out of `setting_keys`. `admin.packages.self_update` remains a non-configurable transparency row because no separate self-update mutation route exists in this slice. -- Fixed Setup review registration-mode labels to use Setup-owned translation keys instead of depending on Admin settings labels during unauthenticated setup rendering. -- Split the core Admin Settings backend views into a dedicated provider, moved Twig settings/package form view-model construction into `AdminSettingsFormViewFactory`, and added an identifier map inside the Admin ACL registry so repeated feature checks avoid linear scans while keeping production classes below the soft line limit where a clean boundary existed. -- Addressed the first review findings by deriving pending-registration token actions from persisted token state instead of `return_to`, separating ACL group definition permissions from user group-assignment permissions, rechecking target-domain ACL before API/browser live-operation continuation starts, disabling adjacent UI controls from the same policy decisions, and documenting that ACL rows are assigned by operational responsibility rather than by view/menu placement. -- Addressed the second review findings by applying `admin.settings.security` to all Security settings fields including Captcha, adding explicit `admin.scheduler` visible/mutable gates to the concrete Scheduler web controller and disabled Scheduler controls, keeping pending account-token review actions under `admin.users.review` across browser/API surfaces, and documenting/test-covering that trusted registered Scheduler tasks run under Scheduler authority rather than target-domain feature gates. -- Updated translations, runtime catalogues, drafts, class map, and focused tests for Owner-default Package Lifecycle ACL behavior and the new `Settings/ACL` view. -- Verification: `php -l` on changed PHP entry points/tests; `bin/lint` for changed templates/translations/CSS; `php bin/console lint:container`; `php bin/phpunit tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`; `php bin/phpunit tests/Controller/ApiPackageControllerTest.php`; `php bin/phpunit tests/Controller/BackendControllerTest.php --filter 'Package|SettingsRoutes|AclSettings|AdminRegisteredBackendViewRoute'`; `php bin/phpunit tests/Controller/ApiSettingsControllerTest.php tests/Core/Config/SettingsApiReadModelTest.php tests/Core/Config/CoreSettingsFormHandlerTest.php`; `php bin/phpunit tests/Controller/ApiUserControllerTest.php --filter 'FeatureReadOnly'`; `php bin/phpunit tests/Controller/ApiAdminOperationalControllerTest.php --filter 'LogsFeatureReadOnly|FeatureReadOnly'`; `php bin/phpunit tests/Controller/AdminUserControllerTest.php --filter 'FeatureReadOnly'`; `php bin/phpunit tests/Controller/BackendControllerTest.php --filter 'LogsFeatureReadOnly|FeatureReadOnly|AclSettingsMatrix'`; `php bin/phpunit tests/Controller/BackendControllerTest.php --filter 'testSetupRouteWalksToReviewWithoutAuthentication'`; `php bin/phpunit tests/Controller/ApiAdminOperationalControllerTest.php tests/Controller/ApiUserControllerTest.php tests/Controller/AdminUserControllerTest.php tests/Controller/BackendControllerTest.php`; post-readiness refactor checks with `bin/lint --diff`, `php bin/console lint:container`, focused Navigation/Twig/ACL-cache/Backend/Settings/API tests, and full `bin/phpunit`; second review checks with `php bin/phpunit tests/Controller/ApiSettingsControllerTest.php tests/Core/Config/CoreSettingsFormHandlerTest.php tests/Core/Config/CoreSettingsRegistryTest.php tests/Core/Config/SettingsApiReadModelTest.php tests/Controller/AdminSchedulerControllerTest.php tests/Controller/ApiAdminOperationalControllerTest.php tests/Controller/ApiUserControllerTest.php tests/Controller/AdminUserControllerTest.php tests/Controller/BackendControllerTest.php`, `php bin/phpunit tests/Controller/ApiAdminOperationalControllerTest.php tests/Scheduler/SchedulerRunnerTest.php`, `php bin/console lint:container`, `bin/lint --diff`, `bin/jstest`, and full `bin/phpunit`. +### 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index 6c9b638f..e496d5e2 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -1,7 +1,7 @@ # Developer Worklog History > **Status**: Active -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Preserve compacted branch/PR history moved out of `dev/WORKLOG.md` at branch boundaries. @@ -9,6 +9,12 @@ 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-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. +- Hardened the slice through review rounds: separated ACL-group definition permissions from user group membership mutations, rechecked target-domain ACL for live-operation continuations, applied `admin.settings.security` to all Security settings fields including Captcha, gated the concrete Scheduler web controller, and documented policy decisions that pending account-token review actions use `admin.users.review` and trusted registered Scheduler tasks use `admin.scheduler`. +- Closed the branch with updated translations, runtime catalogues, drafts, class map, worklog notes, focused regression coverage, full PHPUnit, JavaScript tests, lint, and container validation. + ### 2026-06-16 feat-security-abuse-foundation - Implemented the Abuse Foundation slice: passive security-signal model and recording, request intent/action-cost classification, suspicious probe matching, visitor/IP-bucket evidence handling, configurable probe patterns, session/visitor mismatch signals, and database/file-backed Admin log browsing refinements. - Hardened the slice through review passes: retention-aware signal/log reads, portable database search, source-aware Admin Log filters and pagination, safe path/token sanitization, locale-aware route classification, cache invalidation for probe patterns, and clearer rate-enforcement handoff policy for future limiter/ban branches. diff --git a/dev/draft/0.1.x-ErrorHandlingValidation.md b/dev/draft/0.1.x-ErrorHandlingValidation.md index 303c7f1c..cbec3f73 100644 --- a/dev/draft/0.1.x-ErrorHandlingValidation.md +++ b/dev/draft/0.1.x-ErrorHandlingValidation.md @@ -71,8 +71,9 @@ Hard exceptions are still appropriate for programming errors, impossible states, - **Decision recorded:** Invalid module or theme manifests should disable only the affected package during discovery and should be logged with error details. - **Decision recorded:** Failed operation snapshots are stored only when `APP_DEBUG` is enabled and are automatically purged when the error is resolved or debug mode is disabled. - **Decision recorded:** Error handling includes user-definable custom error pages for common HTTP status codes. -- **Decision recorded:** `App\View\Http\HttpErrorRenderer` owns recoverable HTTP error presentation. Callers should return its response instead of throwing when they can safely continue the request. +- **Decision recorded:** `App\View\Http\HttpErrorRenderer` owns recoverable browser HTTP error presentation through `HttpErrorRenderer::resolve()`. Callers should return its response instead of throwing when they can safely continue the request; API JSON errors stay on the API responder boundary. Callers may force a minimal bare HTML response when a block surface should avoid full error-page rendering. - **Decision recorded:** The HTTP error fallback order is `/system/error-pages/{status}` content, then `@frontend/error-pages/{status}.html.twig`, then `@frontend/error-pages/default.html.twig`. +- **Decision recorded:** Before setup completion, the browser HTTP error resolver returns DB-free minimal HTML `no-store` responses for known `4xx`/`5xx` statuses instead of resolving custom system error content. The minimal HTML includes the status code/text, an optional explicit bare-context line, and a Request ID resolved directly through `AccessRequestMetadata`. - **Decision recorded:** `401` for anonymous users renders the frontend login template with `http_error` Twig variables and still returns status `401`; authenticated users receiving `401` continue through the normal system-content/status-template/default error-page fallback. - **Decision recorded:** Workflow issues, logs, output, and validation flows use a shared `App\Core\Message\Message` shape with a log level, machine-readable code, translatable key, parameters, and context. Core-owned message constants live in domain-owned `*MessageCode` and `*MessageKey` catalogues that are aggregated by `App\Core\Message\MessageCode` and `App\Core\Message\MessageKey` for tooling and translation checks. Catalogue constant names and values are namespace-bound to their owning scope. Third-party modules and themes may provide their own deterministic package-namespaced codes and keys, but system/core namespaces remain reserved. Domain code may use `MessageException::invalidArgument()` to carry a structured message at hard invariant boundaries without embedding user-facing text in exceptions. - **Decision recorded:** Recoverable runtime, package, theme, hook, asset, and rendering failures should return `WorkflowResult`/`Message` diagnostics or render a controlled error response wherever possible. Hard throws are reserved for programmer errors, invalid value-object construction, setup/CLI aborts, and guard rails that cannot safely continue in-place. diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index 842771bd..187bbce4 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -28,7 +28,7 @@ The API branch should implement the platform foundation and the first content re - Use REST-style HTTP endpoints under `/api/v1` as the first public API style. GraphQL can be revisited only when clients need cross-resource graph queries that REST cannot express cleanly. - Use OpenAPI documentation for the public contract. `NelmioApiDocBundle` is an acceptable dependency if it keeps controller/DTO documentation smaller and easier to maintain than a custom documentation generator. - Keep API Platform out of the first implementation unless a later review shows its generated-resource model fits Studio's domain-owned ACL and serialization boundaries without exposing entities. -- Treat `read_only` keys as method-gated API keys: they may authenticate `GET`, `HEAD`, and `OPTIONS` requests only. Mutating methods such as `POST`, `PUT`, `PATCH`, and `DELETE` require an active `read_write` key and still need domain authorization. +- Treat `read_only` keys as method-gated API keys: they may authenticate `GET`, `HEAD`, and safe `OPTIONS` requests only. Bearer preflights use `Access-Control-Request-Method` for this method gate, so `OPTIONS` requests that ask to perform `POST`, `PUT`, `PATCH`, or `DELETE` require an active `read_write` key and still need domain authorization. - Use the API key owner as the authenticated user context. The API must not define a parallel role or group model. A read-write key cannot create, update, publish, delete, or otherwise mutate a resource unless the owning user could do so according to the target domain's ACL rules. - Allow endpoint definitions to opt into anonymous public read access with `allow_public`. The default must be private/authenticated. Public anonymous access is read-only and uses `AccessActor::anonymous()`; invalid Bearer keys still fail authentication instead of falling back to anonymous access. - Return deterministic JSON `503 Service Unavailable` responses for `/api/v1/**` when setup is not complete, maintenance mode is active for non-admin API callers, or Doctrine/DBAL reports database unavailability. The API must not fall through to setup redirects, HTML error pages, or non-deterministic exception output. @@ -177,11 +177,11 @@ The API branch should implement the platform foundation and the first content re - **Decision recorded:** API keys are stored as `prefix`, `hmac_hash`, and `encrypted_key`. The prefix supports display/diagnostics, the HMAC supports lookup, and the encrypted payload supports password-gated reveal flows. Use `APP_SECRET` as the first encryption/HMAC root secret. - **Decision recorded:** API key encryption/HMAC behavior should move to the shared `APP_SECRET`-rooted, context-labeled, versioned secret protector once that foundation exists. - **Decision recorded:** API key lifecycle states are `read_write`, `read_only`, and `revoked`. Status labels and simple permission semantics live on the API key status enum so later UI, CLI, and API handlers can reuse the same rules. -- **Decision recorded:** `read_only` is a method gate. Read-only keys may call `GET`, `HEAD`, and `OPTIONS`; mutating methods require `read_write` and the target domain's ACL approval. +- **Decision recorded:** `read_only` is a method gate. Read-only keys may call `GET`, `HEAD`, and safe `OPTIONS`; mutating methods require `read_write` and the target domain's ACL approval. Bearer preflights are evaluated against `Access-Control-Request-Method`, not only the transport-level `OPTIONS` method, so read-only keys cannot preflight-probe write/admin surfaces indefinitely. - **Decision recorded:** Public anonymous reads are endpoint-definition opt-ins through `allow_public`. Missing Bearer credentials and unrelated non-Bearer authorization schemes may continue as anonymous only for these endpoints, while invalid Bearer credentials remain authentication failures. - **Decision recorded:** `/api/v1/**` availability failures are API responses, not setup redirects or generic HTML failures. Incomplete setup, maintenance mode, and Doctrine/DBAL failures return stable Message-layer `503` JSON payloads with `Retry-After`; maintenance bypass is evaluated after Bearer authentication so only admin-level API keys bypass it. - **Decision recorded:** API availability is configuration-controlled through `api.enabled`. When disabled, `/api/v1/**` returns stable Message-layer `503` JSON, while frontend API-key management is hidden for non-owner users. Owners keep key-management access for operational integrations such as scheduler and cron setup. CORS is configured separately through API settings and should remain closed unless explicit origins are configured. -- **Decision recorded:** API CORS is disabled by default and controlled through API settings. When enabled, only configured origins receive CORS response headers or successful preflight responses; wildcard origins must be configured explicitly. +- **Decision recorded:** API CORS is disabled by default and controlled through API settings. When enabled, only configured origins receive CORS response headers or successful anonymous preflight responses; wildcard origins must be configured explicitly. `OPTIONS` requests with an actual `Authorization` header are not short-circuited by CORS and are classified by `Access-Control-Request-Method` for rate-limit accounting, so credentialed preflight probes cannot bypass API write/admin budgets. Bearer remains the only API authentication scheme; unrelated schemes may continue anonymously only on endpoint-defined public reads outside credentialed preflights. - **Decision recorded:** API requests remain part of operational access logging but are separated in anonymized statistics through the `api` surface plus explicit `api_requests` and `page_requests` aggregate counters. Inbound `X-Correlation-ID` and valid `X-Request-ID` headers are logged as external correlation IDs while the system keeps its own generated request ID for internal joins. Versioned `/api/v1/**` responses expose the internal `X-Request-ID` response header for support/debugging and echo a validated inbound correlation header as `X-Correlation-ID` when present; both headers are documented in OpenAPI and exposed to configured CORS browser clients. - **Decision recorded:** The API should not introduce fine-grained token scopes in the first implementation. Role and group scopes come from the owning user, while key status can only reduce write capability. - **Decision recorded:** Revoked API keys remain audit-relevant after their original user account is purged. Deleted-user cleanup reassigns retained revoked keys to a stable system deleted-user account, and future API-audit review of revoked-key usage should trigger a warning mail to the original affected user when an address is still known. diff --git a/dev/draft/0.4.x-FrontendDeliveryCaching.md b/dev/draft/0.4.x-FrontendDeliveryCaching.md index 17124690..50cb2389 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-16 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Draft for public content delivery, snapshot/cache boundaries, HTTP caching, invalidation, and performance-oriented rendering. @@ -18,6 +18,8 @@ The first implementation does not need a complex static-site generator, but it s Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, filesystem cache by default, AssetMapper hashed assets, and optional adapters later. Admin tools should show enough cache and delivery status that operators can recover from stale output without shell access. +The Security rate-limit `panic` profile is also intended as the future switch point for an operational cache panic mode. During DDoS-like or extreme anonymous traffic events, the delivery layer may set a short-lived TTL lock that forces anonymous public traffic onto cache-backed delivery only, while the Security layer applies the strictest rate limits. This mode must protect public read delivery without bypassing authentication, ACL checks, CSRF, probe blocking, audit, or Owner/Admin recovery paths. + ## 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. @@ -30,6 +32,8 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - Keep snapshot-like export/read artifacts optional and adapter-backed until a concrete performance need requires them. - Include public delivery behavior in backup/restore and self-update compatibility checks when generated artifacts or metadata are involved. - Keep private or ACL-restricted content out of public cache entries unless the cache key and delivery path are permission-aware. +- Design a cache panic mode that can be activated manually or by a later abuse/operations signal through a bounded TTL lock. While active, anonymous public requests should be served from already-safe public cache entries where possible, cache misses should fail predictably or use an explicitly allowed lightweight fallback, and authenticated/Admin/Owner paths must keep their normal security and recovery behavior. +- Coordinate cache panic mode with the Security `panic` rate-limit profile instead of creating a separate public knob with conflicting semantics. ## Testing & Validation - Test public route rendering uses published content only. @@ -38,12 +42,15 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - Test ACL-restricted content is not leaked through public caches. - 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. - Run asset build commands after frontend delivery asset changes. ## Implementation Notes - **Decision recorded:** Public delivery should use a controlled read path separate from mutable editor workflows. - **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. - **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. +- **Open:** Define cache panic trigger sources, TTL bounds, lock storage, anonymous cache-miss behavior, operator diagnostics, and the exact coupling to the Security rate-limit mode before implementation. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index e52ec5ec..bb019cb9 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -29,7 +29,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 1. Add an abuse namespace with value objects for subject, request family, request intent, action cost, and passive signal. 2. Add subject resolution for IP bucket, visitor ID, authenticated user UID, API key UID/prefix, and safe combined subject keys through one reviewed client-identity resolver. -3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, CORS preflight, scheduler trigger, login, registration, password reset, setup apply, package/admin operation, upload/archive validation, export/download, import, public form submits, and suspicious probe. +3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, CORS preflight, exact `/cron/run` scheduler trigger, login, registration, password reset, exact setup review apply, package/admin operation, upload/archive validation, export/download, import, public form submits, and suspicious probe. 4. Add a central action-cost catalogue with website and API families. Costs are symbolic defaults, not limiter calls yet. 5. Add database-backed message, audit, and access log projections in parallel to the existing 30-day rotating file logs. 6. Move Admin/API log browsing from file scanning to the database projection, with one tab/source for message, audit, access, and security-signal events. @@ -60,7 +60,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Subject resolution emits visitor, IP-bucket, authenticated-user, API-key, safe API-key-prefix, and combined subjects. IP buckets and combined IP subjects are HMAC-derived and never expose raw IP addresses; invalid Bearer tokens may contribute only a validated public prefix, never submitted secret material. - Probe-path detection is configurable through a simple editable pattern-list setting, not a raw JSON field. The UI should present one regular expression per line and may accept quoted CSV imports for convenience; unquoted newline entries must be preserved as-is so commas inside regex syntax such as `{4,6}` remain valid. Empty or invalid lists fall back to protected defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. - The normalized probe-pattern list may use a small Symfony-native cache to keep the passive subscriber out of the request-time configuration hot path. Security settings saves must invalidate that cache, and the future unified caching strategy should re-evaluate whether this feature-local cache should move into a shared namespace/invalidation model. -- Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and CORS preflight receive no ordinary enforcement cost in this branch; suspicious probes, setup apply, and mutating admin/API workflows receive higher symbolic costs for later limiter branches. +- 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. @@ -97,7 +97,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Tests and validation - Test subject resolution for anonymous, visitor-cookie, authenticated user, valid API key, invalid API key, and scheduler trigger. -- Test intent classification for browser, prefetch, API read/write/preflight, `/api/live/**`, login, registration, password reset, setup apply, privileged admin operations, upload/archive validation, export/download, and suspicious probes. +- Test intent classification for browser, prefetch, API read/write/preflight, `/api/live/**`, login, registration, password reset, exact setup review apply, exact `/cron/run` scheduler trigger, privileged admin operations, upload/archive validation, export/download, and suspicious probes. - Test configurable probe-path defaults, newline and quoted-CSV pattern parsing, invalid-pattern fallback, comma-bearing regex syntax, cached config reads, and high-signal probe classification. - Test suspicious probe rules do not collide with legitimate upload, package, import, backup, restore, media, and editor routes. - Test probe-pattern normalization and false-positive avoidance for ordinary application routes. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 3e9e0338..ac8a5a8e 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -1,7 +1,7 @@ # Security policy defaults > **Status**: Draft -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Define first implementation defaults for Security hardening branches before runtime work begins. @@ -44,9 +44,9 @@ The defaults are not an Admin UI requirement. Admin-configurable policy can be a Runtime enforcement must use one deterministic order so the same request is not handled differently by unrelated branches: -1. Resolve trusted client identity, visitor identity, authenticated session/user, API key context, request family, request intent, and safe subject keys. +1. Resolve trusted client identity, visitor identity, request family, request intent, and safe pre-auth subject keys; resolve authenticated session/user and API key context before ordinary rate-limit decisions. 2. Apply static asset, generated asset, setup/maintenance, and `/api/live/**` classification before ordinary website/API rate decisions. -3. Resolve active Admin/Owner context before ban and rate checks so recovery protections and ordinary rate-limit exemptions can be evaluated safely. +3. Resolve active Admin/Owner context before ordinary ban and rate checks so recovery protections and ordinary rate-limit exemptions can be evaluated safely. 4. Allow the recovery-login bypass path to render the normal login form before active visitor/IP bans or exhausted ordinary website buckets block it, while still applying the dedicated recovery-login bucket. 5. Classify high-signal probes early and return the generic probe response without revealing route existence. 6. Check active bans except where Admin/Owner protection or the recovery-login rendering rule applies. @@ -79,40 +79,59 @@ The first implementation should introduce a route/action authority matrix or pol These are first implementation defaults. Branches may adjust them only with tests and a worklog note explaining the review reason. +Rate-limit implementation must keep action costs separate from bucket budgets. The action-cost catalogue assigns stable semantic costs to request intents, while a dedicated rate-limit policy catalogue owns bucket descriptors, capacities, windows, TTL/retry metadata, reset eligibility, diagnostics labels, and profile scaling. This keeps later tuning centralized and allows future config-backed thresholds to attach at the policy-catalogue boundary without changing classifiers, subscribers, or controllers. + +Descriptor capacities are implementation credit budgets generated from the action counts shown in the policy table. When a bucket family has one unique action-cost value, the rate-policy catalogue multiplies the documented action count by that cost so Symfony `consume(n)` never asks a limiter to consume more tokens than the bucket can hold. Strict and panic profile scaling keeps a per-bucket minimum for two legitimate costed requests, so even the strongest profile still allows normal one-shot workflows without degrading open. Suspicious probes and scheduler triggers are explicit single-action exceptions because their profile behavior is interval-based. + +The first Admin-facing rate setting is one Owner-gated Security setting with four modes: + +- `off`: central facade gate allows requests without calling limiter storage. Authentication, authorization, CSRF, suspicious-probe `400` handling, passive abuse signals, audit, and diagnostics remain active. +- `standard`: default policy values from the bucket catalogue. +- `strict`: derived from `standard` by fixed multipliers that reduce capacity and/or extend windows/retry floors for elevated pressure. +- `panic`: derived from `standard` by stronger fixed multipliers for temporary emergency pressure. + +`strict` and `panic` must be calculated from the standard bucket descriptors instead of duplicating every bucket threshold. The derived values must be covered by tests so the code shows exactly how much capacity and retry behavior changes in each mode. `Retry-After` should use Symfony limiter metadata where available; descriptor-provided retry values act only as documented floors or special-case overrides such as recovery login. + | Policy | Default | Subject | Success reset | | --- | --- | --- | --- | -| Login failures | 5 failed attempts per 15 minutes | Visitor ID plus username/email hash where safe; IP bucket as secondary signal | Successful credential login resets only the login-attempt bucket | -| Recovery login bypass | 2 credential attempts per minute, 10 per hour, retry after 30 minutes once exhausted | Visitor ID plus username/email hash where safe; IP bucket as secondary signal | Successful credential login re-evaluates active bans/limits under authenticated policy | -| Registration submissions | 3 submissions per hour and 10 per day | Visitor ID; IP bucket as secondary signal | No automatic global reset | -| Password-reset requests | 3 requests per hour and 10 per day | Visitor ID plus normalized email hash where safe; IP bucket as secondary signal | No automatic global reset | +| Login failures | 5 failed attempts per 15 minutes | HMAC-redacted submitted username/email plus Visitor ID and IP bucket | Successful credential login resets only the login-attempt bucket for the same submitted-account/visitor/IP subjects | +| Recovery login bypass | 2 recovery-login requests per minute, 10 per hour, retry after 30 minutes once exhausted | HMAC-redacted submitted username/email plus Visitor ID and IP bucket | Successful credential login re-evaluates active bans/limits under authenticated policy | +| Registration submissions | 3 submissions per hour and 10 per day | HMAC-redacted submitted email or invitation token plus Visitor ID and IP bucket | No automatic global reset | +| Password-reset requests | 3 requests per hour and 10 per day | HMAC-redacted submitted email or reset token plus Visitor ID and IP bucket | No automatic global reset | | Contact form submissions | 3 submissions per 10 minutes and 20 per day | Visitor ID; IP bucket as secondary signal | No automatic global reset | | Captcha failures | 5 failures per 10 minutes | Challenge subject plus visitor ID | Verified provider-backed captcha may reset the scoped challenge/form bucket only | | Website deliberate burst | 30 deliberate browser route requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | | Website deliberate sustained | 300 deliberate browser route requests per 30 minutes | Visitor ID; IP bucket as secondary signal | No success reset | | Turbo/browser prefetch observation | 120 safe prefetch `GET` requests per minute and 600 per 30 minutes | Visitor ID; IP bucket as secondary signal | No ordinary rejection by itself; records lower-confidence passive signals | -| Versioned API read | 600 safe requests per minute | API key fingerprint or visitor/anonymous subject | No success reset | -| Versioned API write | 60 mutating requests per minute | API key fingerprint | No success reset | +| Versioned API read | 600 safe requests per minute | Verified API key fingerprint after authentication, otherwise Visitor ID/IP fallback | No success reset | +| Versioned API write | 60 mutating requests per minute | Verified API key fingerprint after authentication, otherwise Visitor ID/IP fallback; submitted API-key prefixes are not primary limiter subjects | No success reset | | Public anonymous API read | 120 safe requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | -| Scheduler trigger | 5 trigger attempts per minute and 60 per hour | API key fingerprint plus scheduler endpoint subject | No success reset | -| Suspicious probes | 1 high-signal probe per 10 minutes | Visitor ID plus IP bucket | No success reset; return generic `400`; may drain suspicious buckets | +| Scheduler trigger | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger per hour | HMAC-redacted submitted scheduler credential, with IP bucket fallback/secondary subject before controller authentication, including when a user session is present | No success reset | +| Suspicious probes | Standard: 1 high-signal probe per 10 minutes; Strict: 1 per 15 minutes; Panic: 1 per 20 minutes | Visitor ID plus IP bucket | No success reset; return generic `400`; may drain suspicious buckets | Website global buckets count application/browser route handling, not static assets, generated assets, or `/api/live/**` polling. The first implementation should enforce both deliberate website buckets: the burst bucket catches very fast click/submit loops, while the sustained bucket catches automated crawling that stays just below the per-minute limit. -`/api/live/**` remains outside ordinary rate-limit rejection. Clear abuse on live endpoints records passive signals and may affect global suspicious handling, but live polling and captcha refreshes should not receive the normal website/API `429` path. +`/api/live/**` remains outside ordinary rate-limit rejection. High-signal probe paths below `/api/live/**` still return the generic suspicious-probe `400`; normal live polling and captcha refreshes should not receive the normal website/API `429` path. -Turbo/browser prefetch for safe `GET` requests should not spend the same budget as deliberate navigation. Use a dedicated prefetch observation bucket or lower-confidence passive signal weighting; do not let spoofable prefetch headers bypass authentication, authorization, CSRF, or domain validation. Expensive or side-effect-adjacent links should disable prefetch rather than relying on rate-limit forgiveness. +Turbo/browser prefetch for safe `GET` requests should not spend the same budget as deliberate navigation. Use a dedicated prefetch observation bucket or lower-confidence passive signal weighting; do not let spoofable prefetch headers bypass authentication, authorization, CSRF, domain validation, recovery-login bypass buckets, or Admin export/download/diagnostic buckets. Expensive or side-effect-adjacent links should disable prefetch rather than relying on rate-limit forgiveness. -Scheduler trigger limits must support a normal once-per-minute external cron. The scheduled tasks still use internal due-state logic, run locks, and task policies, so frequent legitimate scheduler calls are expected and should not be treated as abuse by themselves. +Scheduler trigger limits must support a normal once-per-minute external cron in `standard`. `strict` and `panic` intentionally control the allowed external trigger interval instead of using the ordinary profile multiplier logic: `strict` allows one trigger per 15 minutes, and `panic` allows one trigger per hour. The scheduled tasks still use internal due-state logic, run locks, and task policies, so legitimate scheduler calls are expected and should not be treated as abuse by themselves. Scheduler interval `429` responses are operational feedback for the caller and must not create passive security signals or extra abuse diagnostics by themselves; the configured caller already logs the response and can adjust its interval. The interval bucket applies only to the exact `/cron/run` route and must use the submitted scheduler credential after HMAC redaction, with IP bucket fallback/secondary anchoring, because `/cron/run` authenticates inside the controller after the pre-controller interval guard. Other reserved `/cron/*` paths must not spend the scheduler interval bucket. Scheduler IP secondary anchoring remains active even when a browser user session is present, so rotating invalid query credentials cannot create fresh interval buckets from the same source. Registered authenticated users receive higher limits than anonymous visitors where the workflow is not already account-specific. The first default is a 2x multiplier for deliberate website navigation and public-read style API usage after the request resolves to an active authenticated user. Login, registration, password-reset, captcha, scheduler, and suspicious-probe policies keep their explicit workflow limits. -Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner session are exempt from ordinary rate-limit rejection. They may still record diagnostics and passive signals, but the request path must preserve Owner recovery and administrative operation access. +Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner session are exempt from ordinary rate-limit rejection, except for the scheduler trigger surface where a mutable Owner API key is the expected credential and the configured scheduler interval must still be enforced. A mutating API request made with a read-only Owner API key is also not ordinary allowed Owner traffic; it must spend the API write/admin bucket before the read-only denial is returned. Credentialed `OPTIONS` preflights use `Access-Control-Request-Method` for this decision, so read-only Owner keys cannot bypass write/admin buckets by probing unsafe routes through transport-level `OPTIONS`. Owner traffic may still record diagnostics and passive signals, but the request path must preserve Owner recovery and administrative operation access outside those explicit exceptions. + +Limiter storage degradation is fail-open by policy. If limiter storage, locking, or consume/reset operations fail, the facade should allow the request, emit safe Message-layer diagnostics where possible, and avoid creating an invisible Owner, login, setup, API, or scheduler lockout. + +Symfony limiter storage keys must be isolated by the active descriptor shape, including profile-derived capacity/window values, so changing between `standard`, `strict`, and `panic` does not reuse stale fixed-window state. Cache-backed limiter consumption should use the configured Symfony lock factory so concurrent failed credentials or API requests cannot race through the same remaining budget. + +Multi-bucket requests must not partially spend earlier buckets when a later bucket rejects the request. The facade should pre-check all planned descriptor/subject candidates, then commit only when every candidate still has capacity. This preserves account-scoped workflow protection without letting a visitor that is already blocked by local or global website budgets poison other users' shared submitted-account buckets. This policy deliberately avoids a cross-bucket transaction manager: concurrent requests can race between pre-check and commit, but Symfony per-key locking bounds that race to a timing-dependent request, and subsequent requests see the exhausted bucket during pre-check. That residual race is accepted as simpler and reviewable because it does not provide a practical way to repeatedly drain unrelated account buckets. ## Probe Path Policy - Probe paths are configurable as an editable pattern list, not as raw JSON. The default UI should use one regular expression per line and may accept quoted CSV imports; unquoted newline entries must be preserved as-is so commas inside regex syntax remain valid. The shipped defaults cover high-signal requests such as `.env`, `.git`, backup archives, database dumps, common admin panels from other software, shell upload probes, and known scanner paths. -- High-signal probes are never treated as normal website navigation. The default response is a generic `400 Bad Request` without revealing whether the path exists, and the event records a suspicious probe signal. -- One high-signal probe per subject per 10 minutes is the first threshold. Further probes may drain suspicious buckets and feed auto-ban decisions when auto-ban is enabled. +- High-signal probes are never treated as normal website navigation. The default response is a generic `400 Bad Request` without revealing whether the path exists, and the event records a suspicious probe signal. Probe blocking must run before response-producing availability, setup, maintenance, live/API, and ordinary technical-exclusion gates so exposed install or disabled-feature surfaces cannot bypass the hardened response. +- One high-signal probe per subject per 10 minutes is the first threshold. Strict and panic extend that window while keeping a single-probe credit floor so profile scaling cannot produce an unusable capacity below the suspicious-probe action cost. Further probes may drain suspicious buckets and feed auto-ban decisions when auto-ban is enabled. - Honeypot probe paths should remain restrictive. They may share the same generic `400` response and signal path even when they do not map to real routes. - Probe-path configuration should use anchored, normalized path patterns with tests that prove common application routes, package routes, media routes, and editor routes are not accidentally captured. - Probe-path configuration changes should be auditable once Security settings exist. @@ -122,17 +141,18 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner - Rate-limit exhaustion returns `429 Too Many Requests` with `Retry-After` when a reliable retry time exists. - Active temporary bans return a generic `403 Forbidden` by default, also with `Retry-After` when the ban expiry is known. The response must not expose raw reason internals, subject keys, IP data, or bucket names. -- High-signal probes return generic `400 Bad Request` and must not reveal whether a probed path, file, or package exists. +- High-signal probes return generic `400 Bad Request` and must not reveal whether a probed path, file, or package exists. Probe handling should run before package loaders and other response-producing request gates, then force a minimal `400 Invalid Request` HTML response for browser probes while leaving the passive response-time signal recorder able to persist the security signal. - Browser responses use the shared HTML error/recovery renderer. Versioned API, scheduler, and JSON-request responses use the stable JSON error shape for their request family. -- Security block, recovery, captcha, login, and bypass responses are `no-store` by default. +- Security block, recovery, captcha, login, and bypass responses are `no-store` by default. Shared rendered HTTP error pages also set `no-store` centrally so customized system error content cannot be cached accidentally. - `/api/live/**` should return cheap JSON, token/access checks where needed, `no-store`, and passive signals; it should not enter ordinary website/API `429` rendering. ## Additional Security Surface Coverage The codebase and other feature drafts expose several security-relevant surfaces beyond login, captcha, API, scheduler, and probes. The first Security branches should cover them through classification, cost catalogues, diagnostics, or explicit deferred follow-ups rather than inventing separate local policies later. -- Setup/install mode is its own request family. Before setup completion, final setup apply attempts should receive a workflow bucket and passive signals because no Owner session exists yet. After setup completion, setup routes must not become public alternative admin entry points. Setup ActionLog/live-operation payloads must stay tokenized, `no-store`, and redacted. -- CORS preflight and API metadata requests are API-family traffic, not write attempts. Successful allowed `OPTIONS` preflights should be cheap and must not spend mutating API budget; invalid origin/method/header combinations may record passive signals. Invalid Bearer credentials remain authentication failures and must never fall back to anonymous public reads. +- Setup/install mode is its own request family. Before setup completion, rate limiting must not touch Config, DBAL, subject resolution, limiter storage, or content-backed error rendering for ordinary setup wizard traffic. The exact final review-step apply submission (`POST /setup/review` with `_setup_action=apply`) is the only setup request that may reach the setup-apply limiter before setup completion; it may resolve the mode through DB-ready/default-backed Config fallback and use cache/lock limiter storage. Wizard navigation, language, site, database-test, admin, and backtracking posts must not spend the setup-apply bucket. Static default suspicious-probe matching may still return a DB-free minimal HTML `400 no-store` response before setup completion, and setup-apply `429` responses before completion must also stay DB-free and `no-store`. Shared browser rendering for all known `4xx`/`5xx` statuses must return minimal HTML `no-store` responses before setup completion instead of resolving custom system error content. After setup completion, setup routes must not become public alternative admin entry points. Setup ActionLog/live-operation payloads must stay tokenized, `no-store`, and redacted. +- CORS preflight and API metadata requests are API-family traffic, not write attempts. Successful allowed anonymous `OPTIONS` preflights should be cheap and must not spend mutating API budget; invalid origin/method/header combinations may record passive signals. `OPTIONS` requests that carry any non-empty `Authorization` header are credentialed preflights and must be classified by `Access-Control-Request-Method` for rate limits. Invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads; unrelated non-Bearer schemes remain anonymous only for endpoint-defined public reads on non-preflight requests. +- Technical roots such as `/api`, `/api/live`, `/cron`, `/setup`, generated assets, the profiler, and the toolbar are raw prefixless path scopes: they remain locale-aware through the resolved request locale, but URL locale prefixes are not accepted as aliases for these routes. A localized content or UI path that looks like `/de/api/...` or `/de/cron/run` must not inherit API, scheduler, setup, static-asset, or JSON-response behavior unless an actual technical route is registered there. - High-impact authenticated/admin workflows need explicit intents and authority decisions even when Owner requests are exempt from ordinary rate-limit rejection: settings mutations, user/ACL changes, package install/activate/purge, backup restore, import apply, export/download, cache or asset rebuild, self-update, scheduler run-now, and diagnostic/support-bundle generation. Trusted registered Scheduler tasks are authorized by the Scheduler feature; live-operation continuations remain authorized by their target-domain feature before follow-up work starts. - Upload and archive handling, including media, package ZIPs, import bundles, backups, and restore artifacts, should not be treated as suspicious probe traffic by path alone. Failed extension, MIME, size, path traversal, nested archive, and manifest-validation checks should feed passive signals with redacted context. - Public-facing unsafe form submissions that are not covered by a more specific workflow remain their own `website_form` bucket. This includes future package-owned public forms such as comments, forum posts, ratings, or similar user-generated content actions. @@ -166,8 +186,8 @@ The codebase and other feature drafts expose several security-relevant surfaces - Visitor IDs and IP buckets that resolve to an active Admin or Owner session must not be banned. - API keys owned by an active Owner and Visitor-ID/IP subjects that resolve to an active Owner session must not be rate-limited by ordinary application buckets. - Owner accounts must retain at least one documented recovery path. A policy that could deny all Owners is invalid. -- Provide a recovery login path such as `/user/login?bypass=1` that renders the normal login form even when the current Visitor ID or IP bucket is banned or ordinary website buckets are exhausted. The bypass flag only bypasses ban/rate checks that would prevent rendering the login form; it does not bypass CSRF, credential validation, login-failure accounting, the dedicated recovery-login bucket, audit logging, or post-login policy re-evaluation. -- The dedicated recovery-login bucket is intentionally small but not lockout-like: 2 credential attempts per minute, 10 per hour, and a 30-minute retry window after exhaustion. +- Provide a recovery login path such as `GET /user/login?bypass=1` that renders the normal login form even when the current Visitor ID or IP bucket is banned or ordinary website buckets are exhausted. The bypass flag only bypasses ban/rate checks that would prevent rendering the login form; it does not bypass CSRF, credential validation, login-failure accounting, the dedicated recovery-login bucket, audit logging, or post-login policy re-evaluation. Unsafe login submissions with `bypass=1` remain normal login attempts. +- The dedicated recovery-login bucket is intentionally small but not lockout-like: 2 recovery-login requests per minute, 10 per hour, and a 30-minute retry window after exhaustion. - Manual unban takes effect immediately and must be audited. ## Captcha Defaults @@ -217,7 +237,8 @@ These are first soft decisions for which values should stay fixed, become protec | Probe-path defaults | Code defaults plus config descriptor | Yes, audited | Defaults remain broad; patterns are anchored/normalized and tested against false positives | | Auto-ban enabled flag | Code default `on` | Yes, bounded | Disabling requires diagnostics; cannot disable Owner recovery, audit, or passive signal recording by accident | | Auto-ban TTLs and escalation windows | Code/config defaults | Yes, bounded | No permanent bans; IP-ban TTL stays below the documented max and IP retention ceiling | -| Rate-limit thresholds and windows | Named code/config defaults | Yes, bounded | Lower values that affect login, scheduler, captcha, or recovery require false-positive/recovery tests; higher public-entry values require policy review | +| Rate-limit mode | Owner-gated Security setting with `off`, `standard`, `strict`, and `panic` | Yes, bounded to those modes first | `off` bypasses limiter consume calls only; suspicious probes, passive signals, auth, ACL, CSRF, audit, and diagnostics stay active | +| Rate-limit thresholds and windows | Dedicated code-level policy catalogue plus derived profile scaling | Yes, later at the catalogue boundary | Lower values that affect login, scheduler, captcha, or recovery require false-positive/recovery tests; higher public-entry values require policy review; future config-backed tuning should attach at the catalogue boundary | | Setup apply/finalization bucket | Named code/config default | Yes, bounded | Must avoid installer lockout; stricter values need documented CLI/manual recovery | | High-impact admin action costs | Action-cost catalogue constants | Possibly later | Authorization, confirmation, audit, and redaction stay mandatory; Owner ordinary-rate exemption does not bypass workflow safety | | Admin/Owner action authority matrix | Code-owned registry plus seeded `acl.admin.features` defaults | Owner-only bounded `Settings/ACL` overrides for descriptor-approved rows | Navigation is not enforcement; Owner-only actions require service/API/live-operation checks; unsafe delegation remains invalid; ACL groups may explicitly grant or restrict specific permissions only after the relevant surface gate is satisfied | diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index c2aba44b..1d7b16ff 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -1,7 +1,7 @@ # Rate enforcement branch plan > **Status**: Draft -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Define the `feat-security-rate-enforcement` implementation plan. @@ -27,44 +27,58 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Implementation sequence -1. Configure named Symfony limiters for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, setup apply, and any already-present contact/import/captcha-failure/high-impact admin flows. -2. Add a rate decision service that maps classified intents and subjects to one or more limiter consumes. -3. Use costed `consume(n)` calls based on the action-cost catalogue. -4. Add scoped `reset()` calls after successful password login and verified provider-backed captcha validation where the workflow explicitly allows it. -5. Add stable `429` rendering: HTML through the shared error renderer for browser workflows and JSON through API responders for versioned API/scheduler flows. -6. Explicitly exclude `/api/live/**` from ordinary rate-limit rejection while preserving passive signal recording. +1. Add a small rate-limit policy catalogue that owns bucket descriptors, profile scaling, retry metadata, reset eligibility, and diagnostics labels separately from request intent classification. +2. Configure named Symfony limiters or build descriptor-derived Symfony limiter factories for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, setup apply, captcha failure, and any already-present import/high-impact admin flows. +3. Add a rate decision service that maps classified intents and subjects to one or more limiter consumes. +4. Use costed `consume(n)` calls based on the action-cost catalogue. +5. Add scoped `reset()` calls after successful password login and verified provider-backed captcha validation where the workflow explicitly allows it. +6. Add stable `429` rendering: HTML through the shared error renderer for browser workflows and JSON through API responders for versioned API/scheduler flows. +7. Explicitly exclude `/api/live/**` from ordinary rate-limit rejection while preserving passive signal recording. ## Public interfaces and data decisions - Rate-limit policy remains application-owned; packages may request classification later but do not define raw Symfony limiter names. - Response metadata includes retry timing where Symfony provides it, without exposing internal bucket identifiers. - Config names use stable system/security namespaces; thresholds are defaults that can become Admin settings later. +- Action costs remain semantic and profile-independent. The action-cost catalogue maps request intent to bucket family and credit cost; the rate-limit policy catalogue maps bucket families to capacity, window, TTL/retry metadata, reset eligibility, and diagnostics. +- Bucket descriptors live in a small dedicated PHP catalogue class so later config-backed threshold tuning can attach at one boundary without changing classifiers, subscribers, or controllers. +- Descriptor limits are stored as credit budgets generated from user-visible action counts. For example, a three-submission registration policy with a five-credit registration cost is represented as a 15-credit bucket so Symfony `consume(n)` never exceeds the bucket capacity and accidentally degrades open. This automatic multiplication is used only for bucket families with one unique action cost. Strict and panic scaling keep a two-action credit floor for ordinary buckets so at least two legitimate costed requests fit in every derived profile window. Suspicious probes and scheduler triggers intentionally keep their explicit single-action floors. +- The first Admin setting for rate limiting is a single Owner-gated Security setting with four modes: `off`, `standard`, `strict`, and `panic`. `standard` is the default. `strict` and `panic` derive from the standard bucket descriptors with fixed multipliers instead of duplicating every threshold by hand. +- `off` is handled by one central facade gate that returns an allowed decision without calling Symfony limiter storage. It does not disable authentication, authorization, CSRF, passive abuse signals, suspicious-probe `400` handling, audit, or diagnostics. - Registration and password-reset success do not reset global buckets by default. - The branch must commit initial threshold defaults from the Security policy defaults as named configuration/constants with behavior tests. Later branches may tune those defaults only with matching draft/worklog notes. - Captcha-triggered limiter resets or `429` recovery require verified provider-backed challenge success. Provider `none`, missing-provider, and disabled-provider auto-success must not reset or refill any bucket. -- Website global policy uses separate deliberate burst and sustained buckets. Turbo/browser prefetch uses a separate lower-confidence observation path so speculative `GET` requests do not exhaust user-facing navigation budgets. -- Scheduler trigger policy must allow normal once-per-minute external cron calls; task due-state logic, locks, and task policies decide whether work actually runs. -- Authenticated users receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define its own explicit bucket. Owner-owned API keys and subjects tied to an active Owner session are exempt from ordinary rate-limit rejection. -- Recovery login bypass uses its own narrow bucket and only bypasses pre-login ban/rate checks needed to render the normal login form. It must not bypass CSRF, credential checks, login-failure accounting, audit logging, or post-login policy re-evaluation. +- Captcha failure gets a dedicated bucket descriptor and the rate facade must expose a scoped reset interface that future verified captcha providers can call. The branch must not add dead captcha routes, providers, or unreachable workflow wiring before the captcha contract/provider branches exist. +- Website global policy uses separate deliberate burst and sustained buckets. Turbo/browser prefetch uses a separate lower-confidence observation path so speculative `GET` requests do not exhaust user-facing navigation budgets. Sensitive safe `GET` workflows such as recovery-login bypass renders and Admin export/download/diagnostic reads are classified before prefetch forgiveness, so spoofable prefetch headers cannot skip their dedicated buckets. +- Deliberate browser burst and sustained protection are explicit bucket descriptors derived from understandable product values. The technical Symfony limiter configuration may be generated or mapped from those descriptors, but review should happen against the catalogue values. +- Scheduler trigger policy must allow normal once-per-minute external cron calls in `standard`, then enforce one trigger per 15 minutes in `strict` and one trigger per hour in `panic`; task due-state logic, locks, and task policies decide whether work actually runs. This is an operational pre-auth interval guard for the exact `/cron/run` route, not an abuse/security signal source for legitimate configured cron callers. Other reserved `/cron/*` paths must not spend the scheduler interval bucket. Submitted scheduler credentials are HMAC-redacted and the scheduler interval keeps IP secondary anchoring even when a browser user session is also present, so rotating invalid query credentials cannot bypass the interval from the same source. +- API, scheduler, setup, generated-asset, profiler, and toolbar roots are raw prefixless technical route scopes: they remain locale-aware through the resolved request locale, but URL locale prefixes are not accepted as aliases for these routes. URL locale-prefix stripping is allowed only for explicit locale-prefix UI/account scopes. A localized lookalike such as `/de/cron/run` or `/de/api/v1/status` is browser/content traffic unless a real technical route is registered there, and must not spend scheduler/API buckets or receive scheduler/API JSON error shapes. +- Authenticated users receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define its own explicit bucket. Owner-owned API keys and subjects tied to an active Owner session are exempt from ordinary rate-limit rejection, except `/cron/run`, where the mutable Owner API key must still spend the scheduler bucket. Mutating API requests made with a read-only Owner API key, including credentialed `OPTIONS` preflights whose `Access-Control-Request-Method` is unsafe, must spend the write/admin bucket before the read-only denial is returned. +- Scheduler `429` responses are expected operational feedback when the external caller runs more frequently than the selected profile allows. They should not create passive security signals or extra abuse diagnostics by themselves; the scheduler caller already observes the response and can adjust its interval. +- Recovery login bypass is the exact `/user/login?bypass=1` browser `GET` path. It uses its own narrow bucket, bypasses ordinary website buckets needed to render the normal login form, and must not bypass CSRF, credential checks, login-failure accounting, audit logging, or post-login policy re-evaluation. Unsafe login submissions with `bypass=1` are still normal login attempts and spend the login workflow bucket. - Workflows that do not exist in the current codebase receive catalogue entries only when doing so does not create dead services, routes, or unreachable tests. -- Limiter keys come only from the shared subject/client-identity resolver and never from raw request headers or user-submitted identifiers. -- Limiter storage degradation must be explicit and tested, including safe diagnostics and Owner recovery behavior. +- Limiter keys come only from the shared subject/client-identity resolver. Raw request headers, API-key material, usernames, email addresses, scheduler credentials, and other user-submitted identifiers must never become keys directly; workflow account subjects and scheduler credential subjects are normalized and HMAC-redacted before they can be used for login, registration, password-reset, or scheduler interval buckets. +- Limiter storage degradation is fail-open by policy: the facade allows the request, records safe Message-layer diagnostics where possible, and preserves Owner recovery instead of returning an invisible hard block. Symfony limiter state is isolated by descriptor capacity/window shape so profile changes do not reuse stale fixed-window state, and cache-backed consume operations use the configured Symfony lock factory. +- Multi-bucket decisions are planned before credits are spent. The facade checks every descriptor/subject consume candidate first and only commits the batch when all candidates still have capacity. If any candidate is already exhausted, the request is rejected against that descriptor without decrementing earlier workflow, global, or account-scoped buckets. This is an intentional lightweight all-or-nothing policy, not a cross-bucket transaction: concurrent requests may still race between pre-check and commit, but per-key limiter locking bounds that race to a single timing-dependent request, after which the exhausted bucket is visible to later pre-checks. The branch accepts that residual race instead of adding complex distributed transaction/rollback logic because it is not a practical account-bucket poisoning vector. - Enforcement follows the Security policy order so workflow buckets, global buckets, suspicious buckets, active bans, recovery-login rendering, and Owner/Admin protections interact predictably. - Rate-limit responses use the documented response semantics: `429`, `Retry-After` when available, family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. +- Suspicious-probe handling runs before package loading and response-producing API availability, setup redirect, and maintenance gates. Probe responses force the shared minimal bare `400 Invalid Request` HTML shape for browser probes while the response-time passive signal recorder can still persist the security signal. Profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. - This branch owns `no-store` behavior for rate-limit, block-adjacent recovery, browser/API/scheduler error, and sensitive retry responses it touches. It should also carry the production HTTP security-header follow-up forward to a dedicated response-hardening/frontend-delivery slice: define and test CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and documented route exceptions without broadening this branch into a full frontend policy rewrite. - Threshold/window configuration should be represented through named policy descriptors with units, defaults, min/max bounds, disabled behavior, and diagnostics labels, even if the first implementation keeps those descriptors as code constants. - Configurable threshold windows must not exceed the retention of the evidence they evaluate. If a bucket or mixed-signal policy depends on database projections, security signals, IP buckets, or audit context with shorter retention, validation must reject or clamp longer windows and expose a clear diagnostic rather than evaluating missing historical data. -- Valid CORS preflights should be cheap and must not spend mutating API budget; invalid preflight probing may spend suspicious/API metadata budget and record passive signals. +- Valid anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry any non-empty `Authorization` header are credentialed preflights, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin bucket by `Access-Control-Request-Method` when the request reaches rate enforcement. API authentication itself remains Bearer-only; unrelated schemes may still be anonymous only for endpoint-defined public reads on non-preflight requests. +- The API integration should choose the smallest reviewable ordering that preserves the policy: valid CORS preflights stay cheap, ordinary API reads/writes are charged after authentication has resolved valid API-key subjects and before authorization failures where practical, invalid API credentials charge stable Visitor/IP fallback buckets through authentication-failure handling, and existing API availability/error boundaries remain stable. - High-impact authenticated/admin operations should use explicit action costs or workflow buckets where the current codebase exposes them. Owner ordinary-rate-limit exemption does not remove workflow confirmation, Admin/Owner action authorization, audit, or redaction requirements. -- Setup finalization/apply attempts need a dedicated workflow bucket because this surface exists before normal Owner/Admin session protections are available. +- Setup finalization/apply attempts need a dedicated workflow bucket because this surface exists before normal Owner/Admin session protections are available. Before `APP_SETUP_COMPLETED`, the rate-limit subscriber must not call Config, DBAL, subject resolution, limiter storage, or content-backed error rendering for ordinary setup wizard traffic; only static default probe-path matching and the exact final `POST /setup/review` `_setup_action=apply` limiter path may run. The final apply limiter may resolve the rate-limit mode through the DB-ready/default-backed Config fallback and use cache/lock limiter storage, while setup-time probe responses and setup-apply `429` responses must use the shared DB-free minimal HTML `no-store` error-renderer bare response path. Shared browser rendering for all known `4xx`/`5xx` statuses must also stay minimal and DB-free until setup completes. ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- Failed login consumes login and global website budget; successful login resets only the login-attempt bucket for that subject. +- If a request has no explicit primary descriptor and is not otherwise excluded, Browser/Admin/Editor traffic should still fall back to the global website burst/sustained buckets. This keeps descriptor gaps such as safe Admin navigation from becoming unlimited crawl/refresh surfaces. +- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail, including manual `POST /user/login?bypass=1` requests; unsafe invalid API credentials consume stable Visitor/IP fallback buckets through the same authentication-failure path, including high-impact Admin API mutation families. Safe login, registration, and password-reset form renders do not spend workflow-specific buckets. Submitted-account workflow buckets always consume local Visitor/IP guards before HMAC-redacted account/email subjects so a source that is already locally blocked cannot poison other users' shared account buckets. Account-token workflows such as `/user/invitation/{token}` and `/user/reset-password/{token}` use HMAC-redacted token subjects so the same leaked token is shared across visitors without exposing token material or requiring database lookup during subject resolution. Recovery-login bypass renders are the explicit exception: `/user/login?bypass=1` `GET` spends the dedicated recovery-login bucket while bypassing ordinary website buckets. Successful login resets only the login-attempt bucket for the same subject keys, including HMAC-redacted submitted-account keys, and the active rate profile. - Read-only API keys hitting write routes should still follow API write policy before or alongside authorization failure as decided by the handler order. - CORS preflight storms should not block legitimate configured browser clients through the write limiter, but invalid origin/header/method scans should remain visible to abuse diagnostics. -- `/api/live/**` operation polling must continue to function during long admin operations. +- `/api/live/**` operation polling must continue to function during long admin operations, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. - Export/download/log/support-bundle endpoints must use `no-store`, redaction, and permission checks even when the rate limiter allows them. - Concurrent failures and immediate success/reset sequences must not accidentally reset unrelated global buckets or hide suspicious mixed-action behavior. - HTML `429` pages may render a captcha recovery step only when an active provider can render and validate a real challenge. Without that provider, use ordinary retry-after behavior. @@ -73,28 +87,33 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test each guarded workflow below and above threshold. - Test global burst and sustained website budgets catch mixed suspicious actions without counting static assets or ordinary `/api/live/**` polling. -- Test Turbo/browser prefetch does not exhaust deliberate website buckets and still records passive signals for excessive speculative traffic. -- Test scheduler triggers allow normal minutely cron calls while still limiting obvious trigger storms. -- Test setup apply, CORS preflight, high-impact admin operation, Admin-vs-Owner authority outcomes, export/download, and upload/archive validation classification attach to the expected buckets when those workflows exist. -- Test authenticated-user higher limits and Owner ordinary-rate-limit exemptions for active sessions and Owner-owned API keys. -- Test recovery-login bypass rendering, dedicated recovery bucket exhaustion, retry-after behavior, and successful-login policy re-evaluation. +- Test Turbo/browser prefetch does not exhaust deliberate website buckets, does not bypass sensitive recovery/export/download classifications, and still records passive signals for excessive speculative traffic. +- Test scheduler triggers allow normal minutely cron calls while still limiting repeated trigger attempts by stable redacted scheduler credential, even when cookies or user agents change, and by IP secondary anchoring when invalid submitted scheduler credentials rotate under an authenticated browser context. +- Test setup apply, CORS preflight, high-impact admin operation, Admin-vs-Owner authority outcomes, export/download, and upload/archive validation classification attach to the expected buckets when those workflows exist. Setup wizard navigation/database-test/backtracking posts before final review apply must not spend the setup-apply bucket or invoke DB-backed rate-limit services before setup completion; the exact final apply post must still reach the setup-apply limiter. +- Test authenticated-user higher limits and Owner ordinary-rate-limit exemptions for active sessions and Owner-owned API keys, plus the explicit read-only Owner API key write-denial exception for both actual unsafe requests and credentialed preflights with unsafe requested methods. +- Test that valid authenticated browser/API requests are evaluated after Symfony authentication, while failed login/API credentials still spend stable workflow buckets through authentication-failure events. +- Test recovery-login bypass rendering through the normal request stage, dedicated recovery bucket exhaustion, retry-after behavior, and successful-login policy re-evaluation. - Test policy descriptor validation for invalid, missing, overly permissive, and overly restrictive threshold/window values where configuration is introduced. +- Test profile resolution for `off`, `standard`, `strict`, and `panic`, including the central `off` facade gate that performs no limiter consume. +- Test that strict and panic profile values derive from the standard catalogue descriptors through documented multipliers. - Test that configurable limiter and mixed-signal windows are rejected or clamped when they exceed the retained evidence required by that policy. -- Test successful login resets only the login bucket. +- Test successful login resets only the login bucket, including the submitted-account key used by failed login enforcement. - Test verified captcha success can reset only the configured scoped bucket, while provider `none`/missing/disabled success resets nothing. +- Test the captcha failure bucket descriptor and the dormant scoped reset interface without wiring a non-existing captcha provider. - Test captcha-on-`429` is unavailable without an active provider and falls back to retry-after behavior. - Test `/api/live/**` never receives ordinary rate-limit `429`. -- Test browser HTML and API JSON `429` shapes. +- Test browser HTML and API/scheduler JSON `429` shapes, including path-boundary checks so lookalike browser content such as `/cronjobs` does not receive scheduler JSON. - Test response cache headers and redaction for browser/API/scheduler limit failures. - Test that any `no-store` headers added in this branch are route-scoped and do not claim to complete the full production HTTP security-header policy until the dedicated response-hardening/frontend-delivery slice defines CSP and related headers. - Test that non-existing optional workflows are not wired as dead routes/services and that later workflow branches have a clear catalogue attachment point. -- Test limiter storage degradation and concurrent consume/reset behavior for the highest-risk workflows. +- Test limiter storage degradation, profile-isolated limiter state, and locked consume behavior for the highest-risk workflows. - Test configured limiter service wiring with `lint:container`. ## Documentation and tracking - Update Security draft thresholds and reset behavior. - Update Security policy defaults if implementation evidence changes any threshold, subject, or reset policy. +- Update the existing Security settings page with the Owner-gated rate-limit mode setting and matching translations when the implementation lands. - Update API/Scheduler notes for JSON `429` behavior. - Keep the HTTP security-header production-hardening follow-up linked from this branch if the full policy is still deferred after rate enforcement. - Update class map for facade/enforcement services. @@ -111,3 +130,4 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Known workflows are rate-limited through one facade. - Successful human outcomes can clear scoped local buckets without weakening global abuse detection. +- Browser/API/scheduler rate-limit responses are redacted, include only safe request references, and use `no-store` plus `Retry-After` where available. diff --git a/dev/manual/local-agent-tooling-snippets.md b/dev/manual/local-agent-tooling-snippets.md index dbf824ab..25517f1e 100644 --- a/dev/manual/local-agent-tooling-snippets.md +++ b/dev/manual/local-agent-tooling-snippets.md @@ -23,7 +23,7 @@ Project-wide developer commands that are useful for agent workflows live outside | `bin/lint` | Runs the full project lint suite or focused lint checks. | | `bin/lint --diff` | Lints supported files from the current staged and unstaged Git diff when Git and a work tree are available. | | `bin/lint --staged` | Lints supported files staged for commit when Git and a work tree are available. | -| `php bin/console render:route /path` | Renders a route through the Symfony kernel with optional debug `--role`, `--user`, `--method`, `--host`, `--https`, and `--setup-completed=0` context. | +| `php bin/console render:route /path` | Renders a route through the Symfony kernel in development/test environments with optional debug `--role`, `--user`, `--method`, `--host`, `--https`, `--header`, `--include-headers`, and `--setup-completed=0` context. | ## Cleanup notes diff --git a/importmap.php b/importmap.php index e3f26ccb..bd4dee3f 100755 --- a/importmap.php +++ b/importmap.php @@ -28,13 +28,9 @@ '@hotwired/stimulus' => ['version' => '3.2.2'], '@hotwired/turbo' => ['version' => '8.0.23'], 'codemirror' => ['version' => '6.0.2'], - '@codemirror/view' => ['version' => '6.43.0'], '@codemirror/state' => ['version' => '6.6.0'], '@codemirror/language' => ['version' => '6.12.3'], '@codemirror/commands' => ['version' => '6.10.3'], - '@codemirror/search' => ['version' => '6.7.0'], - '@codemirror/autocomplete' => ['version' => '6.20.2'], - '@codemirror/lint' => ['version' => '6.9.6'], 'style-mod' => ['version' => '4.1.3'], 'w3c-keyname' => ['version' => '2.2.8'], 'crelt' => ['version' => '1.0.6'], @@ -56,7 +52,6 @@ '@codemirror/lang-json' => ['version' => '6.0.2'], '@lezer/json' => ['version' => '1.0.3'], '@codemirror/lang-markdown' => ['version' => '6.5.0'], - '@lezer/markdown' => ['version' => '1.6.3'], 'tom-select' => ['version' => '2.6.1'], '@orchidjs/sifter' => ['version' => '1.1.0'], '@orchidjs/unicode-variants' => ['version' => '1.1.2'], @@ -65,7 +60,6 @@ 'tom-select/dist/css/tom-select.bootstrap4.css' => ['version' => '2.6.1', 'type' => 'css'], 'tom-select/dist/css/tom-select.bootstrap5.css' => ['version' => '2.6.1', 'type' => 'css'], 'chart.js' => ['version' => '4.5.1'], - '@kurkle/color' => ['version' => '0.3.4'], 'cropperjs' => ['version' => '1.6.2'], 'cropperjs/dist/cropper.min.css' => ['version' => '1.6.2', 'type' => 'css'], '@symfony/ux-live-component' => ['path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js'], @@ -92,4 +86,10 @@ 'leaflet' => ['version' => '1.9.4'], 'leaflet/dist/leaflet.min.css' => ['version' => '1.9.4', 'type' => 'css'], '@symfony/ux-leaflet-map' => ['path' => './vendor/symfony/ux-leaflet-map/assets/dist/map_controller.js'], + '@codemirror/lint' => ['version' => '6.9.7'], + '@lezer/markdown' => ['version' => '1.6.4'], + '@kurkle/color' => ['version' => '0.4.0'], + '@codemirror/autocomplete' => ['version' => '6.20.3'], + '@codemirror/view' => ['version' => '6.43.1'], + '@codemirror/search' => ['version' => '6.7.1'], ]; diff --git a/src/Api/Http/ApiTraceHeaderSubscriber.php b/src/Api/Http/ApiTraceHeaderSubscriber.php index 1037a1bd..78032872 100644 --- a/src/Api/Http/ApiTraceHeaderSubscriber.php +++ b/src/Api/Http/ApiTraceHeaderSubscriber.php @@ -4,6 +4,7 @@ namespace App\Api\Http; +use App\Api\Security\ApiRequestMethodPolicy; use App\Core\Log\AccessRequestMetadata; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ResponseEvent; @@ -11,8 +12,10 @@ final readonly class ApiTraceHeaderSubscriber implements EventSubscriberInterface { - public function __construct(private AccessRequestMetadata $accessRequestMetadata) - { + public function __construct( + private AccessRequestMetadata $accessRequestMetadata, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } /** @@ -27,7 +30,7 @@ public static function getSubscribedEvents(): array public function onKernelResponse(ResponseEvent $event): void { - if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api/v1')) { + if (!$event->isMainRequest() || !$this->methodPolicy->isApiV1Request($event->getRequest())) { return; } diff --git a/src/Api/Security/ApiAvailabilitySubscriber.php b/src/Api/Security/ApiAvailabilitySubscriber.php index 5ff4f230..562bfa4f 100644 --- a/src/Api/Security/ApiAvailabilitySubscriber.php +++ b/src/Api/Security/ApiAvailabilitySubscriber.php @@ -16,6 +16,7 @@ public function __construct( private ApiAvailabilityCheckerInterface $availabilityChecker, private ApiUnavailableResponder $unavailableResponder, private ApiFeaturePolicy $apiFeaturePolicy, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -28,7 +29,7 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { - if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api/v1')) { + if (!$event->isMainRequest() || !$this->methodPolicy->isApiV1Request($event->getRequest())) { return; } diff --git a/src/Api/Security/ApiContentTypeSubscriber.php b/src/Api/Security/ApiContentTypeSubscriber.php index ddee9965..7a5f0920 100644 --- a/src/Api/Security/ApiContentTypeSubscriber.php +++ b/src/Api/Security/ApiContentTypeSubscriber.php @@ -20,6 +20,7 @@ public function __construct( private ApiEndpointRegistry $endpoints, private ApiResponder $responder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -40,7 +41,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiCorsSubscriber.php b/src/Api/Security/ApiCorsSubscriber.php index 1367b077..aa40905c 100644 --- a/src/Api/Security/ApiCorsSubscriber.php +++ b/src/Api/Security/ApiCorsSubscriber.php @@ -18,8 +18,10 @@ private const ALLOWED_HEADERS = 'Authorization, Content-Type, Accept, Accept-Language, X-Correlation-ID, X-Request-ID'; private const EXPOSED_HEADERS = 'X-Request-ID, X-Correlation-ID'; - public function __construct(private ApiFeaturePolicy $apiFeaturePolicy) - { + public function __construct( + private ApiFeaturePolicy $apiFeaturePolicy, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } /** @@ -40,7 +42,11 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!$this->isApiRequest($request) || !$this->isPreflight($request)) { + if (!$this->methodPolicy->isApiV1Request($request) || !$this->methodPolicy->isCorsPreflight($request)) { + return; + } + + if ($this->methodPolicy->hasAuthorizationHeader($request)) { return; } @@ -61,7 +67,7 @@ public function onKernelResponse(ResponseEvent $event): void } $request = $event->getRequest(); - if (!$this->isApiRequest($request)) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } @@ -73,18 +79,6 @@ public function onKernelResponse(ResponseEvent $event): void $this->applyHeaders($event->getResponse(), $origin); } - private function isApiRequest(Request $request): bool - { - return str_starts_with($request->getPathInfo(), '/api/v1'); - } - - private function isPreflight(Request $request): bool - { - return $request->isMethod(Request::METHOD_OPTIONS) - && is_string($request->headers->get('Origin')) - && is_string($request->headers->get('Access-Control-Request-Method')); - } - private function allowedOrigin(Request $request): ?string { if (!$this->apiFeaturePolicy->corsEnabled()) { diff --git a/src/Api/Security/ApiDatabaseExceptionSubscriber.php b/src/Api/Security/ApiDatabaseExceptionSubscriber.php index bd4548f6..07ce0101 100644 --- a/src/Api/Security/ApiDatabaseExceptionSubscriber.php +++ b/src/Api/Security/ApiDatabaseExceptionSubscriber.php @@ -11,8 +11,10 @@ final readonly class ApiDatabaseExceptionSubscriber implements EventSubscriberInterface { - public function __construct(private ApiUnavailableResponder $unavailableResponder) - { + public function __construct( + private ApiUnavailableResponder $unavailableResponder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } public static function getSubscribedEvents(): array @@ -24,7 +26,7 @@ public static function getSubscribedEvents(): array public function onKernelException(ExceptionEvent $event): void { - if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api/v1')) { + if (!$event->isMainRequest() || !$this->methodPolicy->isApiV1Request($event->getRequest())) { return; } diff --git a/src/Api/Security/ApiEndpointAccessSubscriber.php b/src/Api/Security/ApiEndpointAccessSubscriber.php index 697392a9..6c3467f6 100644 --- a/src/Api/Security/ApiEndpointAccessSubscriber.php +++ b/src/Api/Security/ApiEndpointAccessSubscriber.php @@ -23,6 +23,7 @@ public function __construct( private ApiEndpointRegistry $endpoints, private ApiResponder $responder, private SystemPackageMetadataProvider $systemPackageMetadata, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -40,7 +41,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiEndpointPermissionSubscriber.php b/src/Api/Security/ApiEndpointPermissionSubscriber.php index 93d85e75..0829c4ba 100644 --- a/src/Api/Security/ApiEndpointPermissionSubscriber.php +++ b/src/Api/Security/ApiEndpointPermissionSubscriber.php @@ -23,6 +23,7 @@ public function __construct( private ApiEndpointRegistry $endpoints, private ApiEndpointAccessPolicy $policy, private ApiResponder $responder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -40,7 +41,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiKeyAuthenticator.php b/src/Api/Security/ApiKeyAuthenticator.php index 7169dcd7..3c02f067 100644 --- a/src/Api/Security/ApiKeyAuthenticator.php +++ b/src/Api/Security/ApiKeyAuthenticator.php @@ -27,12 +27,13 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ApiKeyVault $apiKeyVault, private readonly ApiSecurityHandler $securityHandler, + private readonly ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } public function supports(Request $request): ?bool { - return str_starts_with($request->getPathInfo(), '/api/v1') + return $this->methodPolicy->isApiV1Request($request) && $this->hasBearerAuthorizationScheme($request); } diff --git a/src/Api/Security/ApiMaintenanceModeSubscriber.php b/src/Api/Security/ApiMaintenanceModeSubscriber.php index 314b8b03..76fbf0da 100644 --- a/src/Api/Security/ApiMaintenanceModeSubscriber.php +++ b/src/Api/Security/ApiMaintenanceModeSubscriber.php @@ -15,6 +15,7 @@ public function __construct( private bool $maintenanceEnabled, private ApiUnavailableResponder $unavailableResponder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -28,7 +29,7 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); - if (!$this->maintenanceEnabled || !$event->isMainRequest() || $event->hasResponse() || !str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->maintenanceEnabled || !$event->isMainRequest() || $event->hasResponse() || !$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiReadOnlyMethodSubscriber.php b/src/Api/Security/ApiReadOnlyMethodSubscriber.php index 65c69f71..e8f91a04 100644 --- a/src/Api/Security/ApiReadOnlyMethodSubscriber.php +++ b/src/Api/Security/ApiReadOnlyMethodSubscriber.php @@ -18,8 +18,10 @@ final readonly class ApiReadOnlyMethodSubscriber implements EventSubscriberInterface { - public function __construct(private ApiResponder $responder) - { + public function __construct( + private ApiResponder $responder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } public static function getSubscribedEvents(): array @@ -42,7 +44,7 @@ public function onKernelRequest(RequestEvent $event): void return; } - if ($this->isAllowedReadOnlyMethod($request)) { + if ($this->methodPolicy->isSafeEffectiveMethod($request)) { return; } @@ -57,12 +59,4 @@ public function onKernelRequest(RequestEvent $event): void )); } - private function isAllowedReadOnlyMethod(Request $request): bool - { - return in_array($request->getMethod(), [ - Request::METHOD_GET, - Request::METHOD_HEAD, - Request::METHOD_OPTIONS, - ], true); - } } diff --git a/src/Api/Security/ApiRequestMethodPolicy.php b/src/Api/Security/ApiRequestMethodPolicy.php new file mode 100644 index 00000000..1fc0f7fd --- /dev/null +++ b/src/Api/Security/ApiRequestMethodPolicy.php @@ -0,0 +1,71 @@ +paths = $paths ?? new PathScopeMatcher(); + } + + public function isApiV1Request(Request $request): bool + { + return $this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1'); + } + + public function isCorsPreflight(Request $request): bool + { + return $request->isMethod(Request::METHOD_OPTIONS) + && is_string($request->headers->get('Origin')) + && is_string($request->headers->get('Access-Control-Request-Method')); + } + + public function isCredentialedOptions(Request $request): bool + { + return $request->isMethod(Request::METHOD_OPTIONS) && $this->hasAuthorizationHeader($request); + } + + public function hasAuthorizationHeader(Request $request): bool + { + return '' !== trim((string) $request->headers->get('Authorization', '')); + } + + public function effectiveMethod(Request $request): string + { + if ($request->isMethod(Request::METHOD_OPTIONS)) { + return $this->requestedPreflightMethod($request) + ?? ($this->isCredentialedOptions($request) ? Request::METHOD_GET : Request::METHOD_OPTIONS); + } + + return strtoupper($request->getMethod()); + } + + public function isSafeEffectiveMethod(Request $request): bool + { + return $this->isSafeMethod($this->effectiveMethod($request)); + } + + public function isSafeMethod(string $method): bool + { + return in_array(strtoupper($method), [ + Request::METHOD_GET, + Request::METHOD_HEAD, + Request::METHOD_OPTIONS, + ], true); + } + + public function requestedPreflightMethod(Request $request): ?string + { + $method = $request->headers->get('Access-Control-Request-Method'); + + return is_string($method) && '' !== trim($method) ? strtoupper(trim($method)) : null; + } +} diff --git a/src/Backend/AdminControllerContext.php b/src/Backend/AdminControllerContext.php index a713fb92..992afe36 100644 --- a/src/Backend/AdminControllerContext.php +++ b/src/Backend/AdminControllerContext.php @@ -31,7 +31,7 @@ public function accessResponse(Request $request, mixed $user): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); diff --git a/src/Command/RenderRouteCommand.php b/src/Command/RenderRouteCommand.php index 5875ac21..b6a940a5 100644 --- a/src/Command/RenderRouteCommand.php +++ b/src/Command/RenderRouteCommand.php @@ -21,8 +21,10 @@ )] final class RenderRouteCommand extends Command { - public function __construct(private readonly RouteRenderer $renderer) - { + public function __construct( + private readonly RouteRenderer $renderer, + private readonly string $environment, + ) { parent::__construct(); } @@ -35,14 +37,22 @@ protected function configure(): void ->addOption('user', null, InputOption::VALUE_REQUIRED, 'Existing username to render as.') ->addOption('host', null, InputOption::VALUE_REQUIRED, 'HTTP host for the synthetic request.', 'localhost') ->addOption('https', null, InputOption::VALUE_NONE, 'Render the request as HTTPS.') + ->addOption('header', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'HTTP header to send with the synthetic request, for example "Accept: application/json".') ->addOption('setup-completed', null, InputOption::VALUE_REQUIRED, 'Set to 0 to render setup-required routes without the debug completion bypass.', '1') - ->addOption('include-status', null, InputOption::VALUE_NONE, 'Print the response status line before the response body.'); + ->addOption('include-status', null, InputOption::VALUE_NONE, 'Print the response status line before the response body.') + ->addOption('include-headers', null, InputOption::VALUE_NONE, 'Print response headers before the response body.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + if ('prod' === $this->environment) { + $io->error('The render:route command is available only for development and test environments.'); + + return Command::FAILURE; + } + try { $role = $this->nullableString($input->getOption('role')); $username = $this->nullableString($input->getOption('user')); @@ -59,6 +69,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int setupCompleted: $this->truthy((string) $input->getOption('setup-completed')), host: (string) $input->getOption('host'), secure: (bool) $input->getOption('https'), + headers: $this->headers($input->getOption('header')), )); } catch (\Throwable $error) { $io->error($error->getMessage()); @@ -70,6 +81,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('HTTP %d', $result->statusCode)); } + if ((bool) $input->getOption('include-headers')) { + foreach ($result->headers as $name => $values) { + foreach ($values as $value) { + $output->writeln($name.': '.$value); + } + } + $output->writeln(''); + } + $output->write($result->content); return $result->statusCode >= 500 ? Command::FAILURE : Command::SUCCESS; @@ -94,6 +114,39 @@ private function nullableString(mixed $value): ?string return is_string($value) && '' !== trim($value) ? trim($value) : null; } + /** + * @return array> + */ + private function headers(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + $headers = []; + foreach ($values as $value) { + if (!is_string($value) || !str_contains($value, ':')) { + throw new \InvalidArgumentException('Headers must use "Name: value" syntax.'); + } + + [$name, $headerValue] = explode(':', $value, 2); + $name = trim($name); + $headerValue = trim($headerValue); + + if ('' === $name || 1 !== preg_match('/^[A-Za-z0-9-]+$/', $name)) { + throw new \InvalidArgumentException(sprintf('Header name "%s" is invalid.', $name)); + } + + if (1 === preg_match('/[\r\n\x00]/', $headerValue)) { + throw new \InvalidArgumentException(sprintf('Header "%s" contains unsupported control characters.', $name)); + } + + $headers[$name][] = $headerValue; + } + + return $headers; + } + private function truthy(string $value): bool { return in_array(strtolower(trim($value, " \t\n\r\0\x0B'\"")), ['1', 'true', 'yes'], true); diff --git a/src/Content/Routing/ContentRouteGuard.php b/src/Content/Routing/ContentRouteGuard.php index dddf2f26..95243a09 100644 --- a/src/Content/Routing/ContentRouteGuard.php +++ b/src/Content/Routing/ContentRouteGuard.php @@ -22,6 +22,7 @@ 'api', 'assets', '_profiler', + 'profiler', '_wdt', 'build', 'packages', diff --git a/src/Controller/AdminAclGroupController.php b/src/Controller/AdminAclGroupController.php index 27862a17..e7497652 100644 --- a/src/Controller/AdminAclGroupController.php +++ b/src/Controller/AdminAclGroupController.php @@ -286,7 +286,7 @@ private function updateGroup(Request $request, AclGroup $group): ?Response private function startAclGroupLiveOperation(Request $request, AclGroup $group, string $action, array $payload = []): Response { if (!$this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser()))) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => self::FEATURE, 'required_state' => 'mutable', ]); @@ -324,7 +324,7 @@ private function featureResponse(Request $request, bool $mutable): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => self::FEATURE, 'required_state' => $mutable ? 'mutable' : 'visible', ]); diff --git a/src/Controller/AdminOperationController.php b/src/Controller/AdminOperationController.php index a7a432d6..391b51fc 100644 --- a/src/Controller/AdminOperationController.php +++ b/src/Controller/AdminOperationController.php @@ -116,7 +116,7 @@ public function detail(Request $request, string $operationId): Response $report = $this->liveOperationRunStore->report($operationId); if (null === $report) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'area' => BackendArea::Admin->value, 'operation_id' => $operationId, ]); @@ -157,7 +157,7 @@ public function continue(Request $request, string $operationId): Response $targetFeature = $this->operationFeatures->mutationFeatureForOperation((string) $continuation['operation']); if (is_string($targetFeature) && !$this->adminAcl->isMutable($targetFeature, $this->adminContext->actor($this->getUser()))) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => $targetFeature, 'required_state' => 'mutable', ]); @@ -204,7 +204,7 @@ private function featureResponse(Request $request, bool $mutable): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => self::FEATURE, 'required_state' => $mutable ? 'mutable' : 'visible', ]); diff --git a/src/Controller/AdminPackageController.php b/src/Controller/AdminPackageController.php index c0671eb0..ee52ab84 100644 --- a/src/Controller/AdminPackageController.php +++ b/src/Controller/AdminPackageController.php @@ -137,7 +137,7 @@ public function detail(Request $request, string $packageName): Response } if (!$this->adminAcl->isVisible(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, @@ -149,7 +149,7 @@ public function detail(Request $request, string $packageName): Response return $this->backendActionResponder->respond($request, $this->getUser()); } - return $this->httpError->render(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, ]); @@ -158,7 +158,7 @@ public function detail(Request $request, string $packageName): Response $package = $this->packageLifecycleAdmin->package($packageName); if (null === $package) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, ]); @@ -183,7 +183,7 @@ public function lifecycle(Request $request, string $packageName, string $action) $lifecycleState = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor()); if (!$lifecycleState->isVisible()) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, 'action' => $action, @@ -198,7 +198,7 @@ public function lifecycle(Request $request, string $packageName, string $action) $review = $this->packageLifecycleAdmin->review($packageName, $action); if (null === $review['package']) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, 'action' => $action, diff --git a/src/Controller/AdminSchedulerController.php b/src/Controller/AdminSchedulerController.php index af7eda4f..a36d3072 100644 --- a/src/Controller/AdminSchedulerController.php +++ b/src/Controller/AdminSchedulerController.php @@ -62,7 +62,7 @@ public function runNow(Request $request, string $identifier): Response $task = $this->registeredTask($identifier); if (!$task instanceof SchedulerTask) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'task' => $identifier, ]); } @@ -112,7 +112,7 @@ public function detail(Request $request, string $identifier): Response $task = $this->registeredTask($identifier); if (!$task instanceof SchedulerTask) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'task' => $identifier, ]); } @@ -143,7 +143,7 @@ private function featureResponse(Request $request, bool $mutable): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => self::FEATURE, 'required_state' => $mutable ? 'mutable' : 'visible', ]); diff --git a/src/Controller/AdminUserController.php b/src/Controller/AdminUserController.php index 51536523..1d84e06c 100644 --- a/src/Controller/AdminUserController.php +++ b/src/Controller/AdminUserController.php @@ -306,7 +306,7 @@ private function featureResponse(Request $request, bool $mutable): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => self::FEATURE, 'required_state' => $mutable ? 'mutable' : 'visible', ]); diff --git a/src/Controller/AdminUserInvitationController.php b/src/Controller/AdminUserInvitationController.php index 03ad3b8a..afa93ec1 100644 --- a/src/Controller/AdminUserInvitationController.php +++ b/src/Controller/AdminUserInvitationController.php @@ -153,7 +153,7 @@ private function adminAccessResponse(Request $request): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); @@ -165,7 +165,7 @@ private function featureResponse(Request $request, string $feature): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => $feature, 'required_state' => 'mutable', ]); diff --git a/src/Controller/AdminUserReviewController.php b/src/Controller/AdminUserReviewController.php index 58974618..b67dee57 100644 --- a/src/Controller/AdminUserReviewController.php +++ b/src/Controller/AdminUserReviewController.php @@ -193,7 +193,7 @@ private function adminAccessResponse(Request $request): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); @@ -209,7 +209,7 @@ private function featureResponse(Request $request, bool $mutable): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => 'admin.users.review', 'required_state' => $mutable ? 'mutable' : 'visible', ]); diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index ab31dde1..b902307d 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -68,7 +68,7 @@ public function logDetail(Request $request, string $entryId): Response return $access; } if (!$this->adminAcl->isVisible('admin.logs', $this->actor())) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => 'admin.logs', 'required_state' => 'visible', ]); @@ -77,7 +77,7 @@ public function logDetail(Request $request, string $entryId): Response $source = $request->query->get('source', 'message'); $source = is_string($source) ? $source : 'message'; if (in_array($source, ['audit', 'security_signal'], true) && !$this->adminAcl->isMutable('admin.logs', $this->actor())) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'feature' => 'admin.logs', 'required_state' => 'mutable', 'source' => $source, @@ -125,7 +125,7 @@ private function handle(Request $request, BackendArea $area, string $path = ''): $decision = $this->accessGuard->decide($area, $this->getUser()); if (!$decision->isGranted()) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => $area->value, 'access_decision' => $decision->toArray(), ]); @@ -139,7 +139,7 @@ private function handle(Request $request, BackendArea $area, string $path = ''): $view = $result->view(); if (null !== $view && !$this->viewAllows($view, $actor)) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => $area->value, 'view' => $view->uid(), ]); @@ -173,7 +173,7 @@ private function adminAccessResponse(Request $request): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); @@ -279,7 +279,7 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): } if (!$result instanceof FormSubmissionResult) { - return $this->httpError->render(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ 'area' => $view->area()->value, 'view' => $view->uid(), ]); @@ -312,7 +312,7 @@ private function mutationDeniedResponse(Request $request, BackendViewDefinition return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => $view->area()->value, 'view' => $view->uid(), 'access_feature' => $feature, diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 201dfea1..ef6adc12 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -14,6 +14,8 @@ use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use App\Security\UserFlowConfig; use App\View\SystemPackageMetadataProvider; @@ -84,6 +86,14 @@ public function allDefinitions(): array 'persist' => false, 'access_feature' => 'admin.settings.security', ], sortOrder: 30), + new CoreSettingDefinition('security', RateLimitPolicyCatalogue::MODE_KEY, 'admin.settings.fields.rate_limit_mode.label', RateLimitProfile::Standard->value, ConfigValueType::String, FormInputType::Select, options: [ + RateLimitProfile::Off->value => 'admin.settings.options.rate_limit_mode.off', + RateLimitProfile::Standard->value => 'admin.settings.options.rate_limit_mode.standard', + RateLimitProfile::Strict->value => 'admin.settings.options.rate_limit_mode.strict', + RateLimitProfile::Panic->value => 'admin.settings.options.rate_limit_mode.panic', + ], validation: ['required' => true], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 35), new CoreSettingDefinition('security', ConfigAuditLogPolicy::ENABLED_KEY, 'admin.settings.fields.audit_enabled.label', true, ConfigValueType::Boolean, metadata: [ 'access_feature' => 'admin.settings.security', ], sortOrder: 40), diff --git a/src/Core/Log/AccessRequestMetadata.php b/src/Core/Log/AccessRequestMetadata.php index c9ea4ffd..61a46ba7 100644 --- a/src/Core/Log/AccessRequestMetadata.php +++ b/src/Core/Log/AccessRequestMetadata.php @@ -5,6 +5,7 @@ namespace App\Core\Log; use App\Content\Routing\ContentRouteLocalization; +use App\Core\Routing\RequestPathResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,9 +19,11 @@ private const MIN_REQUEST_ID_LENGTH = 8; private const REQUEST_ID_PATTERN = '/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/'; private const REDACTED_SEGMENT = '[redacted]'; + private RequestPathResolver $paths; - public function __construct(private ?ContentRouteLocalization $routeLocalization = null) + public function __construct(?ContentRouteLocalization $routeLocalization = null, ?RequestPathResolver $paths = null) { + $this->paths = $paths ?? new RequestPathResolver($routeLocalization); } public function markStarted(Request $request): void @@ -189,35 +192,7 @@ public function trace(Request $request, string $visitorId): array */ private function segments(Request $request): array { - $segments = array_values(array_filter(explode('/', trim($request->getPathInfo(), '/')), static fn (string $segment): bool => '' !== $segment)); - $locale = $this->localePrefix($request); - - if (is_string($locale) && '' !== $locale && ($segments[0] ?? null) === $locale) { - array_shift($segments); - } - - return $segments; - } - - private function localePrefix(Request $request): ?string - { - $segments = explode('/', trim($request->getPathInfo(), '/')); - $firstSegment = $segments[0] ?? ''; - - if ('' === $firstSegment || !$this->hasLocalizedReservedPath($segments)) { - return null; - } - - $locale = $request->attributes->get('_locale'); - if (is_string($locale) && $firstSegment === $locale) { - return $firstSegment; - } - - if (null !== $this->routeLocalization && $this->routeLocalization->isEnabled() && in_array($firstSegment, $this->routeLocalization->availableLanguages(), true)) { - return $firstSegment; - } - - return null; + return $this->paths->segments($request); } /** @@ -234,14 +209,6 @@ private function matchesSegments(array $pathSegments, string ...$segments): bool return [] !== $segments; } - /** - * @param list $segments - */ - private function hasLocalizedReservedPath(array $segments): bool - { - return in_array($segments[1] ?? '', ['admin', 'api', 'editor', 'setup'], true); - } - /** * @return list */ diff --git a/src/Core/Messenger/DeferredMessengerDrainSubscriber.php b/src/Core/Messenger/DeferredMessengerDrainSubscriber.php index 745667fa..719fed7f 100644 --- a/src/Core/Messenger/DeferredMessengerDrainSubscriber.php +++ b/src/Core/Messenger/DeferredMessengerDrainSubscriber.php @@ -4,14 +4,17 @@ namespace App\Core\Messenger; +use App\Core\Routing\PathScopeMatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\KernelEvents; final readonly class DeferredMessengerDrainSubscriber implements EventSubscriberInterface { - public function __construct(private DeferredMessengerDrain $drain) - { + public function __construct( + private DeferredMessengerDrain $drain, + private PathScopeMatcher $paths = new PathScopeMatcher(), + ) { } /** @@ -31,7 +34,7 @@ public function onKernelTerminate(TerminateEvent $event): void } $request = $event->getRequest(); - if ('scheduler_cron_run' === $request->attributes->get('_route') || str_starts_with($request->getPathInfo(), '/cron/run')) { + if ('scheduler_cron_run' === $request->attributes->get('_route') || $this->paths->matchesExactSegments($request->getPathInfo(), 'cron', 'run')) { return; } diff --git a/src/Core/Routing/PathScopeMatcher.php b/src/Core/Routing/PathScopeMatcher.php new file mode 100644 index 00000000..ebfb59b6 --- /dev/null +++ b/src/Core/Routing/PathScopeMatcher.php @@ -0,0 +1,57 @@ +segments($prefix); + if ([] === $prefixSegments) { + return '/' === $path; + } + + return $this->matchesSegments($path, ...$prefixSegments); + } + + public function matchesAnyPrefix(string $path, string ...$prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($this->matchesPrefix($path, $prefix)) { + return true; + } + } + + return false; + } + + public function matchesSegments(string $path, string ...$segments): bool + { + $pathSegments = $this->segments($path); + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== trim($segment, '/')) { + return false; + } + } + + return [] !== $segments; + } + + public function matchesExactSegments(string $path, string ...$segments): bool + { + return count($this->segments($path)) === count($segments) && $this->matchesSegments($path, ...$segments); + } + + /** + * @return list + */ + private function segments(string $path): array + { + return array_values(array_filter( + explode('/', trim($path, '/')), + static fn (string $segment): bool => '' !== $segment, + )); + } +} diff --git a/src/Core/Routing/RequestPathResolver.php b/src/Core/Routing/RequestPathResolver.php new file mode 100644 index 00000000..b9045806 --- /dev/null +++ b/src/Core/Routing/RequestPathResolver.php @@ -0,0 +1,100 @@ + + */ + private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user']; + + public function __construct(private ?ContentRouteLocalization $routeLocalization = null) + { + } + + /** + * @return list + */ + public function segments(Request $request): array + { + $segments = $this->segmentsFromPath($request->getPathInfo()); + $locale = $this->localePrefix($request, $segments); + + if (is_string($locale) && ($segments[0] ?? null) === $locale) { + array_shift($segments); + } + + return $segments; + } + + public function matches(Request $request, string ...$segments): bool + { + $pathSegments = $this->segments($request); + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== trim($segment, '/')) { + return false; + } + } + + return [] !== $segments; + } + + public function matchesExact(Request $request, string ...$segments): bool + { + return count($this->segments($request)) === count($segments) && $this->matches($request, ...$segments); + } + + /** + * @param list ...$scopes + */ + public function matchesAny(Request $request, array ...$scopes): bool + { + foreach ($scopes as $scope) { + if ($this->matches($request, ...$scope)) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private function segmentsFromPath(string $path): array + { + return array_values(array_filter( + explode('/', trim($path, '/')), + static fn (string $segment): bool => '' !== $segment, + )); + } + + /** + * @param list $segments + */ + private function localePrefix(Request $request, array $segments): ?string + { + $firstSegment = $segments[0] ?? ''; + + if ('' === $firstSegment || !in_array($segments[1] ?? '', self::LOCALE_PREFIX_SCOPED_SEGMENTS, true)) { + return null; + } + + $locale = $request->attributes->get('_locale'); + if (is_string($locale) && $firstSegment === $locale) { + return $firstSegment; + } + + if (null !== $this->routeLocalization && $this->routeLocalization->isEnabled() && in_array($firstSegment, $this->routeLocalization->availableLanguages(), true)) { + return $firstSegment; + } + + return null; + } +} diff --git a/src/Debug/RouteRenderOptions.php b/src/Debug/RouteRenderOptions.php index 5e10fdc9..334c335f 100644 --- a/src/Debug/RouteRenderOptions.php +++ b/src/Debug/RouteRenderOptions.php @@ -16,6 +16,8 @@ public function __construct( public bool $setupCompleted = true, public string $host = 'localhost', public bool $secure = false, + /** @var array> */ + public array $headers = [], ) { } } diff --git a/src/Debug/RouteRenderer.php b/src/Debug/RouteRenderer.php index 7c7359af..e25eb6a3 100644 --- a/src/Debug/RouteRenderer.php +++ b/src/Debug/RouteRenderer.php @@ -5,6 +5,7 @@ namespace App\Debug; use App\Api\Http\ApiRequestContext; +use App\Api\Security\ApiRequestMethodPolicy; use App\Database\DatabaseReadyState; use App\Entity\ApiKey; use App\Entity\UserAccount; @@ -28,6 +29,7 @@ public function __construct( private EntityManagerInterface $entityManager, private TokenStorageInterface $tokenStorage, private SessionFactoryInterface $sessionFactory, + private ApiRequestMethodPolicy $apiMethods = new ApiRequestMethodPolicy(), ) { } @@ -37,7 +39,7 @@ public function render(RouteRenderOptions $options): RouteRenderResult $request = $this->createRequest($options); $user = $this->resolveUser($options); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->apiMethods->isApiV1Request($request)) { return $this->renderBrowserRequest($request, $options, $user); } @@ -116,7 +118,7 @@ private function renderBrowserRequest(Request $request, RouteRenderOptions $opti private function createRequest(RouteRenderOptions $options): Request { - return Request::create( + $request = Request::create( $this->normalizePath($options->path), strtoupper($options->method), [], @@ -127,6 +129,12 @@ private function createRequest(RouteRenderOptions $options): Request 'HTTPS' => $options->secure ? 'on' : 'off', ], ); + + foreach ($options->headers as $name => $values) { + $request->headers->set($name, $values); + } + + return $request; } private function resolveUser(RouteRenderOptions $options): ?UserAccount diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index 21f07fa7..ede611c4 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -5,7 +5,10 @@ namespace App\Security\Abuse; use App\Api\Http\ApiRequestContext; +use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use App\Core\Statistics\VisitorIdGenerator; +use App\Core\Validation\EmailAddress; use App\Entity\UserAccount; use App\Security\AccessLevelAwareUserInterface; use Symfony\Component\HttpFoundation\Request; @@ -14,12 +17,18 @@ final readonly class AbuseSubjectResolver { private const PLACEHOLDER = 'n/a'; + private RequestPathResolver $paths; + private PathScopeMatcher $rawPaths; public function __construct( private VisitorIdGenerator $visitorIdGenerator, private TokenStorageInterface $tokenStorage, private string $secret, + ?RequestPathResolver $paths = null, + ?PathScopeMatcher $rawPaths = null, ) { + $this->paths = $paths ?? new RequestPathResolver(); + $this->rawPaths = $rawPaths ?? new PathScopeMatcher(); } public function resolve(Request $request): AbuseSubjectResolution @@ -62,6 +71,16 @@ public function resolve(Request $request): AbuseSubjectResolution } } + $schedulerCredential = $this->submittedSchedulerCredential($request); + if ($schedulerCredential instanceof AbuseSubject) { + $subjects[] = $schedulerCredential; + } + + $submittedAccount = $this->submittedAccount($request); + if ($submittedAccount instanceof AbuseSubject) { + $subjects[] = $submittedAccount; + } + return new AbuseSubjectResolution($subjects); } @@ -91,6 +110,135 @@ private function submittedApiKeyPrefix(Request $request): ?string return 1 === preg_match('/^[A-Za-z0-9_-]{4,16}$/', $prefix) ? $prefix : null; } + private function submittedSchedulerCredential(Request $request): ?AbuseSubject + { + if (!$this->rawPaths->matchesExactSegments($request->getPathInfo(), 'cron', 'run')) { + return null; + } + + $authorization = $request->headers->get('Authorization'); + if (is_string($authorization) && 1 === preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { + return $this->schedulerCredentialSubject(trim($matches[1])); + } + + $auth = $request->query->get('auth'); + + return is_string($auth) ? $this->schedulerCredentialSubject(trim($auth)) : null; + } + + private function schedulerCredentialSubject(string $token): ?AbuseSubject + { + if ('' === $token) { + return null; + } + + return new AbuseSubject(AbuseSubjectType::SchedulerCredential, $this->bucket('scheduler_credential', substr($token, 0, 128))); + } + + private function submittedAccount(Request $request): ?AbuseSubject + { + $segments = $this->paths->segments($request); + $route = $request->attributes->get('_route'); + $token = $request->attributes->get('token'); + + if ('user_invitation_accept' === $route) { + return $this->submittedTokenSubject('registration_token', $token); + } + + if ('user_password_reset_token' === $route) { + return $this->submittedTokenSubject('password_reset_token', $token); + } + + if ('user_security_review' === $route) { + return $this->submittedTokenSubject('security_review_token', $token); + } + + if ($this->matchesExactSegments($segments, 'user', 'login')) { + return $this->submittedAccountSubject('login', $request->request->get('username')); + } + + if ($this->matchesExactSegments($segments, 'user', 'register')) { + return $this->submittedAccountSubject('registration_email', $request->request->get('email'), email: true); + } + + if ($this->matchesSegments($segments, 'user', 'invitation') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('registration_token', $submittedToken); + } + + if ($this->matchesExactSegments($segments, 'user', 'reset-password')) { + return $this->submittedAccountSubject('password_reset_email', $request->request->get('email'), email: true); + } + + if ($this->matchesSegments($segments, 'user', 'reset-password') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('password_reset_token', $submittedToken); + } + + if ($this->matchesSegments($segments, 'user', 'security-review') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('security_review_token', $submittedToken); + } + + return null; + } + + private function submittedTokenSubject(string $scope, mixed $token): ?AbuseSubject + { + if (!is_string($token) || 1 !== preg_match('/^[a-f0-9]{64}$/i', $token)) { + return null; + } + + return $this->submittedAccountSubject($scope, $token); + } + + /** + * @param list $segments + */ + private function matchesSegments(array $segments, string ...$expected): bool + { + foreach ($expected as $index => $segment) { + if (($segments[$index] ?? null) !== $segment) { + return false; + } + } + + return [] !== $expected; + } + + /** + * @param list $segments + */ + private function matchesExactSegments(array $segments, string ...$expected): bool + { + return count($segments) === count($expected) && $this->matchesSegments($segments, ...$expected); + } + + /** + * @param list $segments + */ + private function tokenSegment(array $segments, int $index): ?string + { + $token = $segments[$index] ?? null; + + return is_string($token) && count($segments) === $index + 1 && 1 === preg_match('/^[a-f0-9]{64}$/i', $token) ? $token : null; + } + + private function submittedAccountSubject(string $scope, mixed $value, bool $email = false): ?AbuseSubject + { + if (!is_scalar($value)) { + return null; + } + + $normalized = trim((string) $value); + $normalized = $email ? EmailAddress::normalize($normalized) : strtolower($normalized); + $normalized = substr($normalized, 0, 190); + if ('' === $normalized) { + return null; + } + + return new AbuseSubject(AbuseSubjectType::SubmittedAccount, $this->bucket($scope, $normalized), false, [ + 'scope' => $scope, + ]); + } + private function bucket(string $scope, string $value): string { return substr(hash_hmac('sha256', 'abuse.subject.'.$scope.'|'.$value, $this->secret), 0, 40); diff --git a/src/Security/Abuse/AbuseSubjectType.php b/src/Security/Abuse/AbuseSubjectType.php index df61e4c7..7ae9d974 100644 --- a/src/Security/Abuse/AbuseSubjectType.php +++ b/src/Security/Abuse/AbuseSubjectType.php @@ -11,5 +11,7 @@ enum AbuseSubjectType: string case User = 'user'; case ApiKey = 'api_key'; case ApiKeyPrefix = 'api_key_prefix'; + case SchedulerCredential = 'scheduler_credential'; + case SubmittedAccount = 'submitted_account'; case Combined = 'combined'; } diff --git a/src/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php index 7cb1afdf..d84c42d1 100644 --- a/src/Security/Abuse/ActionCostCatalogue.php +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -14,8 +14,10 @@ public function costFor(AbuseRequestProfile $profile): ActionCost RequestIntent::CorsPreflight => new ActionCost('api_preflight', 0, false), RequestIntent::SuspiciousProbe => new ActionCost('suspicious_probe', 10), RequestIntent::Login => new ActionCost('login', 1), + RequestIntent::RecoveryLogin => new ActionCost('recovery_login', 1), RequestIntent::Registration => new ActionCost('registration', 5), RequestIntent::PasswordReset => new ActionCost('password_reset', 3), + RequestIntent::CaptchaFailure => new ActionCost('captcha_failure', 1), RequestIntent::Contact => new ActionCost('contact', 3), RequestIntent::SchedulerTrigger => new ActionCost('scheduler', 1), RequestIntent::SetupApply => new ActionCost('setup_apply', 8), @@ -35,6 +37,35 @@ public function costFor(AbuseRequestProfile $profile): ActionCost }; } + /** + * @return array + */ + public function uniqueCreditsByBucketFamily(): array + { + $credits = []; + $mixedFamilies = []; + + foreach (RequestIntent::cases() as $intent) { + $cost = $this->costFor($this->sampleProfile($intent)); + if (!$cost->ordinaryEnforcement() || $cost->credits() < 1) { + continue; + } + + $family = $cost->bucketFamily(); + if (isset($credits[$family]) && $credits[$family] !== $cost->credits()) { + $mixedFamilies[$family] = true; + unset($credits[$family]); + continue; + } + + if (!isset($mixedFamilies[$family])) { + $credits[$family] = $cost->credits(); + } + } + + return $credits; + } + private function defaultBucket(RequestFamily $family): string { return match ($family) { @@ -44,4 +75,31 @@ private function defaultBucket(RequestFamily $family): string default => 'website', }; } + + private function sampleProfile(RequestIntent $intent): AbuseRequestProfile + { + return new AbuseRequestProfile( + match ($intent) { + RequestIntent::ApiRead, RequestIntent::ApiWrite, RequestIntent::CorsPreflight, RequestIntent::LiveApi => RequestFamily::Api, + RequestIntent::SchedulerTrigger => RequestFamily::Scheduler, + RequestIntent::SetupApply => RequestFamily::Setup, + RequestIntent::PackageAdminOperation, + RequestIntent::SettingsMutation, + RequestIntent::UserAclMutation, + RequestIntent::UploadArchiveValidation, + RequestIntent::ExportDownload, + RequestIntent::ImportOperation, + RequestIntent::BackupRestore, + RequestIntent::DiagnosticsSupport, + RequestIntent::AdminOperation => RequestFamily::Admin, + default => RequestFamily::Browser, + }, + $intent, + 'GET', + '/', + 'test', + RequestIntent::TurboPrefetch === $intent, + RequestIntent::SuspiciousProbe === $intent, + ); + } } diff --git a/src/Security/Abuse/RequestIntent.php b/src/Security/Abuse/RequestIntent.php index c848141c..6193aa29 100644 --- a/src/Security/Abuse/RequestIntent.php +++ b/src/Security/Abuse/RequestIntent.php @@ -17,6 +17,7 @@ enum RequestIntent: string case CaptchaRefresh = 'captcha_refresh'; case CaptchaFailure = 'captcha_failure'; case Login = 'login'; + case RecoveryLogin = 'recovery_login'; case Registration = 'registration'; case PasswordReset = 'password_reset'; case Contact = 'contact'; diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 40ab0a74..74266528 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -4,15 +4,26 @@ namespace App\Security\Abuse; +use App\Api\Security\ApiRequestMethodPolicy; use App\Content\Routing\ContentRouteLocalization; +use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use Symfony\Component\HttpFoundation\Request; final readonly class RequestIntentClassifier { + private RequestPathResolver $paths; + private PathScopeMatcher $rawPaths; + public function __construct( private SuspiciousProbePathMatcher $probePathMatcher = new SuspiciousProbePathMatcher(), - private ?ContentRouteLocalization $routeLocalization = null, + ?ContentRouteLocalization $routeLocalization = null, + private ApiRequestMethodPolicy $apiMethods = new ApiRequestMethodPolicy(), + ?RequestPathResolver $paths = null, + ?PathScopeMatcher $rawPaths = null, ) { + $this->paths = $paths ?? new RequestPathResolver($routeLocalization); + $this->rawPaths = $rawPaths ?? new PathScopeMatcher(); } public function classify(Request $request): AbuseRequestProfile @@ -21,13 +32,13 @@ public function classify(Request $request): AbuseRequestProfile $path = $request->getPathInfo(); $segments = $this->segments($request); $route = $this->route($request); - $family = $this->family($segments); + $family = $this->family($request, $segments); $prefetch = $this->isPrefetch($request); $suspiciousProbe = $this->probePathMatcher->isProbe($path); return new AbuseRequestProfile( $family, - $this->intent($method, $segments, $route, $family, $prefetch, $suspiciousProbe), + $this->intent($request, $method, $segments, $route, $family, $prefetch, $suspiciousProbe), $method, substr($path, 0, 1024), $route, @@ -36,16 +47,15 @@ public function classify(Request $request): AbuseRequestProfile ); } - /** - * @param list $segments - */ - private function family(array $segments): RequestFamily + private function family(Request $request, array $segments): RequestFamily { + $rawPath = $request->getPathInfo(); + return match (true) { - $this->matchesSegments($segments, 'api', 'live') => RequestFamily::LiveApi, - $this->matchesSegments($segments, 'api') => RequestFamily::Api, - $this->matchesSegments($segments, 'cron') => RequestFamily::Scheduler, - $this->matchesSegments($segments, 'setup') => RequestFamily::Setup, + $this->rawPaths->matchesSegments($rawPath, 'api', 'live') => RequestFamily::LiveApi, + $this->rawPaths->matchesSegments($rawPath, 'api') => RequestFamily::Api, + $this->rawPaths->matchesSegments($rawPath, 'cron') => RequestFamily::Scheduler, + $this->rawPaths->matchesSegments($rawPath, 'setup') => RequestFamily::Setup, $this->matchesSegments($segments, 'admin') => RequestFamily::Admin, $this->matchesSegments($segments, 'editor') => RequestFamily::Editor, default => RequestFamily::Browser, @@ -53,6 +63,7 @@ private function family(array $segments): RequestFamily } private function intent( + Request $request, string $method, array $segments, string $route, @@ -64,12 +75,10 @@ private function intent( return RequestIntent::SuspiciousProbe; } - if ('OPTIONS' === $method) { - return RequestIntent::CorsPreflight; - } - if (RequestFamily::Scheduler === $family) { - return RequestIntent::SchedulerTrigger; + return $this->schedulerTrigger($request) + ? RequestIntent::SchedulerTrigger + : RequestIntent::BrowserNavigation; } if (RequestFamily::LiveApi === $family) { @@ -77,42 +86,84 @@ private function intent( } if (RequestFamily::Api === $family) { + if ('OPTIONS' === $method) { + if ($this->apiMethods->hasAuthorizationHeader($request)) { + return $this->apiIntentForMethod($this->apiMethods->effectiveMethod($request), $segments, $route); + } + + return RequestIntent::CorsPreflight; + } + if ($this->matchesSegments($segments, 'api', 'v1', 'admin') && !$this->safeMethod($method)) { return $this->adminMutationIntent($this->apiAdminSegments($segments), $route); } - return in_array($method, ['GET', 'HEAD'], true) ? RequestIntent::ApiRead : RequestIntent::ApiWrite; + return $this->apiIntentForMethod($method, $segments, $route); } - if ($prefetch && 'GET' === $method) { - return RequestIntent::TurboPrefetch; + if ('OPTIONS' === $method) { + return RequestIntent::CorsPreflight; } if (RequestFamily::Setup === $family && !$this->safeMethod($method)) { - return RequestIntent::SetupApply; + return $this->setupApply($request, $segments) + ? RequestIntent::SetupApply + : RequestIntent::BrowserNavigation; + } + + $adminReadIntent = RequestFamily::Admin === $family ? $this->adminReadIntent($segments, $route) : null; + if ($adminReadIntent instanceof RequestIntent) { + return $adminReadIntent; } if (RequestFamily::Admin === $family && !$this->safeMethod($method)) { return $this->adminMutationIntent($segments, $route); } + if ($this->recoveryLogin($request, $method, $segments, $route)) { + return RequestIntent::RecoveryLogin; + } + + if ($prefetch && 'GET' === $method) { + return RequestIntent::TurboPrefetch; + } + return match (true) { - $this->routeIs($route, 'user_login') || $this->matchesSegments($segments, 'user', 'login') => RequestIntent::Login, - $this->routeIs($route, 'user_register', 'user_invitation_accept') || $this->matchesSegments($segments, 'user', 'register') || $this->matchesSegments($segments, 'user', 'invitation') => RequestIntent::Registration, - $this->routeIs($route, 'user_reset_password', 'user_password_reset_token', 'user_security_review') || $this->matchesSegments($segments, 'user', 'password-reset') || $this->matchesSegments($segments, 'user', 'reset-password') || $this->matchesSegments($segments, 'user', 'security-review') => RequestIntent::PasswordReset, + !$this->safeMethod($method) && ($this->routeIs($route, 'user_login') || $this->matchesSegments($segments, 'user', 'login')) => RequestIntent::Login, + !$this->safeMethod($method) && ($this->routeIs($route, 'user_register', 'user_invitation_accept') || $this->matchesSegments($segments, 'user', 'register') || $this->matchesSegments($segments, 'user', 'invitation')) => RequestIntent::Registration, + !$this->safeMethod($method) && ($this->routeIs($route, 'user_reset_password', 'user_password_reset_token', 'user_security_review') || $this->matchesSegments($segments, 'user', 'password-reset') || $this->matchesSegments($segments, 'user', 'reset-password') || $this->matchesSegments($segments, 'user', 'security-review')) => RequestIntent::PasswordReset, !$this->safeMethod($method) => RequestIntent::FormSubmit, default => RequestIntent::BrowserNavigation, }; } + private function recoveryLogin(Request $request, string $method, array $segments, string $route): bool + { + return 'GET' === $method + && $this->matchesSegments($segments, 'user', 'login') + && $this->routeIs($route, 'user_login', 'n/a') + && '1' === (string) $request->query->get('bypass', ''); + } + + private function setupApply(Request $request, array $segments): bool + { + return $this->matchesExactSegments($segments, 'setup', 'review') + && 'apply' === (string) $request->request->get('_setup_action', ''); + } + + private function schedulerTrigger(Request $request): bool + { + return $this->rawPaths->matchesExactSegments($request->getPathInfo(), 'cron', 'run'); + } + private function adminMutationIntent(array $segments, string $route): RequestIntent { return match (true) { $this->matchesSegments($segments, 'admin', 'settings') || $this->routeHasToken($route, 'settings') => RequestIntent::SettingsMutation, $this->matchesSegments($segments, 'admin', 'users') || $this->routeHasToken($route, 'users') || $this->routeHasToken($route, 'acl') => RequestIntent::UserAclMutation, - $this->matchesSegments($segments, 'admin', 'packages') || $this->routeHasToken($route, 'package') || $this->routeHasToken($route, 'packages') => RequestIntent::PackageAdminOperation, $this->hasSegment($segments, 'upload', 'archive', 'media') || $this->routeHasAnyToken($route, 'upload', 'archive', 'media') => RequestIntent::UploadArchiveValidation, $this->hasSegment($segments, 'export', 'download') || $this->routeHasAnyToken($route, 'export', 'download') => RequestIntent::ExportDownload, + $this->matchesSegments($segments, 'admin', 'packages') || $this->routeHasToken($route, 'package') || $this->routeHasToken($route, 'packages') => RequestIntent::PackageAdminOperation, $this->hasSegment($segments, 'import') || $this->routeHasToken($route, 'import') => RequestIntent::ImportOperation, $this->hasSegment($segments, 'backup', 'restore') || $this->routeHasAnyToken($route, 'backup', 'restore') => RequestIntent::BackupRestore, $this->hasSegment($segments, 'diagnostic', 'diagnostics', 'support') || $this->routeHasAnyToken($route, 'diagnostic', 'diagnostics', 'support') => RequestIntent::DiagnosticsSupport, @@ -120,6 +171,25 @@ private function adminMutationIntent(array $segments, string $route): RequestInt }; } + private function adminReadIntent(array $segments, string $route): ?RequestIntent + { + return match (true) { + $this->hasSegment($segments, 'export', 'download') || $this->routeHasAnyToken($route, 'export', 'download') => RequestIntent::ExportDownload, + $this->hasSegment($segments, 'diagnostic', 'diagnostics', 'support') || $this->routeHasAnyToken($route, 'diagnostic', 'diagnostics', 'support') => RequestIntent::DiagnosticsSupport, + default => null, + }; + } + + private function apiIntentForMethod(string $method, array $segments, string $route): RequestIntent + { + $method = strtoupper($method); + if ($this->matchesSegments($segments, 'api', 'v1', 'admin') && !$this->safeMethod($method)) { + return $this->adminMutationIntent($this->apiAdminSegments($segments), $route); + } + + return in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) ? RequestIntent::ApiRead : RequestIntent::ApiWrite; + } + private function isPrefetch(Request $request): bool { foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { @@ -159,9 +229,6 @@ private function routeHasAnyToken(string $route, string ...$tokens): bool return [] !== array_intersect($tokens, $this->routeTokens($route)); } - /** - * @return list - */ private function routeTokens(string $route): array { return array_values(array_filter( @@ -170,45 +237,11 @@ private function routeTokens(string $route): array )); } - /** - * @return list - */ private function segments(Request $request): array { - $segments = array_values(array_filter(explode('/', trim($request->getPathInfo(), '/')), static fn (string $segment): bool => '' !== $segment)); - $locale = $this->localePrefix($request); - - if (is_string($locale) && '' !== $locale && ($segments[0] ?? null) === $locale) { - array_shift($segments); - } - - return $segments; + return $this->paths->segments($request); } - private function localePrefix(Request $request): ?string - { - $segments = explode('/', trim($request->getPathInfo(), '/')); - $firstSegment = $segments[0] ?? ''; - - if ('' === $firstSegment || !$this->hasLocalizedReservedPath($segments)) { - return null; - } - - $locale = $request->attributes->get('_locale'); - if (is_string($locale) && $firstSegment === $locale) { - return $firstSegment; - } - - if (null !== $this->routeLocalization && $this->routeLocalization->isEnabled() && in_array($firstSegment, $this->routeLocalization->availableLanguages(), true)) { - return $firstSegment; - } - - return null; - } - - /** - * @param list $pathSegments - */ private function matchesSegments(array $pathSegments, string ...$segments): bool { foreach ($segments as $index => $segment) { @@ -220,9 +253,11 @@ private function matchesSegments(array $pathSegments, string ...$segments): bool return [] !== $segments; } - /** - * @param list $pathSegments - */ + private function matchesExactSegments(array $pathSegments, string ...$segments): bool + { + return count($pathSegments) === count($segments) && $this->matchesSegments($pathSegments, ...$segments); + } + private function hasSegment(array $pathSegments, string ...$segments): bool { foreach ($segments as $segment) { @@ -234,11 +269,6 @@ private function hasSegment(array $pathSegments, string ...$segments): bool return false; } - /** - * @param list $segments - * - * @return list - */ private function apiAdminSegments(array $segments): array { return $this->matchesSegments($segments, 'api', 'v1', 'admin') @@ -246,11 +276,4 @@ private function apiAdminSegments(array $segments): array : $segments; } - /** - * @param list $segments - */ - private function hasLocalizedReservedPath(array $segments): bool - { - return in_array($segments[1] ?? '', ['admin', 'api', 'cron', 'editor', 'setup', 'user'], true); - } } diff --git a/src/Security/Api/SelfServiceApiHandler.php b/src/Security/Api/SelfServiceApiHandler.php index 7a9f848d..530fd33a 100644 --- a/src/Security/Api/SelfServiceApiHandler.php +++ b/src/Security/Api/SelfServiceApiHandler.php @@ -18,6 +18,7 @@ use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Message\MessageException; +use App\Core\Routing\PathScopeMatcher; use App\Core\Validation\EmailAddress; use App\Entity\ApiKey; use App\Entity\UserAccount; @@ -48,6 +49,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private PathScopeMatcher $paths = new PathScopeMatcher(), ) { } @@ -68,7 +70,7 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $this->notFound($request); } - if (str_starts_with($request->getPathInfo(), '/api/v1/user/api-keys')) { + if ($this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1', 'user', 'api-keys')) { return $this->handleApiKeys($request, $user); } diff --git a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php new file mode 100644 index 00000000..3b0b304a --- /dev/null +++ b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php @@ -0,0 +1,56 @@ + + */ + public static function getSubscribedEvents(): array + { + return [ + LoginFailureEvent::class => 'onLoginFailure', + LoginSuccessEvent::class => 'onLoginSuccess', + ]; + } + + public function onLoginSuccess(LoginSuccessEvent $event): void + { + $this->resets->resetLoginAttempts($event->getRequest()); + } + + public function onLoginFailure(LoginFailureEvent $event): void + { + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { + return; + } + + $result = $this->enforcer->check($request, RateLimitEnforcementStage::AuthenticationFailure); + if ($result->isAllowed()) { + return; + } + + $event->setResponse($this->responses->tooManyRequests($request, $result)); + } + + private function enabledForRequest(?string $testOptIn): bool + { + return 'test' !== $this->environment || '1' === $testOptIn; + } +} diff --git a/src/Security/RateLimit/RateLimitBucketDescriptor.php b/src/Security/RateLimit/RateLimitBucketDescriptor.php new file mode 100644 index 00000000..27ce1c32 --- /dev/null +++ b/src/Security/RateLimit/RateLimitBucketDescriptor.php @@ -0,0 +1,139 @@ + $stages + */ + public function __construct( + private string $name, + private string $bucketFamily, + private int $limit, + private int $windowSeconds, + private string $diagnosticsLabel, + private bool $profileScalable = true, + private ?int $retryAfterFloorSeconds = null, + private bool $resettable = false, + private int $minimumLimit = 1, + private ?RateLimitSubjectPolicy $subjectPolicy = null, + private array $stages = [RateLimitEnforcementStage::All], + ) { + } + + public function name(): string + { + return $this->name; + } + + public function bucketFamily(): string + { + return $this->bucketFamily; + } + + public function limit(): int + { + return $this->limit; + } + + public function windowSeconds(): int + { + return $this->windowSeconds; + } + + public function diagnosticsLabel(): string + { + return $this->diagnosticsLabel; + } + + public function retryAfterFloorSeconds(): ?int + { + return $this->retryAfterFloorSeconds; + } + + public function resettable(): bool + { + return $this->resettable; + } + + public function minimumLimit(): int + { + return $this->minimumLimit; + } + + public function subjectPolicy(): RateLimitSubjectPolicy + { + return $this->subjectPolicy ?? new RateLimitSubjectPolicy([]); + } + + public function handlesStage(RateLimitEnforcementStage $stage): bool + { + return RateLimitEnforcementStage::All === $stage + || in_array(RateLimitEnforcementStage::All, $this->stages, true) + || in_array($stage, $this->stages, true); + } + + public function scaled(RateLimitProfile $profile): self + { + if (!$this->profileScalable || RateLimitProfile::Standard === $profile || RateLimitProfile::Off === $profile) { + return $this; + } + + return new self( + $this->name, + $this->bucketFamily, + max($this->minimumLimit, (int) floor($this->limit * $profile->capacityMultiplier())), + max(1, (int) ceil($this->windowSeconds * $profile->windowMultiplier())), + $this->diagnosticsLabel, + $this->profileScalable, + null === $this->retryAfterFloorSeconds + ? null + : max(1, (int) ceil($this->retryAfterFloorSeconds * $profile->retryAfterMultiplier())), + $this->resettable, + $this->minimumLimit, + $this->subjectPolicy, + $this->stages, + ); + } + + public function withWindowSeconds(int $windowSeconds): self + { + return new self( + $this->name, + $this->bucketFamily, + $this->limit, + max(1, $windowSeconds), + $this->diagnosticsLabel, + $this->profileScalable, + $this->retryAfterFloorSeconds, + $this->resettable, + $this->minimumLimit, + $this->subjectPolicy, + $this->stages, + ); + } + + public function withCapacityMultiplier(int $multiplier): self + { + if ($multiplier <= 1) { + return $this; + } + + return new self( + $this->name, + $this->bucketFamily, + $this->limit * $multiplier, + $this->windowSeconds, + $this->diagnosticsLabel, + $this->profileScalable, + $this->retryAfterFloorSeconds, + $this->resettable, + $this->minimumLimit * $multiplier, + $this->subjectPolicy, + $this->stages, + ); + } +} diff --git a/src/Security/RateLimit/RateLimitCheckResult.php b/src/Security/RateLimit/RateLimitCheckResult.php new file mode 100644 index 00000000..b50e1373 --- /dev/null +++ b/src/Security/RateLimit/RateLimitCheckResult.php @@ -0,0 +1,57 @@ +allowed; + } + + public function suspiciousProbe(): bool + { + return $this->suspiciousProbe; + } + + public function storageDegraded(): bool + { + return $this->storageDegraded; + } + + public function retryAfterSeconds(): ?int + { + return $this->retryAfterSeconds; + } + + public function diagnosticsLabel(): ?string + { + return $this->diagnosticsLabel; + } +} diff --git a/src/Security/RateLimit/RateLimitEnforcementStage.php b/src/Security/RateLimit/RateLimitEnforcementStage.php new file mode 100644 index 00000000..fb9f1f73 --- /dev/null +++ b/src/Security/RateLimit/RateLimitEnforcementStage.php @@ -0,0 +1,18 @@ +inspector->inspect($request); + $profile = $inspection['profile']; + $subjectResolution = $inspection['subjects']; + $cost = $inspection['cost']; + $mode = RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)); + + if ($profile->suspiciousProbe()) { + if (!in_array($stage, [RateLimitEnforcementStage::All, RateLimitEnforcementStage::SuspiciousProbe], true)) { + return RateLimitCheckResult::allow(); + } + + return $this->checkSuspiciousProbe($profile, $subjectResolution, $cost, $mode); + } + + if (!$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost, $stage)) { + return RateLimitCheckResult::allow(); + } + + return $this->consume($profile, $subjectResolution, $cost, $mode, $stage); + } + + private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): RateLimitCheckResult + { + if (!$mode->consumesLimiterStorage()) { + return RateLimitCheckResult::blockSuspiciousProbe(); + } + + $result = $this->consume($profile, $subjects, $cost, $mode, RateLimitEnforcementStage::SuspiciousProbe); + + return RateLimitCheckResult::blockSuspiciousProbe($result->storageDegraded()); + } + + private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): RateLimitCheckResult + { + try { + $plannedConsumes = []; + $credits = max(1, $cost->credits()); + + foreach ($this->descriptors($profile, $subjects, $cost, $mode, $stage) as $descriptor) { + $descriptor = $descriptor->withCapacityMultiplier($this->subjects->authenticatedMultiplier($descriptor, $subjects)); + + foreach ($this->subjects->subjectKeys($descriptor, $subjects) as $subjectKey) { + $plannedConsumes[] = [$descriptor, $subjectKey, $credits]; + } + } + + foreach ($plannedConsumes as [$descriptor, $subjectKey, $credits]) { + $retryAfter = $this->limiters->accepts($descriptor, $subjectKey, $credits); + if ($retryAfter instanceof \DateTimeImmutable) { + return RateLimitCheckResult::reject($this->retryAfterSeconds($descriptor, $retryAfter), $descriptor->diagnosticsLabel()); + } + } + + foreach ($plannedConsumes as [$descriptor, $subjectKey, $credits]) { + $retryAfter = $this->limiters->consume($descriptor, $subjectKey, $credits); + if ($retryAfter instanceof \DateTimeImmutable) { + return RateLimitCheckResult::reject($this->retryAfterSeconds($descriptor, $retryAfter), $descriptor->diagnosticsLabel()); + } + } + } catch (\Throwable $exception) { + $this->reportDegradedConsume($profile, $mode, $exception); + + return RateLimitCheckResult::allow(storageDegraded: true); + } + + return RateLimitCheckResult::allow(); + } + + /** + * @return list + */ + private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array + { + $primaryFamily = $this->bucketFamily($cost, $subjects); + $families = []; + $primaryDescriptors = $this->descriptorsForFamily($primaryFamily, $mode, $stage); + + if ($stage->consumesWebsiteFamily() && $this->shouldConsumeWebsiteFamily($profile, $primaryFamily)) { + $families[] = 'website'; + } + + $descriptors = $primaryDescriptors; + foreach (array_values(array_unique($families)) as $family) { + array_push($descriptors, ...$this->descriptorsForFamily($family, $mode, $stage)); + } + + return $descriptors; + } + + /** + * @return list + */ + private function descriptorsForFamily(string $family, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array + { + return array_values(array_filter( + $this->catalogue->descriptorsForFamily($family, $mode), + static fn (RateLimitBucketDescriptor $descriptor): bool => $descriptor->handlesStage($stage), + )); + } + + private function bucketFamily(ActionCost $cost, AbuseSubjectResolution $subjects): string + { + if ('api_read' === $cost->bucketFamily() && !$subjects->first(AbuseSubjectType::ApiKey)) { + return 'api_public_read'; + } + + return $cost->bucketFamily(); + } + + private function shouldConsumeWebsiteFamily(AbuseRequestProfile $profile, string $bucketFamily): bool + { + if (!in_array($profile->family(), [RequestFamily::Browser, RequestFamily::Admin, RequestFamily::Editor], true)) { + return false; + } + + if (RequestIntent::TurboPrefetch === $profile->intent()) { + return false; + } + + return !in_array($bucketFamily, ['website', 'website_prefetch', 'recovery_login'], true); + } + + private function retryAfterSeconds(RateLimitBucketDescriptor $descriptor, \DateTimeImmutable $retryAfter): int + { + $seconds = max(1, $retryAfter->getTimestamp() - time()); + $floor = $descriptor->retryAfterFloorSeconds(); + + return null === $floor ? $seconds : max($seconds, $floor); + } + + private function isOwnerExempt(Request $request, AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitEnforcementStage $stage): bool + { + if (RateLimitEnforcementStage::AuthenticationFailure === $stage) { + return false; + } + + if (RequestFamily::Scheduler === $profile->family() || 'scheduler' === $cost->bucketFamily()) { + return false; + } + + if (RequestFamily::Api === $profile->family() && !$this->apiMethods->isSafeEffectiveMethod($request) && $this->readOnlyApiKey($request)) { + return false; + } + + return $this->subjects->hasOwner($subjects); + } + + private function readOnlyApiKey(Request $request): bool + { + return ApiKeyStatus::ReadOnly === ApiRequestContext::fromRequest($request)?->apiKeyStatus(); + } + + private function reportDegradedConsume(AbuseRequestProfile $profile, RateLimitProfile $mode, \Throwable $exception): void + { + $context = [ + 'profile' => $mode->value, + 'intent' => $profile->intent()->value, + 'family' => $profile->family()->value, + 'exception_class' => $exception::class, + ]; + + $this->messages->report( + Message::warning(SecurityMessageCode::RATE_LIMIT_STORAGE_DEGRADED, SecurityMessageKey::RATE_LIMIT_STORAGE_DEGRADED, context: $context), + ['operation' => 'security.rate_limit.consume'], + ); + } +} diff --git a/src/Security/RateLimit/RateLimitLimiterFactory.php b/src/Security/RateLimit/RateLimitLimiterFactory.php new file mode 100644 index 00000000..ab1efca6 --- /dev/null +++ b/src/Security/RateLimit/RateLimitLimiterFactory.php @@ -0,0 +1,62 @@ + */ + private array $factories = []; + + public function __construct( + private readonly CacheItemPoolInterface $cachePool, + private readonly ?LockFactory $lockFactory = null, + ) { + } + + public function consume(RateLimitBucketDescriptor $descriptor, string $subjectKey, int $credits): \DateTimeImmutable|true + { + $limit = $this->factory($descriptor)->create($subjectKey)->consume($credits); + + return $limit->isAccepted() ? true : $limit->getRetryAfter(); + } + + public function accepts(RateLimitBucketDescriptor $descriptor, string $subjectKey, int $credits): \DateTimeImmutable|true + { + $limit = $this->factory($descriptor)->create($subjectKey)->consume(0); + + return $limit->getRemainingTokens() >= $credits ? true : $limit->getRetryAfter(); + } + + public function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey): void + { + $this->factory($descriptor)->create($subjectKey)->reset(); + } + + private function factory(RateLimitBucketDescriptor $descriptor): RateLimiterFactory + { + $key = implode('|', [ + $descriptor->name(), + (string) $descriptor->limit(), + (string) $descriptor->windowSeconds(), + ]); + + return $this->factories[$key] ??= new RateLimiterFactory([ + 'id' => implode('.', [ + 'system.rate', + $descriptor->name(), + (string) $descriptor->limit(), + (string) $descriptor->windowSeconds(), + ]), + 'policy' => 'fixed_window', + 'limit' => $descriptor->limit(), + 'interval' => $descriptor->windowSeconds().' seconds', + ], new CacheStorage($this->cachePool), $this->lockFactory); + } +} diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php new file mode 100644 index 00000000..7b10e921 --- /dev/null +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -0,0 +1,232 @@ + true, + 'suspicious_probe' => true, + ]; + private const WEBSITE_COMPANION_FAMILIES = [ + 'website_form', + 'registration', + 'password_reset', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', + ]; + + /** + * @var array + */ + private array $creditCosts; + + public function __construct(?ActionCostCatalogue $actionCosts = null) + { + $this->creditCosts = ($actionCosts ?? new ActionCostCatalogue())->uniqueCreditsByBucketFamily(); + } + + /** + * @return list + */ + public function descriptors(RateLimitProfile $profile = RateLimitProfile::Standard): array + { + return array_map( + fn (RateLimitBucketDescriptor $descriptor): RateLimitBucketDescriptor => $this->profileDescriptor($descriptor, $profile), + $this->standardDescriptors(), + ); + } + + /** + * @return list + */ + public function descriptorsForFamily(string $bucketFamily, RateLimitProfile $profile = RateLimitProfile::Standard): array + { + return array_values(array_filter( + $this->descriptors($profile), + static fn (RateLimitBucketDescriptor $descriptor): bool => $descriptor->bucketFamily() === $bucketFamily, + )); + } + + public function descriptor(string $name, RateLimitProfile $profile = RateLimitProfile::Standard): ?RateLimitBucketDescriptor + { + foreach ($this->descriptors($profile) as $descriptor) { + if ($descriptor->name() === $name) { + return $descriptor; + } + } + + return null; + } + + /** + * @return list + */ + private function standardDescriptors(): array + { + return [ + $this->bucket('login.failure', 'login', 5, 900, 'security.rate.login', resettable: true), + $this->bucket('recovery.login.minute', 'recovery_login', 2, 60, 'security.rate.recovery_login', false, 1800), + $this->bucket('recovery.login.hour', 'recovery_login', 10, 3600, 'security.rate.recovery_login', false, 1800), + $this->bucket('registration.hour', 'registration', 3, 3600, 'security.rate.registration'), + $this->bucket('registration.day', 'registration', 10, 86400, 'security.rate.registration'), + $this->bucket('password_reset.hour', 'password_reset', 3, 3600, 'security.rate.password_reset'), + $this->bucket('password_reset.day', 'password_reset', 10, 86400, 'security.rate.password_reset'), + $this->bucket('captcha.failure', 'captcha_failure', 5, 600, 'security.rate.captcha_failure', resettable: true), + $this->bucket('website.deliberate.burst', 'website', 30, 60, 'security.rate.website_burst'), + $this->bucket('website.deliberate.sustained', 'website', 300, 1800, 'security.rate.website_sustained'), + $this->bucket('website.form', 'website_form', 5, 600, 'security.rate.website_form'), + $this->bucket('website.prefetch.minute', 'website_prefetch', 120, 60, 'security.rate.prefetch_observation'), + $this->bucket('website.prefetch.sustained', 'website_prefetch', 600, 1800, 'security.rate.prefetch_observation'), + $this->bucket('api.read', 'api_read', 600, 60, 'security.rate.api_read'), + $this->bucket('api.public_read', 'api_public_read', 120, 60, 'security.rate.api_public_read'), + $this->bucket('api.write', 'api_write', 60, 60, 'security.rate.api_write'), + $this->bucket('scheduler.interval', 'scheduler', 1, 60, 'security.rate.scheduler', false), + $this->bucket('setup.apply', 'setup_apply', 5, 900, 'security.rate.setup_apply'), + $this->bucket('admin.mutation', 'admin_mutation', 30, 300, 'security.rate.admin_mutation'), + $this->bucket('upload_archive.validation', 'upload_archive', 20, 600, 'security.rate.upload_archive'), + $this->bucket('download_diagnostics', 'download_diagnostics', 30, 600, 'security.rate.download_diagnostics'), + $this->bucket('suspicious.probe', 'suspicious_probe', 1, 600, 'security.rate.suspicious_probe'), + ]; + } + + private function profileDescriptor(RateLimitBucketDescriptor $descriptor, RateLimitProfile $profile): RateLimitBucketDescriptor + { + if ('scheduler.interval' === $descriptor->name()) { + return match ($profile) { + RateLimitProfile::Strict => $descriptor->withWindowSeconds(900), + RateLimitProfile::Panic => $descriptor->withWindowSeconds(3600), + default => $descriptor, + }; + } + + return $descriptor->scaled($profile); + } + + private function bucket( + string $name, + string $family, + int $actionLimit, + int $windowSeconds, + string $diagnosticsLabel, + bool $profileScalable = true, + ?int $retryAfterFloorSeconds = null, + bool $resettable = false, + ): RateLimitBucketDescriptor { + $cost = $this->creditCostForFamily($family); + $minimumLimit = $this->minimumLimitForFamily($family, $cost); + + return new RateLimitBucketDescriptor( + $name, + $family, + max(1, $actionLimit) * $cost, + $windowSeconds, + $diagnosticsLabel, + $profileScalable, + $retryAfterFloorSeconds, + $resettable, + $minimumLimit, + $this->subjectPolicyForFamily($family), + $this->stagesForFamily($family), + ); + } + + private function creditCostForFamily(string $family): int + { + if ('api_public_read' === $family) { + return max(1, $this->creditCosts['api_read'] ?? 1); + } + + return max(1, $this->creditCosts[$family] ?? 1); + } + + private function minimumLimitForFamily(string $family, int $cost): int + { + $minimumCost = $cost; + if ('website' === $family) { + foreach (self::WEBSITE_COMPANION_FAMILIES as $companion) { + $minimumCost = max($minimumCost, $this->creditCostForFamily($companion)); + } + } + + $actions = isset(self::SINGLE_ACTION_FLOOR_FAMILIES[$family]) ? 1 : self::MIN_ACTIONS_PER_DERIVED_PROFILE; + + return $actions * $minimumCost; + } + + private function subjectPolicyForFamily(string $family): RateLimitSubjectPolicy + { + $apiSubjects = [ + AbuseSubjectType::ApiKey, + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::IpBucket, + ]; + $defaultSubjects = [ + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::ApiKey, + AbuseSubjectType::ApiKeyPrefix, + AbuseSubjectType::IpBucket, + ]; + + if ('scheduler' === $family) { + return new RateLimitSubjectPolicy( + [AbuseSubjectType::SchedulerCredential, AbuseSubjectType::IpBucket, AbuseSubjectType::Visitor], + ipSecondary: true, + ipSecondaryWithAuthenticatedSubject: true, + ); + } + + return new RateLimitSubjectPolicy( + in_array($family, ['api_read', 'api_public_read', 'api_write', 'admin_mutation', 'upload_archive', 'download_diagnostics'], true) + ? $apiSubjects + : $defaultSubjects, + submittedAccountScope: in_array($family, ['login', 'recovery_login', 'registration', 'password_reset'], true), + ipSecondary: in_array($family, [ + 'website', + 'website_form', + 'login', + 'recovery_login', + 'registration', + 'password_reset', + 'captcha_failure', + 'setup_apply', + 'suspicious_probe', + 'api_read', + 'api_write', + 'api_public_read', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', + ], true), + authenticatedMultiplier: in_array($family, ['website', 'api_read', 'api_public_read'], true), + ); + } + + /** + * @return list + */ + private function stagesForFamily(string $family): array + { + return match ($family) { + 'suspicious_probe' => [RateLimitEnforcementStage::SuspiciousProbe], + 'login' => [RateLimitEnforcementStage::AuthenticationFailure], + 'recovery_login' => [RateLimitEnforcementStage::Ordinary], + 'api_read', 'api_public_read', 'api_write', 'admin_mutation', 'upload_archive', 'download_diagnostics' => [ + RateLimitEnforcementStage::Ordinary, + RateLimitEnforcementStage::AuthenticationFailure, + ], + default => [RateLimitEnforcementStage::Ordinary], + }; + } +} diff --git a/src/Security/RateLimit/RateLimitProfile.php b/src/Security/RateLimit/RateLimitProfile.php new file mode 100644 index 00000000..f86d52f5 --- /dev/null +++ b/src/Security/RateLimit/RateLimitProfile.php @@ -0,0 +1,54 @@ + 1.0, + self::Strict => 0.5, + self::Panic => 0.25, + }; + } + + public function windowMultiplier(): float + { + return match ($this) { + self::Off, self::Standard => 1.0, + self::Strict => 1.5, + self::Panic => 2.0, + }; + } + + public function retryAfterMultiplier(): float + { + return match ($this) { + self::Off, self::Standard => 1.0, + self::Strict => 1.5, + self::Panic => 2.0, + }; + } +} diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php new file mode 100644 index 00000000..1e02152c --- /dev/null +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -0,0 +1,124 @@ +probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); + $this->paths = $paths ?? new PathScopeMatcher(); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => [ + ['onKernelRequestProbe', 4096], + ['onKernelRequestOrdinary', 3], + ], + ]; + } + + public function onKernelRequestProbe(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { + return; + } + + if (!$this->probePathMatcher->isProbe($request->getPathInfo())) { + return; + } + + $this->enforcer->check($request, RateLimitEnforcementStage::SuspiciousProbe); + + $event->setResponse($this->responses->invalidRequest($request)); + } + + public function onKernelRequestOrdinary(RequestEvent $event): void + { + if (!$event->isMainRequest() || $event->hasResponse()) { + return; + } + + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing')) || $this->excludedRequest($request)) { + return; + } + + $setupCompleted = $this->setupCompleted(); + if (!$setupCompleted && !$this->setupApplyRequest($request)) { + return; + } + + $this->apply($event, RateLimitEnforcementStage::Ordinary, bareResponse: !$setupCompleted); + } + + private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bool $bareResponse = false): void + { + $request = $event->getRequest(); + $result = $this->enforcer->check($request, $stage); + if ($result->isAllowed()) { + return; + } + + if ($bareResponse) { + $event->setResponse($this->responses->bare($request, Response::HTTP_TOO_MANY_REQUESTS, $result->retryAfterSeconds())); + + return; + } + + $event->setResponse($result->suspiciousProbe() + ? $this->responses->suspiciousProbe($request) + : $this->responses->tooManyRequests($request, $result)); + } + + private function excludedRequest(Request $request): bool + { + return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/live', '/assets', '/build', '/_profiler', '/_wdt') + || in_array($request->getPathInfo(), ['/favicon.ico', '/robots.txt'], true); + } + + private function setupApplyRequest(Request $request): bool + { + return 'POST' === strtoupper($request->getMethod()) + && $this->paths->matchesExactSegments($request->getPathInfo(), 'setup', 'review') + && 'apply' === (string) $request->request->get('_setup_action', ''); + } + + private function enabledForRequest(?string $testOptIn): bool + { + return 'test' !== $this->environment || '1' === $testOptIn; + } + + private function setupCompleted(): bool + { + return $this->setupCompletionMarker->isComplete($this->projectDir, $this->environment); + } +} diff --git a/src/Security/RateLimit/RateLimitResetService.php b/src/Security/RateLimit/RateLimitResetService.php new file mode 100644 index 00000000..b2be8f27 --- /dev/null +++ b/src/Security/RateLimit/RateLimitResetService.php @@ -0,0 +1,98 @@ +profile(); + $descriptor = $this->catalogue->descriptor('login.failure', $profile); + if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable() || !$profile->consumesLimiterStorage()) { + return false; + } + + $subjectResolution = $this->inspector->inspect($request)['subjects']; + $reset = false; + + foreach ($this->subjects->subjectKeys($descriptor, $subjectResolution) as $subjectKey) { + $reset = $this->reset($descriptor, $subjectKey) || $reset; + } + + return $reset; + } + + public function resetVerifiedCaptchaFailure(Request $request, ?string $provider, bool $verified): bool + { + $provider = is_string($provider) ? trim($provider) : ''; + $profile = $this->profile(); + if (!$verified || '' === $provider || 'none' === strtolower($provider) || !$profile->consumesLimiterStorage()) { + return false; + } + + $descriptor = $this->catalogue->descriptor('captcha.failure', $profile); + if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable()) { + return false; + } + + $subjectResolution = $this->inspector->inspect($request)['subjects']; + $reset = false; + + foreach ($this->subjects->subjectKeys($descriptor, $subjectResolution) as $subjectKey) { + $reset = $this->reset($descriptor, $subjectKey) || $reset; + } + + return $reset; + } + + private function profile(): RateLimitProfile + { + return RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)); + } + + private function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey): bool + { + try { + $this->limiters->reset($descriptor, $subjectKey); + + return true; + } catch (\Throwable $exception) { + $this->reportDegradedReset($descriptor, $exception); + + return false; + } + } + + private function reportDegradedReset(RateLimitBucketDescriptor $descriptor, \Throwable $exception): void + { + $context = [ + 'bucket' => $descriptor->diagnosticsLabel(), + 'exception_class' => $exception::class, + ]; + + $this->messages->report( + Message::warning(SecurityMessageCode::RATE_LIMIT_RESET_DEGRADED, SecurityMessageKey::RATE_LIMIT_RESET_DEGRADED, context: $context), + ['operation' => 'security.rate_limit.reset'], + ); + } +} diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php new file mode 100644 index 00000000..5437825e --- /dev/null +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -0,0 +1,105 @@ +paths = $paths ?? new PathScopeMatcher(); + } + + public function tooManyRequests(Request $request, RateLimitCheckResult $result): Response + { + $response = $this->jsonSurface($request) + ? $this->apiResponse($request, Response::HTTP_TOO_MANY_REQUESTS) + : $this->httpError->resolve(Response::HTTP_TOO_MANY_REQUESTS, $request, context: $this->context($request)); + + if (null !== $result->retryAfterSeconds()) { + $response->headers->set('Retry-After', (string) $result->retryAfterSeconds()); + } + + return $this->noStore($response); + } + + public function suspiciousProbe(Request $request): Response + { + $response = $this->jsonSurface($request) + ? $this->apiResponse($request, Response::HTTP_BAD_REQUEST) + : $this->httpError->resolve(Response::HTTP_BAD_REQUEST, $request, context: $this->context($request)); + + return $this->noStore($response); + } + + public function invalidRequest(Request $request): Response + { + return $this->httpError->bare(Response::HTTP_BAD_REQUEST, $request, $this->context($request) + [ + 'bare_context' => 'Invalid Request', + ]); + } + + public function bare(Request $request, int $status, ?int $retryAfterSeconds = null): Response + { + $headers = []; + $context = $this->context($request); + if (null !== $retryAfterSeconds) { + $headers['Retry-After'] = (string) $retryAfterSeconds; + $context['bare_context'] = 'retry-after: '.$retryAfterSeconds; + } + + return $this->httpError->bare($status, $request, $context, $headers); + } + + private function apiResponse(Request $request, int $status): Response + { + $message = Response::HTTP_TOO_MANY_REQUESTS === $status + ? Message::warning(SecurityMessageCode::RATE_LIMIT_EXCEEDED, SecurityMessageKey::RATE_LIMIT_EXCEEDED) + : Message::warning(SecurityMessageCode::RATE_LIMIT_REQUEST_REJECTED, SecurityMessageKey::RATE_LIMIT_REQUEST_REJECTED); + + return $this->apiResponder->error( + $message, + $status, + $request, + $this->context($request), + ); + } + + /** + * @return array{request_id: string} + */ + private function context(Request $request): array + { + return ['request_id' => $this->requestMetadata->requestId($request)]; + } + + private function noStore(Response $response): Response + { + $response->headers->set('Cache-Control', 'no-store'); + + return $response; + } + + private function jsonSurface(Request $request): bool + { + return $this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1') + || $this->paths->matchesSegments($request->getPathInfo(), 'cron'); + } +} diff --git a/src/Security/RateLimit/RateLimitSubjectPolicy.php b/src/Security/RateLimit/RateLimitSubjectPolicy.php new file mode 100644 index 00000000..49ad7fa7 --- /dev/null +++ b/src/Security/RateLimit/RateLimitSubjectPolicy.php @@ -0,0 +1,50 @@ + $preferredTypes + */ + public function __construct( + private array $preferredTypes, + private bool $submittedAccountScope = false, + private bool $ipSecondary = false, + private bool $ipSecondaryWithAuthenticatedSubject = false, + private bool $authenticatedMultiplier = false, + ) { + } + + /** + * @return list + */ + public function preferredTypes(): array + { + return $this->preferredTypes; + } + + public function submittedAccountScope(): bool + { + return $this->submittedAccountScope; + } + + public function ipSecondary(): bool + { + return $this->ipSecondary; + } + + public function ipSecondaryWithAuthenticatedSubject(): bool + { + return $this->ipSecondaryWithAuthenticatedSubject; + } + + public function authenticatedMultiplier(): bool + { + return $this->authenticatedMultiplier; + } +} diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php new file mode 100644 index 00000000..8f8dea71 --- /dev/null +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -0,0 +1,119 @@ + + */ + public function subjectKeys(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): array + { + if ($this->usesSubmittedAccountScope($descriptor)) { + $submittedAccount = $subjects->first(AbuseSubjectType::SubmittedAccount); + if ($submittedAccount instanceof AbuseSubject) { + return $this->subjectKeysFor($descriptor, array_filter([ + $subjects->first(AbuseSubjectType::Visitor), + $subjects->first(AbuseSubjectType::IpBucket), + $submittedAccount, + ])); + } + } + + $primary = $this->primarySubject($descriptor, $subjects); + if (!$primary instanceof AbuseSubject) { + return []; + } + + $keys = [$this->subjectKey($descriptor, $primary)]; + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + + if ($ipBucket instanceof AbuseSubject && $this->includeIpSecondary($descriptor, $subjects)) { + $keys[] = $this->subjectKey($descriptor, $ipBucket); + } + + return array_values(array_unique($keys)); + } + + public function hasOwner(AbuseSubjectResolution $subjects): bool + { + $user = $subjects->first(AbuseSubjectType::User); + + return $user instanceof AbuseSubject + && (int) ($user->context()['access_level'] ?? AccessLevel::PUBLIC) >= AccessLevel::OWNER; + } + + public function authenticatedMultiplier(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): int + { + if ($this->hasOwner($subjects) || !$subjects->first(AbuseSubjectType::User) instanceof AbuseSubject) { + return 1; + } + + return $descriptor->subjectPolicy()->authenticatedMultiplier() + ? RateLimitPolicyCatalogue::AUTHENTICATED_MULTIPLIER + : 1; + } + + private function primarySubject(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): ?AbuseSubject + { + foreach ($this->preferredTypes($descriptor) as $type) { + $subject = $subjects->first($type); + if ($subject instanceof AbuseSubject) { + return $subject; + } + } + + return $subjects->primary(); + } + + /** + * @return list + */ + private function preferredTypes(RateLimitBucketDescriptor $descriptor): array + { + return $descriptor->subjectPolicy()->preferredTypes(); + } + + private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): bool + { + $policy = $descriptor->subjectPolicy(); + if (!$policy->ipSecondary()) { + return false; + } + + return $policy->ipSecondaryWithAuthenticatedSubject() + || (!$subjects->first(AbuseSubjectType::User) instanceof AbuseSubject && !$subjects->first(AbuseSubjectType::ApiKey) instanceof AbuseSubject); + } + + private function usesSubmittedAccountScope(RateLimitBucketDescriptor $descriptor): bool + { + return $descriptor->subjectPolicy()->submittedAccountScope(); + } + + public function subjectKey(RateLimitBucketDescriptor $descriptor, AbuseSubject $subject): string + { + return $descriptor->name().':'.$subject->type()->value.':'.$subject->identifier(); + } + + /** + * @param iterable $subjects + * + * @return list + */ + private function subjectKeysFor(RateLimitBucketDescriptor $descriptor, iterable $subjects): array + { + $keys = []; + foreach ($subjects as $subject) { + $keys[] = $this->subjectKey($descriptor, $subject); + } + + return array_values(array_unique($keys)); + } +} diff --git a/src/Security/SecurityMessageCode.php b/src/Security/SecurityMessageCode.php index 41002052..b413a0ea 100644 --- a/src/Security/SecurityMessageCode.php +++ b/src/Security/SecurityMessageCode.php @@ -21,4 +21,8 @@ final class SecurityMessageCode public const API_KEY_PERMISSION_DENIED = 'api_key.permission_denied'; public const API_KEY_PERMISSION_WRITE_REQUIRED = 'api_key.permission_write_required'; public const API_KEY_PERMISSION_REVOKED = 'api_key.permission_revoked'; + public const RATE_LIMIT_EXCEEDED = 'rate_limit.exceeded'; + public const RATE_LIMIT_REQUEST_REJECTED = 'rate_limit.request_rejected'; + public const RATE_LIMIT_STORAGE_DEGRADED = 'rate_limit.storage_degraded'; + public const RATE_LIMIT_RESET_DEGRADED = 'rate_limit.reset_degraded'; } diff --git a/src/Security/SecurityMessageKey.php b/src/Security/SecurityMessageKey.php index fdbf4042..0cbc8ff8 100644 --- a/src/Security/SecurityMessageKey.php +++ b/src/Security/SecurityMessageKey.php @@ -39,4 +39,8 @@ final class SecurityMessageKey public const API_KEY_PERMISSION_DENIED = 'message.api_key.permission.denied'; public const API_KEY_PERMISSION_WRITE_REQUIRED = 'message.api_key.permission.write_required'; public const API_KEY_PERMISSION_REVOKED = 'message.api_key.permission.revoked'; + public const RATE_LIMIT_EXCEEDED = 'message.rate_limit.exceeded'; + public const RATE_LIMIT_REQUEST_REJECTED = 'message.rate_limit.request_rejected'; + public const RATE_LIMIT_STORAGE_DEGRADED = 'message.rate_limit.storage_degraded'; + public const RATE_LIMIT_RESET_DEGRADED = 'message.rate_limit.reset_degraded'; } diff --git a/src/View/Http/HttpErrorRenderer.php b/src/View/Http/HttpErrorRenderer.php index cfd862f0..0f6a1c2c 100644 --- a/src/View/Http/HttpErrorRenderer.php +++ b/src/View/Http/HttpErrorRenderer.php @@ -7,6 +7,8 @@ use App\Content\Read\PublishedContentResolver; use App\Content\Render\ContentFieldsetRenderer; use App\Core\Access\AccessActor; +use App\Core\Log\AccessRequestMetadata; +use App\Setup\SetupCompletionMarker; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,35 +23,52 @@ public function __construct( private PublishedContentResolver $contentResolver, private ContentFieldsetRenderer $fieldsetRenderer, private Security $security, + private SetupCompletionMarker $setupCompletionMarker, + private AccessRequestMetadata $requestMetadata, + private string $projectDir, + private string $environment, private bool $debug = false, ) { } public function notFound(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_NOT_FOUND, $request, $exception); + return $this->resolve(Response::HTTP_NOT_FOUND, $request, exception: $exception); } public function unauthorized(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_UNAUTHORIZED, $request, $exception); + return $this->resolve(Response::HTTP_UNAUTHORIZED, $request, exception: $exception); } public function forbidden(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_FORBIDDEN, $request, $exception); + return $this->resolve(Response::HTTP_FORBIDDEN, $request, exception: $exception); } public function maintenance(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_SERVICE_UNAVAILABLE, $request, $exception); + return $this->resolve(Response::HTTP_SERVICE_UNAVAILABLE, $request, exception: $exception); } /** * @param array $context + * @param array $headers */ - public function render(int $statusCode, Request $request, ?Throwable $exception = null, array $context = []): Response + public function bare(int $statusCode, ?Request $request = null, array $context = [], array $headers = []): Response { + return $this->bareResponse($statusCode, $request, $context, $headers); + } + + /** + * @param array $context + */ + public function resolve(int $statusCode, Request $request, array $context = [], ?Throwable $exception = null, bool $forceBare = false): Response + { + if ($forceBare || $this->preSetupBareStatus($statusCode)) { + return $this->bareResponse($statusCode, $request, $context); + } + $variables = $this->variables($statusCode, $request, $exception, $context); if (Response::HTTP_UNAUTHORIZED === $statusCode && !$this->isAuthenticated()) { @@ -118,7 +137,87 @@ private function renderSystemErrorContent(int $statusCode, Request $request, arr */ private function renderTemplate(string $template, array $variables, int $statusCode): Response { - return new Response($this->twig->render($template, $variables), $statusCode); + $response = new Response($this->twig->render($template, $variables), $statusCode); + $response->headers->set('Cache-Control', 'no-store'); + + return $response; + } + + private function preSetupBareStatus(int $statusCode): bool + { + return $this->knownErrorStatus($statusCode) + && !$this->setupCompletionMarker->isComplete($this->projectDir, $this->environment); + } + + /** + * @param array $context + * @param array $headers + */ + private function bareResponse(int $statusCode, ?Request $request = null, array $context = [], array $headers = []): Response + { + return new Response($this->bareHtml($statusCode, $request, $context), $statusCode, [ + ...$headers, + 'Cache-Control' => 'no-store', + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + } + + /** + * @param array $context + */ + private function bareHtml(int $statusCode, ?Request $request, array $context): string + { + $statusText = Response::$statusTexts[$statusCode] ?? 'HTTP Error'; + $contextText = $this->bareContextText($context); + $contextHtml = null === $contextText ? '' : "\n

".$this->escape($contextText).'

'; + + return '' + ."\n".'' + ."\n".'

'.$statusCode.' - '.$this->escape($statusText).'

' + .$contextHtml + ."\n".'
Request-ID: '.$this->escape($this->bareRequestId($request, $context)).'
'; + } + + /** + * @param array $context + */ + private function bareContextText(array $context): ?string + { + $value = $context['bare_context'] ?? null; + if (!is_scalar($value)) { + return null; + } + + $text = trim((string) $value); + + return '' === $text ? null : substr($text, 0, 500); + } + + /** + * @param array $context + */ + private function bareRequestId(?Request $request, array $context): string + { + if ($request instanceof Request) { + return $this->requestMetadata->requestId($request); + } + + $contextRequestId = $context['request_id'] ?? null; + if (is_scalar($contextRequestId) && '' !== trim((string) $contextRequestId)) { + return substr((string) $contextRequestId, 0, 64); + } + + return 'n/a'; + } + + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function knownErrorStatus(int $statusCode): bool + { + return $statusCode >= 400 && $statusCode < 600 && isset(Response::$statusTexts[$statusCode]); } /** diff --git a/src/View/Http/HttpErrorSubscriber.php b/src/View/Http/HttpErrorSubscriber.php index 0341cb52..d6cbd651 100644 --- a/src/View/Http/HttpErrorSubscriber.php +++ b/src/View/Http/HttpErrorSubscriber.php @@ -36,7 +36,7 @@ public function onKernelException(ExceptionEvent $event): void return; } - $response = $this->renderer->render($exception->getStatusCode(), $event->getRequest(), $exception); + $response = $this->renderer->resolve($exception->getStatusCode(), $event->getRequest(), exception: $exception); $response->headers->add($exception->getHeaders()); $event->setResponse($response); } diff --git a/tests/Api/Http/ApiTraceHeaderSubscriberTest.php b/tests/Api/Http/ApiTraceHeaderSubscriberTest.php index e160e705..57142361 100644 --- a/tests/Api/Http/ApiTraceHeaderSubscriberTest.php +++ b/tests/Api/Http/ApiTraceHeaderSubscriberTest.php @@ -49,6 +49,17 @@ public function testItIgnoresNonVersionedApiResponses(): void self::assertNull($response->headers->get('X-Correlation-ID')); } + public function testItIgnoresApiLookalikeResponses(): void + { + $request = Request::create('/api/v10/status'); + $response = new Response('ok'); + + $this->subscriber()->onKernelResponse($this->responseEvent($request, $response)); + + self::assertNull($response->headers->get('X-Request-ID')); + self::assertNull($response->headers->get('X-Correlation-ID')); + } + private function subscriber(): ApiTraceHeaderSubscriber { return new ApiTraceHeaderSubscriber(new AccessRequestMetadata()); diff --git a/tests/Api/Security/ApiAvailabilitySubscriberTest.php b/tests/Api/Security/ApiAvailabilitySubscriberTest.php index 5d6c5f54..d289b520 100644 --- a/tests/Api/Security/ApiAvailabilitySubscriberTest.php +++ b/tests/Api/Security/ApiAvailabilitySubscriberTest.php @@ -92,6 +92,20 @@ public function isAvailable(): bool self::assertFalse($event->hasResponse()); } + public function testItIgnoresApiLookalikeRequests(): void + { + $event = $this->event('/api/v10/status'); + + $this->subscriber(new class implements ApiAvailabilityCheckerInterface { + public function isAvailable(): bool + { + return false; + } + })->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + private function subscriber(ApiAvailabilityCheckerInterface $availabilityChecker, bool $enabled = true): ApiAvailabilitySubscriber { $config = $this->config(); diff --git a/tests/Api/Security/ApiCorsSubscriberTest.php b/tests/Api/Security/ApiCorsSubscriberTest.php index 050a39e7..c9361d18 100644 --- a/tests/Api/Security/ApiCorsSubscriberTest.php +++ b/tests/Api/Security/ApiCorsSubscriberTest.php @@ -47,6 +47,19 @@ public function testItIgnoresDisallowedOrigins(): void self::assertFalse($event->hasResponse()); } + public function testItDoesNotShortCircuitPreflightsWithActualAuthorizationHeader(): void + { + $event = $this->requestEvent(Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + ])); + + $this->subscriber(['https://client.example'])->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + public function testItAddsCorsHeadersToAllowedApiResponses(): void { $request = Request::create('/api/v1/status', 'GET', server: [ diff --git a/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php b/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php index 7f6abf57..0fa2e628 100644 --- a/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php +++ b/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php @@ -46,6 +46,36 @@ public function testItAllowsSafeRequestsForReadOnlyKeys(): void self::assertFalse($event->hasResponse()); } + public function testItBlocksUnsafePreflightMethodsForReadOnlyKeys(): void + { + $request = Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]); + $this->context(ApiKeyStatus::ReadOnly)->attachTo($request); + $event = new RequestEvent($this->kernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber()->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()->getStatusCode()); + $payload = json_decode((string) $event->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame('api_key.permission_write_required', $payload['error']['code']); + self::assertSame('OPTIONS', $payload['error']['context']['method']); + } + + public function testItAllowsSafePreflightMethodsForReadOnlyKeys(): void + { + $request = Request::create('/api/v1/status', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ]); + $this->context(ApiKeyStatus::ReadOnly)->attachTo($request); + $event = new RequestEvent($this->kernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber()->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + public function testItAllowsMutatingRequestsForReadWriteKeys(): void { $request = Request::create('/api/v1/status', 'POST'); diff --git a/tests/Api/Security/ApiRequestMethodPolicyTest.php b/tests/Api/Security/ApiRequestMethodPolicyTest.php new file mode 100644 index 00000000..2a7ab3f4 --- /dev/null +++ b/tests/Api/Security/ApiRequestMethodPolicyTest.php @@ -0,0 +1,95 @@ + + */ + public static function effectiveMethodCases(): iterable + { + yield 'ordinary get' => [ + Request::create('/api/v1/status'), + Request::METHOD_GET, + true, + ]; + yield 'ordinary post' => [ + Request::create('/api/v1/content/items', Request::METHOD_POST), + Request::METHOD_POST, + false, + ]; + yield 'preflight uses requested unsafe method' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => Request::METHOD_PATCH, + ]), + Request::METHOD_PATCH, + false, + ]; + yield 'credentialed preflight uses requested unsafe method' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => Request::METHOD_PATCH, + 'HTTP_AUTHORIZATION' => 'Basic credential-probe', + ]), + Request::METHOD_PATCH, + false, + ]; + yield 'malformed bearer preflight still uses requested unsafe method' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => Request::METHOD_DELETE, + 'HTTP_AUTHORIZATION' => 'Bearer', + ]), + Request::METHOD_DELETE, + false, + ]; + yield 'credentialed preflight without requested method defaults to read' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_AUTHORIZATION' => 'Bearer', + ]), + Request::METHOD_GET, + true, + ]; + } + + #[DataProvider('effectiveMethodCases')] + public function testEffectiveMethod(Request $request, string $method, bool $safe): void + { + $policy = new ApiRequestMethodPolicy(); + + self::assertSame($method, $policy->effectiveMethod($request)); + self::assertSame($safe, $policy->isSafeEffectiveMethod($request)); + } + + /** + * @return iterable + */ + public static function apiPathCases(): iterable + { + yield 'api root' => ['/api/v1', true]; + yield 'api child' => ['/api/v1/status', true]; + yield 'api lookalike' => ['/api/v10/status', false]; + yield 'browser content' => ['/docs/api/v1', false]; + } + + #[DataProvider('apiPathCases')] + public function testApiPathUsesSegmentBoundaries(string $path, bool $api): void + { + self::assertSame($api, (new ApiRequestMethodPolicy())->isApiV1Request(Request::create($path))); + } + + public function testApiPathDoesNotUseLocalizedRequestSegments(): void + { + $request = Request::create('/de/api/v1/status'); + $request->attributes->set('_locale', 'de'); + + self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request($request)); + self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request(Request::create('/de/api/v1/status'))); + } +} diff --git a/tests/Backend/PackageDependencyLabelParserTest.php b/tests/Backend/PackageDependencyLabelParserTest.php index 4b4c8fcf..ed482a7a 100644 --- a/tests/Backend/PackageDependencyLabelParserTest.php +++ b/tests/Backend/PackageDependencyLabelParserTest.php @@ -14,10 +14,10 @@ public function testItParsesDependencyLabelsFromManifestJson(): void $parser = new PackageDependencyLabelParser(); self::assertSame([ - 'system 0.2.4', + 'system 0.2.5', 'demo-module', 'provider 1.0', - ], $parser->parse('[["system","0.2.4"],"demo-module",["provider","1.0",{"ignored":true}]]')); + ], $parser->parse('[["system","0.2.5"],"demo-module",["provider","1.0",{"ignored":true}]]')); } public function testItKeepsMalformedDependencyValuesVisible(): void diff --git a/tests/Command/RenderRouteCommandTest.php b/tests/Command/RenderRouteCommandTest.php index 618a6045..717dcefd 100644 --- a/tests/Command/RenderRouteCommandTest.php +++ b/tests/Command/RenderRouteCommandTest.php @@ -5,6 +5,12 @@ namespace App\Tests\Command; use App\Command\RenderRouteCommand; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\Debug\RouteRenderer; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -22,6 +28,19 @@ public function testItRendersPublicRoutesFromTheConsole(): void self::assertStringContainsString('Sign in', $tester->getDisplay()); } + public function testItIsBlockedInProductionEnvironment(): void + { + self::bootKernel(); + $command = new RenderRouteCommand(self::getContainer()->get(RouteRenderer::class), 'prod'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute(['path' => '/user/login']); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('render:route command is available only', $tester->getDisplay()); + self::assertStringContainsString('environments', $tester->getDisplay()); + } + public function testItRendersProtectedRoutesWithDebugRoleContext(): void { self::bootKernel(); @@ -44,6 +63,44 @@ public function testItRendersApiRoutesWithDebugApiContext(): void self::assertStringContainsString('"type":"user_profile"', $tester->getDisplay()); } + public function testItRejectsInvalidSyntheticHeaders(): void + { + self::bootKernel(); + $tester = new CommandTester(self::getContainer()->get(RenderRouteCommand::class)); + + $exitCode = $tester->execute([ + 'path' => '/user/login', + '--header' => ["X-Test: ok\nInjected: nope"], + ]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('unsupported control characters', $tester->getDisplay()); + } + + public function testCronRunRenderEnforcesSchedulerRateLimitForApiKeyContext(): void + { + self::bootKernel(); + $this->setRateLimitMode(RateLimitProfile::Standard); + $command = self::getContainer()->get(RenderRouteCommand::class); + + $first = new CommandTester($command); + $firstExit = $first->execute($this->cronRenderInput()); + + self::assertSame(Command::SUCCESS, $firstExit); + self::assertStringContainsString('HTTP 200', $first->getDisplay()); + + $second = new CommandTester($command); + $secondExit = $second->execute($this->cronRenderInput()); + $display = $second->getDisplay(); + + self::assertSame(Command::SUCCESS, $secondExit); + self::assertStringContainsString('HTTP 429', $display); + self::assertStringContainsString('Retry-After:', $display); + self::assertStringContainsString('Cache-Control:', $display); + self::assertStringContainsString('no-store', $display); + self::assertStringContainsString('"code":"rate_limit.exceeded"', $display); + } + public function testItDoesNotOverrideExistingUserRoles(): void { self::bootKernel(); @@ -54,4 +111,32 @@ public function testItDoesNotOverrideExistingUserRoles(): void self::assertSame(Command::FAILURE, $exitCode); self::assertStringContainsString('The --role option cannot override an existing --user role.', $tester->getDisplay()); } + + /** + * @return array + */ + private function cronRenderInput(): array + { + return [ + 'path' => '/cron/run', + '--role' => 'public', + '--include-status' => true, + '--include-headers' => true, + '--header' => [ + 'Authorization: Bearer test_seed_read_write_key', + 'X-Rate-Limit-Testing: 1', + ], + ]; + } + + private function setRateLimitMode(RateLimitProfile $profile): void + { + $cache = self::getContainer()->get('cache.rate_limiter'); + self::assertInstanceOf(CacheItemPoolInterface::class, $cache); + $cache->clear(); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, $profile->value, ConfigValueType::String, modifiedBy: 'test'); + } } diff --git a/tests/Content/Routing/ContentRouteGuardTest.php b/tests/Content/Routing/ContentRouteGuardTest.php index 60ff1a89..fed23786 100644 --- a/tests/Content/Routing/ContentRouteGuardTest.php +++ b/tests/Content/Routing/ContentRouteGuardTest.php @@ -37,7 +37,23 @@ public function testItRejectsVariantMarkersBeforeTheLastPathSegment(): void public function testItRejectsReservedPathPrefixes(): void { - foreach (['system', 'user', 'setup', 'cron', 'admin', 'editor', 'packages'] as $prefix) { + foreach ([ + 'system', + 'user', + 'setup', + 'cron', + 'admin', + 'editor', + 'api', + 'assets', + 'build', + '_profiler', + 'profiler', + '_wdt', + 'packages', + 'media', + 'files', + ] as $prefix) { try { (new ContentRouteGuard())->assertPathAllowed(sprintf('/%s/example', $prefix)); self::fail(sprintf('Expected prefix "%s" to be reserved.', $prefix)); diff --git a/tests/Controller/ApiSettingsControllerTest.php b/tests/Controller/ApiSettingsControllerTest.php index 7f3f525b..81f0113d 100644 --- a/tests/Controller/ApiSettingsControllerTest.php +++ b/tests/Controller/ApiSettingsControllerTest.php @@ -11,6 +11,8 @@ use App\Entity\ApiKey; use App\Security\ApiKeyStatus; use App\Security\ApiKeyVault; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -183,6 +185,7 @@ public function testSecuritySettingsSectionAclHidesAndRejectsSecurityFieldsForDe 'values' => [ 'security.captcha.enabled' => false, 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Panic->value, ], ], JSON_THROW_ON_ERROR)); @@ -203,6 +206,7 @@ public function testSecuritySettingsCanBeReadAndPatchedByOwnerApiKeys(): void 'values' => [ 'security.captcha.enabled' => true, 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Strict->value, ], ], JSON_THROW_ON_ERROR)); @@ -210,6 +214,7 @@ public function testSecuritySettingsCanBeReadAndPatchedByOwnerApiKeys(): void $payload = $this->jsonPayload($client->getResponse()->getContent()); self::assertContains('security.captcha.enabled', $payload['meta']['updated_keys']); self::assertContains('security.captcha.provider', $payload['meta']['updated_keys']); + self::assertContains(RateLimitPolicyCatalogue::MODE_KEY, $payload['meta']['updated_keys']); } finally { $this->removeApiKeyUser('apisetsecown'); } diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 74ac1bab..4ee224d4 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -23,6 +23,7 @@ use App\Core\Workflow\WorkflowResult; use App\Entity\AclGroup; use App\Entity\ExtensionPackage; +use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\UserAccountStatus; use App\Security\UserFlowConfig; use App\Setup\SetupCompletionMarker; @@ -1161,6 +1162,7 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorTextContains('h1', 'Security settings'); self::assertSelectorExists('form#admin-settings-security'); self::assertSelectorExists('select[name="security.captcha.provider"]'); + self::assertSelectorExists(sprintf('select[name="%s"]', RateLimitPolicyCatalogue::MODE_KEY)); self::assertSelectorExists(sprintf('input[name="%s"]', ConfigAuditLogPolicy::ENABLED_KEY)); self::assertSelectorExists(sprintf('input[name="%s[]"]', ConfigAuditLogPolicy::EVENTS_KEY)); self::assertSelectorExists('input[name="security.signals.retention_days"]'); diff --git a/tests/Controller/PublicContentErrorPageTest.php b/tests/Controller/PublicContentErrorPageTest.php index 232c12ed..d5fab69a 100644 --- a/tests/Controller/PublicContentErrorPageTest.php +++ b/tests/Controller/PublicContentErrorPageTest.php @@ -20,6 +20,7 @@ public function testItReturnsNotFoundForMissingContent(): void self::assertSelectorTextContains('h1', 'Page not found'); self::assertSelectorTextContains('.system-frontend-error-reference', 'Request ID'); self::assertSelectorNotExists('.system-frontend-error-reference dd:nth-of-type(2)'); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); } public function testItReturnsForbiddenForReservedCronPrefix(): void diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php new file mode 100644 index 00000000..cc7c07af --- /dev/null +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -0,0 +1,537 @@ +server('198.51.100.10')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 16; ++$i) { + $client->request('GET', '/home'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('GET', '/home'); + + self::assertResponseStatusCodeSame(429); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + self::assertNotNull($client->getResponse()->headers->get('Retry-After')); + self::assertStringContainsString('Request ID', $client->getResponse()->getContent()); + self::assertStringNotContainsString('website.deliberate', $client->getResponse()->getContent()); + self::assertStringNotContainsString('ip_bucket', $client->getResponse()->getContent()); + } + + public function testApiRateLimitReturnsJsonWithRequestIdAndNoInternalDetails(): void + { + $client = self::createClient(server: $this->server('198.51.100.11')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 30; ++$i) { + $client->request('GET', '/api/v1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('GET', '/api/v1'); + + self::assertResponseStatusCodeSame(429); + self::assertStringStartsWith('application/json', (string) $client->getResponse()->headers->get('Content-Type')); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + self::assertNotNull($client->getResponse()->headers->get('Retry-After')); + + $payload = json_decode($client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame('rate_limit.exceeded', $payload['error']['code']); + self::assertArrayHasKey('request_id', $payload['error']['context']); + self::assertStringNotContainsString('api.public_read', $client->getResponse()->getContent()); + self::assertStringNotContainsString('ip_bucket', $client->getResponse()->getContent()); + } + + public function testSuspiciousProbeReturnsGenericBadRequestEvenWhenModeIsOff(): void + { + $client = self::createClient(server: $this->server('198.51.100.12')); + $this->setMode(RateLimitProfile::Off); + + $client->request('GET', '/.env'); + + self::assertResponseStatusCodeSame(400); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + self::assertStringContainsString('Invalid Request', $client->getResponse()->getContent()); + self::assertStringContainsString('Request-ID', $client->getResponse()->getContent()); + self::assertStringNotContainsString('suspicious.probe', $client->getResponse()->getContent()); + } + + public function testLiveSuspiciousProbeIsBlockedBeforeLiveApiExclusion(): void + { + $client = self::createClient(server: $this->server('198.51.100.17')); + $this->setMode(RateLimitProfile::Off); + + $client->request('GET', '/api/live/.env'); + + self::assertResponseStatusCodeSame(400); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + } + + public function testApiSuspiciousProbeIsBlockedBeforeApiDisabledGate(): void + { + $client = self::createClient(server: $this->server('198.51.100.27')); + $this->setMode(RateLimitProfile::Off); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $config->set(ApiFeaturePolicy::ENABLED_KEY, false, ConfigValueType::Boolean); + + $client->request('GET', '/api/v1/.env'); + + self::assertResponseStatusCodeSame(400); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + } finally { + $config->set(ApiFeaturePolicy::ENABLED_KEY, true, ConfigValueType::Boolean); + } + } + + public function testPrefetchAndLiveApiPathsAreNotChargedToOrdinaryLimiter(): void + { + $client = self::createClient(server: $this->server('198.51.100.13')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 12; ++$i) { + $client->request('GET', '/home', server: ['HTTP_SEC_PURPOSE' => 'prefetch']); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + $client->request('GET', '/api/live/status'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } + + public function testBuildAssetsAreNotChargedToOrdinaryLimiter(): void + { + $client = self::createClient(server: $this->server('198.51.100.14')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 12; ++$i) { + $client->request('GET', '/build/app.js'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } + + public function testInvalidLoginSubmissionsSpendLoginBudgetBeforeAuthenticationResponse(): void + { + $client = self::createClient(server: $this->server('198.51.100.15')); + $this->setMode(RateLimitProfile::Standard); + + for ($i = 0; $i < 5; ++$i) { + $client->request('POST', '/user/login', parameters: [ + 'username' => 'missing-user', + 'password' => 'wrong-password', + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('POST', '/user/login', parameters: [ + 'username' => 'missing-user', + 'password' => 'wrong-password', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testInvalidBearerRequestsSpendApiBudgetBeforeAuthenticationResponse(): void + { + $client = self::createClient(server: $this->server('198.51.100.16')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 30; ++$i) { + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer invalid%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid31.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testInvalidBearerOptionsRequestsSpendApiBudgetBeforeAuthenticationResponse(): void + { + $client = self::createClient(server: $this->server('198.51.100.22')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 30; ++$i) { + $client->request('OPTIONS', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer option%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('OPTIONS', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer option31.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testCorsBearerPreflightsSpendAuthFailureBudgetBeforeCorsShortCircuit(): void + { + $client = self::createClient(server: $this->server('198.51.100.28')); + $this->setMode(RateLimitProfile::Panic); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + + for ($i = 0; $i < 7; ++$i) { + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => sprintf('Bearer corsadm%02d.invalid-secret', $i), + ]); + self::assertNotSame(204, $client->getResponse()->getStatusCode()); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer corsadm08.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + } + } + + public function testMalformedBearerPreflightsSpendAuthFailureBudgetBeforeCorsShortCircuit(): void + { + $client = self::createClient(server: $this->server('198.51.100.30')); + $this->setMode(RateLimitProfile::Panic); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + + for ($i = 0; $i < 7; ++$i) { + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer ', + ]); + self::assertNotSame(204, $client->getResponse()->getStatusCode()); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer ', + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + } + } + + public function testRecoveryLoginRendersSpendRecoveryBucket(): void + { + $client = self::createClient(server: $this->server('198.51.100.23')); + $this->setMode(RateLimitProfile::Standard); + + $client->request('GET', '/user/login?bypass=1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + $client->request('GET', '/user/login?bypass=1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + + $client->request('GET', '/user/login?bypass=1'); + + self::assertResponseStatusCodeSame(429); + } + + public function testPanicRecoveryLoginRenderAndSubmitAreNotRateLimited(): void + { + $client = self::createClient(server: $this->server('198.51.100.26')); + $this->setMode(RateLimitProfile::Panic); + + $client->request('GET', '/user/login?bypass=1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + + $client->request('POST', '/user/login', parameters: [ + 'username' => 'missing-recovery-user', + 'password' => 'wrong-password', + ]); + + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + public function testRotatingInvalidBearerPrefixesDoNotBypassApiWriteBudget(): void + { + $client = self::createClient(server: $this->server('198.51.100.18')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 15; ++$i) { + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer write%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer write16.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testInvalidBearerAdminMutationsSpendAuthFailureBudget(): void + { + $client = self::createClient(server: $this->server('198.51.100.21')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 7; ++$i) { + $client->request('PATCH', '/api/v1/admin/settings/general', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer admin%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('PATCH', '/api/v1/admin/settings/general', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer admin08.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testReadOnlyOwnerApiKeyMutationsSpendApiWriteBudgetBeforeDenial(): void + { + $prefix = 'rlownro'; + $client = self::createClient(server: $this->server('198.51.100.24')); + $plainKey = $this->createOwnerApiKey($prefix, ApiKeyStatus::ReadOnly); + + try { + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 15; ++$i) { + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $this->removeApiKey($prefix); + } + } + + public function testReadOnlyOwnerApiKeyUnsafePreflightsSpendAdminBudgetBeforeDenial(): void + { + $prefix = 'rlownpf'; + $client = self::createClient(server: $this->server('198.51.100.29')); + $plainKey = $this->createOwnerApiKey($prefix, ApiKeyStatus::ReadOnly); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $this->setMode(RateLimitProfile::Panic); + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + + for ($i = 0; $i < 7; ++$i) { + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + self::assertResponseStatusCodeSame(403); + } + + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + $this->removeApiKey($prefix); + } + } + + public function testSchedulerIntervalUsesStableBearerCredentialAcrossVisitorChanges(): void + { + $first = self::createClient(server: [ + ...$this->server('198.51.100.25'), + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]); + $this->setMode(RateLimitProfile::Standard); + + $first->request('GET', '/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + ]); + self::assertNotSame(429, $first->getResponse()->getStatusCode()); + + self::ensureKernelShutdown(); + + $second = self::createClient(server: [ + ...$this->server('198.51.100.25'), + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ]); + $second->request('GET', '/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testSignedInSchedulerIntervalKeepsIpAnchorAcrossRotatingQueryCredentials(): void + { + $client = self::createClient(server: $this->server('198.51.100.31')); + $this->loginTestUser($client, $this->adminUser()); + $this->setMode(RateLimitProfile::Standard); + + $client->request('GET', '/cron/run?auth=scheduler-token-a'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + + $client->request('GET', '/cron/run?auth=scheduler-token-b'); + + self::assertResponseStatusCodeSame(429); + } + + public function testValidOwnerApiKeyUsesPostAuthOwnerExemption(): void + { + $prefix = 'rlowner'; + $client = self::createClient(server: $this->server('198.51.100.19')); + $plainKey = $this->createOwnerApiKey($prefix); + + try { + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 35; ++$i) { + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } finally { + $this->removeApiKey($prefix); + } + } + + public function testSignedInOwnerUsesPostAuthOwnerExemption(): void + { + $client = self::createClient(server: $this->server('198.51.100.20')); + $this->loginTestUser($client, $this->adminUser()); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 12; ++$i) { + $client->request('GET', '/home'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'RateLimitEnforcementControllerTest', + 'HTTP_X_RATE_LIMIT_TESTING' => '1', + ]; + } + + private function setMode(RateLimitProfile $profile): void + { + $cache = self::getContainer()->get('cache.rate_limiter'); + self::assertInstanceOf(CacheItemPoolInterface::class, $cache); + $cache->clear(); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, $profile->value, ConfigValueType::String, modifiedBy: 'test'); + } + + private function adminUser(): UserAccount + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $user = $entityManager->getRepository(UserAccount::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(UserAccount::class, $user); + + return $user; + } + + private function createOwnerApiKey(string $prefix, ApiKeyStatus $status = ApiKeyStatus::ReadWrite): string + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $this->removeApiKey($prefix); + + $vault = self::getContainer()->get(ApiKeyVault::class); + self::assertInstanceOf(ApiKeyVault::class, $vault); + + $plainKey = $vault->generatePlainKey($prefix); + $apiKey = new ApiKey( + '63000000-0000-7000-8000-'.substr(md5($prefix), 0, 12), + $prefix, + $vault->hmac($plainKey), + $vault->encrypt($plainKey, $prefix), + $this->adminUser(), + $status, + ); + + $entityManager->persist($apiKey); + $entityManager->flush(); + + return $plainKey; + } + + private function removeApiKey(string $prefix): void + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $existing = $entityManager->getRepository(ApiKey::class)->findOneBy(['prefix' => $prefix]); + if ($existing instanceof ApiKey) { + $entityManager->remove($existing); + $entityManager->flush(); + } + } +} diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index 19bea0c4..07f3bc22 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -16,6 +16,8 @@ use App\Form\FormSubmissionHandler; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use App\View\SystemPackageMetadataProvider; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; @@ -93,6 +95,7 @@ public function testItInvalidatesSuspiciousProbePatternCacheWhenSecuritySettings $result = $handler->submit('security', [ 'security.captcha.enabled' => '0', 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Strict->value, ConfigAuditLogPolicy::ENABLED_KEY => '1', ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', @@ -100,10 +103,36 @@ public function testItInvalidatesSuspiciousProbePatternCacheWhenSecuritySettings ], 'test'); self::assertTrue($result->isValid()); + self::assertSame(RateLimitProfile::Strict->value, $config->get(RateLimitPolicyCatalogue::MODE_KEY)); self::assertTrue((new SuspiciousProbePathMatcher($config, cache: $cache))->isProbe('/new-probe')); self::assertFalse((new SuspiciousProbePathMatcher($config, cache: $cache))->isProbe('/old-probe')); } + public function testItRejectsInvalidRateLimitModes(): void + { + $config = new Config($this->connection()); + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + ); + + $result = $handler->submit('security', [ + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => 'forever', + ConfigAuditLogPolicy::ENABLED_KEY => '1', + ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', + SuspiciousProbePathMatcher::PATTERNS_KEY => SuspiciousProbePathMatcher::defaultPatternText(), + ], 'test'); + + self::assertFalse($result->isValid()); + self::assertSame(['admin.settings.form.errors.choice'], $result->errors()[RateLimitPolicyCatalogue::MODE_KEY]); + self::assertNull($config->get(RateLimitPolicyCatalogue::MODE_KEY)); + } + private function registry(): CoreSettingsRegistry { $projectDir = dirname(__DIR__, 3); diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 228143f0..0fde14f6 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -17,6 +17,8 @@ use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use App\Security\UserFlowConfig; use App\View\SystemPackageMetadataProvider; use PHPUnit\Framework\TestCase; @@ -62,17 +64,27 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.enabled', 'security.captcha.provider', 'security.captcha.preview', + RateLimitPolicyCatalogue::MODE_KEY, ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, SuspiciousProbePathMatcher::PATTERNS_KEY, ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $security)); self::assertSame(FormInputType::Captcha, $security[2]->formField()->inputType()); - self::assertSame(FormInputType::MultiSelect, $security[4]->formField()->inputType()); - self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[4]->defaultValue()); - self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[5]->defaultValue()); - self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[6]->defaultValue()); - self::assertSame(FormInputType::Textarea, $security[6]->formField()->inputType()); + self::assertSame(FormInputType::Select, $security[3]->formField()->inputType()); + self::assertSame(RateLimitProfile::Standard->value, $security[3]->defaultValue()); + self::assertSame([ + RateLimitProfile::Off->value => 'admin.settings.options.rate_limit_mode.off', + RateLimitProfile::Standard->value => 'admin.settings.options.rate_limit_mode.standard', + RateLimitProfile::Strict->value => 'admin.settings.options.rate_limit_mode.strict', + RateLimitProfile::Panic->value => 'admin.settings.options.rate_limit_mode.panic', + ], $security[3]->formField()->options()); + self::assertSame('admin.settings.security', $security[3]->metadata()['access_feature']); + self::assertSame(FormInputType::MultiSelect, $security[5]->formField()->inputType()); + self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[5]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[6]->defaultValue()); + self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[7]->defaultValue()); + self::assertSame(FormInputType::Textarea, $security[7]->formField()->inputType()); self::assertSame([ DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, @@ -130,6 +142,7 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void self::assertFalse($provider->defaultValue(MaxMindGeoIpConfig::ENABLED_KEY)); self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $provider->defaultValue(MaxMindGeoIpConfig::DATABASE_PATH_KEY)); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $provider->defaultValue(SuspiciousProbePathMatcher::PATTERNS_KEY)); + self::assertSame(RateLimitProfile::Standard->value, $provider->defaultValue(RateLimitPolicyCatalogue::MODE_KEY)); self::assertSame((new AdminFeatureDefaults())->overrides(), $provider->defaultValue(AdminFeatureOverrideStore::CONFIG_KEY)); self::assertFalse($provider->hasDefault('security.captcha.preview')); self::assertNull($provider->defaultValue('security.captcha.preview')); diff --git a/tests/Core/Log/AccessRequestMetadataTest.php b/tests/Core/Log/AccessRequestMetadataTest.php index 0d510b0d..7e59186b 100644 --- a/tests/Core/Log/AccessRequestMetadataTest.php +++ b/tests/Core/Log/AccessRequestMetadataTest.php @@ -109,7 +109,7 @@ public function testItGatesLocalizedSurfacePrefixesByRouteLocaleOrEnabledRoutePr self::assertSame('public', $disabled->surface(Request::create('/de/api/v1/status'))); self::assertSame('admin', $disabled->surface($localizedRoute)); self::assertSame('admin', $enabled->surface(Request::create('/de/admin/logs'))); - self::assertSame('api', $enabled->surface(Request::create('/de/api/v1/status'))); + self::assertSame('public', $enabled->surface(Request::create('/de/api/v1/status'))); } private function routeLocalization(bool $enabled): ContentRouteLocalization diff --git a/tests/Core/Manifest/ManifestParserTest.php b/tests/Core/Manifest/ManifestParserTest.php index 30a2b412..6bef5d1f 100644 --- a/tests/Core/Manifest/ManifestParserTest.php +++ b/tests/Core/Manifest/ManifestParserTest.php @@ -68,7 +68,7 @@ public function testItReportsDuplicateKeys(): void { $result = (new ManifestParser())->parse(<<<'MANIFEST' APP_VERSION=0.1.0 - APP_VERSION=0.2.4 + APP_VERSION=0.2.5 MANIFEST); self::assertFalse($result->isSuccess()); diff --git a/tests/Core/Message/MessageCodeTest.php b/tests/Core/Message/MessageCodeTest.php index e567e4e2..212a2226 100644 --- a/tests/Core/Message/MessageCodeTest.php +++ b/tests/Core/Message/MessageCodeTest.php @@ -105,7 +105,7 @@ private static function namePrefixes(): array SystemSecurityMessageCode::class => ['SYSTEM_'], TranslationMessageCode::class => ['TRANSLATION_'], SchedulerMessageCode::class => ['SCHEDULER_'], - SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_'], + SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_'], SetupMessageCode::class => ['SETUP_'], ViewMessageCode::class => ['VIEW_'], ]; @@ -136,7 +136,7 @@ private static function valuePrefixes(): array SystemSecurityMessageCode::class => ['system.'], TranslationMessageCode::class => ['translation.'], SchedulerMessageCode::class => ['scheduler.'], - SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.'], + SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.', 'rate_limit.'], SetupMessageCode::class => ['setup.'], ViewMessageCode::class => ['view.'], ]; diff --git a/tests/Core/Message/MessageKeyTest.php b/tests/Core/Message/MessageKeyTest.php index 6c88eca7..2b73dd1c 100644 --- a/tests/Core/Message/MessageKeyTest.php +++ b/tests/Core/Message/MessageKeyTest.php @@ -154,7 +154,7 @@ private static function namePrefixes(): array TranslationMessageKey::class => ['TRANSLATION_'], NavigationMessageKey::class => ['MENU_'], SchedulerMessageKey::class => ['SCHEDULER_'], - SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_'], + SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_'], SetupMessageKey::class => ['SETUP_'], ViewMessageKey::class => ['VIEW_'], ]; @@ -187,7 +187,7 @@ private static function valuePrefixes(): array TranslationMessageKey::class => ['message.translation.'], NavigationMessageKey::class => ['message.menu.'], SchedulerMessageKey::class => ['message.scheduler.'], - SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.'], + SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.', 'message.rate_limit.'], SetupMessageKey::class => ['message.setup.'], ViewMessageKey::class => ['message.view.'], ]; diff --git a/tests/Core/Messenger/DeferredMessengerDrainTest.php b/tests/Core/Messenger/DeferredMessengerDrainTest.php index 24c4b0c1..21395a25 100644 --- a/tests/Core/Messenger/DeferredMessengerDrainTest.php +++ b/tests/Core/Messenger/DeferredMessengerDrainTest.php @@ -139,6 +139,31 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c $this->removeDirectory($projectDir); } + public function testSubscriberDoesNotSkipSchedulerLookalikeRequests(): void + { + $projectDir = $this->createTemporaryProjectDirectory('messenger-drain-scheduler-lookalike'); + $connection = $this->connectionWithMessengerTable(); + $starter = new RecordingDeferredMessengerStarter(); + $settings = $this->schedulerSettings($connection, true); + $drain = new DeferredMessengerDrain($connection, $starter, $projectDir, 'test', schedulerSettings: $settings); + $request = Request::create('/cron/runaway'); + + (new DeferredMessengerDrainSubscriber($drain))->onKernelTerminate(new TerminateEvent( + new class implements HttpKernelInterface { + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + }, + $request, + new Response(), + )); + + self::assertCount(1, $starter->starts); + + $this->removeDirectory($projectDir); + } + public function testItLogsDispatchFailureWhenDetachedStartFails(): void { $projectDir = $this->createTemporaryProjectDirectory('messenger-drain-scheduler-failure'); diff --git a/tests/Core/Routing/PathScopeMatcherTest.php b/tests/Core/Routing/PathScopeMatcherTest.php new file mode 100644 index 00000000..29fb0cfd --- /dev/null +++ b/tests/Core/Routing/PathScopeMatcherTest.php @@ -0,0 +1,58 @@ + + */ + public static function prefixCases(): iterable + { + yield 'exact path' => ['/api/v1', '/api/v1', true]; + yield 'child path' => ['/api/v1/status', '/api/v1', true]; + yield 'lookalike path' => ['/api/v10/status', '/api/v1', false]; + yield 'segment sibling' => ['/cronjobs', '/cron', false]; + yield 'prefix without leading slash' => ['/build/app.js', 'build', true]; + yield 'root does not match every path' => ['/docs', '/', false]; + yield 'root matches only root' => ['/', '/', true]; + } + + #[DataProvider('prefixCases')] + public function testMatchesPrefixUsesSegmentBoundaries(string $path, string $prefix, bool $matches): void + { + self::assertSame($matches, (new PathScopeMatcher())->matchesPrefix($path, $prefix)); + } + + public function testMatchesAnyPrefixUsesSameSegmentRules(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesAnyPrefix('/_wdt/token', '/assets', '/_wdt')); + self::assertFalse($matcher->matchesAnyPrefix('/_wdtfoo', '/assets', '/_wdt')); + } + + public function testMatchesSegmentsPinsExplicitPathParts(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesSegments('/api/v1/content', 'api', 'v1')); + self::assertFalse($matcher->matchesSegments('/api/v10/content', 'api', 'v1')); + self::assertFalse($matcher->matchesSegments('/de/api/v1/content', 'api', 'v1')); + } + + public function testMatchesExactSegmentsRejectsChildrenAndLocalizedLookalikes(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesExactSegments('/cron/run', 'cron', 'run')); + self::assertFalse($matcher->matchesExactSegments('/cron/run/extra', 'cron', 'run')); + self::assertFalse($matcher->matchesExactSegments('/de/cron/run', 'cron', 'run')); + } +} diff --git a/tests/Core/Routing/RequestPathResolverTest.php b/tests/Core/Routing/RequestPathResolverTest.php new file mode 100644 index 00000000..208b7e5a --- /dev/null +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -0,0 +1,71 @@ +attributes->set('_locale', 'de'); + $content = Request::create('/de/about'); + $content->attributes->set('_locale', 'de'); + + self::assertSame(['admin', 'settings', 'security'], $resolver->segments($admin)); + self::assertSame(['de', 'about'], $resolver->segments($content)); + } + + public function testItDoesNotStripLocalePrefixForPrefixlessTechnicalScopes(): void + { + $resolver = new RequestPathResolver(); + $api = Request::create('/de/api/v1/status'); + $api->attributes->set('_locale', 'de'); + $cron = Request::create('/de/cron/run'); + $cron->attributes->set('_locale', 'de'); + + self::assertFalse($resolver->matches($api, 'api', 'v1')); + self::assertFalse($resolver->matchesExact($cron, 'cron', 'run')); + self::assertFalse($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + self::assertFalse($resolver->matches(Request::create('/apiary'), 'api')); + } + + public function testItUsesEnabledRoutePrefixLanguages(): void + { + $resolver = new RequestPathResolver($this->routeLocalization(true)); + + self::assertTrue($resolver->matches(Request::create('/de/admin/logs'), 'admin')); + self::assertFalse($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + self::assertFalse($resolver->matchesExact(Request::create('/de/cron/run'), 'cron', 'run')); + self::assertFalse($resolver->matches(Request::create('/fr/api/v1/status'), 'api', 'v1')); + self::assertFalse((new RequestPathResolver($this->routeLocalization(false)))->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + } + + private function routeLocalization(bool $enabled): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, $enabled, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, new TranslationLanguageCatalog(dirname(__DIR__, 3))); + } + + 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)'); + + return $connection; + } +} diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index 44527aad..cc39f68c 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -101,4 +101,106 @@ public function testItKeepsInvalidBearerTokensToSafePrefixSubjects(): void self::assertSame('publicPrefix', $subject->identifier()); self::assertStringNotContainsString('secret-token-material', json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); } + + public function testItAddsRedactedSchedulerCredentialSubjects(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $bearer = Request::create('/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer scheduler.secret-token-material', + ]); + $query = Request::create('/cron/run?auth=scheduler.secret-token-material'); + + $bearerSubject = $resolver->resolve($bearer)->first(AbuseSubjectType::SchedulerCredential); + $querySubject = $resolver->resolve($query)->first(AbuseSubjectType::SchedulerCredential); + + self::assertNotNull($bearerSubject); + self::assertNotNull($querySubject); + self::assertSame($bearerSubject->identifier(), $querySubject->identifier()); + self::assertStringNotContainsString('scheduler.secret-token-material', json_encode($bearerSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItDoesNotAddSchedulerCredentialSubjectsForLocalizedCronLookalikes(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/de/cron/run?auth=scheduler.secret-token-material'); + $request->attributes->set('_locale', 'de'); + + self::assertNull($resolver->resolve($request)->first(AbuseSubjectType::SchedulerCredential)); + } + + public function testItAddsRedactedSubmittedAccountSubjectsForAuthWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $login = Request::create('/user/login', 'POST', ['username' => 'Admin']); + $reset = Request::create('/user/reset-password', 'POST', ['email' => 'ADMIN@Example.TEST']); + + $loginSubject = $resolver->resolve($login)->first(AbuseSubjectType::SubmittedAccount); + $resetSubject = $resolver->resolve($reset)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($loginSubject); + self::assertNotNull($resetSubject); + self::assertSame('login', $loginSubject->context()['scope']); + self::assertSame('password_reset_email', $resetSubject->context()['scope']); + self::assertStringNotContainsString('Admin', json_encode($loginSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('ADMIN@Example.TEST', json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsRedactedSubmittedTokenSubjectsForAccountTokenWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $invitationToken = str_repeat('a', 64); + $resetToken = str_repeat('b', 64); + $reviewToken = str_repeat('c', 64); + + $invitationSubject = $resolver->resolve(Request::create('/user/invitation/'.$invitationToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + $resetSubject = $resolver->resolve(Request::create('/user/reset-password/'.$resetToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + $reviewSubject = $resolver->resolve(Request::create('/user/security-review/'.$reviewToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($invitationSubject); + self::assertNotNull($resetSubject); + self::assertNotNull($reviewSubject); + self::assertSame('registration_token', $invitationSubject->context()['scope']); + self::assertSame('password_reset_token', $resetSubject->context()['scope']); + self::assertSame('security_review_token', $reviewSubject->context()['scope']); + self::assertStringNotContainsString($invitationToken, json_encode($invitationSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($resetToken, json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($reviewToken, json_encode($reviewSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsSubmittedTokenSubjectsFromRouteAttributesForLocalizedAccountTokenWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $token = str_repeat('d', 64); + $request = Request::create('/de/user/security-review/'.$token, 'POST'); + $request->attributes->set('_route', 'user_security_review'); + $request->attributes->set('_locale', 'de'); + $request->attributes->set('token', $token); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($subject); + self::assertSame('security_review_token', $subject->context()['scope']); + self::assertStringNotContainsString($token, json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsSubmittedAccountSubjectsFromLocalizedPathSegments(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/de/user/login', 'POST', ['username' => 'Admin']); + $request->attributes->set('_locale', 'de'); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($subject); + self::assertSame('login', $subject->context()['scope']); + self::assertNull($resolver->resolve(Request::create('/de/user/login', 'POST', ['username' => 'Admin']))->first(AbuseSubjectType::SubmittedAccount)); + } + + public function testItDoesNotAddSubmittedAccountSubjectsForLookalikePaths(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/user/login-extra', 'POST', ['username' => 'Admin']); + + self::assertNull($resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount)); + } } diff --git a/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php index 3a1f4429..73a49d5a 100644 --- a/tests/Security/Abuse/ActionCostCatalogueTest.php +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -36,7 +36,14 @@ public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic() $probe = $catalogue->costFor($classifier->classify(Request::create('/.env'))); $apiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/content/items', 'POST'))); $adminApiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/admin/operations/cleanup', 'POST'))); - $setupApply = $catalogue->costFor($classifier->classify(Request::create('/setup', 'POST'))); + $schedulerTrigger = $catalogue->costFor($classifier->classify(Request::create('/cron/run'))); + $schedulerNotFound = $catalogue->costFor($classifier->classify(Request::create('/cron/not-found'))); + $setupWizard = $catalogue->costFor($classifier->classify(Request::create('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]))); + $setupApply = $catalogue->costFor($classifier->classify(Request::create('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))); self::assertSame('suspicious_probe', $probe->bucketFamily()); self::assertSame(10, $probe->credits()); @@ -44,7 +51,24 @@ public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic() self::assertSame(5, $apiWrite->credits()); self::assertSame('admin_mutation', $adminApiWrite->bucketFamily()); self::assertSame(8, $adminApiWrite->credits()); + self::assertSame('scheduler', $schedulerTrigger->bucketFamily()); + self::assertSame('website', $schedulerNotFound->bucketFamily()); + self::assertSame('setup', $setupWizard->bucketFamily()); + self::assertSame(1, $setupWizard->credits()); self::assertSame('setup_apply', $setupApply->bucketFamily()); self::assertSame(8, $setupApply->credits()); } + + public function testItExposesUniqueBucketFamilyCostsForPolicyBudgets(): void + { + $catalogue = new ActionCostCatalogue(); + $costs = $catalogue->uniqueCreditsByBucketFamily(); + + self::assertSame(5, $costs['registration']); + self::assertSame(3, $costs['password_reset']); + self::assertSame(5, $costs['api_write']); + self::assertSame(10, $costs['suspicious_probe']); + self::assertArrayNotHasKey('live_api', $costs); + self::assertArrayNotHasKey('api_preflight', $costs); + } } diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 45b79024..4c4dd651 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -29,10 +29,10 @@ public static function requestCases(): iterable RequestFamily::LiveApi, RequestIntent::LiveApi, ]; - yield 'localized live api cheap json' => [ + yield 'localized api-like content path is browser navigation' => [ self::localizedRequest('/de/api/live/alerts', 'GET', 'de'), - RequestFamily::LiveApi, - RequestIntent::LiveApi, + RequestFamily::Browser, + RequestIntent::BrowserNavigation, ]; yield 'api write' => [ Request::create('/api/v1/content/items', 'POST'), @@ -54,10 +54,10 @@ public static function requestCases(): iterable RequestFamily::Api, RequestIntent::AdminOperation, ]; - yield 'localized admin api package mutation is package admin mutation' => [ + yield 'localized admin api-like content path is form submit' => [ self::localizedRequest('/de/api/v1/admin/packages/demo/reset-fault', 'POST', 'de'), - RequestFamily::Api, - RequestIntent::PackageAdminOperation, + RequestFamily::Browser, + RequestIntent::FormSubmit, ]; yield 'apiary public content is not api' => [ Request::create('/apiary'), @@ -74,6 +74,16 @@ public static function requestCases(): iterable RequestFamily::Admin, RequestIntent::SettingsMutation, ]; + yield 'admin package upload uses upload archive bucket before broad package bucket' => [ + Request::create('/admin/packages/upload', 'POST'), + RequestFamily::Admin, + RequestIntent::UploadArchiveValidation, + ]; + yield 'admin download uses download diagnostics bucket even for safe method' => [ + Request::create('/admin/logs/download'), + RequestFamily::Admin, + RequestIntent::ExportDownload, + ]; yield 'public path containing reserved segment is public' => [ Request::create('/docs/api/reference', 'POST'), RequestFamily::Browser, @@ -129,21 +139,101 @@ public static function requestCases(): iterable RequestFamily::Api, RequestIntent::CorsPreflight, ]; + yield 'authorization options request is charged as api read' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + ]), + RequestFamily::Api, + RequestIntent::ApiRead, + ]; + yield 'authorization options request honors requested unsafe api method' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]), + RequestFamily::Api, + RequestIntent::ApiWrite, + ]; + yield 'authorization options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'malformed bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer ', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'empty bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; yield 'turbo prefetch' => [ Request::create('/docs', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), RequestFamily::Browser, RequestIntent::TurboPrefetch, ]; + yield 'recovery login bypass ignores spoofed prefetch' => [ + Request::create('/user/login?bypass=1', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'admin download ignores spoofed prefetch' => [ + Request::create('/admin/logs/download', server: ['HTTP_PURPOSE' => 'prefetch']), + RequestFamily::Admin, + RequestIntent::ExportDownload, + ]; yield 'scheduler trigger' => [ Request::create('/cron/run'), RequestFamily::Scheduler, RequestIntent::SchedulerTrigger, ]; + yield 'localized cron-like content path is browser navigation' => [ + self::localizedRequest('/de/cron/run', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'scheduler reserved non-run path is ordinary navigation' => [ + Request::create('/cron/not-found'), + RequestFamily::Scheduler, + RequestIntent::BrowserNavigation, + ]; + yield 'scheduler trigger requires exact path' => [ + Request::create('/cron/run/extra'), + RequestFamily::Scheduler, + RequestIntent::BrowserNavigation, + ]; + yield 'setup wizard post is setup navigation' => [ + Request::create('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]), + RequestFamily::Setup, + RequestIntent::BrowserNavigation, + ]; yield 'setup apply' => [ - Request::create('/setup', 'POST'), + Request::create('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]), RequestFamily::Setup, RequestIntent::SetupApply, ]; + yield 'setup apply requires exact review path' => [ + Request::create('/setup/review/extra', 'POST', [ + '_setup_action' => 'apply', + ]), + RequestFamily::Setup, + RequestIntent::BrowserNavigation, + ]; yield 'settings mutation' => [ Request::create('/admin/settings/security', 'POST'), RequestFamily::Admin, @@ -164,6 +254,36 @@ public static function requestCases(): iterable RequestFamily::Admin, RequestIntent::AdminOperation, ]; + yield 'login form render is ordinary navigation' => [ + Request::create('/user/login'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'recovery login bypass uses recovery intent' => [ + Request::create('/user/login?bypass=1'), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'recovery login bypass post stays login intent' => [ + Request::create('/user/login?bypass=1', 'POST'), + RequestFamily::Browser, + RequestIntent::Login, + ]; + yield 'registration form render is ordinary navigation' => [ + Request::create('/user/register'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'password reset form render is ordinary navigation' => [ + Request::create('/user/reset-password'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'public login post is login intent' => [ + Request::create('/user/login', 'POST'), + RequestFamily::Browser, + RequestIntent::Login, + ]; yield 'public password reset stays public reset intent' => [ Request::create('/user/password-reset', 'POST'), RequestFamily::Browser, @@ -199,11 +319,11 @@ public function testItClassifiesRequestIntent(Request $request, RequestFamily $f self::assertSame($intent, $profile->intent()); } - public function testItDoesNotTreatOrdinaryUploadRoutesAsProbePaths(): void + public function testItClassifiesOrdinaryUploadRoutesAsUploadArchiveValidation(): void { $profile = (new RequestIntentClassifier())->classify(Request::create('/admin/packages/upload', 'POST')); - self::assertSame(RequestIntent::PackageAdminOperation, $profile->intent()); + self::assertSame(RequestIntent::UploadArchiveValidation, $profile->intent()); self::assertFalse($profile->suspiciousProbe()); } diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php new file mode 100644 index 00000000..48dd417d --- /dev/null +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -0,0 +1,820 @@ +connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config, cachePool: new FailingCachePool()); + + for ($i = 0; $i < 40; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + } + + public function testStorageFailureFailsOpenWithDiagnosticsFlag(): void + { + $messages = new RecordingRateLimitMessageReporter(); + $result = $this->enforcer(cachePool: new FailingCachePool(), messages: $messages)->check($this->request('/home')); + + self::assertTrue($result->isAllowed()); + self::assertTrue($result->storageDegraded()); + self::assertSame(SecurityMessageCode::RATE_LIMIT_STORAGE_DEGRADED, $messages->records[0]['message']->code()); + } + + public function testLoginWorkflowRejectsBeforeWebsiteBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLoginFormRendersDoNotSpendLoginWorkflowBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 6; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login'))->isAllowed()); + } + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + + $result = $enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); + } + + public function testPanicRecoveryLoginRenderAndSubmitFitBudgets(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'recovery-owner', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure)->isAllowed()); + } + + public function testBypassLoginPostUsesLoginFailureBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1', 'POST', [ + 'username' => 'manual-bypass', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure)->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login?bypass=1', 'POST', [ + 'username' => 'manual-bypass', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLoginAttemptsShareSubmittedAccountAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'shared-admin', + 'password' => 'wrong', + ], $this->server('203.0.113.'.(20 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'shared-admin', + 'password' => 'wrong', + ], $this->server('203.0.113.99'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLocalLoginExhaustionDoesNotSpendSubmittedAccountBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'local-block', + 'password' => 'wrong', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ]))->isAllowed()); + } + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ], $this->server('203.0.113.'.(60 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ], $this->server('203.0.113.90'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testPasswordResetAttemptsShareSubmittedEmailAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'target@example.test', + ], $this->server('203.0.113.'.(40 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'TARGET@EXAMPLE.TEST', + ], $this->server('203.0.113.100'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testPasswordResetTokenAttemptsShareSubmittedTokenAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + $token = str_repeat('a', 64); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password/'.$token, 'POST', [], $this->server('203.0.113.'.(100 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password/'.$token, 'POST', [], $this->server('203.0.113.110'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testLocalPasswordResetExhaustionDoesNotSpendSubmittedEmailBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'local-block@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 2; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'victim@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'victim@example.test', + ], $this->server('203.0.113.'.(70 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'VICTIM@EXAMPLE.TEST', + ], $this->server('203.0.113.95'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testLocalRegistrationExhaustionDoesNotSpendSubmittedEmailBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'local-block@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 2; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'victim-registration@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'victim-registration@example.test', + ], $this->server('203.0.113.'.(80 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'VICTIM-REGISTRATION@EXAMPLE.TEST', + ], $this->server('203.0.113.96'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testInvitationTokenAttemptsShareSubmittedTokenAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + $token = str_repeat('b', 64); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/invitation/'.$token, 'POST', [], $this->server('203.0.113.'.(120 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/invitation/'.$token, 'POST', [], $this->server('203.0.113.130'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testWebsiteExhaustionDoesNotSpendRegistrationAccountBucket(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $blocked = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'global-victim@example.test', + ])); + + self::assertFalse($blocked->isAllowed()); + self::assertSame('security.rate.website_burst', $blocked->diagnosticsLabel()); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'global-victim@example.test', + ], $this->server('203.0.113.'.(140 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'GLOBAL-VICTIM@EXAMPLE.TEST', + ], $this->server('203.0.113.150'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testWebsiteExhaustionDoesNotSpendPasswordResetAccountBucket(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $blocked = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'global-reset@example.test', + ])); + + self::assertFalse($blocked->isAllowed()); + self::assertSame('security.rate.website_burst', $blocked->diagnosticsLabel()); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'global-reset@example.test', + ], $this->server('203.0.113.'.(160 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'GLOBAL-RESET@EXAMPLE.TEST', + ], $this->server('203.0.113.170'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testOwnerIsExemptFromOrdinaryRateLimitRejection(): void + { + $tokenStorage = $this->tokenStorage(UserRole::Owner); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + for ($i = 0; $i < 40; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + } + + public function testOwnerApiContextDoesNotBypassAuthenticationFailureBudgets(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $request = $this->request('/api/v1/admin/settings/general', 'PATCH'); + $this->apiContext(ApiKeyStatus::ReadWrite, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::AuthenticationFailure); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testAuthenticatedUsersReceiveWebsiteMultiplier(): void + { + $tokenStorage = $this->tokenStorage(UserRole::User); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + for ($i = 0; $i < RateLimitPolicyCatalogue::AUTHENTICATED_MULTIPLIER * 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/home')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + } + + public function testSchedulerRequestsAreNotOwnerExempt(): void + { + $tokenStorage = $this->tokenStorage(UserRole::Owner); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + self::assertTrue($enforcer->check($this->request('/cron/run', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSchedulerIntervalOnlyAppliesToCronRun(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/cron/not-found', 'GET'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/de/cron/run', 'GET'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/cron/run', 'GET'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'GET')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testAdminNavigationFallsBackToWebsiteBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/admin/logs'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/admin/logs')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + } + + public function testStrictSchedulerIntervalRejectsSecondRunWithinFifteenMinutes(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + self::assertTrue($enforcer->check($this->request('/cron/run', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertGreaterThanOrEqual(1, $result->retryAfterSeconds() ?? 0); + } + + public function testSchedulerIntervalUsesStableSubmittedCredentialAcrossVisitorChanges(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/cron/run?auth=scheduler-token', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.77', + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run?auth=scheduler-token', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.77', + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSchedulerIntervalKeepsIpAnchorForAuthenticatedUsersWithRotatingCredentials(): void + { + $tokenStorage = $this->tokenStorage(UserRole::User); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + self::assertTrue($enforcer->check($this->request('/cron/run?auth=scheduler-token-a', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.78', + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run?auth=scheduler-token-b', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.78', + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config, cachePool: new FailingCachePool()); + + $result = $enforcer->check($this->request('/.env')); + + self::assertFalse($result->isAllowed()); + self::assertTrue($result->suspiciousProbe()); + self::assertFalse($result->storageDegraded()); + } + + public function testAdminAuthFailureBudgetUsesIpSecondaryAcrossVisitorChanges(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $result = $enforcer->check($this->request('/api/v1/admin/settings/general', 'PATCH', [], [ + 'REMOTE_ADDR' => '203.0.113.88', + 'HTTP_USER_AGENT' => 'AdminProbe/'.$i, + 'HTTP_AUTHORIZATION' => sprintf('Bearer admin%02d.invalid-secret', $i), + ]), RateLimitEnforcementStage::AuthenticationFailure); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeyMutationsAreNotOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 16; ++$i) { + $request = $this->request('/api/v1/content/items', 'POST'); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.api_write', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeyUnsafePreflightsAreNotOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $request = $this->request('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer read-only-owner', + ]); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeySafePreflightsRemainOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 20; ++$i) { + $request = $this->request('/api/v1/status', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + 'HTTP_AUTHORIZATION' => 'Bearer read-only-owner', + ]); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + self::assertTrue($enforcer->check($request, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + } + + public function testCredentialedNonBearerPreflightsSpendRequestedMethodBucket(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $result = $enforcer->check($this->request('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Basic credential-probe', + ]), RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadWriteOwnerApiKeyMutationsRemainOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 20; ++$i) { + $request = $this->request('/api/v1/content/items', 'POST'); + $this->apiContext(ApiKeyStatus::ReadWrite, UserRole::Owner)->attachTo($request); + self::assertTrue($enforcer->check($request, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + } + + public function testPanicAdminMutationConsumesWebsiteBucketWithoutStorageDegradation(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $messages = new RecordingRateLimitMessageReporter(); + $enforcer = $this->enforcer(config: $config, messages: $messages); + + self::assertTrue($enforcer->check($this->request('/admin/settings/security', 'POST'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/admin/settings/security', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/admin/settings/security', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertFalse($result->storageDegraded()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + self::assertSame([], $messages->records); + } + + public function testSetupWizardPostsDoNotSpendSetupApplyBudget(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 12; ++$i) { + self::assertTrue($enforcer->check($this->request('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]))->isAllowed()); + } + + self::assertTrue($enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.setup_apply', $result->diagnosticsLabel()); + } + + public function testRepresentativeRequestPathsReachExpectedBuckets(): void + { + $cases = [ + ['/user/register', 'POST', ['email' => 'registration@example.test'], 'security.rate.registration', 4], + ['/user/reset-password', 'POST', ['email' => 'reset@example.test'], 'security.rate.password_reset', 4], + ['/contact', 'POST', [], 'security.rate.website_form', 3], + ['/api/v1/content/items', 'GET', [], 'security.rate.api_public_read', 31], + ['/api/v1/content/items', 'POST', [], 'security.rate.api_write', 16], + ['/cron/run', 'POST', [], 'security.rate.scheduler', 2], + ['/setup/review', 'POST', ['_setup_action' => 'apply'], 'security.rate.setup_apply', 3], + ['/admin/settings/security', 'POST', [], 'security.rate.website_burst', 8], + ['/admin/packages/upload', 'POST', [], 'security.rate.website_burst', 6], + ['/admin/logs/download', 'GET', [], 'security.rate.website_burst', 8], + ]; + + foreach ($cases as [$path, $method, $parameters, $label, $attempts]) { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < $attempts; ++$i) { + $result = $enforcer->check($this->request($path, $method, $parameters)); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed(), $path); + self::assertSame($label, $result->diagnosticsLabel(), $path); + } + } + + private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = null, ?CacheItemPoolInterface $cachePool = null, ?RecordingRateLimitMessageReporter $messages = null): RateLimitEnforcer + { + $tokenStorage ??= new TokenStorage(); + $inspector = new AbuseRequestInspector( + new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), $tokenStorage, 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ); + + return new RateLimitEnforcer( + $inspector, + $config ?? new Config($this->connection()), + new RateLimitPolicyCatalogue(), + new RateLimitSubjectSelector(), + new RateLimitLimiterFactory($cachePool ?? new ArrayAdapter()), + $messages ?? new RecordingRateLimitMessageReporter(), + ); + } + + /** + * @param array $parameters + * @param array $server + */ + private function request(string $path, string $method = 'GET', array $parameters = [], array $server = []): Request + { + return Request::create($path, $method, $parameters, server: [ + ...$this->server('203.0.113.9'), + ...$server, + ]); + } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'RateLimitEnforcerTest-'.$ip, + ]; + } + + private function tokenStorage(UserRole $role): TokenStorage + { + $user = new UserAccount( + '99999999-0000-7000-8000-000000000001', + 'rate_limit_'.$role->value, + 'rate-limit-'.$role->value.'@example.test', + 'hash', + role: $role, + ); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + return $tokenStorage; + } + + private function apiContext(ApiKeyStatus $status, UserRole $role): ApiRequestContext + { + $user = new UserAccount( + '99999999-0000-7000-8000-000000000101', + 'rate_limit_api_'.$role->value, + 'rate-limit-api-'.$role->value.'@example.test', + 'hash', + role: $role, + ); + + return ApiRequestContext::fromApiKey(new ApiKey( + '99999999-0000-7000-8000-000000000201', + 'rlapi', + str_repeat('a', 64), + 'encrypted', + $user, + $status, + )); + } + + 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)'); + + return $connection; + } +} + +final class FailingCachePool implements CacheItemPoolInterface +{ + public function getItem(string $key): CacheItemInterface + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function getItems(array $keys = []): iterable + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function hasItem(string $key): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function clear(): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function deleteItem(string $key): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function deleteItems(array $keys): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function save(CacheItemInterface $item): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function saveDeferred(CacheItemInterface $item): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function commit(): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } +} diff --git a/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php new file mode 100644 index 00000000..820a59b1 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php @@ -0,0 +1,85 @@ +descriptor('website.deliberate.burst', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $panic); + + $factory = new RateLimitLimiterFactory(new ArrayAdapter()); + $subjectKey = 'website.deliberate.burst:visitor:profile-isolation'; + + for ($i = 0; $i < $panic->limit() - 1; ++$i) { + self::assertTrue($factory->consume($standard, $subjectKey, 1)); + } + + for ($i = 0; $i < $panic->limit(); ++$i) { + self::assertTrue($factory->consume($panic, $subjectKey, 1)); + } + + self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($panic, $subjectKey, 1)); + } + + public function testConsumeUsesConfiguredLockFactory(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $descriptor = $catalogue->descriptor('login.failure', RateLimitProfile::Standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $descriptor); + + $lockFactory = new TrackingRateLimitLockFactory(); + $factory = new RateLimitLimiterFactory(new ArrayAdapter(), $lockFactory); + + self::assertTrue($factory->consume($descriptor, 'login.failure:visitor:lock-test', 1)); + self::assertGreaterThanOrEqual(1, $lockFactory->createdLocks); + } + + public function testAcceptsChecksCapacityWithoutSpendingCredits(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $descriptor = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $descriptor); + + $factory = new RateLimitLimiterFactory(new ArrayAdapter()); + $subjectKey = 'scheduler.interval:visitor:accepts-test'; + + self::assertTrue($factory->accepts($descriptor, $subjectKey, 1)); + self::assertTrue($factory->accepts($descriptor, $subjectKey, 1)); + self::assertTrue($factory->consume($descriptor, $subjectKey, 1)); + self::assertInstanceOf(\DateTimeImmutable::class, $factory->accepts($descriptor, $subjectKey, 1)); + } +} + +final class TrackingRateLimitLockFactory extends LockFactory +{ + public int $createdLocks = 0; + + public function __construct() + { + parent::__construct(new InMemoryStore()); + } + + public function createLock(string $resource, ?float $ttl = 300.0, bool $autoRelease = true): SharedLockInterface + { + ++$this->createdLocks; + + return parent::createLock($resource, $ttl, $autoRelease); + } +} diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php new file mode 100644 index 00000000..0111d196 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -0,0 +1,236 @@ +descriptor('login.failure'); + $burst = $catalogue->descriptor('website.deliberate.burst'); + $probe = $catalogue->descriptor('suspicious.probe'); + $captcha = $catalogue->descriptor('captcha.failure'); + + self::assertNotNull($login); + self::assertSame('login', $login->bucketFamily()); + self::assertSame(5, $login->limit()); + self::assertSame(900, $login->windowSeconds()); + self::assertTrue($login->resettable()); + self::assertNotNull($burst); + self::assertSame(30, $burst->limit()); + self::assertSame(60, $burst->windowSeconds()); + self::assertNotNull($probe); + self::assertSame(10, $probe->limit()); + self::assertSame(10, $probe->minimumLimit()); + self::assertSame(600, $probe->windowSeconds()); + self::assertNotNull($captcha); + self::assertTrue($captcha->resettable()); + } + + public function testStrictAndPanicProfilesDeriveFromStandardDescriptors(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Standard); + $strict = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(30, $standard->limit()); + self::assertSame(60, $standard->windowSeconds()); + self::assertSame(16, $strict->limit()); + self::assertSame(90, $strict->windowSeconds()); + self::assertSame(16, $panic->limit()); + self::assertSame(120, $panic->windowSeconds()); + } + + public function testRecoveryBucketsStayStableAcrossProfiles(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($panic); + self::assertSame($standard->limit(), $panic->limit()); + self::assertSame($standard->windowSeconds(), $panic->windowSeconds()); + } + + public function testProbeScalingKeepsOneActionFloorWhileExtendingWindow(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $strict = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Panic); + + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(10, $strict->limit()); + self::assertSame(900, $strict->windowSeconds()); + self::assertSame(10, $panic->limit()); + self::assertSame(1200, $panic->windowSeconds()); + } + + public function testPolicyUsesActionCostsAsCreditMultipliers(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $registration = $catalogue->descriptor('registration.hour'); + $apiWrite = $catalogue->descriptor('api.write'); + + self::assertNotNull($registration); + self::assertSame(15, $registration->limit()); + self::assertSame(10, $registration->minimumLimit()); + self::assertNotNull($apiWrite); + self::assertSame(300, $apiWrite->limit()); + self::assertSame(10, $apiWrite->minimumLimit()); + } + + public function testDescriptorsOwnEnforcementStages(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $login = $catalogue->descriptor('login.failure'); + $recovery = $catalogue->descriptor('recovery.login.minute'); + $apiWrite = $catalogue->descriptor('api.write'); + $probe = $catalogue->descriptor('suspicious.probe'); + + self::assertNotNull($login); + self::assertTrue($login->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + self::assertFalse($login->handlesStage(RateLimitEnforcementStage::Ordinary)); + + self::assertNotNull($recovery); + self::assertTrue($recovery->handlesStage(RateLimitEnforcementStage::Ordinary)); + self::assertFalse($recovery->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + + self::assertNotNull($apiWrite); + self::assertTrue($apiWrite->handlesStage(RateLimitEnforcementStage::Ordinary)); + self::assertTrue($apiWrite->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + + self::assertNotNull($probe); + self::assertTrue($probe->handlesStage(RateLimitEnforcementStage::SuspiciousProbe)); + self::assertFalse($probe->handlesStage(RateLimitEnforcementStage::Ordinary)); + } + + public function testDescriptorsOwnSubjectPolicy(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $login = $catalogue->descriptor('login.failure'); + $adminMutation = $catalogue->descriptor('admin.mutation'); + $scheduler = $catalogue->descriptor('scheduler.interval'); + $website = $catalogue->descriptor('website.deliberate.burst'); + + self::assertNotNull($login); + self::assertTrue($login->subjectPolicy()->submittedAccountScope()); + + self::assertNotNull($adminMutation); + self::assertSame([ + AbuseSubjectType::ApiKey, + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::IpBucket, + ], $adminMutation->subjectPolicy()->preferredTypes()); + + self::assertNotNull($scheduler); + self::assertTrue($scheduler->subjectPolicy()->ipSecondary()); + self::assertTrue($scheduler->subjectPolicy()->ipSecondaryWithAuthenticatedSubject()); + + self::assertNotNull($website); + self::assertTrue($website->subjectPolicy()->authenticatedMultiplier()); + } + + public function testProfileScalingKeepsMinimumCostedActionsAvailable(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + foreach ($catalogue->descriptors(RateLimitProfile::Panic) as $descriptor) { + self::assertGreaterThanOrEqual($descriptor->minimumLimit(), $descriptor->limit(), $descriptor->name()); + } + } + + public function testDescriptorFloorsCoverTwoActionsExceptExplicitIntervalPolicies(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $expectedMinimums = [ + 'login.failure' => 2, + 'recovery.login.minute' => 2, + 'recovery.login.hour' => 2, + 'registration.hour' => 10, + 'registration.day' => 10, + 'password_reset.hour' => 6, + 'password_reset.day' => 6, + 'captcha.failure' => 2, + 'website.deliberate.burst' => 16, + 'website.deliberate.sustained' => 16, + 'website.form' => 4, + 'website.prefetch.minute' => 2, + 'website.prefetch.sustained' => 2, + 'api.read' => 2, + 'api.public_read' => 2, + 'api.write' => 10, + 'scheduler.interval' => 1, + 'setup.apply' => 16, + 'admin.mutation' => 16, + 'upload_archive.validation' => 16, + 'download_diagnostics' => 8, + 'suspicious.probe' => 10, + ]; + + foreach ($expectedMinimums as $name => $minimum) { + $descriptor = $catalogue->descriptor($name); + self::assertNotNull($descriptor, $name); + self::assertSame($minimum, $descriptor->minimumLimit(), $name); + } + } + + public function testWebsiteBurstFloorCoversTwoHighCostCompanionActions(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + + self::assertNotNull($panic); + self::assertSame(16, $panic->minimumLimit()); + self::assertSame(16, $panic->limit()); + } + + public function testSchedulerProfileIntervalsUseExplicitCronPolicy(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Standard); + $strict = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(1, $standard->limit()); + self::assertSame(60, $standard->windowSeconds()); + self::assertSame(1, $strict->limit()); + self::assertSame(900, $strict->windowSeconds()); + self::assertSame(1, $panic->limit()); + self::assertSame(3600, $panic->windowSeconds()); + } + + public function testOffProfileDoesNotConsumeLimiterStorage(): void + { + self::assertFalse(RateLimitProfile::Off->consumesLimiterStorage()); + self::assertTrue(RateLimitProfile::Standard->consumesLimiterStorage()); + self::assertSame(RateLimitProfile::Standard, RateLimitProfile::fromMixed('unknown')); + } +} diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php new file mode 100644 index 00000000..8e8afbd2 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -0,0 +1,344 @@ +previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null; + $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null; + $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY); + $_SERVER[SetupCompletionMarker::KEY] = '1'; + } + + protected function tearDown(): void + { + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + + if (null !== $this->previousServerValue) { + $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue; + } + + if (null !== $this->previousEnvValue) { + $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue; + } + + is_string($this->previousPutenvValue) + ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue) + : putenv(SetupCompletionMarker::KEY); + } + + /** + * @return iterable + */ + public static function excludedPathCases(): iterable + { + yield 'live api root' => ['/api/live', true]; + yield 'live api child' => ['/api/live/status', true]; + yield 'live api sibling' => ['/api/live-status', false]; + yield 'assets child' => ['/assets/app.css', true]; + yield 'assets sibling' => ['/assets-preview', false]; + yield 'build child' => ['/build/app.js', true]; + yield 'build sibling' => ['/builder', false]; + yield 'profiler root' => ['/_profiler', true]; + yield 'profiler child' => ['/_profiler/123', true]; + yield 'profiler sibling' => ['/_profilerfoo', false]; + yield 'toolbar child' => ['/_wdt/123', true]; + yield 'toolbar sibling' => ['/_wdtfoo', false]; + } + + #[DataProvider('excludedPathCases')] + public function testExcludedPathUsesSegmentBoundaries(string $path, bool $excluded): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new PathScopeMatcher()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + + self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); + } + + public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new PathScopeMatcher()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + $localized = Request::create('/de/api/live/status'); + $localized->attributes->set('_locale', 'de'); + + self::assertFalse($method->invoke($subscriber, $localized)); + self::assertFalse($method->invoke($subscriber, Request::create('/de/api/live/status'))); + } + + public function testProbePriorityRunsBeforeResponseProducingGates(): void + { + $events = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + + self::assertSame(['onKernelRequestProbe', 4096], $events[0]); + self::assertGreaterThan(1024, $events[0][1]); + self::assertGreaterThan(768, $events[0][1]); + self::assertGreaterThan(512, $events[0][1]); + self::assertGreaterThan(256, $events[0][1]); + } + + public function testProbeHookSkipsFullEnforcerForNonProbePaths(): 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), + ); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + Request::create('/home'), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertFalse($event->hasResponse()); + } + + public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void + { + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + putenv(SetupCompletionMarker::KEY); + $subscriber = $this->subscriberWithRealEnforcer(); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + Request::create('/.env'), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode()); + self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+    }
+
+    public function testProbeHookUsesForcedBareResponseAfterSetupCompletion(): void
+    {
+        $subscriber = $this->subscriberWithRealEnforcer();
+        $event = new RequestEvent(
+            new RateLimitRequestSubscriberTestKernel(),
+            Request::create('/.env'),
+            HttpKernelInterface::MAIN_REQUEST,
+        );
+
+        $subscriber->onKernelRequestProbe($event);
+
+        self::assertTrue($event->hasResponse());
+        self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
+        self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+    }
+
+    public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+        $subscriber = $this->subscriberWithUninitializedEnforcer();
+        $event = new RequestEvent(
+            new RateLimitRequestSubscriberTestKernel(),
+            Request::create('/setup/database', 'POST', ['_setup_action' => 'test_database']),
+            HttpKernelInterface::MAIN_REQUEST,
+        );
+
+        $subscriber->onKernelRequestOrdinary($event);
+
+        self::assertFalse($event->hasResponse());
+    }
+
+    public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void
+    {
+        $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
+        $paths->setValue($subscriber, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'setupApplyRequest');
+
+        self::assertTrue($method->invoke($subscriber, Request::create('/setup/review', 'POST', [
+            '_setup_action' => 'apply',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/database', 'POST', [
+            '_setup_action' => 'test_database',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/review/extra', 'POST', [
+            '_setup_action' => 'apply',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/review', 'GET', [
+            '_setup_action' => 'apply',
+        ])));
+    }
+
+    public function testSetupApplyBeforeSetupCompletionUsesBareTooManyRequestsResponse(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+        $subscriber = $this->subscriberWithRealEnforcer();
+        $event = null;
+
+        for ($i = 0; $i < 6; ++$i) {
+            $event = new RequestEvent(
+                new RateLimitRequestSubscriberTestKernel(),
+                Request::create('/setup/review', 'POST', ['_setup_action' => 'apply'], server: [
+                    'REMOTE_ADDR' => '203.0.113.54',
+                    'HTTP_USER_AGENT' => 'SetupApplyLimiterTest',
+                ]),
+                HttpKernelInterface::MAIN_REQUEST,
+            );
+
+            $subscriber->onKernelRequestOrdinary($event);
+        }
+
+        self::assertNotNull($event);
+        self::assertTrue($event->hasResponse());
+        self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
+        self::assertStringContainsString('429 - Too Many Requests', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('retry-after:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+        self::assertNotNull($event->getResponse()->headers->get('Retry-After'));
+    }
+
+    private function subscriberWithUninitializedEnforcer(): RateLimitRequestSubscriber
+    {
+        return new RateLimitRequestSubscriber(
+            (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(),
+            $this->responseRenderer(),
+            'prod',
+            new SetupCompletionMarker(),
+            dirname(__DIR__, 3),
+            new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+        );
+    }
+
+    private function subscriberWithRealEnforcer(): RateLimitRequestSubscriber
+    {
+        $inspector = new AbuseRequestInspector(
+            new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+            new RequestIntentClassifier(),
+            new ActionCostCatalogue(),
+        );
+        $enforcer = new RateLimitEnforcer(
+            $inspector,
+            new Config($this->connection()),
+            new RateLimitPolicyCatalogue(),
+            new RateLimitSubjectSelector(),
+            new RateLimitLimiterFactory(new ArrayAdapter()),
+            new class implements MessageReporterInterface {
+                public function report(Message $message, array $context = []): Message
+                {
+                    return $message;
+                }
+
+                public function reportBatch(iterable $records): array
+                {
+                    $messages = [];
+                    foreach ($records as $record) {
+                        $messages[] = $record['message'];
+                    }
+
+                    return $messages;
+                }
+            },
+        );
+
+        return new RateLimitRequestSubscriber(
+            $enforcer,
+            $this->responseRenderer(),
+            'prod',
+            new SetupCompletionMarker(),
+            dirname(__DIR__, 3),
+            new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+        );
+    }
+
+    private function responseRenderer(): RateLimitResponseRenderer
+    {
+        return new RateLimitResponseRenderer(
+            new HttpErrorRenderer(
+                (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(),
+                new SetupCompletionMarker(),
+                new AccessRequestMetadata(),
+                dirname(__DIR__, 3),
+                'test',
+            ),
+            (new ReflectionClass(ApiResponder::class))->newInstanceWithoutConstructor(),
+            new AccessRequestMetadata(),
+        );
+    }
+
+    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)');
+
+        return $connection;
+    }
+}
+
+final class RateLimitRequestSubscriberTestKernel implements HttpKernelInterface
+{
+    public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response
+    {
+        return new Response();
+    }
+}
diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php
new file mode 100644
index 00000000..992d02c2
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php
@@ -0,0 +1,243 @@
+services();
+        $request = $this->request('/user/login', 'POST');
+
+        for ($i = 0; $i < 5; ++$i) {
+            self::assertTrue($enforcer->check($request)->isAllowed());
+        }
+
+        self::assertFalse($enforcer->check($request)->isAllowed());
+        self::assertTrue($resets->resetLoginAttempts($request));
+        self::assertTrue($enforcer->check($request)->isAllowed());
+    }
+
+    public function testLoginSuccessResetClearsSubmittedAccountLoginAttempts(): void
+    {
+        [$enforcer, $resets] = $this->services();
+
+        for ($i = 0; $i < 5; ++$i) {
+            self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+                'username' => 'shared-admin',
+                'password' => 'wrong',
+            ], [
+                'REMOTE_ADDR' => '203.0.113.'.(20 + $i),
+            ]))->isAllowed());
+        }
+
+        self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'wrong',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.90',
+        ]))->isAllowed());
+
+        self::assertTrue($resets->resetLoginAttempts($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'correct',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.91',
+        ])));
+
+        self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'wrong',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.92',
+        ]))->isAllowed());
+    }
+
+    public function testLoginSuccessResetUsesActiveProfileDescriptor(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String);
+        [$enforcer, $resets] = $this->services(config: $config);
+        $request = $this->request('/user/login', 'POST');
+
+        self::assertTrue($enforcer->check($request)->isAllowed());
+        self::assertTrue($enforcer->check($request)->isAllowed());
+        self::assertFalse($enforcer->check($request)->isAllowed());
+        self::assertTrue($resets->resetLoginAttempts($request));
+        self::assertTrue($enforcer->check($request)->isAllowed());
+    }
+
+    public function testCaptchaResetRequiresVerifiedProviderBackedSuccess(): void
+    {
+        [, $resets] = $this->services();
+        $request = $this->request('/captcha/submit', 'POST');
+
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'none', true));
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'turnstile', false));
+        self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+    }
+
+    public function testCaptchaResetUsesActiveProfileDescriptor(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String);
+        $cache = new ArrayAdapter();
+        $factory = new RateLimitLimiterFactory($cache);
+        [, $resets] = $this->services(config: $config, factory: $factory);
+        $request = $this->request('/captcha/submit', 'POST');
+        $catalogue = new RateLimitPolicyCatalogue();
+        $descriptor = $catalogue->descriptor('captcha.failure', RateLimitProfile::Panic);
+        self::assertNotNull($descriptor);
+        $inspector = $this->inspector();
+        $selector = new RateLimitSubjectSelector();
+        $subjectKeys = $selector->subjectKeys($descriptor, $inspector->inspect($request)['subjects']);
+        self::assertNotSame([], $subjectKeys);
+
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+    }
+
+    public function testOffModeDoesNotTouchResetStorage(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String);
+        [, $resets] = $this->services(config: $config, factory: new RateLimitLimiterFactory(new ResetFailingCachePool()));
+
+        self::assertFalse($resets->resetLoginAttempts($this->request('/user/login', 'POST')));
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+    }
+
+    public function testResetFailureReportsThroughMessageLayer(): void
+    {
+        $messages = new RecordingRateLimitMessageReporter();
+        [, $resets] = $this->services(factory: new RateLimitLimiterFactory(new ResetFailingCachePool()), messages: $messages);
+
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+        self::assertSame(SecurityMessageCode::RATE_LIMIT_RESET_DEGRADED, $messages->records[0]['message']->code());
+        self::assertSame('security.rate_limit.reset', $messages->records[0]['context']['operation']);
+    }
+
+    /**
+     * @return array{0: RateLimitEnforcer, 1: RateLimitResetService}
+     */
+    private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null, ?RecordingRateLimitMessageReporter $messages = null): array
+    {
+        $inspector = $this->inspector();
+        $catalogue = new RateLimitPolicyCatalogue();
+        $selector = new RateLimitSubjectSelector();
+        $factory ??= new RateLimitLimiterFactory(new ArrayAdapter());
+        $config ??= new Config($this->connection());
+        $messages ??= new RecordingRateLimitMessageReporter();
+
+        return [
+            new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $messages),
+            new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $messages),
+        ];
+    }
+
+    private function inspector(): AbuseRequestInspector
+    {
+        return new AbuseRequestInspector(
+            new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+            new RequestIntentClassifier(),
+            new ActionCostCatalogue(),
+        );
+    }
+
+    /**
+     * @param array $parameters
+     * @param array $server
+     */
+    private function request(string $path, string $method, array $parameters = [], array $server = []): Request
+    {
+        return Request::create($path, $method, $parameters, server: [
+            'REMOTE_ADDR' => '203.0.113.50',
+            'HTTP_USER_AGENT' => 'RateLimitResetServiceTest',
+            ...$server,
+        ]);
+    }
+
+    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)');
+
+        return $connection;
+    }
+}
+
+final class ResetFailingCachePool implements CacheItemPoolInterface
+{
+    public function getItem(string $key): CacheItemInterface
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function getItems(array $keys = []): iterable
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function hasItem(string $key): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function clear(): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function deleteItem(string $key): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function deleteItems(array $keys): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function save(CacheItemInterface $item): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function saveDeferred(CacheItemInterface $item): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function commit(): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+}
diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
new file mode 100644
index 00000000..1747003b
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
@@ -0,0 +1,51 @@
+
+     */
+    public static function jsonSurfaceCases(): iterable
+    {
+        yield 'api v1' => ['/api/v1/status', true];
+        yield 'cron root' => ['/cron', true];
+        yield 'cron child' => ['/cron/run', true];
+        yield 'cron lookalike content' => ['/cronjobs', false];
+        yield 'browser content' => ['/docs', false];
+    }
+
+    #[DataProvider('jsonSurfaceCases')]
+    public function testJsonSurfaceUsesPathBoundaries(string $path, bool $json): void
+    {
+        $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+        $paths->setValue($renderer, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+
+        self::assertSame($json, $method->invoke($renderer, Request::create($path)));
+    }
+
+    public function testJsonSurfaceDoesNotUseLocalizedTechnicalPathSegments(): void
+    {
+        $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+        $paths->setValue($renderer, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+        $localized = Request::create('/de/cron/run');
+        $localized->attributes->set('_locale', 'de');
+
+        self::assertFalse($method->invoke($renderer, $localized));
+        self::assertFalse($method->invoke($renderer, Request::create('/de/cron/run')));
+    }
+}
diff --git a/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
new file mode 100644
index 00000000..fd7b477e
--- /dev/null
+++ b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
@@ -0,0 +1,37 @@
+}>
+     */
+    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) {
+            $messages[] = $this->report($record['message'], $record['context'] ?? []);
+        }
+
+        return $messages;
+    }
+}
diff --git a/tests/View/Http/HttpErrorRendererTest.php b/tests/View/Http/HttpErrorRendererTest.php
new file mode 100644
index 00000000..a6e6a391
--- /dev/null
+++ b/tests/View/Http/HttpErrorRendererTest.php
@@ -0,0 +1,125 @@
+previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null;
+        $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null;
+        $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY);
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+    }
+
+    protected function tearDown(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+
+        if (null !== $this->previousServerValue) {
+            $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue;
+        }
+
+        if (null !== $this->previousEnvValue) {
+            $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue;
+        }
+
+        is_string($this->previousPutenvValue)
+            ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue)
+            : putenv(SetupCompletionMarker::KEY);
+    }
+
+    /**
+     * @return iterable
+     */
+    public static function setupBareStatusCases(): iterable
+    {
+        foreach (Response::$statusTexts as $statusCode => $statusText) {
+            if ($statusCode >= 400 && $statusCode < 600) {
+                yield sprintf('%d %s', $statusCode, $statusText) => [$statusCode];
+            }
+        }
+    }
+
+    #[DataProvider('setupBareStatusCases')]
+    public function testItReturnsBareKnownErrorResponsesBeforeSetupCompletion(int $statusCode): void
+    {
+        $response = $this->renderer()->resolve($statusCode, Request::create('/setup/missing'));
+
+        self::assertSame($statusCode, $response->getStatusCode());
+        self::assertStringContainsString(sprintf(
+            '%d - %s',
+            $statusCode,
+            htmlspecialchars(Response::$statusTexts[$statusCode], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+        ), (string) $response->getContent());
+        self::assertStringContainsString('
Request-ID:', (string) $response->getContent());
+        self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
+        self::assertStringContainsString('text/html', (string) $response->headers->get('Content-Type'));
+    }
+
+    public function testBareResponseKeepsAdditionalHeadersAndContext(): void
+    {
+        $request = Request::create('/setup/review');
+        $request->attributes->set('_access_request_id', 'req-test-123');
+        $response = $this->renderer()->bare(Response::HTTP_TOO_MANY_REQUESTS, $request, [
+            'bare_context' => 'retry-after: 60',
+        ], ['Retry-After' => '60']);
+
+        self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+        self::assertStringContainsString('429 - Too Many Requests', (string) $response->getContent());
+        self::assertStringContainsString('

retry-after: 60

', (string) $response->getContent()); + self::assertStringContainsString('
Request-ID: req-test-123
', (string) $response->getContent()); + self::assertSame('60', $response->headers->get('Retry-After')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + public function testResolveCanForceBareResponseAfterSetupCompletion(): void + { + $_SERVER[SetupCompletionMarker::KEY] = '1'; + $request = Request::create('/blocked'); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'req-forced'); + $response = $this->renderer()->resolve(Response::HTTP_FORBIDDEN, $request, [ + 'bare_context' => '', + ], forceBare: true); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + self::assertStringContainsString('403 - Forbidden', (string) $response->getContent()); + self::assertStringContainsString('

<blocked>

', (string) $response->getContent()); + self::assertStringContainsString('
Request-ID: req-forced
', (string) $response->getContent()); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + private function renderer(): HttpErrorRenderer + { + return new HttpErrorRenderer( + (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(), + new SetupCompletionMarker(), + new AccessRequestMetadata(), + dirname(__DIR__, 2), + 'test', + ); + } +} diff --git a/tests/View/Twig/ViewTwigExtensionTest.php b/tests/View/Twig/ViewTwigExtensionTest.php index 07db3cac..6b489efc 100644 --- a/tests/View/Twig/ViewTwigExtensionTest.php +++ b/tests/View/Twig/ViewTwigExtensionTest.php @@ -18,7 +18,7 @@ public function testItExposesViewGlobalsFunctionsAndMarkdownFilter(): void '{{ view_context().system_package.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ package_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}', )->render(); - self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.4|

ok

', $html); + self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.5|

ok

', $html); } public function testItRendersSafeHtmlAttributes(): void diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 6665041d..13c73baf 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -769,6 +769,40 @@ admin: required: 'Dieses Feld ist erforderlich.' save_failed: 'Die Einstellungen konnten nicht gespeichert werden.' default_acl_group_unavailable: 'Trage eine bestehende ACL-Gruppe mit Mindestrolle User oder niedriger ein oder lasse das Feld leer.' + fields: + captcha_enabled: + label: 'Captcha aktivieren' + captcha_provider: + label: 'Captcha-Provider' + captcha_preview: + label: 'Captcha-Vorschau' + rate_limit_mode: + label: 'Ratenbegrenzung' + audit_enabled: + label: 'Audit-Logging aktivieren' + audit_events: + label: 'Audit-Ereigniskategorien' + security_signal_retention_days: + label: 'Retention für Security-Signale' + help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.' + security_probe_path_patterns: + label: 'Pfadmuster für verdächtige Probes' + help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' + options: + captcha: + none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Streng' + panic: 'Panik' + audit: + authentication: 'Authentifizierungsereignisse' + backend_actions: 'Backend-Wartungsaktionen' + operations: 'Operations-Wartungsaktionen' + packages: 'Paket-Lifecycle-Aktionen' + settings: 'Einstellungsänderungen' + other: 'Weitere zukünftige Audit-Ereignisse' acl: actions: save: 'ACL-Matrix speichern' @@ -908,6 +942,8 @@ admin: label: 'Captcha-Provider' captcha_preview: label: 'Captcha-Vorschau' + rate_limit_mode: + label: 'Ratenbegrenzung' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: @@ -974,6 +1010,11 @@ admin: setup_warnings: 'Setup-Warnungen' captcha: none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Streng' + panic: 'Panik' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 9c4e9c74..0f190e2d 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -119,6 +119,11 @@ message: completed: 'GeoIP2-Datenbank wurde unter "%path%" aktualisiert.' account_app_secret_rotation: manual_owner_reset_required: 'APP_SECRET-Rotation-Recovery konnte nicht alle Owner-Reset-Links zustellen. Nutze die Emergency-Recovery-Datei, sofern vorhanden, oder führe "%command%" für die gelisteten Owner manuell aus.' + rate_limit: + exceeded: 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.' + 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.' event: hook: invalid: 'Event-Hook "%event%" ist keine gültige öffentliche Hook-Definition.' diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index e35dc84b..f5a25ba4 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -226,6 +226,9 @@ ui: 403: title: 'Zugriff verweigert' message: 'Diese Route ist geschützt oder nicht für direkten Zugriff verfügbar.' + 400: + title: 'Ungültige Anfrage' + message: 'Die Anfrage konnte nicht akzeptiert werden.' 404: title: 'Seite nicht gefunden' message: 'Die angefragte Seite wurde nicht gefunden oder ist nicht veröffentlicht.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 89f72725..2fecffd0 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -769,6 +769,40 @@ admin: required: 'This field is required.' save_failed: 'The settings could not be saved.' default_acl_group_unavailable: 'Enter an existing ACL group with minimum role User or lower, or leave the field empty.' + fields: + captcha_enabled: + label: 'Enable captcha' + captcha_provider: + label: 'Captcha provider' + captcha_preview: + label: 'Captcha preview' + rate_limit_mode: + label: 'Rate limiting' + audit_enabled: + label: 'Enable audit logging' + audit_events: + label: 'Audit event categories' + security_signal_retention_days: + label: 'Security signal retention' + help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.' + security_probe_path_patterns: + label: 'Suspicious probe path patterns' + help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.' + options: + captcha: + none: 'No captcha provider' + rate_limit_mode: + off: 'Off' + standard: 'Standard' + strict: 'Strict' + panic: 'Panic' + audit: + authentication: 'Authentication events' + backend_actions: 'Backend maintenance actions' + operations: 'Operations maintenance actions' + packages: 'Package lifecycle actions' + settings: 'Settings changes' + other: 'Other future audit events' acl: actions: save: 'Save ACL matrix' @@ -908,6 +942,8 @@ admin: label: 'Captcha provider' captcha_preview: label: 'Captcha preview' + rate_limit_mode: + label: 'Rate limiting' audit_enabled: label: 'Enable audit logging' audit_events: @@ -974,6 +1010,11 @@ admin: setup_warnings: 'Setup warnings' captcha: none: 'No captcha provider' + rate_limit_mode: + off: 'Off' + standard: 'Standard' + strict: 'Strict' + panic: 'Panic' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index 8e0526ea..f2be7e66 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -119,6 +119,11 @@ message: completed: 'GeoIP2 database was updated at "%path%".' account_app_secret_rotation: manual_owner_reset_required: 'APP_SECRET rotation recovery could not deliver every owner reset link. Use the emergency recovery file when available, or run "%command%" manually for the listed owners.' + rate_limit: + exceeded: 'Too many requests. Please wait a moment before trying again.' + 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.' event: hook: invalid: 'Event hook "%event%" is not a valid public hook definition.' diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index 864c372e..bc4002b6 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -226,6 +226,9 @@ ui: 403: title: 'Access denied' message: 'This route is protected or not available for direct access.' + 400: + title: 'Bad request' + message: 'The request could not be accepted.' 404: title: 'Page not found' message: 'The requested page could not be found or is not published.'