From 59ef450a0259b6425a757ab349e1ca0c2a6cf746 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 01:14:54 +0200 Subject: [PATCH 01/38] Start rate enforcement branch --- composer.lock | 26 +++++++++++++------------- dev/WORKLOG.md | 22 ++++------------------ dev/WORKLOG_HISTORY.md | 8 +++++++- 3 files changed, 24 insertions(+), 32 deletions(-) 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/dev/WORKLOG.md b/dev/WORKLOG.md index dd0281a4..61e27731 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-16 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -79,23 +79,9 @@ ## 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-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. ### 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. From 349526ba66af9f6b4765ef67f1b2a65137f9324a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 13:40:09 +0200 Subject: [PATCH 02/38] Document rate enforcement policy catalogue --- dev/WORKLOG.md | 1 + .../security-hardening/policy-defaults.md | 18 +++++++++-- .../security-hardening/rate-enforcement.md | 30 +++++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 61e27731..a04f6adf 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -82,6 +82,7 @@ ### 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 3e9e0338..9a67e847 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. @@ -79,6 +79,17 @@ 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. + +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 | @@ -108,6 +119,8 @@ Registered authenticated users receive higher limits than anonymous visitors whe 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. +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 diagnostics where possible, and avoid creating an invisible Owner, login, setup, API, or scheduler lockout. + ## 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. @@ -217,7 +230,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..2a8cda22 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,34 +27,42 @@ 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 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. +- 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. +- 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. +- 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; 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. +- Recovery login bypass is the exact `/user/login?bypass=1` browser path. It 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. - 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 storage degradation is fail-open by policy: the facade allows the request, records safe diagnostics where possible, and preserves Owner recovery instead of returning an invisible hard block. - 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`. - 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. +- The API integration should choose the smallest reviewable ordering that preserves the policy: valid CORS preflights stay cheap, ordinary API reads/writes are charged before authorization failures where practical, 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. @@ -79,9 +87,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 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 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. @@ -95,6 +106,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. From 3e3ff789c0977e46909920b32edaeb70ce954655 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 13:59:53 +0200 Subject: [PATCH 03/38] Add rate limit policy catalogue --- .../RateLimit/RateLimitBucketDescriptor.php | 75 ++++++++++++++ .../RateLimit/RateLimitPolicyCatalogue.php | 98 +++++++++++++++++++ src/Security/RateLimit/RateLimitProfile.php | 54 ++++++++++ .../RateLimitPolicyCatalogueTest.php | 75 ++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src/Security/RateLimit/RateLimitBucketDescriptor.php create mode 100644 src/Security/RateLimit/RateLimitPolicyCatalogue.php create mode 100644 src/Security/RateLimit/RateLimitProfile.php create mode 100644 tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php diff --git a/src/Security/RateLimit/RateLimitBucketDescriptor.php b/src/Security/RateLimit/RateLimitBucketDescriptor.php new file mode 100644 index 00000000..664af593 --- /dev/null +++ b/src/Security/RateLimit/RateLimitBucketDescriptor.php @@ -0,0 +1,75 @@ +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 scaled(RateLimitProfile $profile): self + { + if (!$this->profileScalable || RateLimitProfile::Standard === $profile || RateLimitProfile::Off === $profile) { + return $this; + } + + return new self( + $this->name, + $this->bucketFamily, + max(1, (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, + ); + } +} diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php new file mode 100644 index 00000000..7650ef88 --- /dev/null +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -0,0 +1,98 @@ + + */ + public function descriptors(RateLimitProfile $profile = RateLimitProfile::Standard): array + { + return array_map( + static fn (RateLimitBucketDescriptor $descriptor): RateLimitBucketDescriptor => $descriptor->scaled($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', 3, 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.minute', 'scheduler', 5, 60, 'security.rate.scheduler'), + $this->bucket('scheduler.hour', 'scheduler', 60, 3600, 'security.rate.scheduler'), + $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', false), + ]; + } + + private function bucket( + string $name, + string $family, + int $limit, + int $windowSeconds, + string $diagnosticsLabel, + bool $profileScalable = true, + ?int $retryAfterFloorSeconds = null, + bool $resettable = false, + ): RateLimitBucketDescriptor { + return new RateLimitBucketDescriptor( + $name, + $family, + $limit, + $windowSeconds, + $diagnosticsLabel, + $profileScalable, + $retryAfterFloorSeconds, + $resettable, + ); + } +} 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/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php new file mode 100644 index 00000000..cda4153f --- /dev/null +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -0,0 +1,75 @@ +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(1, $probe->limit()); + 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(15, $strict->limit()); + self::assertSame(90, $strict->windowSeconds()); + self::assertSame(7, $panic->limit()); + self::assertSame(120, $panic->windowSeconds()); + } + + public function testNonScalableBucketsStayStableAcrossProfiles(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($panic); + self::assertSame($standard->limit(), $panic->limit()); + self::assertSame($standard->windowSeconds(), $panic->windowSeconds()); + } + + public function testOffProfileDoesNotConsumeLimiterStorage(): void + { + self::assertFalse(RateLimitProfile::Off->consumesLimiterStorage()); + self::assertTrue(RateLimitProfile::Standard->consumesLimiterStorage()); + self::assertSame(RateLimitProfile::Standard, RateLimitProfile::fromMixed('unknown')); + } +} From 39695a544c5427fd03946fb02678d308781ec7d9 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:02:35 +0200 Subject: [PATCH 04/38] Add rate limit mode setting --- .../Config/Settings/CoreSettingsRegistry.php | 10 +++++++ .../Controller/ApiSettingsControllerTest.php | 5 ++++ tests/Controller/BackendControllerTest.php | 2 ++ .../Config/CoreSettingsFormHandlerTest.php | 29 +++++++++++++++++++ .../Core/Config/CoreSettingsRegistryTest.php | 23 +++++++++++---- translations/languages/de/admin.yaml | 7 +++++ translations/languages/en/admin.yaml | 7 +++++ 7 files changed, 78 insertions(+), 5 deletions(-) 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/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/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/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 6665041d..28d75789 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -908,6 +908,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 +976,11 @@ admin: setup_warnings: 'Setup-Warnungen' captcha: none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Strikt' + panic: 'Panic' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 89f72725..1bf55e19 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -908,6 +908,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 +976,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' From 8b115661b69dc4870b07aff810e1be4ee798e8ed Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:09:16 +0200 Subject: [PATCH 05/38] Add rate limit decision facade --- config/services.yaml | 4 + src/Security/Abuse/ActionCostCatalogue.php | 1 + .../RateLimit/RateLimitBucketDescriptor.php | 18 ++ .../RateLimit/RateLimitCheckResult.php | 57 +++++ src/Security/RateLimit/RateLimitEnforcer.php | 135 ++++++++++++ .../RateLimit/RateLimitLimiterFactory.php | 47 ++++ .../RateLimit/RateLimitPolicyCatalogue.php | 20 +- .../RateLimit/RateLimitSubjectSelector.php | 112 ++++++++++ .../RateLimit/RateLimitEnforcerTest.php | 202 ++++++++++++++++++ 9 files changed, 586 insertions(+), 10 deletions(-) create mode 100644 src/Security/RateLimit/RateLimitCheckResult.php create mode 100644 src/Security/RateLimit/RateLimitEnforcer.php create mode 100644 src/Security/RateLimit/RateLimitLimiterFactory.php create mode 100644 src/Security/RateLimit/RateLimitSubjectSelector.php create mode 100644 tests/Security/RateLimit/RateLimitEnforcerTest.php diff --git a/config/services.yaml b/config/services.yaml index cac21fd7..a6225d14 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -260,6 +260,10 @@ services: arguments: $providers: !tagged_iterator { tag: system.acl_group_reference_provider } + App\Security\RateLimit\RateLimitLimiterFactory: + arguments: + $cachePool: '@cache.rate_limiter' + App\Localization\TranslationLanguageCatalog: arguments: $projectDir: '%kernel.project_dir%' diff --git a/src/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php index 7cb1afdf..d06fd32e 100644 --- a/src/Security/Abuse/ActionCostCatalogue.php +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -16,6 +16,7 @@ public function costFor(AbuseRequestProfile $profile): ActionCost RequestIntent::Login => new ActionCost('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), diff --git a/src/Security/RateLimit/RateLimitBucketDescriptor.php b/src/Security/RateLimit/RateLimitBucketDescriptor.php index 664af593..11ea92f0 100644 --- a/src/Security/RateLimit/RateLimitBucketDescriptor.php +++ b/src/Security/RateLimit/RateLimitBucketDescriptor.php @@ -72,4 +72,22 @@ public function scaled(RateLimitProfile $profile): self $this->resettable, ); } + + 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, + ); + } } 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/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php new file mode 100644 index 00000000..41919ab5 --- /dev/null +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -0,0 +1,135 @@ +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()) { + return $this->checkSuspiciousProbe($profile, $subjectResolution, $mode); + } + + if (!$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->subjects->hasOwner($subjectResolution)) { + return RateLimitCheckResult::allow(); + } + + return $this->consume($profile, $subjectResolution, $cost, $mode); + } + + private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, RateLimitProfile $mode): RateLimitCheckResult + { + if (!$mode->consumesLimiterStorage()) { + return RateLimitCheckResult::blockSuspiciousProbe(); + } + + $result = $this->consume($profile, $subjects, new ActionCost('suspicious_probe', 1), $mode); + + return RateLimitCheckResult::blockSuspiciousProbe($result->storageDegraded()); + } + + private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): RateLimitCheckResult + { + try { + foreach ($this->descriptors($profile, $subjects, $cost, $mode) as $descriptor) { + $descriptor = $descriptor->withCapacityMultiplier($this->subjects->authenticatedMultiplier($descriptor, $subjects)); + + foreach ($this->subjects->subjectKeys($descriptor, $subjects) as $subjectKey) { + $retryAfter = $this->limiters->consume($descriptor, $subjectKey, max(1, $cost->credits())); + if ($retryAfter instanceof \DateTimeImmutable) { + return RateLimitCheckResult::reject($this->retryAfterSeconds($descriptor, $retryAfter), $descriptor->diagnosticsLabel()); + } + } + } + } catch (\Throwable $exception) { + $this->logger->warning('security.rate_limiter.degraded', [ + 'profile' => $mode->value, + 'intent' => $profile->intent()->value, + 'family' => $profile->family()->value, + 'exception_class' => $exception::class, + ]); + + return RateLimitCheckResult::allow(storageDegraded: true); + } + + return RateLimitCheckResult::allow(); + } + + /** + * @return list + */ + private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): array + { + $families = [$this->bucketFamily($cost, $subjects)]; + + if ($this->shouldConsumeWebsiteFamily($profile, $families[0])) { + $families[] = 'website'; + } + + $descriptors = []; + foreach (array_values(array_unique($families)) as $family) { + array_push($descriptors, ...$this->catalogue->descriptorsForFamily($family, $mode)); + } + + return $descriptors; + } + + 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'], 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); + } +} diff --git a/src/Security/RateLimit/RateLimitLimiterFactory.php b/src/Security/RateLimit/RateLimitLimiterFactory.php new file mode 100644 index 00000000..70451ac0 --- /dev/null +++ b/src/Security/RateLimit/RateLimitLimiterFactory.php @@ -0,0 +1,47 @@ + */ + private array $factories = []; + + public function __construct(private readonly CacheItemPoolInterface $cachePool) + { + } + + 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 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' => 'system.rate.'.$descriptor->name(), + 'policy' => 'fixed_window', + 'limit' => $descriptor->limit(), + 'interval' => $descriptor->windowSeconds().' seconds', + ], new CacheStorage($this->cachePool)); + } +} diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php index 7650ef88..01f7f5cf 100644 --- a/src/Security/RateLimit/RateLimitPolicyCatalogue.php +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -51,25 +51,25 @@ private function standardDescriptors(): array $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('registration.hour', 'registration', 15, 3600, 'security.rate.registration'), + $this->bucket('registration.day', 'registration', 50, 86400, 'security.rate.registration'), + $this->bucket('password_reset.hour', 'password_reset', 9, 3600, 'security.rate.password_reset'), + $this->bucket('password_reset.day', 'password_reset', 30, 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', 3, 600, 'security.rate.website_form'), + $this->bucket('website.form', 'website_form', 10, 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('api.write', 'api_write', 300, 60, 'security.rate.api_write'), $this->bucket('scheduler.minute', 'scheduler', 5, 60, 'security.rate.scheduler'), $this->bucket('scheduler.hour', 'scheduler', 60, 3600, 'security.rate.scheduler'), - $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('setup.apply', 'setup_apply', 40, 900, 'security.rate.setup_apply'), + $this->bucket('admin.mutation', 'admin_mutation', 240, 300, 'security.rate.admin_mutation'), + $this->bucket('upload_archive.validation', 'upload_archive', 160, 600, 'security.rate.upload_archive'), + $this->bucket('download_diagnostics', 'download_diagnostics', 120, 600, 'security.rate.download_diagnostics'), $this->bucket('suspicious.probe', 'suspicious_probe', 1, 600, 'security.rate.suspicious_probe', false), ]; } diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php new file mode 100644 index 00000000..f01fd376 --- /dev/null +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -0,0 +1,112 @@ + + */ + public function subjectKeys(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): array + { + $primary = $this->primarySubject($descriptor, $subjects); + if (!$primary instanceof AbuseSubject) { + return []; + } + + $keys = [$this->key($descriptor, $primary)]; + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + + if ($ipBucket instanceof AbuseSubject && $this->includeIpSecondary($descriptor, $subjects)) { + $keys[] = $this->key($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 in_array($descriptor->bucketFamily(), ['website', 'api_read', 'api_public_read'], true) + ? 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 + { + if (str_starts_with($descriptor->bucketFamily(), 'api_')) { + return [ + AbuseSubjectType::ApiKey, + AbuseSubjectType::ApiKeyPrefix, + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + ]; + } + + return [ + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::ApiKey, + AbuseSubjectType::ApiKeyPrefix, + AbuseSubjectType::IpBucket, + ]; + } + + private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): bool + { + if ($subjects->first(AbuseSubjectType::User) instanceof AbuseSubject || $subjects->first(AbuseSubjectType::ApiKey) instanceof AbuseSubject) { + return false; + } + + return in_array($descriptor->bucketFamily(), [ + 'website', + 'website_form', + 'login', + 'recovery_login', + 'registration', + 'password_reset', + 'captcha_failure', + 'setup_apply', + 'suspicious_probe', + 'api_public_read', + ], true); + } + + private function key(RateLimitBucketDescriptor $descriptor, AbuseSubject $subject): string + { + return $descriptor->name().':'.$subject->type()->value.':'.$subject->identifier(); + } +} diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php new file mode 100644 index 00000000..ca831db2 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -0,0 +1,202 @@ +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 + { + $result = $this->enforcer(cachePool: new FailingCachePool())->check($this->request('/home')); + + self::assertTrue($result->isAllowed()); + self::assertTrue($result->storageDegraded()); + } + + 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 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 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 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()); + } + + private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = null, ?CacheItemPoolInterface $cachePool = 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()), + new NullLogger(), + ); + } + + private function request(string $path, string $method = 'GET'): Request + { + return Request::create($path, $method, server: [ + 'REMOTE_ADDR' => '203.0.113.9', + 'HTTP_USER_AGENT' => 'RateLimitEnforcerTest', + ]); + } + + 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 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'); + } +} From e1f6acc5fa6d2f2f12adc7cf8c9766412101a87d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:13:19 +0200 Subject: [PATCH 06/38] Enforce rate limits on requests --- .../RateLimit/RateLimitRequestSubscriber.php | 58 ++++++++++ .../RateLimit/RateLimitResponseRenderer.php | 81 +++++++++++++ src/Security/SecurityMessageCode.php | 2 + src/Security/SecurityMessageKey.php | 2 + .../RateLimitEnforcementControllerTest.php | 107 ++++++++++++++++++ tests/Core/Message/MessageCodeTest.php | 4 +- tests/Core/Message/MessageKeyTest.php | 4 +- translations/languages/de/message.yaml | 3 + translations/languages/de/ui.yaml | 3 + translations/languages/en/message.yaml | 3 + translations/languages/en/ui.yaml | 3 + 11 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 src/Security/RateLimit/RateLimitRequestSubscriber.php create mode 100644 src/Security/RateLimit/RateLimitResponseRenderer.php create mode 100644 tests/Controller/RateLimitEnforcementControllerTest.php diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php new file mode 100644 index 00000000..74858c1b --- /dev/null +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -0,0 +1,58 @@ + + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', -2], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || $event->hasResponse()) { + return; + } + + $request = $event->getRequest(); + if ($this->excludedPath($request->getPathInfo())) { + return; + } + + $result = $this->enforcer->check($request); + if ($result->isAllowed()) { + return; + } + + $event->setResponse($result->suspiciousProbe() + ? $this->responses->suspiciousProbe($request) + : $this->responses->tooManyRequests($request, $result)); + } + + private function excludedPath(string $path): bool + { + return str_starts_with($path, '/api/live/') + || str_starts_with($path, '/assets/') + || str_starts_with($path, '/_profiler') + || str_starts_with($path, '/_wdt') + || in_array($path, ['/favicon.ico', '/robots.txt'], true); + } +} diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php new file mode 100644 index 00000000..76b6fa46 --- /dev/null +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -0,0 +1,81 @@ +jsonSurface($request) + ? $this->apiResponse($request, Response::HTTP_TOO_MANY_REQUESTS) + : $this->httpError->render(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->render(Response::HTTP_BAD_REQUEST, $request, context: $this->context($request)); + + return $this->noStore($response); + } + + 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 str_starts_with($request->getPathInfo(), '/api/v1') + || str_starts_with($request->getPathInfo(), '/cron'); + } +} diff --git a/src/Security/SecurityMessageCode.php b/src/Security/SecurityMessageCode.php index 41002052..731a6504 100644 --- a/src/Security/SecurityMessageCode.php +++ b/src/Security/SecurityMessageCode.php @@ -21,4 +21,6 @@ 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'; } diff --git a/src/Security/SecurityMessageKey.php b/src/Security/SecurityMessageKey.php index fdbf4042..e36a420f 100644 --- a/src/Security/SecurityMessageKey.php +++ b/src/Security/SecurityMessageKey.php @@ -39,4 +39,6 @@ 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'; } diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php new file mode 100644 index 00000000..34a1d818 --- /dev/null +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -0,0 +1,107 @@ +server('198.51.100.10')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 7; ++$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('Request ID', $client->getResponse()->getContent()); + self::assertStringNotContainsString('suspicious.probe', $client->getResponse()->getContent()); + } + + 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()); + } + } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'RateLimitEnforcementControllerTest', + ]; + } + + 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'); + } +} 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/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 9c4e9c74..57893215 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -119,6 +119,9 @@ 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.' 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/message.yaml b/translations/languages/en/message.yaml index 8e0526ea..40152c79 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -119,6 +119,9 @@ 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.' 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.' From b6a53e21f9ff1c3eb95ecf3ae36517a3e693a122 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:15:29 +0200 Subject: [PATCH 07/38] Add rate limit reset hooks --- .../RateLimitAuthenticationSubscriber.php | 30 ++++ .../RateLimit/RateLimitResetService.php | 91 +++++++++++ .../RateLimit/RateLimitSubjectSelector.php | 6 +- .../RateLimit/RateLimitResetServiceTest.php | 151 ++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 src/Security/RateLimit/RateLimitAuthenticationSubscriber.php create mode 100644 src/Security/RateLimit/RateLimitResetService.php create mode 100644 tests/Security/RateLimit/RateLimitResetServiceTest.php diff --git a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php new file mode 100644 index 00000000..88b517a2 --- /dev/null +++ b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php @@ -0,0 +1,30 @@ + + */ + public static function getSubscribedEvents(): array + { + return [ + LoginSuccessEvent::class => 'onLoginSuccess', + ]; + } + + public function onLoginSuccess(LoginSuccessEvent $event): void + { + $this->resets->resetLoginAttempts($event->getRequest()); + } +} diff --git a/src/Security/RateLimit/RateLimitResetService.php b/src/Security/RateLimit/RateLimitResetService.php new file mode 100644 index 00000000..476a2926 --- /dev/null +++ b/src/Security/RateLimit/RateLimitResetService.php @@ -0,0 +1,91 @@ +catalogue->descriptor('login.failure'); + if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable() || !$this->resetStorageEnabled()) { + return false; + } + + $subjectResolution = $this->inspector->inspect($request)['subjects']; + $reset = false; + + foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::IpBucket] as $type) { + $subject = $subjectResolution->first($type); + if (!$subject instanceof AbuseSubject) { + continue; + } + + $reset = $this->reset($descriptor, $this->subjects->subjectKey($descriptor, $subject)) || $reset; + } + + return $reset; + } + + public function resetVerifiedCaptchaFailure(Request $request, ?string $provider, bool $verified): bool + { + $provider = is_string($provider) ? trim($provider) : ''; + if (!$verified || '' === $provider || 'none' === strtolower($provider) || !$this->resetStorageEnabled()) { + return false; + } + + $descriptor = $this->catalogue->descriptor('captcha.failure'); + 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 resetStorageEnabled(): bool + { + return RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)) + ->consumesLimiterStorage(); + } + + private function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey): bool + { + try { + $this->limiters->reset($descriptor, $subjectKey); + + return true; + } catch (\Throwable $exception) { + $this->logger->warning('security.rate_limiter.reset_degraded', [ + 'bucket' => $descriptor->diagnosticsLabel(), + 'exception_class' => $exception::class, + ]); + + return false; + } + } +} diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php index f01fd376..fe356ebb 100644 --- a/src/Security/RateLimit/RateLimitSubjectSelector.php +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -21,11 +21,11 @@ public function subjectKeys(RateLimitBucketDescriptor $descriptor, AbuseSubjectR return []; } - $keys = [$this->key($descriptor, $primary)]; + $keys = [$this->subjectKey($descriptor, $primary)]; $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); if ($ipBucket instanceof AbuseSubject && $this->includeIpSecondary($descriptor, $subjects)) { - $keys[] = $this->key($descriptor, $ipBucket); + $keys[] = $this->subjectKey($descriptor, $ipBucket); } return array_values(array_unique($keys)); @@ -105,7 +105,7 @@ private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, Abuse ], true); } - private function key(RateLimitBucketDescriptor $descriptor, AbuseSubject $subject): string + public function subjectKey(RateLimitBucketDescriptor $descriptor, AbuseSubject $subject): string { return $descriptor->name().':'.$subject->type()->value.':'.$subject->identifier(); } diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php new file mode 100644 index 00000000..e778dc30 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php @@ -0,0 +1,151 @@ +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 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 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)); + } + + /** + * @return array{0: RateLimitEnforcer, 1: RateLimitResetService} + */ + private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null): array + { + $inspector = new AbuseRequestInspector( + new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ); + $catalogue = new RateLimitPolicyCatalogue(); + $selector = new RateLimitSubjectSelector(); + $factory ??= new RateLimitLimiterFactory(new ArrayAdapter()); + $config ??= new Config($this->connection()); + $logger = new NullLogger(); + + return [ + new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $logger), + new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $logger), + ]; + } + + private function request(string $path, string $method): Request + { + return Request::create($path, $method, server: [ + 'REMOTE_ADDR' => '203.0.113.50', + 'HTTP_USER_AGENT' => 'RateLimitResetServiceTest', + ]); + } + + 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'); + } +} From 61a37627bb52d8bd544c272defbb1da12f96b47b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:20:45 +0200 Subject: [PATCH 08/38] Document rate enforcement implementation --- config/services.yaml | 4 ++++ dev/CLASSMAP.md | 3 ++- dev/WORKLOG.md | 3 +++ dev/draft/security-hardening/policy-defaults.md | 2 ++ dev/draft/security-hardening/rate-enforcement.md | 4 +++- src/Security/RateLimit/RateLimitRequestSubscriber.php | 8 +++++++- tests/Controller/RateLimitEnforcementControllerTest.php | 1 + 7 files changed, 22 insertions(+), 3 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index a6225d14..d0150ca0 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -264,6 +264,10 @@ services: arguments: $cachePool: '@cache.rate_limiter' + App\Security\RateLimit\RateLimitRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + App\Localization\TranslationLanguageCatalog: arguments: $projectDir: '%kernel.project_dir%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 7c1ec2dc..e2f0cbb1 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. @@ -200,6 +200,7 @@ | 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/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `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, fail-open storage degradation, stable workflow-then-global consume order, Owner ordinary-rejection exemption, authenticated-user multipliers for ordinary navigation/read buckets, `/api/live/**` and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index a04f6adf..51dab821 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -83,6 +83,9 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 9a67e847..556c0926 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -81,6 +81,8 @@ These are first implementation defaults. Branches may adjust them only with test 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, not necessarily the raw action count shown in the policy table. When an intent costs more than one credit, the descriptor capacity must be high enough for the documented number of attempts so Symfony `consume(n)` never asks a limiter to consume more tokens than the bucket can hold and accidentally turns a valid policy into fail-open degradation. + 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 2a8cda22..79ac6061 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -28,7 +28,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Implementation sequence 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 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. +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. @@ -42,6 +42,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 while the table below describes 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. - 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. @@ -123,3 +124,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/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index 74858c1b..cafd56e5 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -13,6 +13,7 @@ public function __construct( private RateLimitEnforcer $enforcer, private RateLimitResponseRenderer $responses, + private string $environment, ) { } @@ -33,7 +34,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if ($this->excludedPath($request->getPathInfo())) { + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing')) || $this->excludedPath($request->getPathInfo())) { return; } @@ -55,4 +56,9 @@ private function excludedPath(string $path): bool || str_starts_with($path, '/_wdt') || in_array($path, ['/favicon.ico', '/robots.txt'], true); } + + private function enabledForRequest(?string $testOptIn): bool + { + return 'test' !== $this->environment || '1' === $testOptIn; + } } diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index 34a1d818..79108bdf 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -91,6 +91,7 @@ private function server(string $ip): array return [ 'REMOTE_ADDR' => $ip, 'HTTP_USER_AGENT' => 'RateLimitEnforcementControllerTest', + 'HTTP_X_RATE_LIMIT_TESTING' => '1', ]; } From f5704d8eb2a514d10001b06cf89536627fdc53ba Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:33:38 +0200 Subject: [PATCH 09/38] Harden rate limit scheduler policy --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 2 + .../security-hardening/policy-defaults.md | 12 ++-- .../security-hardening/rate-enforcement.md | 8 +-- src/Security/Abuse/ActionCostCatalogue.php | 56 ++++++++++++++++ .../RateLimit/RateLimitBucketDescriptor.php | 25 ++++++- src/Security/RateLimit/RateLimitEnforcer.php | 46 +++++++++---- .../RateLimit/RateLimitPolicyCatalogue.php | 66 ++++++++++++++----- .../RateLimit/RateLimitResetService.php | 25 +++++-- src/Security/SecurityMessageCode.php | 2 + src/Security/SecurityMessageKey.php | 2 + src/View/Http/HttpErrorRenderer.php | 5 +- .../Controller/PublicContentErrorPageTest.php | 1 + .../Abuse/ActionCostCatalogueTest.php | 13 ++++ .../RateLimit/RateLimitEnforcerTest.php | 37 +++++++++-- .../RateLimitPolicyCatalogueTest.php | 46 ++++++++++++- .../RateLimit/RateLimitResetServiceTest.php | 20 ++++-- .../RecordingRateLimitMessageReporter.php | 37 +++++++++++ translations/languages/de/message.yaml | 2 + translations/languages/en/message.yaml | 2 + 20 files changed, 354 insertions(+), 57 deletions(-) create mode 100644 tests/Security/RateLimit/RecordingRateLimitMessageReporter.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index e2f0cbb1..a194602e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,7 @@ | 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/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `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, fail-open storage degradation, stable workflow-then-global consume order, Owner ordinary-rejection exemption, authenticated-user multipliers for ordinary navigation/read buckets, `/api/live/**` and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `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 single-action profile floors, fail-open Message-layer storage diagnostics, stable workflow-then-global consume order, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, authenticated-user multipliers for ordinary navigation/read buckets, `/api/live/**` and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.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` | @@ -371,7 +371,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` | Renders recoverable HTTP errors through system content, frontend error templates, default error fallback, or anonymous-login `401` response, with central `no-store` cache headers for rendered error pages. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `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 51dab821..04aa6235 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -86,6 +86,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 556c0926..6fd7ef7f 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -81,7 +81,7 @@ These are first implementation defaults. Branches may adjust them only with test 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, not necessarily the raw action count shown in the policy table. When an intent costs more than one credit, the descriptor capacity must be high enough for the documented number of attempts so Symfony `consume(n)` never asks a limiter to consume more tokens than the bucket can hold and accidentally turns a valid policy into fail-open degradation. +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 at that single-action cost, so even the strongest profile still allows one legitimate request per configured window instead of degrading open. The first Admin-facing rate setting is one Owner-gated Security setting with four modes: @@ -106,7 +106,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | 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 | | 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 | +| Scheduler trigger | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger 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 | 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. @@ -115,13 +115,13 @@ Website global buckets count application/browser route handling, not static asse 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. -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. 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. Owner traffic may still record diagnostics and passive signals, but the request path must preserve Owner recovery and administrative operation access outside that explicit scheduler exception. -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 diagnostics where possible, and avoid creating an invisible Owner, login, setup, API, or scheduler lockout. +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. ## Probe Path Policy @@ -139,7 +139,7 @@ Limiter storage degradation is fail-open by policy. If limiter storage, locking, - 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. - 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 diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 79ac6061..46adbf59 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -42,7 +42,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 while the table below describes 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. +- 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 single-action credit floor so at least one legitimate request fits in every derived profile window. - 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. @@ -51,12 +51,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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; 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. +- 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. +- 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. - Recovery login bypass is the exact `/user/login?bypass=1` browser path. It 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. - 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 is fail-open by policy: the facade allows the request, records safe diagnostics where possible, and preserves Owner recovery instead of returning an invisible hard block. +- 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. - 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`. - 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. diff --git a/src/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php index d06fd32e..f52527a1 100644 --- a/src/Security/Abuse/ActionCostCatalogue.php +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -36,6 +36,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) { @@ -45,4 +74,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/RateLimit/RateLimitBucketDescriptor.php b/src/Security/RateLimit/RateLimitBucketDescriptor.php index 11ea92f0..72af96c3 100644 --- a/src/Security/RateLimit/RateLimitBucketDescriptor.php +++ b/src/Security/RateLimit/RateLimitBucketDescriptor.php @@ -15,6 +15,7 @@ public function __construct( private bool $profileScalable = true, private ?int $retryAfterFloorSeconds = null, private bool $resettable = false, + private int $minimumLimit = 1, ) { } @@ -53,6 +54,11 @@ public function resettable(): bool return $this->resettable; } + public function minimumLimit(): int + { + return $this->minimumLimit; + } + public function scaled(RateLimitProfile $profile): self { if (!$this->profileScalable || RateLimitProfile::Standard === $profile || RateLimitProfile::Off === $profile) { @@ -62,7 +68,7 @@ public function scaled(RateLimitProfile $profile): self return new self( $this->name, $this->bucketFamily, - max(1, (int) floor($this->limit * $profile->capacityMultiplier())), + max($this->minimumLimit, (int) floor($this->limit * $profile->capacityMultiplier())), max(1, (int) ceil($this->windowSeconds * $profile->windowMultiplier())), $this->diagnosticsLabel, $this->profileScalable, @@ -70,6 +76,22 @@ public function scaled(RateLimitProfile $profile): self ? null : max(1, (int) ceil($this->retryAfterFloorSeconds * $profile->retryAfterMultiplier())), $this->resettable, + $this->minimumLimit, + ); + } + + 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, ); } @@ -88,6 +110,7 @@ public function withCapacityMultiplier(int $multiplier): self $this->profileScalable, $this->retryAfterFloorSeconds, $this->resettable, + $this->minimumLimit * $multiplier, ); } } diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 41919ab5..15d8986d 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -5,6 +5,8 @@ namespace App\Security\RateLimit; use App\Core\Config\Config; +use App\Core\Message\Message; +use App\Core\Message\MessageReporterInterface; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseRequestProfile; use App\Security\Abuse\AbuseSubjectResolution; @@ -12,7 +14,8 @@ use App\Security\Abuse\ActionCost; use App\Security\Abuse\RequestFamily; use App\Security\Abuse\RequestIntent; -use Psr\Log\LoggerInterface; +use App\Security\SecurityMessageCode; +use App\Security\SecurityMessageKey; use Symfony\Component\HttpFoundation\Request; final readonly class RateLimitEnforcer @@ -23,7 +26,7 @@ public function __construct( private RateLimitPolicyCatalogue $catalogue, private RateLimitSubjectSelector $subjects, private RateLimitLimiterFactory $limiters, - private LoggerInterface $logger, + private MessageReporterInterface $messages, ) { } @@ -36,23 +39,23 @@ public function check(Request $request): RateLimitCheckResult $mode = RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)); if ($profile->suspiciousProbe()) { - return $this->checkSuspiciousProbe($profile, $subjectResolution, $mode); + return $this->checkSuspiciousProbe($profile, $subjectResolution, $cost, $mode); } - if (!$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->subjects->hasOwner($subjectResolution)) { + if (!$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($profile, $subjectResolution, $cost)) { return RateLimitCheckResult::allow(); } return $this->consume($profile, $subjectResolution, $cost, $mode); } - private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, RateLimitProfile $mode): RateLimitCheckResult + private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): RateLimitCheckResult { if (!$mode->consumesLimiterStorage()) { return RateLimitCheckResult::blockSuspiciousProbe(); } - $result = $this->consume($profile, $subjects, new ActionCost('suspicious_probe', 1), $mode); + $result = $this->consume($profile, $subjects, $cost, $mode); return RateLimitCheckResult::blockSuspiciousProbe($result->storageDegraded()); } @@ -71,12 +74,7 @@ private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $s } } } catch (\Throwable $exception) { - $this->logger->warning('security.rate_limiter.degraded', [ - 'profile' => $mode->value, - 'intent' => $profile->intent()->value, - 'family' => $profile->family()->value, - 'exception_class' => $exception::class, - ]); + $this->reportDegradedConsume($profile, $mode, $exception); return RateLimitCheckResult::allow(storageDegraded: true); } @@ -132,4 +130,28 @@ private function retryAfterSeconds(RateLimitBucketDescriptor $descriptor, \DateT return null === $floor ? $seconds : max($seconds, $floor); } + + private function isOwnerExempt(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost): bool + { + if (RequestFamily::Scheduler === $profile->family() || 'scheduler' === $cost->bucketFamily()) { + return false; + } + + return $this->subjects->hasOwner($subjects); + } + + 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/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php index 01f7f5cf..de81208e 100644 --- a/src/Security/RateLimit/RateLimitPolicyCatalogue.php +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -4,18 +4,30 @@ namespace App\Security\RateLimit; +use App\Security\Abuse\ActionCostCatalogue; + final readonly class RateLimitPolicyCatalogue { public const MODE_KEY = 'security.rate_limit.mode'; public const AUTHENTICATED_MULTIPLIER = 2; + /** + * @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( - static fn (RateLimitBucketDescriptor $descriptor): RateLimitBucketDescriptor => $descriptor->scaled($profile), + fn (RateLimitBucketDescriptor $descriptor): RateLimitBucketDescriptor => $this->profileDescriptor($descriptor, $profile), $this->standardDescriptors(), ); } @@ -51,48 +63,72 @@ private function standardDescriptors(): array $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', 15, 3600, 'security.rate.registration'), - $this->bucket('registration.day', 'registration', 50, 86400, 'security.rate.registration'), - $this->bucket('password_reset.hour', 'password_reset', 9, 3600, 'security.rate.password_reset'), - $this->bucket('password_reset.day', 'password_reset', 30, 86400, 'security.rate.password_reset'), + $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', 10, 600, 'security.rate.website_form'), + $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', 300, 60, 'security.rate.api_write'), - $this->bucket('scheduler.minute', 'scheduler', 5, 60, 'security.rate.scheduler'), - $this->bucket('scheduler.hour', 'scheduler', 60, 3600, 'security.rate.scheduler'), - $this->bucket('setup.apply', 'setup_apply', 40, 900, 'security.rate.setup_apply'), - $this->bucket('admin.mutation', 'admin_mutation', 240, 300, 'security.rate.admin_mutation'), - $this->bucket('upload_archive.validation', 'upload_archive', 160, 600, 'security.rate.upload_archive'), - $this->bucket('download_diagnostics', 'download_diagnostics', 120, 600, 'security.rate.download_diagnostics'), + $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', false), ]; } + 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 $limit, + int $actionLimit, int $windowSeconds, string $diagnosticsLabel, bool $profileScalable = true, ?int $retryAfterFloorSeconds = null, bool $resettable = false, ): RateLimitBucketDescriptor { + $cost = $this->creditCostForFamily($family); + return new RateLimitBucketDescriptor( $name, $family, - $limit, + max(1, $actionLimit) * $cost, $windowSeconds, $diagnosticsLabel, $profileScalable, $retryAfterFloorSeconds, $resettable, + $cost, ); } + + 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); + } } diff --git a/src/Security/RateLimit/RateLimitResetService.php b/src/Security/RateLimit/RateLimitResetService.php index 476a2926..4cf8fc68 100644 --- a/src/Security/RateLimit/RateLimitResetService.php +++ b/src/Security/RateLimit/RateLimitResetService.php @@ -5,10 +5,13 @@ namespace App\Security\RateLimit; use App\Core\Config\Config; +use App\Core\Message\Message; +use App\Core\Message\MessageReporterInterface; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubject; use App\Security\Abuse\AbuseSubjectType; -use Psr\Log\LoggerInterface; +use App\Security\SecurityMessageCode; +use App\Security\SecurityMessageKey; use Symfony\Component\HttpFoundation\Request; final readonly class RateLimitResetService @@ -19,7 +22,7 @@ public function __construct( private RateLimitPolicyCatalogue $catalogue, private RateLimitSubjectSelector $subjects, private RateLimitLimiterFactory $limiters, - private LoggerInterface $logger, + private MessageReporterInterface $messages, ) { } @@ -80,12 +83,22 @@ private function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey return true; } catch (\Throwable $exception) { - $this->logger->warning('security.rate_limiter.reset_degraded', [ - 'bucket' => $descriptor->diagnosticsLabel(), - 'exception_class' => $exception::class, - ]); + $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/SecurityMessageCode.php b/src/Security/SecurityMessageCode.php index 731a6504..b413a0ea 100644 --- a/src/Security/SecurityMessageCode.php +++ b/src/Security/SecurityMessageCode.php @@ -23,4 +23,6 @@ final class SecurityMessageCode 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 e36a420f..0cbc8ff8 100644 --- a/src/Security/SecurityMessageKey.php +++ b/src/Security/SecurityMessageKey.php @@ -41,4 +41,6 @@ final class SecurityMessageKey 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..b7ec4762 100644 --- a/src/View/Http/HttpErrorRenderer.php +++ b/src/View/Http/HttpErrorRenderer.php @@ -118,7 +118,10 @@ 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; } /** 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/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php index 3a1f4429..bc5db491 100644 --- a/tests/Security/Abuse/ActionCostCatalogueTest.php +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -47,4 +47,17 @@ public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic() 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/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index ca831db2..6e10c085 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -17,13 +17,13 @@ use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; use App\Security\RateLimit\RateLimitSubjectSelector; +use App\Security\SecurityMessageCode; use App\Security\UserRole; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; -use Psr\Log\NullLogger; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; @@ -44,10 +44,12 @@ public function testOffModeDoesNotConsumeLimiterStorage(): void public function testStorageFailureFailsOpenWithDiagnosticsFlag(): void { - $result = $this->enforcer(cachePool: new FailingCachePool())->check($this->request('/home')); + $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 @@ -89,6 +91,33 @@ public function testAuthenticatedUsersReceiveWebsiteMultiplier(): void 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 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 testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void { $config = new Config($this->connection()); @@ -102,7 +131,7 @@ public function testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void self::assertFalse($result->storageDegraded()); } - private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = null, ?CacheItemPoolInterface $cachePool = null): RateLimitEnforcer + private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = null, ?CacheItemPoolInterface $cachePool = null, ?RecordingRateLimitMessageReporter $messages = null): RateLimitEnforcer { $tokenStorage ??= new TokenStorage(); $inspector = new AbuseRequestInspector( @@ -117,7 +146,7 @@ private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = new RateLimitPolicyCatalogue(), new RateLimitSubjectSelector(), new RateLimitLimiterFactory($cachePool ?? new ArrayAdapter()), - new NullLogger(), + $messages ?? new RecordingRateLimitMessageReporter(), ); } diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php index cda4153f..2c406538 100644 --- a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -28,7 +28,8 @@ public function testItExposesStandardPolicyDescriptors(): void self::assertSame(30, $burst->limit()); self::assertSame(60, $burst->windowSeconds()); self::assertNotNull($probe); - self::assertSame(1, $probe->limit()); + self::assertSame(10, $probe->limit()); + self::assertSame(10, $probe->minimumLimit()); self::assertSame(600, $probe->windowSeconds()); self::assertNotNull($captcha); self::assertTrue($captcha->resettable()); @@ -66,6 +67,49 @@ public function testNonScalableBucketsStayStableAcrossProfiles(): void self::assertSame($standard->windowSeconds(), $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(5, $registration->minimumLimit()); + self::assertNotNull($apiWrite); + self::assertSame(300, $apiWrite->limit()); + self::assertSame(5, $apiWrite->minimumLimit()); + } + + public function testProfileScalingKeepsAtLeastOneCostedActionAvailable(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + foreach ($catalogue->descriptors(RateLimitProfile::Panic) as $descriptor) { + self::assertGreaterThanOrEqual($descriptor->minimumLimit(), $descriptor->limit(), $descriptor->name()); + } + } + + 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()); diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php index e778dc30..9724f6c2 100644 --- a/tests/Security/RateLimit/RateLimitResetServiceTest.php +++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php @@ -17,12 +17,12 @@ use App\Security\RateLimit\RateLimitProfile; use App\Security\RateLimit\RateLimitResetService; use App\Security\RateLimit\RateLimitSubjectSelector; +use App\Security\SecurityMessageCode; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; -use Psr\Log\NullLogger; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; @@ -63,10 +63,20 @@ public function testOffModeDoesNotTouchResetStorage(): void 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): array + private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null, ?RecordingRateLimitMessageReporter $messages = null): array { $inspector = new AbuseRequestInspector( new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'), @@ -77,11 +87,11 @@ private function services(?Config $config = null, ?RateLimitLimiterFactory $fact $selector = new RateLimitSubjectSelector(); $factory ??= new RateLimitLimiterFactory(new ArrayAdapter()); $config ??= new Config($this->connection()); - $logger = new NullLogger(); + $messages ??= new RecordingRateLimitMessageReporter(); return [ - new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $logger), - new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $logger), + new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $messages), + new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $messages), ]; } 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/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 57893215..0f190e2d 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -122,6 +122,8 @@ message: 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/en/message.yaml b/translations/languages/en/message.yaml index 40152c79..f2be7e66 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -122,6 +122,8 @@ message: 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.' From c12869e2613fbfbc2571a3525b1b2f03ade6837e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:38:50 +0200 Subject: [PATCH 10/38] Scale suspicious probe profile windows --- dev/WORKLOG.md | 1 + .../security-hardening/policy-defaults.md | 4 ++-- .../security-hardening/rate-enforcement.md | 1 + .../RateLimit/RateLimitPolicyCatalogue.php | 2 +- .../RateLimitPolicyCatalogueTest.php | 21 ++++++++++++++++--- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 04aa6235..5ff1aa73 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -88,6 +88,7 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 6fd7ef7f..0a8ac3a7 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -107,7 +107,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | Versioned API write | 60 mutating requests per minute | API key fingerprint | No success reset | | Public anonymous API read | 120 safe requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | | Scheduler trigger | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger 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 | +| 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. @@ -127,7 +127,7 @@ Limiter storage degradation is fail-open by policy. If limiter storage, locking, - 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. +- 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 46adbf59..d8017c9e 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -59,6 +59,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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 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. diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php index de81208e..c4efe419 100644 --- a/src/Security/RateLimit/RateLimitPolicyCatalogue.php +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -81,7 +81,7 @@ private function standardDescriptors(): array $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', false), + $this->bucket('suspicious.probe', 'suspicious_probe', 1, 600, 'security.rate.suspicious_probe'), ]; } diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php index 2c406538..9ed04e71 100644 --- a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -54,12 +54,12 @@ public function testStrictAndPanicProfilesDeriveFromStandardDescriptors(): void self::assertSame(120, $panic->windowSeconds()); } - public function testNonScalableBucketsStayStableAcrossProfiles(): void + public function testRecoveryBucketsStayStableAcrossProfiles(): void { $catalogue = new RateLimitPolicyCatalogue(); - $standard = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Standard); - $panic = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Panic); + $standard = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Panic); self::assertNotNull($standard); self::assertNotNull($panic); @@ -67,6 +67,21 @@ public function testNonScalableBucketsStayStableAcrossProfiles(): void 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(); From 706c8222bd69cb231b38049021632507b87fa747 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 14:56:49 +0200 Subject: [PATCH 11/38] Cover cron rate limit route rendering --- config/services.yaml | 4 + dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 3 + dev/manual/local-agent-tooling-snippets.md | 2 +- src/Command/RenderRouteCommand.php | 59 ++++++++- src/Debug/RouteRenderOptions.php | 2 + src/Debug/RouteRenderer.php | 8 +- tests/Command/RenderRouteCommandTest.php | 136 +++++++++++++++++++++ 8 files changed, 210 insertions(+), 6 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index d0150ca0..03dd5cbe 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%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index a194602e..a802662e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -107,7 +107,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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 5ff1aa73..451e80f2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -89,6 +89,9 @@ - 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. +- Verification: focused `render:route`, scheduler controller, and rate-limit enforcer PHPUnit coverage; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1435 tests and 9446 assertions. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). 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/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/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..23e358b1 100644 --- a/src/Debug/RouteRenderer.php +++ b/src/Debug/RouteRenderer.php @@ -116,7 +116,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 +127,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/tests/Command/RenderRouteCommandTest.php b/tests/Command/RenderRouteCommandTest.php index 618a6045..a1c46c69 100644 --- a/tests/Command/RenderRouteCommandTest.php +++ b/tests/Command/RenderRouteCommandTest.php @@ -5,9 +5,19 @@ namespace App\Tests\Command; use App\Command\RenderRouteCommand; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\Debug\RouteRenderer; +use App\Entity\UserAccount; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; +use App\Security\UserRole; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Uuid; final class RenderRouteCommandTest extends KernelTestCase { @@ -22,6 +32,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 +67,50 @@ 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 testCronRunRenderEnforcesSchedulerRateLimitForOwnerContext(): void + { + self::bootKernel(); + $this->setRateLimitMode(RateLimitProfile::Standard); + $this->removeTemporaryOwners(); + $username = $this->createTemporaryOwner(); + $command = self::getContainer()->get(RenderRouteCommand::class); + + try { + $first = new CommandTester($command); + $firstExit = $first->execute($this->cronRenderInput($username)); + + self::assertSame(Command::SUCCESS, $firstExit); + self::assertStringContainsString('HTTP 200', $first->getDisplay()); + + $second = new CommandTester($command); + $secondExit = $second->execute($this->cronRenderInput($username)); + $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); + } finally { + $this->removeTemporaryOwner($username); + } + } + public function testItDoesNotOverrideExistingUserRoles(): void { self::bootKernel(); @@ -54,4 +121,73 @@ 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(string $username): array + { + return [ + 'path' => '/cron/run', + '--user' => $username, + '--include-status' => true, + '--include-headers' => true, + '--header' => [ + 'Authorization: Bearer test_seed_read_write_key', + 'X-Rate-Limit-Testing: 1', + ], + ]; + } + + private function createTemporaryOwner(): string + { + $username = 'render-cron-owner-'.substr(str_replace('.', '', uniqid('', true)), 0, 12); + $user = new UserAccount( + Uuid::v7()->toRfc4122(), + $username, + $username.'@example.test', + 'debug-render', + role: UserRole::Owner, + ); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $entityManager->persist($user); + $entityManager->flush(); + + return $username; + } + + private function removeTemporaryOwner(string $username): void + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $user = $entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); + if ($user instanceof UserAccount) { + $entityManager->remove($user); + $entityManager->flush(); + } + } + + private function removeTemporaryOwners(): void + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $entityManager->getConnection()->executeStatement( + "DELETE FROM user_account WHERE username LIKE 'render-cron-owner-%'", + ); + $entityManager->clear(); + } + + 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'); + } } From e8bafbc54bb5c41c4bf2e216133a78ef07277adf Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 15:10:21 +0200 Subject: [PATCH 12/38] Clarify scheduler rate limit policy --- dev/WORKLOG.md | 1 + dev/draft/security-hardening/policy-defaults.md | 4 ++-- dev/draft/security-hardening/rate-enforcement.md | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 451e80f2..3920f9d9 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -91,6 +91,7 @@ - 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. - Verification: focused `render:route`, scheduler controller, and rate-limit enforcer PHPUnit coverage; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1435 tests and 9446 assertions. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 0a8ac3a7..4ed4e013 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -106,7 +106,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | 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 | | Public anonymous API read | 120 safe requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | -| Scheduler trigger | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger per hour | API key fingerprint plus scheduler endpoint subject | No success reset | +| Scheduler trigger | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger per hour | Pre-auth scheduler endpoint interval subject | 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. @@ -115,7 +115,7 @@ Website global buckets count application/browser route handling, not static asse 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. -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 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. 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index d8017c9e..8fa9c7ee 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -51,8 +51,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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. +- 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. - 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. +- 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 path. It 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. - 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. From 222dbc694fe8b595ae9238a65dfd92c73105ca8c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 15:27:02 +0200 Subject: [PATCH 13/38] Record custom error page follow-up --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 3920f9d9..f8b36758 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -70,6 +70,7 @@ - [ ] 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. From 64859294c8b6edaede5e03ea592cdb24352c9cea Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 15:32:17 +0200 Subject: [PATCH 14/38] Version bump -> 0.2.5 --- .manifest | 4 ++-- README.md | 4 ++-- tests/Backend/PackageDependencyLabelParserTest.php | 4 ++-- tests/Core/Manifest/ManifestParserTest.php | 2 +- tests/View/Twig/ViewTwigExtensionTest.php | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) 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/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/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/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/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 From e17395d569f5cd2c562847662aff8c32ea4e133d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 15:39:14 +0200 Subject: [PATCH 15/38] Translate rate limit profile labels --- translations/languages/de/admin.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 28d75789..184fe475 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -979,8 +979,8 @@ admin: rate_limit_mode: off: 'Aus' standard: 'Standard' - strict: 'Strikt' - panic: 'Panic' + strict: 'Streng' + panic: 'Panik' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' From 2f91ceeddfd240c8da9fdac76087885b7f886420 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 15:45:45 +0200 Subject: [PATCH 16/38] Update importmap dependencies --- importmap.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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'], ]; From 268a6bfc5a02feb81299049487b5b241a305362f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 16:24:42 +0200 Subject: [PATCH 17/38] Fix rate enforcement review findings --- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 4 +- .../security-hardening/policy-defaults.md | 4 +- .../security-hardening/rate-enforcement.md | 4 +- src/Content/Routing/ContentRouteGuard.php | 1 + src/Security/Abuse/ActionCostCatalogue.php | 1 + src/Security/Abuse/RequestIntent.php | 1 + .../Abuse/RequestIntentClassifier.php | 23 ++++- src/Security/RateLimit/RateLimitEnforcer.php | 2 +- .../RateLimit/RateLimitRequestSubscriber.php | 3 +- .../RateLimit/RateLimitResetService.php | 15 ++-- tests/Command/RenderRouteCommandTest.php | 89 ++++--------------- .../Content/Routing/ContentRouteGuardTest.php | 18 +++- .../RateLimitEnforcementControllerTest.php | 50 +++++++++++ .../Abuse/RequestIntentClassifierTest.php | 25 ++++++ .../RateLimit/RateLimitEnforcerTest.php | 31 +++++++ .../RateLimit/RateLimitResetServiceTest.php | 51 +++++++++-- 17 files changed, 231 insertions(+), 97 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index a802662e..1fc751ac 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `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 single-action profile floors, fail-open Message-layer storage diagnostics, stable workflow-then-global consume order, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, authenticated-user multipliers for ordinary navigation/read buckets, `/api/live/**` and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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, unsafe-only public auth workflow intents, the exact recovery-login bypass path, admin mutations before broad public reset/password keywords, 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\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `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 single-action profile floors, fail-open Message-layer storage diagnostics, stable workflow-then-global consume order after routing and before Symfony authentication failure responses, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, authenticated-user multipliers for ordinary navigation/read buckets, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, active-profile scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.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` | @@ -263,7 +263,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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f8b36758..9fa1451e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -93,7 +93,9 @@ - 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. -- Verification: focused `render:route`, scheduler controller, and rate-limit enforcer PHPUnit coverage; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1435 tests and 9446 assertions. +- 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. +- Verification: focused syntax checks, focused PHPUnit coverage for request classification, rate-limit enforcer/reset/controller behavior, render-route cron handling, and content route guards; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1447 tests and 9544 assertions. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 4ed4e013..8a040a44 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -95,7 +95,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | 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 | +| Recovery login bypass | 2 recovery-login requests 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 | | Contact form submissions | 3 submissions per 10 minutes and 20 per day | Visitor ID; IP bucket as secondary signal | No automatic global reset | @@ -182,7 +182,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. +- 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 diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 8fa9c7ee..07fb266c 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -54,7 +54,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. - 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. - 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 path. It 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. +- Recovery login bypass is the exact `/user/login?bypass=1` browser 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. - 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 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. @@ -72,7 +72,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## 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. +- Unsafe login submissions consume login and global website budget before authentication failure responses can return; safe login, registration, and password-reset form renders do not spend workflow-specific buckets. Successful login resets only the login-attempt bucket for that subject and 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. 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/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php index f52527a1..d84c42d1 100644 --- a/src/Security/Abuse/ActionCostCatalogue.php +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -14,6 +14,7 @@ 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), 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..02248215 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -27,7 +27,7 @@ public function classify(Request $request): AbuseRequestProfile 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, @@ -53,6 +53,7 @@ private function family(array $segments): RequestFamily } private function intent( + Request $request, string $method, array $segments, string $route, @@ -96,15 +97,29 @@ private function intent( return $this->adminMutationIntent($segments, $route); } + if ($this->recoveryLogin($request, $segments, $route)) { + return RequestIntent::RecoveryLogin; + } + 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, }; } + /** + * @param list $segments + */ + private function recoveryLogin(Request $request, array $segments, string $route): bool + { + return $this->matchesSegments($segments, 'user', 'login') + && $this->routeIs($route, 'user_login', 'n/a') + && '1' === (string) $request->query->get('bypass', ''); + } + private function adminMutationIntent(array $segments, string $route): RequestIntent { return match (true) { diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 15d8986d..36520894 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -120,7 +120,7 @@ private function shouldConsumeWebsiteFamily(AbuseRequestProfile $profile, string return false; } - return !in_array($bucketFamily, ['website', 'website_prefetch'], true); + return !in_array($bucketFamily, ['website', 'website_prefetch', 'recovery_login'], true); } private function retryAfterSeconds(RateLimitBucketDescriptor $descriptor, \DateTimeImmutable $retryAfter): int diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index cafd56e5..dbdcf2a1 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -23,7 +23,7 @@ public function __construct( public static function getSubscribedEvents(): array { return [ - KernelEvents::REQUEST => ['onKernelRequest', -2], + KernelEvents::REQUEST => ['onKernelRequest', 12], ]; } @@ -52,6 +52,7 @@ private function excludedPath(string $path): bool { return str_starts_with($path, '/api/live/') || str_starts_with($path, '/assets/') + || str_starts_with($path, '/build/') || str_starts_with($path, '/_profiler') || str_starts_with($path, '/_wdt') || in_array($path, ['/favicon.ico', '/robots.txt'], true); diff --git a/src/Security/RateLimit/RateLimitResetService.php b/src/Security/RateLimit/RateLimitResetService.php index 4cf8fc68..aa0799f4 100644 --- a/src/Security/RateLimit/RateLimitResetService.php +++ b/src/Security/RateLimit/RateLimitResetService.php @@ -28,8 +28,9 @@ public function __construct( public function resetLoginAttempts(Request $request): bool { - $descriptor = $this->catalogue->descriptor('login.failure'); - if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable() || !$this->resetStorageEnabled()) { + $profile = $this->profile(); + $descriptor = $this->catalogue->descriptor('login.failure', $profile); + if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable() || !$profile->consumesLimiterStorage()) { return false; } @@ -51,11 +52,12 @@ public function resetLoginAttempts(Request $request): bool public function resetVerifiedCaptchaFailure(Request $request, ?string $provider, bool $verified): bool { $provider = is_string($provider) ? trim($provider) : ''; - if (!$verified || '' === $provider || 'none' === strtolower($provider) || !$this->resetStorageEnabled()) { + $profile = $this->profile(); + if (!$verified || '' === $provider || 'none' === strtolower($provider) || !$profile->consumesLimiterStorage()) { return false; } - $descriptor = $this->catalogue->descriptor('captcha.failure'); + $descriptor = $this->catalogue->descriptor('captcha.failure', $profile); if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable()) { return false; } @@ -70,10 +72,9 @@ public function resetVerifiedCaptchaFailure(Request $request, ?string $provider, return $reset; } - private function resetStorageEnabled(): bool + private function profile(): RateLimitProfile { - return RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)) - ->consumesLimiterStorage(); + return RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)); } private function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey): bool diff --git a/tests/Command/RenderRouteCommandTest.php b/tests/Command/RenderRouteCommandTest.php index a1c46c69..717dcefd 100644 --- a/tests/Command/RenderRouteCommandTest.php +++ b/tests/Command/RenderRouteCommandTest.php @@ -8,16 +8,12 @@ use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Debug\RouteRenderer; -use App\Entity\UserAccount; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; -use App\Security\UserRole; -use Doctrine\ORM\EntityManagerInterface; use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\Uid\Uuid; final class RenderRouteCommandTest extends KernelTestCase { @@ -81,34 +77,28 @@ public function testItRejectsInvalidSyntheticHeaders(): void self::assertStringContainsString('unsupported control characters', $tester->getDisplay()); } - public function testCronRunRenderEnforcesSchedulerRateLimitForOwnerContext(): void + public function testCronRunRenderEnforcesSchedulerRateLimitForApiKeyContext(): void { self::bootKernel(); $this->setRateLimitMode(RateLimitProfile::Standard); - $this->removeTemporaryOwners(); - $username = $this->createTemporaryOwner(); $command = self::getContainer()->get(RenderRouteCommand::class); - try { - $first = new CommandTester($command); - $firstExit = $first->execute($this->cronRenderInput($username)); - - self::assertSame(Command::SUCCESS, $firstExit); - self::assertStringContainsString('HTTP 200', $first->getDisplay()); - - $second = new CommandTester($command); - $secondExit = $second->execute($this->cronRenderInput($username)); - $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); - } finally { - $this->removeTemporaryOwner($username); - } + $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 @@ -125,11 +115,11 @@ public function testItDoesNotOverrideExistingUserRoles(): void /** * @return array */ - private function cronRenderInput(string $username): array + private function cronRenderInput(): array { return [ 'path' => '/cron/run', - '--user' => $username, + '--role' => 'public', '--include-status' => true, '--include-headers' => true, '--header' => [ @@ -139,47 +129,6 @@ private function cronRenderInput(string $username): array ]; } - private function createTemporaryOwner(): string - { - $username = 'render-cron-owner-'.substr(str_replace('.', '', uniqid('', true)), 0, 12); - $user = new UserAccount( - Uuid::v7()->toRfc4122(), - $username, - $username.'@example.test', - 'debug-render', - role: UserRole::Owner, - ); - - $entityManager = self::getContainer()->get(EntityManagerInterface::class); - self::assertInstanceOf(EntityManagerInterface::class, $entityManager); - $entityManager->persist($user); - $entityManager->flush(); - - return $username; - } - - private function removeTemporaryOwner(string $username): void - { - $entityManager = self::getContainer()->get(EntityManagerInterface::class); - self::assertInstanceOf(EntityManagerInterface::class, $entityManager); - - $user = $entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); - if ($user instanceof UserAccount) { - $entityManager->remove($user); - $entityManager->flush(); - } - } - - private function removeTemporaryOwners(): void - { - $entityManager = self::getContainer()->get(EntityManagerInterface::class); - self::assertInstanceOf(EntityManagerInterface::class, $entityManager); - $entityManager->getConnection()->executeStatement( - "DELETE FROM user_account WHERE username LIKE 'render-cron-owner-%'", - ); - $entityManager->clear(); - } - private function setRateLimitMode(RateLimitProfile $profile): void { $cache = self::getContainer()->get('cache.rate_limiter'); 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/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index 79108bdf..a11960d3 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -83,6 +83,56 @@ public function testPrefetchAndLiveApiPathsAreNotChargedToOrdinaryLimiter(): voi } } + 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'), + 'HTTP_AUTHORIZATION' => 'Bearer invalidprefix.invalid-secret', + ]); + $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); + } + /** * @return array */ diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 45b79024..1b075534 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -164,6 +164,31 @@ 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 '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, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 6e10c085..6e3829cf 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -66,6 +66,37 @@ public function testLoginWorkflowRejectsBeforeWebsiteBudget(): void 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'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'))->isAllowed()); + + $result = $enforcer->check($this->request('/user/login?bypass=1')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); + } + public function testOwnerIsExemptFromOrdinaryRateLimitRejection(): void { $tokenStorage = $this->tokenStorage(UserRole::Owner); diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php index 9724f6c2..343411de 100644 --- a/tests/Security/RateLimit/RateLimitResetServiceTest.php +++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php @@ -43,6 +43,20 @@ public function testLoginSuccessResetClearsVisitorAndIpLoginAttempts(): void self::assertTrue($enforcer->check($request)->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(); @@ -53,6 +67,28 @@ public function testCaptchaResetRequiresVerifiedProviderBackedSuccess(): void 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::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()); @@ -78,11 +114,7 @@ public function testResetFailureReportsThroughMessageLayer(): void */ private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null, ?RecordingRateLimitMessageReporter $messages = null): array { - $inspector = new AbuseRequestInspector( - new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'), - new RequestIntentClassifier(), - new ActionCostCatalogue(), - ); + $inspector = $this->inspector(); $catalogue = new RateLimitPolicyCatalogue(); $selector = new RateLimitSubjectSelector(); $factory ??= new RateLimitLimiterFactory(new ArrayAdapter()); @@ -95,6 +127,15 @@ private function services(?Config $config = null, ?RateLimitLimiterFactory $fact ]; } + private function inspector(): AbuseRequestInspector + { + return new AbuseRequestInspector( + new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ); + } + private function request(string $path, string $method): Request { return Request::create($path, $method, server: [ From 91cca74a9694cc0dc9dea2acaa9bf69bee2ff73f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 17:02:55 +0200 Subject: [PATCH 18/38] Fix rate limiter auth-stage bypasses --- config/services.yaml | 4 + dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 3 +- .../security-hardening/policy-defaults.md | 18 +-- .../security-hardening/rate-enforcement.md | 9 +- src/Security/Abuse/AbuseSubjectResolver.php | 43 ++++++ src/Security/Abuse/AbuseSubjectType.php | 1 + .../Abuse/RequestIntentClassifier.php | 16 ++- .../RateLimitAuthenticationSubscriber.php | 30 +++- .../RateLimit/RateLimitEnforcementStage.php | 39 ++++++ src/Security/RateLimit/RateLimitEnforcer.php | 20 +-- .../RateLimit/RateLimitRequestSubscriber.php | 32 ++++- .../RateLimit/RateLimitSubjectSelector.php | 40 +++++- .../RateLimitEnforcementControllerTest.php | 131 +++++++++++++++++- .../Abuse/AbuseSubjectResolverTest.php | 25 ++++ .../Abuse/RequestIntentClassifierTest.php | 14 +- .../RateLimit/RateLimitEnforcerTest.php | 92 +++++++++++- 17 files changed, 475 insertions(+), 46 deletions(-) create mode 100644 src/Security/RateLimit/RateLimitEnforcementStage.php diff --git a/config/services.yaml b/config/services.yaml index 03dd5cbe..12b45fb9 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -272,6 +272,10 @@ services: arguments: $environment: '%kernel.environment%' + App\Security\RateLimit\RateLimitAuthenticationSubscriber: + arguments: + $environment: '%kernel.environment%' + App\Localization\TranslationLanguageCatalog: arguments: $projectDir: '%kernel.project_dir%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 1fc751ac..b2cfbcc6 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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, unsafe-only public auth workflow intents, the exact recovery-login bypass path, admin mutations before broad public reset/password keywords, 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\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `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 single-action profile floors, fail-open Message-layer storage diagnostics, stable workflow-then-global consume order after routing and before Symfony authentication failure responses, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, authenticated-user multipliers for ordinary navigation/read buckets, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, active-profile scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 without exposing raw IPs, API secrets, usernames, or email addresses, 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, unsafe-only public auth workflow intents, the exact recovery-login bypass path, 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\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 single-action profile floors, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary exclusions, authentication-failure workflow/API bucket charging through Security events, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, 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, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, active-profile scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 9fa1451e..36a11f19 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -95,7 +95,8 @@ - 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. -- Verification: focused syntax checks, focused PHPUnit coverage for request classification, rate-limit enforcer/reset/controller behavior, render-route cron handling, and content route guards; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1447 tests and 9544 assertions. +- 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 8a040a44..99dfbd98 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -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. @@ -94,24 +94,24 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | 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 recovery-login requests 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 | +| 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 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 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 | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger per hour | Pre-auth scheduler endpoint interval subject | 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 07fb266c..09b224f6 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -56,7 +56,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 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. - 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 keys come only from the shared subject/client-identity resolver. Raw request headers, API-key material, usernames, email addresses, and other user-submitted identifiers must never become keys directly; workflow account subjects are normalized and HMAC-redacted before they can be used for login, registration, or password-reset 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. - 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`. @@ -65,17 +65,17 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. -- The API integration should choose the smallest reviewable ordering that preserves the policy: valid CORS preflights stay cheap, ordinary API reads/writes are charged before authorization failures where practical, and existing API availability/error boundaries remain stable. +- 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. ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- Unsafe login submissions consume login and global website budget before authentication failure responses can return; safe login, registration, and password-reset form renders do not spend workflow-specific buckets. Successful login resets only the login-attempt bucket for that subject and active rate profile. +- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail; safe login, registration, and password-reset form renders do not spend workflow-specific buckets. Successful login resets only the login-attempt bucket for that subject and 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. @@ -88,6 +88,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 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, 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. diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index 21f07fa7..dbc876d4 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -6,6 +6,7 @@ use App\Api\Http\ApiRequestContext; use App\Core\Statistics\VisitorIdGenerator; +use App\Core\Validation\EmailAddress; use App\Entity\UserAccount; use App\Security\AccessLevelAwareUserInterface; use Symfony\Component\HttpFoundation\Request; @@ -62,6 +63,11 @@ public function resolve(Request $request): AbuseSubjectResolution } } + $submittedAccount = $this->submittedAccount($request); + if ($submittedAccount instanceof AbuseSubject) { + $subjects[] = $submittedAccount; + } + return new AbuseSubjectResolution($subjects); } @@ -91,6 +97,43 @@ private function submittedApiKeyPrefix(Request $request): ?string return 1 === preg_match('/^[A-Za-z0-9_-]{4,16}$/', $prefix) ? $prefix : null; } + private function submittedAccount(Request $request): ?AbuseSubject + { + $path = rtrim($request->getPathInfo(), '/') ?: '/'; + + if ('/user/login' === $path) { + return $this->submittedAccountSubject('login', $request->request->get('username')); + } + + if ('/user/register' === $path) { + return $this->submittedAccountSubject('registration_email', $request->request->get('email'), email: true); + } + + if ('/user/reset-password' === $path) { + return $this->submittedAccountSubject('password_reset_email', $request->request->get('email'), email: true); + } + + return 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..d2e6dde5 100644 --- a/src/Security/Abuse/AbuseSubjectType.php +++ b/src/Security/Abuse/AbuseSubjectType.php @@ -11,5 +11,6 @@ enum AbuseSubjectType: string case User = 'user'; case ApiKey = 'api_key'; case ApiKeyPrefix = 'api_key_prefix'; + case SubmittedAccount = 'submitted_account'; case Combined = 'combined'; } diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 02248215..f916c501 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -93,6 +93,11 @@ private function intent( return RequestIntent::SetupApply; } + $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); } @@ -125,9 +130,9 @@ private function adminMutationIntent(array $segments, string $route): RequestInt 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, @@ -135,6 +140,15 @@ 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 isPrefetch(Request $request): bool { foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { diff --git a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php index 88b517a2..3b0b304a 100644 --- a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php +++ b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php @@ -5,12 +5,17 @@ namespace App\Security\RateLimit; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; final readonly class RateLimitAuthenticationSubscriber implements EventSubscriberInterface { - public function __construct(private RateLimitResetService $resets) - { + public function __construct( + private RateLimitResetService $resets, + private RateLimitEnforcer $enforcer, + private RateLimitResponseRenderer $responses, + private string $environment, + ) { } /** @@ -19,6 +24,7 @@ public function __construct(private RateLimitResetService $resets) public static function getSubscribedEvents(): array { return [ + LoginFailureEvent::class => 'onLoginFailure', LoginSuccessEvent::class => 'onLoginSuccess', ]; } @@ -27,4 +33,24 @@ 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/RateLimitEnforcementStage.php b/src/Security/RateLimit/RateLimitEnforcementStage.php new file mode 100644 index 00000000..cb865a30 --- /dev/null +++ b/src/Security/RateLimit/RateLimitEnforcementStage.php @@ -0,0 +1,39 @@ + true, + self::SuspiciousProbe => false, + self::AuthenticationFailure => in_array($cost->bucketFamily(), [ + 'login', + 'recovery_login', + 'api_read', + 'api_public_read', + 'api_write', + ], true), + self::Ordinary => !in_array($cost->bucketFamily(), [ + 'login', + 'recovery_login', + ], true), + }; + } + + public function consumesWebsiteFamily(): bool + { + return self::AuthenticationFailure !== $this; + } +} diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 36520894..6a285e79 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -30,7 +30,7 @@ public function __construct( ) { } - public function check(Request $request): RateLimitCheckResult + public function check(Request $request, RateLimitEnforcementStage $stage = RateLimitEnforcementStage::All): RateLimitCheckResult { $inspection = $this->inspector->inspect($request); $profile = $inspection['profile']; @@ -39,14 +39,18 @@ public function check(Request $request): RateLimitCheckResult $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($profile, $subjectResolution, $cost)) { + if (!$stage->handlesCost($cost) || !$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($profile, $subjectResolution, $cost)) { return RateLimitCheckResult::allow(); } - return $this->consume($profile, $subjectResolution, $cost, $mode); + return $this->consume($profile, $subjectResolution, $cost, $mode, $stage); } private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): RateLimitCheckResult @@ -55,15 +59,15 @@ private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubject return RateLimitCheckResult::blockSuspiciousProbe(); } - $result = $this->consume($profile, $subjects, $cost, $mode); + $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): RateLimitCheckResult + private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): RateLimitCheckResult { try { - foreach ($this->descriptors($profile, $subjects, $cost, $mode) as $descriptor) { + 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) { @@ -85,11 +89,11 @@ private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $s /** * @return list */ - private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): array + private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array { $families = [$this->bucketFamily($cost, $subjects)]; - if ($this->shouldConsumeWebsiteFamily($profile, $families[0])) { + if ($stage->consumesWebsiteFamily() && $this->shouldConsumeWebsiteFamily($profile, $families[0])) { $families[] = 'website'; } diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index dbdcf2a1..7e2d95a6 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -17,17 +17,31 @@ public function __construct( ) { } - /** - * @return array - */ public static function getSubscribedEvents(): array { return [ - KernelEvents::REQUEST => ['onKernelRequest', 12], + KernelEvents::REQUEST => [ + ['onKernelRequestProbe', 12], + ['onKernelRequestOrdinary', 3], + ], ]; } - public function onKernelRequest(RequestEvent $event): void + public function onKernelRequestProbe(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { + return; + } + + $this->apply($event, RateLimitEnforcementStage::SuspiciousProbe); + } + + public function onKernelRequestOrdinary(RequestEvent $event): void { if (!$event->isMainRequest() || $event->hasResponse()) { return; @@ -38,7 +52,13 @@ public function onKernelRequest(RequestEvent $event): void return; } - $result = $this->enforcer->check($request); + $this->apply($event, RateLimitEnforcementStage::Ordinary); + } + + private function apply(RequestEvent $event, RateLimitEnforcementStage $stage): void + { + $request = $event->getRequest(); + $result = $this->enforcer->check($request, $stage); if ($result->isAllowed()) { return; } diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php index fe356ebb..bc239408 100644 --- a/src/Security/RateLimit/RateLimitSubjectSelector.php +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -16,6 +16,17 @@ */ 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([ + $submittedAccount, + $subjects->first(AbuseSubjectType::Visitor), + $subjects->first(AbuseSubjectType::IpBucket), + ])); + } + } + $primary = $this->primarySubject($descriptor, $subjects); if (!$primary instanceof AbuseSubject) { return []; @@ -70,9 +81,9 @@ private function preferredTypes(RateLimitBucketDescriptor $descriptor): array if (str_starts_with($descriptor->bucketFamily(), 'api_')) { return [ AbuseSubjectType::ApiKey, - AbuseSubjectType::ApiKeyPrefix, AbuseSubjectType::User, AbuseSubjectType::Visitor, + AbuseSubjectType::IpBucket, ]; } @@ -101,12 +112,39 @@ private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, Abuse 'captcha_failure', 'setup_apply', 'suspicious_probe', + 'api_read', + 'api_write', 'api_public_read', ], true); } + private function usesSubmittedAccountScope(RateLimitBucketDescriptor $descriptor): bool + { + return in_array($descriptor->bucketFamily(), [ + 'login', + 'recovery_login', + 'registration', + 'password_reset', + ], true); + } + 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/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index a11960d3..d1bcad9f 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -6,13 +6,20 @@ use App\Core\Config\Config; use App\Core\Config\ConfigValueType; +use App\Entity\ApiKey; +use App\Entity\UserAccount; +use App\Security\ApiKeyStatus; +use App\Security\ApiKeyVault; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; +use Doctrine\ORM\EntityManagerInterface; use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; final class RateLimitEnforcementControllerTest extends WebTestCase { + use AuthenticatedClientTrait; + public function testBrowserRateLimitRendersHtmlErrorWithSafeHeaders(): void { $client = self::createClient(server: $this->server('198.51.100.10')); @@ -70,6 +77,17 @@ public function testSuspiciousProbeReturnsGenericBadRequestEvenWhenModeIsOff(): 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 testPrefetchAndLiveApiPathsAreNotChargedToOrdinaryLimiter(): void { $client = self::createClient(server: $this->server('198.51.100.13')); @@ -117,22 +135,74 @@ public function testInvalidLoginSubmissionsSpendLoginBudgetBeforeAuthenticationR public function testInvalidBearerRequestsSpendApiBudgetBeforeAuthenticationResponse(): void { - $client = self::createClient(server: [ - ...$this->server('198.51.100.16'), - 'HTTP_AUTHORIZATION' => 'Bearer invalidprefix.invalid-secret', - ]); + $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'); + $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'); + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid31.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + 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 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 */ @@ -155,4 +225,53 @@ private function setMode(RateLimitProfile $profile): void 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): 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(), + ApiKeyStatus::ReadWrite, + ); + + $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/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index 44527aad..2343f300 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -101,4 +101,29 @@ public function testItKeepsInvalidBearerTokensToSafePrefixSubjects(): void self::assertSame('publicPrefix', $subject->identifier()); self::assertStringNotContainsString('secret-token-material', json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); } + + 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 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/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 1b075534..1e7ab97f 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -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, @@ -224,11 +234,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 index 6e3829cf..a39222d7 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -97,6 +97,44 @@ public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget() self::assertSame('security.rate.recovery_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 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 testOwnerIsExemptFromOrdinaryRateLimitRejection(): void { $tokenStorage = $this->tokenStorage(UserRole::Owner); @@ -162,6 +200,37 @@ public function testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void self::assertFalse($result->storageDegraded()); } + 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/apply', 'POST', [], 'security.rate.setup_apply', 2], + ['/admin/settings/security', 'POST', [], 'security.rate.admin_mutation', 8], + ['/admin/packages/upload', 'POST', [], 'security.rate.upload_archive', 6], + ['/admin/logs/download', 'GET', [], 'security.rate.download_diagnostics', 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(); @@ -181,14 +250,29 @@ private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = ); } - private function request(string $path, string $method = 'GET'): Request + /** + * @param array $parameters + * @param array $server + */ + private function request(string $path, string $method = 'GET', array $parameters = [], array $server = []): Request { - return Request::create($path, $method, server: [ - 'REMOTE_ADDR' => '203.0.113.9', - 'HTTP_USER_AGENT' => 'RateLimitEnforcerTest', + 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( From 7468b1c027192d421a51961372c6f0922b1f4f24 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 17:35:27 +0200 Subject: [PATCH 19/38] Harden rate limiter review edge cases --- config/services.yaml | 1 + dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 2 + .../security-hardening/policy-defaults.md | 4 +- .../security-hardening/rate-enforcement.md | 8 +-- .../RateLimit/RateLimitEnforcementStage.php | 3 + .../RateLimit/RateLimitLimiterFactory.php | 16 +++-- .../RateLimit/RateLimitResetService.php | 11 +-- .../RateLimitEnforcementControllerTest.php | 19 +++++ .../RateLimit/RateLimitLimiterFactoryTest.php | 70 +++++++++++++++++++ .../RateLimit/RateLimitResetServiceTest.php | 44 +++++++++++- 11 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 tests/Security/RateLimit/RateLimitLimiterFactoryTest.php diff --git a/config/services.yaml b/config/services.yaml index 12b45fb9..b9e17146 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -267,6 +267,7 @@ services: App\Security\RateLimit\RateLimitLimiterFactory: arguments: $cachePool: '@cache.rate_limiter' + $lockFactory: '@lock.factory' App\Security\RateLimit\RateLimitRequestSubscriber: arguments: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index b2cfbcc6..1c0b7789 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,7 @@ | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects without exposing raw IPs, API secrets, usernames, or email addresses, 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, unsafe-only public auth workflow intents, the exact recovery-login bypass path, 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\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 single-action profile floors, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary exclusions, authentication-failure workflow/API bucket charging through Security events, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, 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, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` responses with request references, active-profile scoped login-success reset, 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/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `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 single-action profile floors, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, 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, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 36a11f19..8f447b3c 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -97,6 +97,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 99dfbd98..280ab5b1 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -94,7 +94,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | Policy | Default | Subject | Success 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 | +| 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 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 plus Visitor ID and IP bucket | No automatic global reset | @@ -123,6 +123,8 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner 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. + ## 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 09b224f6..50e3166f 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -57,7 +57,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Recovery login bypass is the exact `/user/login?bypass=1` browser 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. - 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. Raw request headers, API-key material, usernames, email addresses, and other user-submitted identifiers must never become keys directly; workflow account subjects are normalized and HMAC-redacted before they can be used for login, registration, or password-reset 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. +- 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. - 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 profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. @@ -72,7 +72,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail; safe login, registration, and password-reset form renders do not spend workflow-specific buckets. Successful login resets only the login-attempt bucket for that subject and active rate profile. +- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail; 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. 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, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. @@ -94,7 +94,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. @@ -103,7 +103,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 diff --git a/src/Security/RateLimit/RateLimitEnforcementStage.php b/src/Security/RateLimit/RateLimitEnforcementStage.php index cb865a30..68e9a47f 100644 --- a/src/Security/RateLimit/RateLimitEnforcementStage.php +++ b/src/Security/RateLimit/RateLimitEnforcementStage.php @@ -24,6 +24,9 @@ public function handlesCost(ActionCost $cost): bool 'api_read', 'api_public_read', 'api_write', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', ], true), self::Ordinary => !in_array($cost->bucketFamily(), [ 'login', diff --git a/src/Security/RateLimit/RateLimitLimiterFactory.php b/src/Security/RateLimit/RateLimitLimiterFactory.php index 70451ac0..427bb0c6 100644 --- a/src/Security/RateLimit/RateLimitLimiterFactory.php +++ b/src/Security/RateLimit/RateLimitLimiterFactory.php @@ -5,6 +5,7 @@ namespace App\Security\RateLimit; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Lock\LockFactory; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\RateLimiter\Storage\CacheStorage; @@ -13,8 +14,10 @@ final class RateLimitLimiterFactory /** @var array */ private array $factories = []; - public function __construct(private readonly CacheItemPoolInterface $cachePool) - { + public function __construct( + private readonly CacheItemPoolInterface $cachePool, + private readonly ?LockFactory $lockFactory = null, + ) { } public function consume(RateLimitBucketDescriptor $descriptor, string $subjectKey, int $credits): \DateTimeImmutable|true @@ -38,10 +41,15 @@ private function factory(RateLimitBucketDescriptor $descriptor): RateLimiterFact ]); return $this->factories[$key] ??= new RateLimiterFactory([ - 'id' => 'system.rate.'.$descriptor->name(), + '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)); + ], new CacheStorage($this->cachePool), $this->lockFactory); } } diff --git a/src/Security/RateLimit/RateLimitResetService.php b/src/Security/RateLimit/RateLimitResetService.php index aa0799f4..b2be8f27 100644 --- a/src/Security/RateLimit/RateLimitResetService.php +++ b/src/Security/RateLimit/RateLimitResetService.php @@ -8,8 +8,6 @@ use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; use App\Security\Abuse\AbuseRequestInspector; -use App\Security\Abuse\AbuseSubject; -use App\Security\Abuse\AbuseSubjectType; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use Symfony\Component\HttpFoundation\Request; @@ -37,13 +35,8 @@ public function resetLoginAttempts(Request $request): bool $subjectResolution = $this->inspector->inspect($request)['subjects']; $reset = false; - foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::IpBucket] as $type) { - $subject = $subjectResolution->first($type); - if (!$subject instanceof AbuseSubject) { - continue; - } - - $reset = $this->reset($descriptor, $this->subjects->subjectKey($descriptor, $subject)) || $reset; + foreach ($this->subjects->subjectKeys($descriptor, $subjectResolution) as $subjectKey) { + $reset = $this->reset($descriptor, $subjectKey) || $reset; } return $reset; diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index d1bcad9f..28862554 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -171,6 +171,25 @@ public function testRotatingInvalidBearerPrefixesDoNotBypassApiWriteBudget(): vo 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 testValidOwnerApiKeyUsesPostAuthOwnerExemption(): void { $prefix = 'rlowner'; diff --git a/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php new file mode 100644 index 00000000..5f80a883 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php @@ -0,0 +1,70 @@ +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); + } +} + +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/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php index 343411de..71f5d211 100644 --- a/tests/Security/RateLimit/RateLimitResetServiceTest.php +++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php @@ -43,6 +43,41 @@ public function testLoginSuccessResetClearsVisitorAndIpLoginAttempts(): void 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()); @@ -136,11 +171,16 @@ private function inspector(): AbuseRequestInspector ); } - private function request(string $path, string $method): Request + /** + * @param array $parameters + * @param array $server + */ + private function request(string $path, string $method, array $parameters = [], array $server = []): Request { - return Request::create($path, $method, server: [ + return Request::create($path, $method, $parameters, server: [ 'REMOTE_ADDR' => '203.0.113.50', 'HTTP_USER_AGENT' => 'RateLimitResetServiceTest', + ...$server, ]); } From ceef826c4504be97a02769db2a28529933be31ec Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 20:49:22 +0200 Subject: [PATCH 20/38] Fix rate limiter bypass review findings --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 2 + .../security-hardening/policy-defaults.md | 8 +- .../security-hardening/rate-enforcement.md | 14 +-- src/Security/Abuse/AbuseSubjectResolver.php | 31 ++++++ src/Security/Abuse/AbuseSubjectType.php | 1 + .../Abuse/RequestIntentClassifier.php | 66 +++++++------ .../RateLimit/RateLimitEnforcementStage.php | 1 - src/Security/RateLimit/RateLimitEnforcer.php | 20 +++- .../RateLimit/RateLimitSubjectSelector.php | 12 +++ .../RateLimitEnforcementControllerTest.php | 90 ++++++++++++++++- .../Abuse/AbuseSubjectResolverTest.php | 17 ++++ .../Abuse/RequestIntentClassifierTest.php | 15 +++ .../RateLimit/RateLimitEnforcerTest.php | 99 ++++++++++++++++++- 14 files changed, 328 insertions(+), 52 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 1c0b7789..420cb6dd 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects without exposing raw IPs, API secrets, usernames, or email addresses, 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, unsafe-only public auth workflow intents, the exact recovery-login bypass path, 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\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 single-action profile floors, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy, 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, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, prefetch, scheduler, setup apply, unsafe-only public auth workflow intents, the exact recovery-login bypass path, 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\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 single-action profile floors, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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, 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8f447b3c..f869020b 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -99,6 +99,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 280ab5b1..aea9a2f6 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -106,7 +106,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | 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 | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger per hour | Pre-auth scheduler endpoint interval subject | No success reset | +| 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 | 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. @@ -115,11 +115,11 @@ Website global buckets count application/browser route handling, not static asse 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. -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. +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 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. 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, 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. Owner traffic may still record diagnostics and passive signals, but the request path must preserve Owner recovery and administrative operation access outside that explicit scheduler exception. +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. 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. @@ -149,7 +149,7 @@ Symfony limiter storage keys must be isolated by the active descriptor shape, in 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. +- 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. `OPTIONS` requests that carry an actual Bearer credential are authentication attempts; invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads. - 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 50e3166f..350f9490 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -52,11 +52,11 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. -- 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. +- 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, and mutating API requests made with a read-only Owner API key, which 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 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. - 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. Raw request headers, API-key material, usernames, email addresses, and other user-submitted identifiers must never become keys directly; workflow account subjects are normalized and HMAC-redacted before they can be used for login, registration, or password-reset buckets. +- 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. - 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`. @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` credential are authentication attempts, not anonymous browser preflights, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. - 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. @@ -72,7 +72,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail; 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. Successful login resets only the login-attempt bucket for the same subject keys, including HMAC-redacted submitted-account keys, and the active rate profile. +- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail; 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. 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, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. @@ -85,11 +85,11 @@ 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 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. - 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 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. - 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, dedicated recovery bucket exhaustion, retry-after behavior, and successful-login policy re-evaluation. +- 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. diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index dbc876d4..38e6851d 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -63,6 +63,11 @@ 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; @@ -97,6 +102,32 @@ 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 + { + $path = rtrim($request->getPathInfo(), '/') ?: '/'; + if ('/cron/run' !== $path) { + 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 { $path = rtrim($request->getPathInfo(), '/') ?: '/'; diff --git a/src/Security/Abuse/AbuseSubjectType.php b/src/Security/Abuse/AbuseSubjectType.php index d2e6dde5..7ae9d974 100644 --- a/src/Security/Abuse/AbuseSubjectType.php +++ b/src/Security/Abuse/AbuseSubjectType.php @@ -11,6 +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/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index f916c501..d008301b 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -36,9 +36,6 @@ public function classify(Request $request): AbuseRequestProfile ); } - /** - * @param list $segments - */ private function family(array $segments): RequestFamily { return match (true) { @@ -65,10 +62,6 @@ private function intent( return RequestIntent::SuspiciousProbe; } - if ('OPTIONS' === $method) { - return RequestIntent::CorsPreflight; - } - if (RequestFamily::Scheduler === $family) { return RequestIntent::SchedulerTrigger; } @@ -78,11 +71,23 @@ private function intent( } if (RequestFamily::Api === $family) { + if ('OPTIONS' === $method) { + if ($this->hasBearerAuthorization($request)) { + return $this->apiIntentForMethod($this->requestedPreflightMethod($request) ?? 'GET', $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 ('OPTIONS' === $method) { + return RequestIntent::CorsPreflight; } if ($prefetch && 'GET' === $method) { @@ -115,9 +120,6 @@ private function intent( }; } - /** - * @param list $segments - */ private function recoveryLogin(Request $request, array $segments, string $route): bool { return $this->matchesSegments($segments, 'user', 'login') @@ -149,6 +151,28 @@ private function adminReadIntent(array $segments, string $route): ?RequestIntent }; } + 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 hasBearerAuthorization(Request $request): bool + { + return 1 === preg_match('/^Bearer\s+.+$/i', (string) $request->headers->get('Authorization', '')); + } + + private function requestedPreflightMethod(Request $request): ?string + { + $method = $request->headers->get('Access-Control-Request-Method'); + + return is_string($method) && '' !== trim($method) ? strtoupper(trim($method)) : null; + } + private function isPrefetch(Request $request): bool { foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { @@ -188,9 +212,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( @@ -199,9 +220,6 @@ 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)); @@ -235,9 +253,6 @@ private function localePrefix(Request $request): ?string return null; } - /** - * @param list $pathSegments - */ private function matchesSegments(array $pathSegments, string ...$segments): bool { foreach ($segments as $index => $segment) { @@ -249,9 +264,6 @@ private function matchesSegments(array $pathSegments, string ...$segments): bool return [] !== $segments; } - /** - * @param list $pathSegments - */ private function hasSegment(array $pathSegments, string ...$segments): bool { foreach ($segments as $segment) { @@ -263,11 +275,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') @@ -275,9 +282,6 @@ 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/RateLimit/RateLimitEnforcementStage.php b/src/Security/RateLimit/RateLimitEnforcementStage.php index 68e9a47f..c3a97c39 100644 --- a/src/Security/RateLimit/RateLimitEnforcementStage.php +++ b/src/Security/RateLimit/RateLimitEnforcementStage.php @@ -30,7 +30,6 @@ public function handlesCost(ActionCost $cost): bool ], true), self::Ordinary => !in_array($cost->bucketFamily(), [ 'login', - 'recovery_login', ], true), }; } diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 6a285e79..0b411832 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -4,6 +4,7 @@ namespace App\Security\RateLimit; +use App\Api\Http\ApiRequestContext; use App\Core\Config\Config; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; @@ -14,6 +15,7 @@ use App\Security\Abuse\ActionCost; use App\Security\Abuse\RequestFamily; use App\Security\Abuse\RequestIntent; +use App\Security\ApiKeyStatus; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use Symfony\Component\HttpFoundation\Request; @@ -46,7 +48,7 @@ public function check(Request $request, RateLimitEnforcementStage $stage = RateL return $this->checkSuspiciousProbe($profile, $subjectResolution, $cost, $mode); } - if (!$stage->handlesCost($cost) || !$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($profile, $subjectResolution, $cost)) { + if (!$stage->handlesCost($cost) || !$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost)) { return RateLimitCheckResult::allow(); } @@ -135,15 +137,29 @@ private function retryAfterSeconds(RateLimitBucketDescriptor $descriptor, \DateT return null === $floor ? $seconds : max($seconds, $floor); } - private function isOwnerExempt(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost): bool + private function isOwnerExempt(Request $request, AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost): bool { if (RequestFamily::Scheduler === $profile->family() || 'scheduler' === $cost->bucketFamily()) { return false; } + if (RequestFamily::Api === $profile->family() && !$this->safeMethod($profile->method()) && $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 safeMethod(string $method): bool + { + return in_array(strtoupper($method), ['GET', 'HEAD', 'OPTIONS'], true); + } + private function reportDegradedConsume(AbuseRequestProfile $profile, RateLimitProfile $mode, \Throwable $exception): void { $context = [ diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php index bc239408..68c0dae8 100644 --- a/src/Security/RateLimit/RateLimitSubjectSelector.php +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -78,6 +78,14 @@ private function primarySubject(RateLimitBucketDescriptor $descriptor, AbuseSubj */ private function preferredTypes(RateLimitBucketDescriptor $descriptor): array { + if ('scheduler' === $descriptor->bucketFamily()) { + return [ + AbuseSubjectType::SchedulerCredential, + AbuseSubjectType::IpBucket, + AbuseSubjectType::Visitor, + ]; + } + if (str_starts_with($descriptor->bucketFamily(), 'api_')) { return [ AbuseSubjectType::ApiKey, @@ -115,6 +123,10 @@ private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, Abuse 'api_read', 'api_write', 'api_public_read', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', + 'scheduler', ], true); } diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index 28862554..cc9500f6 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -152,6 +152,40 @@ public function testInvalidBearerRequestsSpendApiBudgetBeforeAuthenticationRespo 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 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 testRotatingInvalidBearerPrefixesDoNotBypassApiWriteBudget(): void { $client = self::createClient(server: $this->server('198.51.100.18')); @@ -190,6 +224,58 @@ public function testInvalidBearerAdminMutationsSpendAuthFailureBudget(): void 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 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 testValidOwnerApiKeyUsesPostAuthOwnerExemption(): void { $prefix = 'rlowner'; @@ -256,7 +342,7 @@ private function adminUser(): UserAccount return $user; } - private function createOwnerApiKey(string $prefix): string + private function createOwnerApiKey(string $prefix, ApiKeyStatus $status = ApiKeyStatus::ReadWrite): string { $entityManager = self::getContainer()->get(EntityManagerInterface::class); self::assertInstanceOf(EntityManagerInterface::class, $entityManager); @@ -273,7 +359,7 @@ private function createOwnerApiKey(string $prefix): string $vault->hmac($plainKey), $vault->encrypt($plainKey, $prefix), $this->adminUser(), - ApiKeyStatus::ReadWrite, + $status, ); $entityManager->persist($apiKey); diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index 2343f300..f02085d9 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -102,6 +102,23 @@ public function testItKeepsInvalidBearerTokensToSafePrefixSubjects(): void 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 testItAddsRedactedSubmittedAccountSubjectsForAuthWorkflows(): void { $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 1e7ab97f..b86d6acf 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -139,6 +139,21 @@ public static function requestCases(): iterable RequestFamily::Api, RequestIntent::CorsPreflight, ]; + yield 'bearer options request is charged as api read' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid.token', + ]), + RequestFamily::Api, + RequestIntent::ApiRead, + ]; + yield 'bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid.token', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; yield 'turbo prefetch' => [ Request::create('/docs', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), RequestFamily::Browser, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index a39222d7..e953ae52 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -4,19 +4,23 @@ namespace App\Tests\Security\RateLimit; +use App\Api\Http\ApiRequestContext; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\Statistics\VisitorIdGenerator; +use App\Entity\ApiKey; use App\Entity\UserAccount; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubjectResolver; use App\Security\Abuse\ActionCostCatalogue; use App\Security\Abuse\RequestIntentClassifier; use App\Security\RateLimit\RateLimitEnforcer; +use App\Security\RateLimit\RateLimitEnforcementStage; use App\Security\RateLimit\RateLimitLimiterFactory; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; use App\Security\RateLimit\RateLimitSubjectSelector; +use App\Security\ApiKeyStatus; use App\Security\SecurityMessageCode; use App\Security\UserRole; use Doctrine\DBAL\Connection; @@ -88,10 +92,10 @@ public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget() { $enforcer = $this->enforcer(); - self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'))->isAllowed()); - self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'))->isAllowed()); + 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')); + $result = $enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary); self::assertFalse($result->isAllowed()); self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); @@ -187,6 +191,24 @@ public function testStrictSchedulerIntervalRejectsSecondRunWithinFifteenMinutes( 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 testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void { $config = new Config($this->connection()); @@ -200,6 +222,57 @@ public function testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void 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 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 testRepresentativeRequestPathsReachExpectedBuckets(): void { $cases = [ @@ -288,6 +361,26 @@ private function tokenStorage(UserRole $role): TokenStorage 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]); From cb206a4b45d3bf524f9ed20308d63fc2b6c37a1d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 21:06:14 +0200 Subject: [PATCH 21/38] Tighten rate limiter review hardening --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 4 ++ .../security-hardening/policy-defaults.md | 2 +- .../security-hardening/rate-enforcement.md | 4 +- .../Abuse/RequestIntentClassifier.php | 7 ++-- .../RateLimit/RateLimitRequestSubscriber.php | 15 ++++--- .../RateLimitEnforcementControllerTest.php | 16 ++++++++ .../Abuse/RequestIntentClassifierTest.php | 5 +++ .../RateLimit/RateLimitEnforcerTest.php | 33 +++++++++++++++ .../RateLimitRequestSubscriberTest.php | 41 +++++++++++++++++++ translations/languages/de/admin.yaml | 34 +++++++++++++++ translations/languages/en/admin.yaml | 34 +++++++++++++++ 12 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 tests/Security/RateLimit/RateLimitRequestSubscriberTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 420cb6dd..50371f7b 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, prefetch, scheduler, setup apply, unsafe-only public auth workflow intents, the exact recovery-login bypass path, 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\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 single-action profile floors, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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, 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, prefetch, scheduler, setup apply, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path 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\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 single-action profile floors, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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, 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f869020b..293af0d3 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -101,6 +101,10 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index aea9a2f6..8ca4f640 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -183,7 +183,7 @@ 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. +- 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 350f9490..04f55ea9 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -54,7 +54,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. - 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, and mutating API requests made with a read-only Owner API key, which 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 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. +- 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. 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. @@ -72,7 +72,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail; 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. 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. +- 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. 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, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index d008301b..3e9fac5a 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -107,7 +107,7 @@ private function intent( return $this->adminMutationIntent($segments, $route); } - if ($this->recoveryLogin($request, $segments, $route)) { + if ($this->recoveryLogin($request, $method, $segments, $route)) { return RequestIntent::RecoveryLogin; } @@ -120,9 +120,10 @@ private function intent( }; } - private function recoveryLogin(Request $request, array $segments, string $route): bool + private function recoveryLogin(Request $request, string $method, array $segments, string $route): bool { - return $this->matchesSegments($segments, 'user', 'login') + return 'GET' === $method + && $this->matchesSegments($segments, 'user', 'login') && $this->routeIs($route, 'user_login', 'n/a') && '1' === (string) $request->query->get('bypass', ''); } diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index 7e2d95a6..046f82d2 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -70,14 +70,19 @@ private function apply(RequestEvent $event, RateLimitEnforcementStage $stage): v private function excludedPath(string $path): bool { - return str_starts_with($path, '/api/live/') - || str_starts_with($path, '/assets/') - || str_starts_with($path, '/build/') - || str_starts_with($path, '/_profiler') - || str_starts_with($path, '/_wdt') + return $this->pathMatchesPrefix($path, '/api/live') + || $this->pathMatchesPrefix($path, '/assets') + || $this->pathMatchesPrefix($path, '/build') + || $this->pathMatchesPrefix($path, '/_profiler') + || $this->pathMatchesPrefix($path, '/_wdt') || in_array($path, ['/favicon.ico', '/robots.txt'], true); } + private function pathMatchesPrefix(string $path, string $prefix): bool + { + return $path === $prefix || str_starts_with($path, $prefix.'/'); + } + private function enabledForRequest(?string $testOptIn): bool { return 'test' !== $this->environment || '1' === $testOptIn; diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index cc9500f6..ec816651 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -186,6 +186,22 @@ public function testRecoveryLoginRendersSpendRecoveryBucket(): void 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')); diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index b86d6acf..c22e6087 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -199,6 +199,11 @@ public static function requestCases(): iterable 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, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index e953ae52..50e61d2b 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -101,6 +101,39 @@ public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget() 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(); diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php new file mode 100644 index 00000000..f675e30a --- /dev/null +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -0,0 +1,41 @@ + + */ + 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(); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedPath'); + + self::assertSame($excluded, $method->invoke($subscriber, $path)); + } +} diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 184fe475..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' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 1bf55e19..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' From 840d51179e15362235a0e552e32128963be45bf9 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 21:46:06 +0200 Subject: [PATCH 22/38] Harden rate limit probe and profile edges --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 3 + .../security-hardening/policy-defaults.md | 6 +- .../security-hardening/rate-enforcement.md | 8 +-- .../Abuse/RequestIntentClassifier.php | 8 +-- .../RateLimit/RateLimitPolicyCatalogue.php | 30 +++++++++- .../RateLimit/RateLimitRequestSubscriber.php | 2 +- .../RateLimitEnforcementControllerTest.php | 22 +++++++- .../Abuse/RequestIntentClassifierTest.php | 10 ++++ .../RateLimit/RateLimitEnforcerTest.php | 20 ++++++- .../RateLimitPolicyCatalogueTest.php | 56 +++++++++++++++++-- .../RateLimitRequestSubscriberTest.php | 11 ++++ .../RateLimit/RateLimitResetServiceTest.php | 1 + 13 files changed, 159 insertions(+), 22 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 50371f7b..b59806cc 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, prefetch, scheduler, setup apply, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path 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\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 single-action profile floors, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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, 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, scheduler, setup apply, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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, 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 293af0d3..5c329b69 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -74,6 +74,7 @@ - [ ] 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. @@ -105,6 +106,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 8ca4f640..874da3a2 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -81,7 +81,7 @@ These are first implementation defaults. Branches may adjust them only with test 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 at that single-action cost, so even the strongest profile still allows one legitimate request per configured window instead of degrading open. +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: @@ -113,7 +113,7 @@ Website global buckets count application/browser route handling, not static asse `/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 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 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. @@ -128,7 +128,7 @@ Symfony limiter storage keys must be isolated by the active descriptor shape, in ## 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. +- 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 04f55ea9..5600c615 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -42,14 +42,14 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 single-action credit floor so at least one legitimate request fits in every derived profile window. +- 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. - 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. +- 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. - 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, and mutating API requests made with a read-only Owner API key, which must spend the write/admin bucket before the read-only denial is returned. @@ -60,7 +60,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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 profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. +- Suspicious-probe handling runs before response-producing API availability, setup redirect, and maintenance gates. 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. @@ -84,7 +84,7 @@ 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 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. - 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, plus the explicit read-only Owner API key write-denial exception. diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 3e9fac5a..f7366204 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -90,10 +90,6 @@ private function intent( return RequestIntent::CorsPreflight; } - if ($prefetch && 'GET' === $method) { - return RequestIntent::TurboPrefetch; - } - if (RequestFamily::Setup === $family && !$this->safeMethod($method)) { return RequestIntent::SetupApply; } @@ -111,6 +107,10 @@ private function intent( return RequestIntent::RecoveryLogin; } + if ($prefetch && 'GET' === $method) { + return RequestIntent::TurboPrefetch; + } + return match (true) { !$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, diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php index c4efe419..af03b3c9 100644 --- a/src/Security/RateLimit/RateLimitPolicyCatalogue.php +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -10,6 +10,19 @@ { public const MODE_KEY = 'security.rate_limit.mode'; public const AUTHENTICATED_MULTIPLIER = 2; + private const MIN_ACTIONS_PER_DERIVED_PROFILE = 2; + private const SINGLE_ACTION_FLOOR_FAMILIES = [ + 'scheduler' => true, + 'suspicious_probe' => true, + ]; + private const WEBSITE_COMPANION_FAMILIES = [ + 'website_form', + 'registration', + 'password_reset', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', + ]; /** * @var array @@ -109,6 +122,7 @@ private function bucket( bool $resettable = false, ): RateLimitBucketDescriptor { $cost = $this->creditCostForFamily($family); + $minimumLimit = $this->minimumLimitForFamily($family, $cost); return new RateLimitBucketDescriptor( $name, @@ -119,7 +133,7 @@ private function bucket( $profileScalable, $retryAfterFloorSeconds, $resettable, - $cost, + $minimumLimit, ); } @@ -131,4 +145,18 @@ private function creditCostForFamily(string $family): int 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; + } } diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index 046f82d2..44b8ec3d 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -21,7 +21,7 @@ public static function getSubscribedEvents(): array { return [ KernelEvents::REQUEST => [ - ['onKernelRequestProbe', 12], + ['onKernelRequestProbe', 900], ['onKernelRequestOrdinary', 3], ], ]; diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index ec816651..2330696b 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Controller; +use App\Api\ApiFeaturePolicy; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Entity\ApiKey; @@ -25,7 +26,7 @@ public function testBrowserRateLimitRendersHtmlErrorWithSafeHeaders(): void $client = self::createClient(server: $this->server('198.51.100.10')); $this->setMode(RateLimitProfile::Panic); - for ($i = 0; $i < 7; ++$i) { + for ($i = 0; $i < 16; ++$i) { $client->request('GET', '/home'); self::assertNotSame(429, $client->getResponse()->getStatusCode()); } @@ -88,6 +89,25 @@ public function testLiveSuspiciousProbeIsBlockedBeforeLiveApiExclusion(): void 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')); diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index c22e6087..d9c04c8c 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -159,6 +159,16 @@ public static function requestCases(): iterable 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, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 50e61d2b..5a170677 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -306,6 +306,24 @@ public function testReadWriteOwnerApiKeyMutationsRemainOwnerExempt(): void } } + 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 testRepresentativeRequestPathsReachExpectedBuckets(): void { $cases = [ @@ -315,7 +333,7 @@ public function testRepresentativeRequestPathsReachExpectedBuckets(): void ['/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/apply', 'POST', [], 'security.rate.setup_apply', 2], + ['/setup/apply', 'POST', [], 'security.rate.setup_apply', 3], ['/admin/settings/security', 'POST', [], 'security.rate.admin_mutation', 8], ['/admin/packages/upload', 'POST', [], 'security.rate.upload_archive', 6], ['/admin/logs/download', 'GET', [], 'security.rate.download_diagnostics', 8], diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php index 9ed04e71..80ccd0ab 100644 --- a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -48,9 +48,9 @@ public function testStrictAndPanicProfilesDeriveFromStandardDescriptors(): void self::assertNotNull($panic); self::assertSame(30, $standard->limit()); self::assertSame(60, $standard->windowSeconds()); - self::assertSame(15, $strict->limit()); + self::assertSame(16, $strict->limit()); self::assertSame(90, $strict->windowSeconds()); - self::assertSame(7, $panic->limit()); + self::assertSame(16, $panic->limit()); self::assertSame(120, $panic->windowSeconds()); } @@ -91,13 +91,13 @@ public function testPolicyUsesActionCostsAsCreditMultipliers(): void self::assertNotNull($registration); self::assertSame(15, $registration->limit()); - self::assertSame(5, $registration->minimumLimit()); + self::assertSame(10, $registration->minimumLimit()); self::assertNotNull($apiWrite); self::assertSame(300, $apiWrite->limit()); - self::assertSame(5, $apiWrite->minimumLimit()); + self::assertSame(10, $apiWrite->minimumLimit()); } - public function testProfileScalingKeepsAtLeastOneCostedActionAvailable(): void + public function testProfileScalingKeepsMinimumCostedActionsAvailable(): void { $catalogue = new RateLimitPolicyCatalogue(); @@ -106,6 +106,52 @@ public function testProfileScalingKeepsAtLeastOneCostedActionAvailable(): void } } + 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(); diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index f675e30a..52eb4d9c 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; +use Symfony\Component\HttpKernel\KernelEvents; final class RateLimitRequestSubscriberTest extends TestCase { @@ -38,4 +39,14 @@ public function testExcludedPathUsesSegmentBoundaries(string $path, bool $exclud self::assertSame($excluded, $method->invoke($subscriber, $path)); } + + public function testProbePriorityRunsBeforeResponseProducingGates(): void + { + $events = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + + self::assertSame(['onKernelRequestProbe', 900], $events[0]); + self::assertGreaterThan(768, $events[0][1]); + self::assertGreaterThan(512, $events[0][1]); + self::assertGreaterThan(256, $events[0][1]); + } } diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php index 71f5d211..992d02c2 100644 --- a/tests/Security/RateLimit/RateLimitResetServiceTest.php +++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php @@ -118,6 +118,7 @@ public function testCaptchaResetUsesActiveProfileDescriptor(): void $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)); From 47e8d9fffaf8a5039b9ba130d9bccf5c328e4691 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 21:49:18 +0200 Subject: [PATCH 23/38] Document cache panic delivery direction --- dev/WORKLOG.md | 1 + dev/draft/0.4.x-FrontendDeliveryCaching.md | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 5c329b69..8f253fd1 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -108,6 +108,7 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). 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. From e6c266bafb3efb293decaac39a1654c2061b3ff8 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 22:16:22 +0200 Subject: [PATCH 24/38] Charge bearer CORS preflight attempts --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 2 ++ dev/draft/0.4.x-ApiLayer.md | 2 +- .../security-hardening/rate-enforcement.md | 2 +- src/Api/Security/ApiCorsSubscriber.php | 9 +++++ tests/Api/Security/ApiCorsSubscriberTest.php | 13 +++++++ .../RateLimitEnforcementControllerTest.php | 34 +++++++++++++++++++ 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index b59806cc..2af82915 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -61,7 +61,7 @@ | 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/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\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 authentication/rate-limit 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 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8f253fd1..cf7e7644 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -109,6 +109,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index 842771bd..dc35b6ab 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -181,7 +181,7 @@ The API branch should implement the platform foundation and the first content re - **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, so invalid Bearer credentials can still reach authentication failure handling and abuse/rate-limit accounting. - **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/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 5600c615..bf0292e8 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` credential are authentication attempts, not anonymous browser preflights, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. +- Valid anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` credential are authentication attempts, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. - 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. diff --git a/src/Api/Security/ApiCorsSubscriber.php b/src/Api/Security/ApiCorsSubscriber.php index 1367b077..f8703581 100644 --- a/src/Api/Security/ApiCorsSubscriber.php +++ b/src/Api/Security/ApiCorsSubscriber.php @@ -44,6 +44,10 @@ public function onKernelRequest(RequestEvent $event): void return; } + if ($this->hasActualAuthorizationHeader($request)) { + return; + } + $origin = $this->allowedOrigin($request); if (null === $origin) { return; @@ -85,6 +89,11 @@ private function isPreflight(Request $request): bool && is_string($request->headers->get('Access-Control-Request-Method')); } + private function hasActualAuthorizationHeader(Request $request): bool + { + return '' !== trim((string) $request->headers->get('Authorization', '')); + } + private function allowedOrigin(Request $request): ?string { if (!$this->apiFeaturePolicy->corsEnabled()) { diff --git a/tests/Api/Security/ApiCorsSubscriberTest.php b/tests/Api/Security/ApiCorsSubscriberTest.php index 050a39e7..1b272100 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' => 'Bearer invalid.token', + ])); + + $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/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index 2330696b..e54157eb 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -191,6 +191,40 @@ public function testInvalidBearerOptionsRequestsSpendApiBudgetBeforeAuthenticati 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 testRecoveryLoginRendersSpendRecoveryBucket(): void { $client = self::createClient(server: $this->server('198.51.100.23')); From 38e8c574f5fab4a853661f92d9a757c36c227b46 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 22:49:33 +0200 Subject: [PATCH 25/38] Charge read-only owner unsafe preflights --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 2 + dev/draft/0.4.x-ApiLayer.md | 4 +- .../security-hardening/policy-defaults.md | 2 +- .../security-hardening/rate-enforcement.md | 4 +- .../Security/ApiReadOnlyMethodSubscriber.php | 17 ++++++++- src/Security/RateLimit/RateLimitEnforcer.php | 17 ++++++++- .../ApiReadOnlyMethodSubscriberTest.php | 30 +++++++++++++++ .../RateLimitEnforcementControllerTest.php | 36 ++++++++++++++++++ .../RateLimit/RateLimitEnforcerTest.php | 37 +++++++++++++++++++ 10 files changed, 144 insertions(+), 9 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 2af82915..95e558b4 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -61,7 +61,7 @@ | 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/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 that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach authentication/rate-limit 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\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 authentication/rate-limit handling, request-scoped authenticated or anonymous API context, read-only method gating that evaluates Bearer preflights through `Access-Control-Request-Method`, 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 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` | @@ -200,7 +200,7 @@ | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, scheduler, setup apply, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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, 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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 Bearer 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index cf7e7644..be533ea1 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -111,6 +111,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index dc35b6ab..c534fa2b 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,7 +177,7 @@ 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. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 874da3a2..a45b9614 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -119,7 +119,7 @@ Scheduler trigger limits must support a normal once-per-minute external cron in 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, 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. 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. +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. Bearer `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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index bf0292e8..d83ab069 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -52,7 +52,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. -- 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, and mutating API requests made with a read-only Owner API key, which must spend the write/admin bucket before the read-only denial is returned. +- 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 Bearer `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. @@ -87,7 +87,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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, plus the explicit read-only Owner API key write-denial exception. +- 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 Bearer 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. diff --git a/src/Api/Security/ApiReadOnlyMethodSubscriber.php b/src/Api/Security/ApiReadOnlyMethodSubscriber.php index 65c69f71..ee147796 100644 --- a/src/Api/Security/ApiReadOnlyMethodSubscriber.php +++ b/src/Api/Security/ApiReadOnlyMethodSubscriber.php @@ -59,7 +59,22 @@ public function onKernelRequest(RequestEvent $event): void private function isAllowedReadOnlyMethod(Request $request): bool { - return in_array($request->getMethod(), [ + if (!$this->isSafeMethod($request->getMethod())) { + return false; + } + + if (!$request->isMethod(Request::METHOD_OPTIONS)) { + return true; + } + + $requestedMethod = $request->headers->get('Access-Control-Request-Method'); + + return !is_string($requestedMethod) || $this->isSafeMethod($requestedMethod); + } + + private function isSafeMethod(string $method): bool + { + return in_array(strtoupper($method), [ Request::METHOD_GET, Request::METHOD_HEAD, Request::METHOD_OPTIONS, diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 0b411832..38c76ab2 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -143,13 +143,28 @@ private function isOwnerExempt(Request $request, AbuseRequestProfile $profile, A return false; } - if (RequestFamily::Api === $profile->family() && !$this->safeMethod($profile->method()) && $this->readOnlyApiKey($request)) { + if (RequestFamily::Api === $profile->family() && $this->apiWriteAttempt($request, $profile) && $this->readOnlyApiKey($request)) { return false; } return $this->subjects->hasOwner($subjects); } + private function apiWriteAttempt(Request $request, AbuseRequestProfile $profile): bool + { + if (!$this->safeMethod($profile->method())) { + return true; + } + + if ('OPTIONS' !== strtoupper($profile->method())) { + return false; + } + + $requestedMethod = $request->headers->get('Access-Control-Request-Method'); + + return is_string($requestedMethod) && !$this->safeMethod($requestedMethod); + } + private function readOnlyApiKey(Request $request): bool { return ApiKeyStatus::ReadOnly === ApiRequestContext::fromRequest($request)?->apiKeyStatus(); 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/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index e54157eb..5145e83e 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -320,6 +320,42 @@ public function testReadOnlyOwnerApiKeyMutationsSpendApiWriteBudgetBeforeDenial( } } + 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: [ diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 5a170677..60306c64 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -293,6 +293,43 @@ public function testReadOnlyOwnerApiKeyMutationsAreNotOwnerExempt(): void 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 testReadWriteOwnerApiKeyMutationsRemainOwnerExempt(): void { $config = new Config($this->connection()); From 126d19e4bc88748ff14c2eabdb05f68ee0b55ce8 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 23:19:10 +0200 Subject: [PATCH 26/38] Close bearer preflight and scheduler gaps --- dev/CLASSMAP.md | 6 +-- dev/WORKLOG.md | 2 + .../security-hardening/policy-defaults.md | 6 +-- .../security-hardening/rate-enforcement.md | 6 +-- .../Abuse/RequestIntentClassifier.php | 4 +- .../RateLimit/RateLimitSubjectSelector.php | 4 ++ .../RateLimitEnforcementControllerTest.php | 48 +++++++++++++++++++ .../Abuse/RequestIntentClassifierTest.php | 16 +++++++ .../RateLimit/RateLimitEnforcerTest.php | 19 ++++++++ 9 files changed, 101 insertions(+), 10 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 95e558b4..51e8ae8e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -61,7 +61,7 @@ | 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/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 that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach authentication/rate-limit handling, request-scoped authenticated or anonymous API context, read-only method gating that evaluates Bearer preflights through `Access-Control-Request-Method`, 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\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, including malformed Bearer schemes, reach authentication/rate-limit handling, request-scoped authenticated or anonymous API context, read-only method gating that evaluates Bearer preflights through `Access-Control-Request-Method`, 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 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` | @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-bearing `OPTIONS` requests as API authentication attempts while preserving cheap anonymous CORS preflight classification, scheduler, setup apply, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including Bearer `OPTIONS`, 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 Bearer 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-scheme `OPTIONS` requests, including malformed or empty Bearer credentials, as API authentication attempts while preserving cheap anonymous CORS preflight classification, scheduler, setup apply, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed Bearer `OPTIONS`, 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 Bearer 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index be533ea1..4e9724a4 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -113,6 +113,8 @@ - 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. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index a45b9614..b805ee20 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -106,7 +106,7 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | 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 | 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 | No success reset | +| 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. @@ -115,7 +115,7 @@ Website global buckets count application/browser route handling, not static asse 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 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 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. +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 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. 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. @@ -149,7 +149,7 @@ Symfony limiter storage keys must be isolated by the active descriptor shape, in 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. `OPTIONS` requests that carry an actual Bearer credential are authentication attempts; invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads. +- 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. `OPTIONS` requests that carry an actual Bearer scheme, including malformed or empty credentials, are authentication attempts; invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads. - 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index d83ab069..feced962 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -51,7 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. +- 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. 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. - 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 Bearer `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. @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` credential are authentication attempts, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. +- Valid anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` scheme, including malformed or empty Bearer credentials, are authentication attempts, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. - 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. @@ -85,7 +85,7 @@ 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, 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. +- 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. - 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 Bearer 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. diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index f7366204..c16c0ad2 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -164,7 +164,9 @@ private function apiIntentForMethod(string $method, array $segments, string $rou private function hasBearerAuthorization(Request $request): bool { - return 1 === preg_match('/^Bearer\s+.+$/i', (string) $request->headers->get('Authorization', '')); + $authorization = $request->headers->get('Authorization'); + + return is_string($authorization) && 1 === preg_match('/^Bearer(?:\s+|$)/i', $authorization); } private function requestedPreflightMethod(Request $request): ?string diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php index 68c0dae8..9b645cd6 100644 --- a/src/Security/RateLimit/RateLimitSubjectSelector.php +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -106,6 +106,10 @@ private function preferredTypes(RateLimitBucketDescriptor $descriptor): array private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): bool { + if ('scheduler' === $descriptor->bucketFamily()) { + return true; + } + if ($subjects->first(AbuseSubjectType::User) instanceof AbuseSubject || $subjects->first(AbuseSubjectType::ApiKey) instanceof AbuseSubject) { return false; } diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index 5145e83e..65f61d96 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -225,6 +225,40 @@ public function testCorsBearerPreflightsSpendAuthFailureBudgetBeforeCorsShortCir } } + 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')); @@ -382,6 +416,20 @@ public function testSchedulerIntervalUsesStableBearerCredentialAcrossVisitorChan 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'; diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index d9c04c8c..5c3c814f 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -154,6 +154,22 @@ public static function requestCases(): iterable 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, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 60306c64..5f538054 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -242,6 +242,25 @@ public function testSchedulerIntervalUsesStableSubmittedCredentialAcrossVisitorC 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()); From 6aea1c23169f8de575c2fa8f42faf95dcb907c03 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 23:59:53 +0200 Subject: [PATCH 27/38] Harden probe setup and auth-failure limits --- config/services.yaml | 1 + dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 2 + .../security-hardening/policy-defaults.md | 2 +- .../security-hardening/rate-enforcement.md | 4 +- .../Abuse/RequestIntentClassifier.php | 10 +- src/Security/RateLimit/RateLimitEnforcer.php | 8 +- .../RateLimit/RateLimitRequestSubscriber.php | 33 +++++ .../Abuse/ActionCostCatalogueTest.php | 9 +- .../Abuse/RequestIntentClassifierTest.php | 11 +- .../RateLimit/RateLimitEnforcerTest.php | 47 +++++++- .../RateLimitRequestSubscriberTest.php | 114 ++++++++++++++++++ 12 files changed, 234 insertions(+), 11 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index b9e17146..1f5d5421 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -272,6 +272,7 @@ services: App\Security\RateLimit\RateLimitRequestSubscriber: arguments: $environment: '%kernel.environment%' + $projectDir: '%kernel.project_dir%' App\Security\RateLimit\RateLimitAuthenticationSubscriber: arguments: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 51e8ae8e..6fe1a21b 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,7 @@ | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-scheme `OPTIONS` requests, including malformed or empty Bearer credentials, as API authentication attempts while preserving cheap anonymous CORS preflight classification, scheduler, setup apply, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed Bearer `OPTIONS`, 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 Bearer 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed Bearer `OPTIONS`, 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 Bearer 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | @@ -324,7 +324,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 requests are skipped by ordinary rate-limit enforcement except static suspicious-probe matching 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_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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 4e9724a4..7935d73c 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -115,6 +115,8 @@ - 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/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index b805ee20..221df06e 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -148,7 +148,7 @@ Symfony limiter storage keys must be isolated by the active descriptor shape, in 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. +- 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. Only the final review-step apply submission (`POST /setup/review` with `_setup_action=apply`) is classified as the setup-apply intent once rate enforcement is allowed to run; 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 generic `400 no-store` response before setup completion. 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. `OPTIONS` requests that carry an actual Bearer scheme, including malformed or empty credentials, are authentication attempts; invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads. - 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index feced962..7ea63086 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -67,7 +67,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Valid anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` scheme, including malformed or empty Bearer credentials, are authentication attempts, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. - 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 setup wizard traffic; only static default probe-path matching may run, and setup-time probe responses must use a DB-free generic `400 no-store` response. ## Edge cases @@ -86,7 +86,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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, 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. +- 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. - 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 Bearer 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. diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index c16c0ad2..6285c137 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -91,7 +91,9 @@ private function intent( } 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; @@ -128,6 +130,12 @@ private function recoveryLogin(Request $request, string $method, array $segments && '1' === (string) $request->query->get('bypass', ''); } + private function setupApply(Request $request, array $segments): bool + { + return $this->matchesSegments($segments, 'setup', 'review') + && 'apply' === (string) $request->request->get('_setup_action', ''); + } + private function adminMutationIntent(array $segments, string $route): RequestIntent { return match (true) { diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 38c76ab2..595a4f32 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -48,7 +48,7 @@ public function check(Request $request, RateLimitEnforcementStage $stage = RateL return $this->checkSuspiciousProbe($profile, $subjectResolution, $cost, $mode); } - if (!$stage->handlesCost($cost) || !$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost)) { + if (!$stage->handlesCost($cost) || !$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost, $stage)) { return RateLimitCheckResult::allow(); } @@ -137,8 +137,12 @@ private function retryAfterSeconds(RateLimitBucketDescriptor $descriptor, \DateT return null === $floor ? $seconds : max($seconds, $floor); } - private function isOwnerExempt(Request $request, AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost): bool + 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; } diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index 44b8ec3d..be9643b7 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -4,17 +4,26 @@ namespace App\Security\RateLimit; +use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; final readonly class RateLimitRequestSubscriber implements EventSubscriberInterface { + private SuspiciousProbePathMatcher $probePathMatcher; + public function __construct( private RateLimitEnforcer $enforcer, private RateLimitResponseRenderer $responses, private string $environment, + private SetupCompletionMarker $setupCompletionMarker, + private string $projectDir, + ?SuspiciousProbePathMatcher $probePathMatcher = null, ) { + $this->probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); } public static function getSubscribedEvents(): array @@ -38,6 +47,16 @@ public function onKernelRequestProbe(RequestEvent $event): void return; } + if (!$this->probePathMatcher->isProbe($request->getPathInfo())) { + return; + } + + if (!$this->setupCompleted()) { + $event->setResponse($this->bareSuspiciousProbeResponse()); + + return; + } + $this->apply($event, RateLimitEnforcementStage::SuspiciousProbe); } @@ -52,9 +71,18 @@ public function onKernelRequestOrdinary(RequestEvent $event): void return; } + if (!$this->setupCompleted()) { + return; + } + $this->apply($event, RateLimitEnforcementStage::Ordinary); } + private function bareSuspiciousProbeResponse(): Response + { + return new Response('', Response::HTTP_BAD_REQUEST, ['Cache-Control' => 'no-store']); + } + private function apply(RequestEvent $event, RateLimitEnforcementStage $stage): void { $request = $event->getRequest(); @@ -87,4 +115,9 @@ 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/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php index bc5db491..5f004dae 100644 --- a/tests/Security/Abuse/ActionCostCatalogueTest.php +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -36,7 +36,12 @@ 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'))); + $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,6 +49,8 @@ public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic() self::assertSame(5, $apiWrite->credits()); self::assertSame('admin_mutation', $adminApiWrite->bucketFamily()); self::assertSame(8, $adminApiWrite->credits()); + self::assertSame('setup', $setupWizard->bucketFamily()); + self::assertSame(1, $setupWizard->credits()); self::assertSame('setup_apply', $setupApply->bucketFamily()); self::assertSame(8, $setupApply->credits()); } diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 5c3c814f..675aaf7b 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -190,8 +190,17 @@ public static function requestCases(): iterable RequestFamily::Scheduler, RequestIntent::SchedulerTrigger, ]; + 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, ]; diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 5f538054..25636af6 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -182,6 +182,24 @@ public function testOwnerIsExemptFromOrdinaryRateLimitRejection(): void } } + 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); @@ -380,6 +398,33 @@ public function testPanicAdminMutationConsumesWebsiteBucketWithoutStorageDegrada 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 = [ @@ -389,7 +434,7 @@ public function testRepresentativeRequestPathsReachExpectedBuckets(): void ['/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/apply', 'POST', [], 'security.rate.setup_apply', 3], + ['/setup/review', 'POST', ['_setup_action' => 'apply'], 'security.rate.setup_apply', 3], ['/admin/settings/security', 'POST', [], 'security.rate.admin_mutation', 8], ['/admin/packages/upload', 'POST', [], 'security.rate.upload_archive', 6], ['/admin/logs/download', 'GET', [], 'security.rate.download_diagnostics', 8], diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index 52eb4d9c..48d0dc81 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -4,14 +4,51 @@ namespace App\Tests\Security\RateLimit; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\RateLimit\RateLimitRequestSubscriber; +use App\Security\RateLimit\RateLimitEnforcer; +use App\Security\RateLimit\RateLimitResponseRenderer; +use App\Setup\SetupCompletionMarker; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; final class RateLimitRequestSubscriberTest extends TestCase { + private mixed $previousServerValue = null; + private mixed $previousEnvValue = null; + private mixed $previousPutenvValue = false; + + protected function setUp(): void + { + $this->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 */ @@ -49,4 +86,81 @@ public function testProbePriorityRunsBeforeResponseProducingGates(): void 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->subscriberWithUninitializedEnforcer(); + $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('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/review', 'POST', ['_setup_action' => 'apply']), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestOrdinary($event); + + self::assertFalse($event->hasResponse()); + } + + private function subscriberWithUninitializedEnforcer(): RateLimitRequestSubscriber + { + return new RateLimitRequestSubscriber( + (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(), + 'prod', + new SetupCompletionMarker(), + dirname(__DIR__, 3), + new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS), + ); + } +} + +final class RateLimitRequestSubscriberTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } } From 7f81f91b085710bdb6d46084b2a28044eaf929ff Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 00:17:05 +0200 Subject: [PATCH 28/38] Codify review readiness rules --- AGENTS.md | 18 +++++++++++++++++- dev/WORKLOG.md | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) 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/dev/WORKLOG.md b/dev/WORKLOG.md index 7935d73c..2ede5bb6 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -81,6 +81,10 @@ ## Branch Logs **Usage:** Keep concise session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Place new entries chronologically under the matching branch/date heading so reviewers can follow the PR context without reading full verification transcripts. Record meaningful committed or completed changes, decisions, blockers, and follow-ups; keep detailed verification in PR notes unless a result materially affects the worklog context. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. +### 2026-06-18 feat-security-rate-enforcement +- Added binding review-fix rules to `AGENTS.md`: review findings must be traced across adjacent and analogous boundaries before changes, fixed at the narrowest central boundary where practical, kept simple/modular/minimally invasive, checked for unreported neighboring edge cases, and covered with regression tests or documented reasoning for inspected-but-unchanged analogous paths. +- Added binding PR-readiness audit rules to `AGENTS.md`: readiness checklist items must be reviewed as evidence-backed audit passes over the branch diff and affected runtime surfaces, including security/privacy, entry points, sessions/secrets/storage, module boundaries, route/API/live scopes, setup/init/CI, cross-platform and disabled-feature behavior, process/env handling, default seeds, translations/copy, drift, documentation, and captured follow-ups. + ### 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. From 7333fa7265f87720e06cf47550715ff86257bb34 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 01:00:48 +0200 Subject: [PATCH 29/38] Harden setup rate limits and error resolution --- config/services.yaml | 2 + src/Backend/AdminControllerContext.php | 2 +- src/Controller/AdminAclGroupController.php | 4 +- src/Controller/AdminOperationController.php | 6 +- src/Controller/AdminPackageController.php | 10 +- src/Controller/AdminSchedulerController.php | 6 +- src/Controller/AdminUserController.php | 2 +- .../AdminUserInvitationController.php | 4 +- src/Controller/AdminUserReviewController.php | 4 +- src/Controller/BackendController.php | 14 +- .../Abuse/RequestIntentClassifier.php | 16 +- .../RateLimit/RateLimitRequestSubscriber.php | 28 ++-- .../RateLimit/RateLimitResponseRenderer.php | 16 +- src/View/Http/HttpErrorRenderer.php | 106 ++++++++++++- src/View/Http/HttpErrorSubscriber.php | 2 +- .../Abuse/ActionCostCatalogueTest.php | 4 + .../Abuse/RequestIntentClassifierTest.php | 17 +++ .../RateLimit/RateLimitEnforcerTest.php | 13 ++ .../RateLimitRequestSubscriberTest.php | 144 +++++++++++++++++- tests/View/Http/HttpErrorRendererTest.php | 125 +++++++++++++++ 20 files changed, 478 insertions(+), 47 deletions(-) create mode 100644 tests/View/Http/HttpErrorRendererTest.php diff --git a/config/services.yaml b/config/services.yaml index 1f5d5421..44dc979e 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -570,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/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/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/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 6285c137..d12a0b1d 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -63,7 +63,9 @@ private function intent( } if (RequestFamily::Scheduler === $family) { - return RequestIntent::SchedulerTrigger; + return $this->schedulerTrigger($segments) + ? RequestIntent::SchedulerTrigger + : RequestIntent::BrowserNavigation; } if (RequestFamily::LiveApi === $family) { @@ -132,10 +134,15 @@ private function recoveryLogin(Request $request, string $method, array $segments private function setupApply(Request $request, array $segments): bool { - return $this->matchesSegments($segments, 'setup', 'review') + return $this->matchesExactSegments($segments, 'setup', 'review') && 'apply' === (string) $request->request->get('_setup_action', ''); } + private function schedulerTrigger(array $segments): bool + { + return $this->matchesExactSegments($segments, 'cron', 'run'); + } + private function adminMutationIntent(array $segments, string $route): RequestIntent { return match (true) { @@ -275,6 +282,11 @@ private function matchesSegments(array $pathSegments, string ...$segments): bool return [] !== $segments; } + 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) { diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index be9643b7..d5f6a896 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -7,6 +7,7 @@ use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -52,7 +53,7 @@ public function onKernelRequestProbe(RequestEvent $event): void } if (!$this->setupCompleted()) { - $event->setResponse($this->bareSuspiciousProbeResponse()); + $event->setResponse($this->responses->bare($request, Response::HTTP_BAD_REQUEST)); return; } @@ -71,19 +72,15 @@ public function onKernelRequestOrdinary(RequestEvent $event): void return; } - if (!$this->setupCompleted()) { + $setupCompleted = $this->setupCompleted(); + if (!$setupCompleted && !$this->setupApplyRequest($request)) { return; } - $this->apply($event, RateLimitEnforcementStage::Ordinary); - } - - private function bareSuspiciousProbeResponse(): Response - { - return new Response('', Response::HTTP_BAD_REQUEST, ['Cache-Control' => 'no-store']); + $this->apply($event, RateLimitEnforcementStage::Ordinary, bareResponse: !$setupCompleted); } - private function apply(RequestEvent $event, RateLimitEnforcementStage $stage): void + private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bool $bareResponse = false): void { $request = $event->getRequest(); $result = $this->enforcer->check($request, $stage); @@ -91,6 +88,12 @@ private function apply(RequestEvent $event, RateLimitEnforcementStage $stage): v 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)); @@ -111,6 +114,13 @@ private function pathMatchesPrefix(string $path, string $prefix): bool return $path === $prefix || str_starts_with($path, $prefix.'/'); } + private function setupApplyRequest(Request $request): bool + { + return 'POST' === strtoupper($request->getMethod()) + && '/setup/review' === $request->getPathInfo() + && 'apply' === (string) $request->request->get('_setup_action', ''); + } + private function enabledForRequest(?string $testOptIn): bool { return 'test' !== $this->environment || '1' === $testOptIn; diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php index 76b6fa46..32115b22 100644 --- a/src/Security/RateLimit/RateLimitResponseRenderer.php +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -26,7 +26,7 @@ public function tooManyRequests(Request $request, RateLimitCheckResult $result): { $response = $this->jsonSurface($request) ? $this->apiResponse($request, Response::HTTP_TOO_MANY_REQUESTS) - : $this->httpError->render(Response::HTTP_TOO_MANY_REQUESTS, $request, context: $this->context($request)); + : $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()); @@ -39,11 +39,23 @@ public function suspiciousProbe(Request $request): Response { $response = $this->jsonSurface($request) ? $this->apiResponse($request, Response::HTTP_BAD_REQUEST) - : $this->httpError->render(Response::HTTP_BAD_REQUEST, $request, context: $this->context($request)); + : $this->httpError->resolve(Response::HTTP_BAD_REQUEST, $request, context: $this->context($request)); return $this->noStore($response); } + 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 diff --git a/src/View/Http/HttpErrorRenderer.php b/src/View/Http/HttpErrorRenderer.php index b7ec4762..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()) { @@ -124,6 +143,83 @@ private function renderTemplate(string $template, array $variables, int $statusC 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]); + } + /** * @param array $context * 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/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php index 5f004dae..73a49d5a 100644 --- a/tests/Security/Abuse/ActionCostCatalogueTest.php +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -36,6 +36,8 @@ 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'))); + $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', ]))); @@ -49,6 +51,8 @@ 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()); diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 675aaf7b..09b8c018 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -190,6 +190,16 @@ public static function requestCases(): iterable RequestFamily::Scheduler, RequestIntent::SchedulerTrigger, ]; + 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', @@ -204,6 +214,13 @@ public static function requestCases(): iterable 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, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 25636af6..a557761a 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -228,6 +228,19 @@ public function testSchedulerRequestsAreNotOwnerExempt(): void 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('/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 testStrictSchedulerIntervalRejectsSecondRunWithinFifteenMinutes(): void { $config = new Config($this->connection()); diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index 48d0dc81..d2864ca2 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -4,19 +4,41 @@ namespace App\Tests\Security\RateLimit; +use App\Api\Http\ApiResponder; +use App\Content\Read\PublishedContentResolver; +use App\Content\Render\ContentFieldsetRenderer; +use App\Core\Config\Config; +use App\Core\Log\AccessRequestMetadata; +use App\Core\Message\Message; +use App\Core\Message\MessageReporterInterface; +use App\Core\Statistics\VisitorIdGenerator; +use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseSubjectResolver; +use App\Security\Abuse\ActionCostCatalogue; +use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\RateLimit\RateLimitRequestSubscriber; use App\Security\RateLimit\RateLimitEnforcer; +use App\Security\RateLimit\RateLimitLimiterFactory; +use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitResponseRenderer; +use App\Security\RateLimit\RateLimitSubjectSelector; use App\Setup\SetupCompletionMarker; +use App\View\Http\HttpErrorRenderer; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Twig\Environment; final class RateLimitRequestSubscriberTest extends TestCase { @@ -125,6 +147,8 @@ public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void self::assertTrue($event->hasResponse()); self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode()); + self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
         self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
     }
 
@@ -135,7 +159,7 @@ public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void
         $subscriber = $this->subscriberWithUninitializedEnforcer();
         $event = new RequestEvent(
             new RateLimitRequestSubscriberTestKernel(),
-            Request::create('/setup/review', 'POST', ['_setup_action' => 'apply']),
+            Request::create('/setup/database', 'POST', ['_setup_action' => 'test_database']),
             HttpKernelInterface::MAIN_REQUEST,
         );
 
@@ -144,17 +168,133 @@ public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void
         self::assertFalse($event->hasResponse());
     }
 
+    public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void
+    {
+        $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+        $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(),
-            (new ReflectionClass(RateLimitResponseRenderer::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
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', + ); + } +} From d5f42619240c27b42d4211ff8b062145757f4c1d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 01:00:55 +0200 Subject: [PATCH 30/38] Document rate limit and error renderer hardening --- dev/CLASSMAP.md | 8 ++++---- dev/WORKLOG.md | 3 +++ dev/draft/0.1.x-ErrorHandlingValidation.md | 3 ++- dev/draft/security-hardening/abuse-foundation.md | 6 +++--- dev/draft/security-hardening/policy-defaults.md | 4 ++-- dev/draft/security-hardening/rate-enforcement.md | 6 +++--- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 6fe1a21b..035e0da3 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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, Bearer-scheme `OPTIONS` requests, including malformed or empty Bearer credentials, as API authentication attempts while preserving cheap anonymous CORS preflight classification, scheduler, setup apply, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed Bearer `OPTIONS`, 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 Bearer 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, scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, Admin API mutations before generic API writes, Bearer-scheme `OPTIONS` requests, including malformed or empty Bearer credentials, as API authentication attempts 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed Bearer `OPTIONS`, 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 Bearer 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | @@ -324,7 +324,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 whose pre-completion requests are skipped by ordinary rate-limit enforcement except static suspicious-probe matching 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` | @@ -371,7 +371,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, with central `no-store` cache headers for rendered error pages. | `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 2ede5bb6..6921dca5 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,6 +84,9 @@ ### 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. ### 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. 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/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index e52ec5ec..f0c0bd0a 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 CORS preflight receive no ordinary enforcement cost in this branch; 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 221df06e..a4f03ba1 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -115,7 +115,7 @@ Website global buckets count application/browser route handling, not static asse 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 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 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. 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. +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. @@ -148,7 +148,7 @@ Symfony limiter storage keys must be isolated by the active descriptor shape, in 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, rate limiting must not touch Config, DBAL, subject resolution, limiter storage, or content-backed error rendering for ordinary setup wizard traffic. Only the final review-step apply submission (`POST /setup/review` with `_setup_action=apply`) is classified as the setup-apply intent once rate enforcement is allowed to run; 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 generic `400 no-store` response before setup completion. 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. +- 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 `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 an actual Bearer scheme, including malformed or empty credentials, are authentication attempts; invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads. - 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 7ea63086..0f75e0a3 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -51,7 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 `/cron/run`, not an abuse/security signal source for legitimate configured cron callers. 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. +- 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. - 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 Bearer `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. @@ -67,7 +67,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Valid anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` scheme, including malformed or empty Bearer credentials, are authentication attempts, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. - 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. Before `APP_SETUP_COMPLETED`, the rate-limit subscriber must not call Config, DBAL, subject resolution, limiter storage, or content-backed error rendering for setup wizard traffic; only static default probe-path matching may run, and setup-time probe responses must use a DB-free generic `400 no-store` response. +- 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 @@ -86,7 +86,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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, 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. +- 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 Bearer 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. From 15aa164db196544bca5d19e03d88059286c4099e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 01:33:25 +0200 Subject: [PATCH 31/38] Harden credentialed preflight rate checks --- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 2 + dev/draft/0.4.x-ApiLayer.md | 2 +- .../security-hardening/abuse-foundation.md | 2 +- .../security-hardening/policy-defaults.md | 4 +- .../security-hardening/rate-enforcement.md | 8 +- .../Abuse/RequestIntentClassifier.php | 6 +- .../RateLimit/RateLimitSubjectSelector.php | 2 +- tests/Api/Security/ApiCorsSubscriberTest.php | 2 +- .../Abuse/RequestIntentClassifierTest.php | 16 ++- .../RateLimit/RateLimitEnforcerTest.php | 113 ++++++++++++++++++ 11 files changed, 143 insertions(+), 20 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 035e0da3..4d9cbbd9 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -61,7 +61,7 @@ | 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/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 that keeps anonymous preflights cheap while letting actual `Authorization` preflights, including malformed Bearer schemes, reach authentication/rate-limit handling, request-scoped authenticated or anonymous API context, read-only method gating that evaluates Bearer preflights through `Access-Control-Request-Method`, 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\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, read-only method gating that evaluates Bearer preflights through `Access-Control-Request-Method`, 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 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` | @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, Admin API mutations before generic API writes, Bearer-scheme `OPTIONS` requests, including malformed or empty Bearer credentials, as API authentication attempts 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed Bearer `OPTIONS`, 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 Bearer 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 6921dca5..b3212ed8 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -87,6 +87,8 @@ - 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. ### 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. diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index c534fa2b..187bbce4 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -181,7 +181,7 @@ The API branch should implement the platform foundation and the first content re - **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 anonymous preflight responses; wildcard origins must be configured explicitly. `OPTIONS` requests with an actual `Authorization` header are not short-circuited by CORS, so invalid Bearer credentials can still reach authentication failure handling and abuse/rate-limit accounting. +- **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/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index f0c0bd0a..bb019cb9 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -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, exact setup review 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. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index a4f03ba1..b3c410bf 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -119,7 +119,7 @@ Scheduler trigger limits must support a normal once-per-minute external cron in 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, 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. Bearer `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. +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. @@ -149,7 +149,7 @@ Symfony limiter storage keys must be isolated by the active descriptor shape, in 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, 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 `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 an actual Bearer scheme, including malformed or empty credentials, are authentication attempts; invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads. +- 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. - 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 0f75e0a3..409f81cb 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -52,7 +52,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. -- 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 Bearer `OPTIONS` preflights whose `Access-Control-Request-Method` is unsafe, must spend the write/admin bucket before the read-only denial is returned. +- 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. @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry an actual `Authorization: Bearer ...` scheme, including malformed or empty Bearer credentials, are authentication attempts, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin authentication-failure bucket if the credential fails. +- 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. 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. @@ -72,7 +72,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- 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. 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. +- 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. 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, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. @@ -87,7 +87,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 Bearer preflights with unsafe requested methods. +- 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. diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index d12a0b1d..95fbdb28 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -74,7 +74,7 @@ private function intent( if (RequestFamily::Api === $family) { if ('OPTIONS' === $method) { - if ($this->hasBearerAuthorization($request)) { + if ($this->hasAuthorizationHeader($request)) { return $this->apiIntentForMethod($this->requestedPreflightMethod($request) ?? 'GET', $segments, $route); } @@ -177,11 +177,11 @@ private function apiIntentForMethod(string $method, array $segments, string $rou return in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) ? RequestIntent::ApiRead : RequestIntent::ApiWrite; } - private function hasBearerAuthorization(Request $request): bool + private function hasAuthorizationHeader(Request $request): bool { $authorization = $request->headers->get('Authorization'); - return is_string($authorization) && 1 === preg_match('/^Bearer(?:\s+|$)/i', $authorization); + return is_string($authorization) && '' !== trim($authorization); } private function requestedPreflightMethod(Request $request): ?string diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php index 9b645cd6..5e05da93 100644 --- a/src/Security/RateLimit/RateLimitSubjectSelector.php +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -20,9 +20,9 @@ public function subjectKeys(RateLimitBucketDescriptor $descriptor, AbuseSubjectR $submittedAccount = $subjects->first(AbuseSubjectType::SubmittedAccount); if ($submittedAccount instanceof AbuseSubject) { return $this->subjectKeysFor($descriptor, array_filter([ - $submittedAccount, $subjects->first(AbuseSubjectType::Visitor), $subjects->first(AbuseSubjectType::IpBucket), + $submittedAccount, ])); } } diff --git a/tests/Api/Security/ApiCorsSubscriberTest.php b/tests/Api/Security/ApiCorsSubscriberTest.php index 1b272100..c9361d18 100644 --- a/tests/Api/Security/ApiCorsSubscriberTest.php +++ b/tests/Api/Security/ApiCorsSubscriberTest.php @@ -52,7 +52,7 @@ public function testItDoesNotShortCircuitPreflightsWithActualAuthorizationHeader $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' => 'Bearer invalid.token', + 'HTTP_AUTHORIZATION' => 'Basic unrelated', ])); $this->subscriber(['https://client.example'])->onKernelRequest($event); diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 09b8c018..d166fd13 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -139,16 +139,24 @@ public static function requestCases(): iterable RequestFamily::Api, RequestIntent::CorsPreflight, ]; - yield 'bearer options request is charged as api read' => [ + yield 'authorization options request is charged as api read' => [ Request::create('/api/v1/content/items', 'OPTIONS', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer invalid.token', + 'HTTP_AUTHORIZATION' => 'Basic unrelated', ]), RequestFamily::Api, RequestIntent::ApiRead, ]; - yield 'bearer options request honors requested unsafe admin method' => [ + 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' => 'Bearer invalid.token', + 'HTTP_AUTHORIZATION' => 'Basic unrelated', 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', ]), RequestFamily::Api, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index a557761a..314357b0 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -154,6 +154,40 @@ public function testLoginAttemptsShareSubmittedAccountAcrossVisitors(): void 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(); @@ -172,6 +206,66 @@ public function testPasswordResetAttemptsShareSubmittedEmailAcrossVisitors(): vo 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 testOwnerIsExemptFromOrdinaryRateLimitRejection(): void { $tokenStorage = $this->tokenStorage(UserRole::Owner); @@ -380,6 +474,25 @@ public function testReadOnlyOwnerApiKeySafePreflightsRemainOwnerExempt(): void } } + 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()); From 2452b470dde81603254fb27b35edf294af54efad Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 02:07:37 +0200 Subject: [PATCH 32/38] Prevent partial rate limit bucket spends --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 2 + .../security-hardening/policy-defaults.md | 6 +- .../security-hardening/rate-enforcement.md | 5 +- src/Security/Abuse/AbuseSubjectResolver.php | 8 ++ src/Security/RateLimit/RateLimitEnforcer.php | 22 ++++- .../RateLimit/RateLimitLimiterFactory.php | 7 ++ .../RateLimit/RateLimitResponseRenderer.php | 7 +- .../Abuse/AbuseSubjectResolverTest.php | 17 ++++ .../RateLimit/RateLimitEnforcerTest.php | 94 ++++++++++++++++++- .../RateLimit/RateLimitLimiterFactoryTest.php | 15 +++ .../RateLimitResponseRendererTest.php | 35 +++++++ 12 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 tests/Security/RateLimit/RateLimitResponseRendererTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 4d9cbbd9..d418ca09 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,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 plus HMAC-redacted submitted-account workflow subjects and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, or email addresses, 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/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, 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\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, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations, fail-open Message-layer storage diagnostics, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.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 workflow 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 `/api/live/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, 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\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, 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, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, segment-bounded scheduler JSON response detection, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index b3212ed8..ec790975 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -89,6 +89,8 @@ - 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. ### 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. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index b3c410bf..5f60223c 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -96,8 +96,8 @@ The first Admin-facing rate setting is one Owner-gated Security setting with fou | --- | --- | --- | --- | | 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 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 plus Visitor ID and IP bucket | No automatic global reset | +| 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 | @@ -125,6 +125,8 @@ Limiter storage degradation is fail-open by policy. If limiter storage, locking, 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. + ## 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 409f81cb..7a2b6eb1 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -58,6 +58,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. 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. - 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 response-producing API availability, setup redirect, and maintenance gates. Profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. @@ -72,7 +73,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. -- 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. 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. +- 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, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. @@ -99,7 +100,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index 38e6851d..997c647d 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -140,10 +140,18 @@ private function submittedAccount(Request $request): ?AbuseSubject return $this->submittedAccountSubject('registration_email', $request->request->get('email'), email: true); } + if (1 === preg_match('#^/user/invitation/([a-f0-9]{64})$#i', $path, $matches)) { + return $this->submittedAccountSubject('registration_token', $matches[1]); + } + if ('/user/reset-password' === $path) { return $this->submittedAccountSubject('password_reset_email', $request->request->get('email'), email: true); } + if (1 === preg_match('#^/user/reset-password/([a-f0-9]{64})$#i', $path, $matches)) { + return $this->submittedAccountSubject('password_reset_token', $matches[1]); + } + return null; } diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 595a4f32..d0e86f5d 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -69,14 +69,28 @@ private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubject 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) { - $retryAfter = $this->limiters->consume($descriptor, $subjectKey, max(1, $cost->credits())); - if ($retryAfter instanceof \DateTimeImmutable) { - return RateLimitCheckResult::reject($this->retryAfterSeconds($descriptor, $retryAfter), $descriptor->diagnosticsLabel()); - } + $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) { diff --git a/src/Security/RateLimit/RateLimitLimiterFactory.php b/src/Security/RateLimit/RateLimitLimiterFactory.php index 427bb0c6..ab1efca6 100644 --- a/src/Security/RateLimit/RateLimitLimiterFactory.php +++ b/src/Security/RateLimit/RateLimitLimiterFactory.php @@ -27,6 +27,13 @@ public function consume(RateLimitBucketDescriptor $descriptor, string $subjectKe 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(); diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php index 32115b22..a59daa27 100644 --- a/src/Security/RateLimit/RateLimitResponseRenderer.php +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -88,6 +88,11 @@ private function noStore(Response $response): Response private function jsonSurface(Request $request): bool { return str_starts_with($request->getPathInfo(), '/api/v1') - || str_starts_with($request->getPathInfo(), '/cron'); + || $this->pathMatchesPrefix($request->getPathInfo(), '/cron'); + } + + private function pathMatchesPrefix(string $path, string $prefix): bool + { + return $path === $prefix || str_starts_with($path, $prefix.'/'); } } diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index f02085d9..856459bd 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -136,6 +136,23 @@ public function testItAddsRedactedSubmittedAccountSubjectsForAuthWorkflows(): vo 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); + + $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); + + self::assertNotNull($invitationSubject); + self::assertNotNull($resetSubject); + self::assertSame('registration_token', $invitationSubject->context()['scope']); + self::assertSame('password_reset_token', $resetSubject->context()['scope']); + self::assertStringNotContainsString($invitationToken, json_encode($invitationSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($resetToken, json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + } + public function testItDoesNotAddSubmittedAccountSubjectsForLookalikePaths(): void { $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 314357b0..ad49b660 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -206,6 +206,21 @@ public function testPasswordResetAttemptsShareSubmittedEmailAcrossVisitors(): vo 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(); @@ -266,6 +281,79 @@ public function testLocalRegistrationExhaustionDoesNotSpendSubmittedEmailBuckets 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); @@ -561,9 +649,9 @@ public function testRepresentativeRequestPathsReachExpectedBuckets(): void ['/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.admin_mutation', 8], - ['/admin/packages/upload', 'POST', [], 'security.rate.upload_archive', 6], - ['/admin/logs/download', 'GET', [], 'security.rate.download_diagnostics', 8], + ['/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]) { diff --git a/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php index 5f80a883..820a59b1 100644 --- a/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php +++ b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php @@ -50,6 +50,21 @@ public function testConsumeUsesConfiguredLockFactory(): void 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 diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php new file mode 100644 index 00000000..0e30558d --- /dev/null +++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php @@ -0,0 +1,35 @@ + + */ + 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(); + $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface'); + + self::assertSame($json, $method->invoke($renderer, Request::create($path))); + } +} From 6db53a523800822d21f1d8047fe76a8a2e703e49 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 02:15:54 +0200 Subject: [PATCH 33/38] Document rate limit consume tradeoff --- dev/WORKLOG.md | 1 + dev/draft/security-hardening/policy-defaults.md | 2 +- dev/draft/security-hardening/rate-enforcement.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ec790975..eef01317 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -91,6 +91,7 @@ - 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. ### 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. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 5f60223c..afaf66fe 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -125,7 +125,7 @@ Limiter storage degradation is fail-open by policy. If limiter storage, locking, 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. +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 diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 7a2b6eb1..e9f59c7c 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -58,7 +58,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. 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. +- 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 response-producing API availability, setup redirect, and maintenance gates. Profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. From 3ece8aec9d7b159d7fa95d4b951a9b4e8f7fbb56 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 02:33:25 +0200 Subject: [PATCH 34/38] Centralize API effective method policy --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + src/Api/Security/ApiCorsSubscriber.php | 29 ++----- .../Security/ApiReadOnlyMethodSubscriber.php | 31 ++----- src/Api/Security/ApiRequestMethodPolicy.php | 68 +++++++++++++++ .../Abuse/RequestIntentClassifier.php | 20 +---- src/Security/RateLimit/RateLimitEnforcer.php | 24 +----- .../Security/ApiRequestMethodPolicyTest.php | 86 +++++++++++++++++++ 8 files changed, 175 insertions(+), 86 deletions(-) create mode 100644 src/Api/Security/ApiRequestMethodPolicy.php create mode 100644 tests/Api/Security/ApiRequestMethodPolicyTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index d418ca09..85724cf7 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -61,7 +61,7 @@ | 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/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 that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, read-only method gating that evaluates Bearer preflights through `Access-Control-Request-Method`, 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index eef01317..23843cd4 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -92,6 +92,7 @@ - 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. ### 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. diff --git a/src/Api/Security/ApiCorsSubscriber.php b/src/Api/Security/ApiCorsSubscriber.php index f8703581..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,11 +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->hasActualAuthorizationHeader($request)) { + if ($this->methodPolicy->hasAuthorizationHeader($request)) { return; } @@ -65,7 +67,7 @@ public function onKernelResponse(ResponseEvent $event): void } $request = $event->getRequest(); - if (!$this->isApiRequest($request)) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } @@ -77,23 +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 hasActualAuthorizationHeader(Request $request): bool - { - return '' !== trim((string) $request->headers->get('Authorization', '')); - } - private function allowedOrigin(Request $request): ?string { if (!$this->apiFeaturePolicy->corsEnabled()) { diff --git a/src/Api/Security/ApiReadOnlyMethodSubscriber.php b/src/Api/Security/ApiReadOnlyMethodSubscriber.php index ee147796..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,27 +59,4 @@ public function onKernelRequest(RequestEvent $event): void )); } - private function isAllowedReadOnlyMethod(Request $request): bool - { - if (!$this->isSafeMethod($request->getMethod())) { - return false; - } - - if (!$request->isMethod(Request::METHOD_OPTIONS)) { - return true; - } - - $requestedMethod = $request->headers->get('Access-Control-Request-Method'); - - return !is_string($requestedMethod) || $this->isSafeMethod($requestedMethod); - } - - private function isSafeMethod(string $method): bool - { - return in_array(strtoupper($method), [ - 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..d06425dc --- /dev/null +++ b/src/Api/Security/ApiRequestMethodPolicy.php @@ -0,0 +1,68 @@ +pathMatchesPrefix($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; + } + + private function pathMatchesPrefix(string $path, string $prefix): bool + { + return $path === $prefix || str_starts_with($path, $prefix.'/'); + } +} diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 95fbdb28..51a74c26 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -4,6 +4,7 @@ namespace App\Security\Abuse; +use App\Api\Security\ApiRequestMethodPolicy; use App\Content\Routing\ContentRouteLocalization; use Symfony\Component\HttpFoundation\Request; @@ -12,6 +13,7 @@ public function __construct( private SuspiciousProbePathMatcher $probePathMatcher = new SuspiciousProbePathMatcher(), private ?ContentRouteLocalization $routeLocalization = null, + private ApiRequestMethodPolicy $apiMethods = new ApiRequestMethodPolicy(), ) { } @@ -74,8 +76,8 @@ private function intent( if (RequestFamily::Api === $family) { if ('OPTIONS' === $method) { - if ($this->hasAuthorizationHeader($request)) { - return $this->apiIntentForMethod($this->requestedPreflightMethod($request) ?? 'GET', $segments, $route); + if ($this->apiMethods->hasAuthorizationHeader($request)) { + return $this->apiIntentForMethod($this->apiMethods->effectiveMethod($request), $segments, $route); } return RequestIntent::CorsPreflight; @@ -177,20 +179,6 @@ private function apiIntentForMethod(string $method, array $segments, string $rou return in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) ? RequestIntent::ApiRead : RequestIntent::ApiWrite; } - private function hasAuthorizationHeader(Request $request): bool - { - $authorization = $request->headers->get('Authorization'); - - return is_string($authorization) && '' !== trim($authorization); - } - - private function requestedPreflightMethod(Request $request): ?string - { - $method = $request->headers->get('Access-Control-Request-Method'); - - return is_string($method) && '' !== trim($method) ? strtoupper(trim($method)) : null; - } - private function isPrefetch(Request $request): bool { foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index d0e86f5d..249062c1 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -5,6 +5,7 @@ namespace App\Security\RateLimit; use App\Api\Http\ApiRequestContext; +use App\Api\Security\ApiRequestMethodPolicy; use App\Core\Config\Config; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; @@ -29,6 +30,7 @@ public function __construct( private RateLimitSubjectSelector $subjects, private RateLimitLimiterFactory $limiters, private MessageReporterInterface $messages, + private ApiRequestMethodPolicy $apiMethods = new ApiRequestMethodPolicy(), ) { } @@ -161,38 +163,18 @@ private function isOwnerExempt(Request $request, AbuseRequestProfile $profile, A return false; } - if (RequestFamily::Api === $profile->family() && $this->apiWriteAttempt($request, $profile) && $this->readOnlyApiKey($request)) { + if (RequestFamily::Api === $profile->family() && !$this->apiMethods->isSafeEffectiveMethod($request) && $this->readOnlyApiKey($request)) { return false; } return $this->subjects->hasOwner($subjects); } - private function apiWriteAttempt(Request $request, AbuseRequestProfile $profile): bool - { - if (!$this->safeMethod($profile->method())) { - return true; - } - - if ('OPTIONS' !== strtoupper($profile->method())) { - return false; - } - - $requestedMethod = $request->headers->get('Access-Control-Request-Method'); - - return is_string($requestedMethod) && !$this->safeMethod($requestedMethod); - } - private function readOnlyApiKey(Request $request): bool { return ApiKeyStatus::ReadOnly === ApiRequestContext::fromRequest($request)?->apiKeyStatus(); } - private function safeMethod(string $method): bool - { - return in_array(strtoupper($method), ['GET', 'HEAD', 'OPTIONS'], true); - } - private function reportDegradedConsume(AbuseRequestProfile $profile, RateLimitProfile $mode, \Throwable $exception): void { $context = [ diff --git a/tests/Api/Security/ApiRequestMethodPolicyTest.php b/tests/Api/Security/ApiRequestMethodPolicyTest.php new file mode 100644 index 00000000..78bb33e5 --- /dev/null +++ b/tests/Api/Security/ApiRequestMethodPolicyTest.php @@ -0,0 +1,86 @@ + + */ + 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))); + } +} From bd64934d82097ea74574f1cca2d369e9430aa1be Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 02:37:10 +0200 Subject: [PATCH 35/38] Centralize path scope matching --- dev/CLASSMAP.md | 1 + dev/WORKLOG.md | 1 + src/Api/Security/ApiRequestMethodPolicy.php | 15 ++++--- src/Core/Routing/PathScopeMatcher.php | 33 +++++++++++++++ .../RateLimit/RateLimitRequestSubscriber.php | 15 +++---- .../RateLimit/RateLimitResponseRenderer.php | 13 +++--- tests/Core/Routing/PathScopeMatcherTest.php | 40 +++++++++++++++++++ .../RateLimitRequestSubscriberTest.php | 3 ++ .../RateLimitResponseRendererTest.php | 3 ++ 9 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 src/Core/Routing/PathScopeMatcher.php create mode 100644 tests/Core/Routing/PathScopeMatcherTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 85724cf7..f6af4bc3 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,6 +60,7 @@ | 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` | Shared segment-bound path-prefix matcher for technical route scopes so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, and toolbar paths cannot accidentally match lookalike public content paths. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php` | | Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | | API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | | API 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 23843cd4..df5ec031 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -93,6 +93,7 @@ - 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 it so `/api/v10`, `/cronjobs`, `/_wdtfoo`, and similar lookalikes do not inherit protected path behavior by raw prefix accident. ### 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. diff --git a/src/Api/Security/ApiRequestMethodPolicy.php b/src/Api/Security/ApiRequestMethodPolicy.php index d06425dc..dc30c2c7 100644 --- a/src/Api/Security/ApiRequestMethodPolicy.php +++ b/src/Api/Security/ApiRequestMethodPolicy.php @@ -4,13 +4,21 @@ namespace App\Api\Security; +use App\Core\Routing\PathScopeMatcher; use Symfony\Component\HttpFoundation\Request; final readonly class ApiRequestMethodPolicy { + private PathScopeMatcher $paths; + + public function __construct(?PathScopeMatcher $paths = null) + { + $this->paths = $paths ?? new PathScopeMatcher(); + } + public function isApiV1Request(Request $request): bool { - return $this->pathMatchesPrefix($request->getPathInfo(), '/api/v1'); + return $this->paths->matchesPrefix($request->getPathInfo(), '/api/v1'); } public function isCorsPreflight(Request $request): bool @@ -60,9 +68,4 @@ public function requestedPreflightMethod(Request $request): ?string return is_string($method) && '' !== trim($method) ? strtoupper(trim($method)) : null; } - - private function pathMatchesPrefix(string $path, string $prefix): bool - { - return $path === $prefix || str_starts_with($path, $prefix.'/'); - } } diff --git a/src/Core/Routing/PathScopeMatcher.php b/src/Core/Routing/PathScopeMatcher.php new file mode 100644 index 00000000..b6c6612c --- /dev/null +++ b/src/Core/Routing/PathScopeMatcher.php @@ -0,0 +1,33 @@ +normalizedPrefix($prefix); + + return $path === $prefix || str_starts_with($path, $prefix.'/'); + } + + public function matchesAnyPrefix(string $path, string ...$prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($this->matchesPrefix($path, $prefix)) { + return true; + } + } + + return false; + } + + private function normalizedPrefix(string $prefix): string + { + $prefix = '/'.trim($prefix, '/'); + + return '//' === $prefix ? '/' : $prefix; + } +} diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index d5f6a896..137a05ca 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -4,6 +4,7 @@ namespace App\Security\RateLimit; +use App\Core\Routing\PathScopeMatcher; use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -15,6 +16,7 @@ final readonly class RateLimitRequestSubscriber implements EventSubscriberInterface { private SuspiciousProbePathMatcher $probePathMatcher; + private PathScopeMatcher $paths; public function __construct( private RateLimitEnforcer $enforcer, @@ -23,8 +25,10 @@ public function __construct( private SetupCompletionMarker $setupCompletionMarker, private string $projectDir, ?SuspiciousProbePathMatcher $probePathMatcher = null, + ?PathScopeMatcher $paths = null, ) { $this->probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); + $this->paths = $paths ?? new PathScopeMatcher(); } public static function getSubscribedEvents(): array @@ -101,19 +105,10 @@ private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bo private function excludedPath(string $path): bool { - return $this->pathMatchesPrefix($path, '/api/live') - || $this->pathMatchesPrefix($path, '/assets') - || $this->pathMatchesPrefix($path, '/build') - || $this->pathMatchesPrefix($path, '/_profiler') - || $this->pathMatchesPrefix($path, '/_wdt') + return $this->paths->matchesAnyPrefix($path, '/api/live', '/assets', '/build', '/_profiler', '/_wdt') || in_array($path, ['/favicon.ico', '/robots.txt'], true); } - private function pathMatchesPrefix(string $path, string $prefix): bool - { - return $path === $prefix || str_starts_with($path, $prefix.'/'); - } - private function setupApplyRequest(Request $request): bool { return 'POST' === strtoupper($request->getMethod()) diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php index a59daa27..f0eeee46 100644 --- a/src/Security/RateLimit/RateLimitResponseRenderer.php +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -7,6 +7,7 @@ use App\Api\Http\ApiResponder; use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; +use App\Core\Routing\PathScopeMatcher; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use App\View\Http\HttpErrorRenderer; @@ -15,11 +16,15 @@ final readonly class RateLimitResponseRenderer { + private PathScopeMatcher $paths; + public function __construct( private HttpErrorRenderer $httpError, private ApiResponder $apiResponder, private AccessRequestMetadata $requestMetadata, + ?PathScopeMatcher $paths = null, ) { + $this->paths = $paths ?? new PathScopeMatcher(); } public function tooManyRequests(Request $request, RateLimitCheckResult $result): Response @@ -87,12 +92,6 @@ private function noStore(Response $response): Response private function jsonSurface(Request $request): bool { - return str_starts_with($request->getPathInfo(), '/api/v1') - || $this->pathMatchesPrefix($request->getPathInfo(), '/cron'); - } - - private function pathMatchesPrefix(string $path, string $prefix): bool - { - return $path === $prefix || str_starts_with($path, $prefix.'/'); + return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/v1', '/cron'); } } diff --git a/tests/Core/Routing/PathScopeMatcherTest.php b/tests/Core/Routing/PathScopeMatcherTest.php new file mode 100644 index 00000000..a2029de7 --- /dev/null +++ b/tests/Core/Routing/PathScopeMatcherTest.php @@ -0,0 +1,40 @@ + + */ + 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 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')); + } +} diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index d2864ca2..6edfe0e1 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -11,6 +11,7 @@ use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; +use App\Core\Routing\PathScopeMatcher; use App\Core\Statistics\VisitorIdGenerator; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubjectResolver; @@ -94,6 +95,8 @@ public static function excludedPathCases(): iterable 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, 'excludedPath'); self::assertSame($excluded, $method->invoke($subscriber, $path)); diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php index 0e30558d..ba757fc9 100644 --- a/tests/Security/RateLimit/RateLimitResponseRendererTest.php +++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Security\RateLimit; +use App\Core\Routing\PathScopeMatcher; use App\Security\RateLimit\RateLimitResponseRenderer; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -28,6 +29,8 @@ public static function jsonSurfaceCases(): iterable 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))); From 52b601911ca1c9a4d5e7e3fef8c748a00213ba46 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 02:45:17 +0200 Subject: [PATCH 36/38] Move rate limit bucket policy into descriptors --- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 4 +- src/Core/Routing/PathScopeMatcher.php | 29 ++++++-- src/Security/Abuse/AbuseSubjectResolver.php | 27 +++++++ .../RateLimit/RateLimitBucketDescriptor.php | 23 ++++++ .../RateLimit/RateLimitEnforcementStage.php | 23 ------ src/Security/RateLimit/RateLimitEnforcer.php | 24 +++++-- .../RateLimit/RateLimitPolicyCatalogue.php | 70 +++++++++++++++++++ .../RateLimit/RateLimitSubjectPolicy.php | 50 +++++++++++++ .../RateLimit/RateLimitSubjectSelector.php | 61 ++-------------- tests/Core/Routing/PathScopeMatcherTest.php | 11 ++- .../Abuse/AbuseSubjectResolverTest.php | 21 ++++++ .../RateLimitPolicyCatalogueTest.php | 56 +++++++++++++++ 13 files changed, 314 insertions(+), 91 deletions(-) create mode 100644 src/Security/RateLimit/RateLimitSubjectPolicy.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index f6af4bc3..ea21dbbc 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,7 +60,7 @@ | 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` | Shared segment-bound path-prefix matcher for technical route scopes so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, and toolbar paths cannot accidentally match lookalike public content paths. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher` | Shared segment-bound path-scope matcher for technical route scopes so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, and toolbar paths cannot accidentally match lookalike or localized public content paths. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php` | | Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | | API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | | API 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` | @@ -200,8 +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 plus HMAC-redacted submitted-account workflow subjects, account-token workflow 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 `/api/live/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, 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\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, 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, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, segment-bounded scheduler JSON response detection, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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 `/api/live/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, 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, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, segment-bounded scheduler JSON response detection, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index df5ec031..f5aa8c19 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -93,7 +93,9 @@ - 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 it so `/api/v10`, `/cronjobs`, `/_wdtfoo`, and similar lookalikes do not inherit protected path behavior by raw prefix accident. +- Added shared segment-bound `PathScopeMatcher` routing helper and moved API v1 detection plus rate-limit technical exclusions/JSON response-surface checks onto it 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. ### 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. diff --git a/src/Core/Routing/PathScopeMatcher.php b/src/Core/Routing/PathScopeMatcher.php index b6c6612c..f478728f 100644 --- a/src/Core/Routing/PathScopeMatcher.php +++ b/src/Core/Routing/PathScopeMatcher.php @@ -8,9 +8,12 @@ { public function matchesPrefix(string $path, string $prefix): bool { - $prefix = $this->normalizedPrefix($prefix); + $prefixSegments = $this->segments($prefix); + if ([] === $prefixSegments) { + return '/' === $path; + } - return $path === $prefix || str_starts_with($path, $prefix.'/'); + return $this->matchesSegments($path, ...$prefixSegments); } public function matchesAnyPrefix(string $path, string ...$prefixes): bool @@ -24,10 +27,26 @@ public function matchesAnyPrefix(string $path, string ...$prefixes): bool return false; } - private function normalizedPrefix(string $prefix): string + public function matchesSegments(string $path, string ...$segments): bool { - $prefix = '/'.trim($prefix, '/'); + $pathSegments = $this->segments($path); + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== trim($segment, '/')) { + return false; + } + } - return '//' === $prefix ? '/' : $prefix; + return [] !== $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/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index 997c647d..1b3903c7 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -131,6 +131,20 @@ private function schedulerCredentialSubject(string $token): ?AbuseSubject private function submittedAccount(Request $request): ?AbuseSubject { $path = rtrim($request->getPathInfo(), '/') ?: '/'; + $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 ('/user/login' === $path) { return $this->submittedAccountSubject('login', $request->request->get('username')); @@ -152,9 +166,22 @@ private function submittedAccount(Request $request): ?AbuseSubject return $this->submittedAccountSubject('password_reset_token', $matches[1]); } + if (1 === preg_match('#^/user/security-review/([a-f0-9]{64})$#i', $path, $matches)) { + return $this->submittedAccountSubject('security_review_token', $matches[1]); + } + 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); + } + private function submittedAccountSubject(string $scope, mixed $value, bool $email = false): ?AbuseSubject { if (!is_scalar($value)) { diff --git a/src/Security/RateLimit/RateLimitBucketDescriptor.php b/src/Security/RateLimit/RateLimitBucketDescriptor.php index 72af96c3..27ce1c32 100644 --- a/src/Security/RateLimit/RateLimitBucketDescriptor.php +++ b/src/Security/RateLimit/RateLimitBucketDescriptor.php @@ -6,6 +6,9 @@ final readonly class RateLimitBucketDescriptor { + /** + * @param list $stages + */ public function __construct( private string $name, private string $bucketFamily, @@ -16,6 +19,8 @@ public function __construct( private ?int $retryAfterFloorSeconds = null, private bool $resettable = false, private int $minimumLimit = 1, + private ?RateLimitSubjectPolicy $subjectPolicy = null, + private array $stages = [RateLimitEnforcementStage::All], ) { } @@ -59,6 +64,18 @@ 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) { @@ -77,6 +94,8 @@ public function scaled(RateLimitProfile $profile): self : max(1, (int) ceil($this->retryAfterFloorSeconds * $profile->retryAfterMultiplier())), $this->resettable, $this->minimumLimit, + $this->subjectPolicy, + $this->stages, ); } @@ -92,6 +111,8 @@ public function withWindowSeconds(int $windowSeconds): self $this->retryAfterFloorSeconds, $this->resettable, $this->minimumLimit, + $this->subjectPolicy, + $this->stages, ); } @@ -111,6 +132,8 @@ public function withCapacityMultiplier(int $multiplier): self $this->retryAfterFloorSeconds, $this->resettable, $this->minimumLimit * $multiplier, + $this->subjectPolicy, + $this->stages, ); } } diff --git a/src/Security/RateLimit/RateLimitEnforcementStage.php b/src/Security/RateLimit/RateLimitEnforcementStage.php index c3a97c39..fb9f1f73 100644 --- a/src/Security/RateLimit/RateLimitEnforcementStage.php +++ b/src/Security/RateLimit/RateLimitEnforcementStage.php @@ -4,8 +4,6 @@ namespace App\Security\RateLimit; -use App\Security\Abuse\ActionCost; - enum RateLimitEnforcementStage { case All; @@ -13,27 +11,6 @@ enum RateLimitEnforcementStage case AuthenticationFailure; case Ordinary; - public function handlesCost(ActionCost $cost): bool - { - return match ($this) { - self::All => true, - self::SuspiciousProbe => false, - self::AuthenticationFailure => in_array($cost->bucketFamily(), [ - 'login', - 'recovery_login', - 'api_read', - 'api_public_read', - 'api_write', - 'admin_mutation', - 'upload_archive', - 'download_diagnostics', - ], true), - self::Ordinary => !in_array($cost->bucketFamily(), [ - 'login', - ], true), - }; - } - public function consumesWebsiteFamily(): bool { return self::AuthenticationFailure !== $this; diff --git a/src/Security/RateLimit/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index 249062c1..d9a4ad6f 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -50,7 +50,7 @@ public function check(Request $request, RateLimitEnforcementStage $stage = RateL return $this->checkSuspiciousProbe($profile, $subjectResolution, $cost, $mode); } - if (!$stage->handlesCost($cost) || !$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost, $stage)) { + if (!$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost, $stage)) { return RateLimitCheckResult::allow(); } @@ -109,20 +109,36 @@ private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $s */ private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array { - $families = [$this->bucketFamily($cost, $subjects)]; + $primaryFamily = $this->bucketFamily($cost, $subjects); + $families = [$primaryFamily]; - if ($stage->consumesWebsiteFamily() && $this->shouldConsumeWebsiteFamily($profile, $families[0])) { + if ([] === $this->descriptorsForFamily($primaryFamily, $mode, $stage)) { + return []; + } + + if ($stage->consumesWebsiteFamily() && $this->shouldConsumeWebsiteFamily($profile, $primaryFamily)) { $families[] = 'website'; } $descriptors = []; foreach (array_values(array_unique($families)) as $family) { - array_push($descriptors, ...$this->catalogue->descriptorsForFamily($family, $mode)); + 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)) { diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php index af03b3c9..7b10e921 100644 --- a/src/Security/RateLimit/RateLimitPolicyCatalogue.php +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -4,6 +4,7 @@ namespace App\Security\RateLimit; +use App\Security\Abuse\AbuseSubjectType; use App\Security\Abuse\ActionCostCatalogue; final readonly class RateLimitPolicyCatalogue @@ -134,6 +135,8 @@ private function bucket( $retryAfterFloorSeconds, $resettable, $minimumLimit, + $this->subjectPolicyForFamily($family), + $this->stagesForFamily($family), ); } @@ -159,4 +162,71 @@ private function minimumLimitForFamily(string $family, int $cost): int 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/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 index 5e05da93..8f8dea71 100644 --- a/src/Security/RateLimit/RateLimitSubjectSelector.php +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -56,7 +56,7 @@ public function authenticatedMultiplier(RateLimitBucketDescriptor $descriptor, A return 1; } - return in_array($descriptor->bucketFamily(), ['website', 'api_read', 'api_public_read'], true) + return $descriptor->subjectPolicy()->authenticatedMultiplier() ? RateLimitPolicyCatalogue::AUTHENTICATED_MULTIPLIER : 1; } @@ -78,70 +78,23 @@ private function primarySubject(RateLimitBucketDescriptor $descriptor, AbuseSubj */ private function preferredTypes(RateLimitBucketDescriptor $descriptor): array { - if ('scheduler' === $descriptor->bucketFamily()) { - return [ - AbuseSubjectType::SchedulerCredential, - AbuseSubjectType::IpBucket, - AbuseSubjectType::Visitor, - ]; - } - - if (str_starts_with($descriptor->bucketFamily(), 'api_')) { - return [ - AbuseSubjectType::ApiKey, - AbuseSubjectType::User, - AbuseSubjectType::Visitor, - AbuseSubjectType::IpBucket, - ]; - } - - return [ - AbuseSubjectType::User, - AbuseSubjectType::Visitor, - AbuseSubjectType::ApiKey, - AbuseSubjectType::ApiKeyPrefix, - AbuseSubjectType::IpBucket, - ]; + return $descriptor->subjectPolicy()->preferredTypes(); } private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): bool { - if ('scheduler' === $descriptor->bucketFamily()) { - return true; - } - - if ($subjects->first(AbuseSubjectType::User) instanceof AbuseSubject || $subjects->first(AbuseSubjectType::ApiKey) instanceof AbuseSubject) { + $policy = $descriptor->subjectPolicy(); + if (!$policy->ipSecondary()) { return false; } - return in_array($descriptor->bucketFamily(), [ - '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', - 'scheduler', - ], true); + return $policy->ipSecondaryWithAuthenticatedSubject() + || (!$subjects->first(AbuseSubjectType::User) instanceof AbuseSubject && !$subjects->first(AbuseSubjectType::ApiKey) instanceof AbuseSubject); } private function usesSubmittedAccountScope(RateLimitBucketDescriptor $descriptor): bool { - return in_array($descriptor->bucketFamily(), [ - 'login', - 'recovery_login', - 'registration', - 'password_reset', - ], true); + return $descriptor->subjectPolicy()->submittedAccountScope(); } public function subjectKey(RateLimitBucketDescriptor $descriptor, AbuseSubject $subject): string diff --git a/tests/Core/Routing/PathScopeMatcherTest.php b/tests/Core/Routing/PathScopeMatcherTest.php index a2029de7..40361697 100644 --- a/tests/Core/Routing/PathScopeMatcherTest.php +++ b/tests/Core/Routing/PathScopeMatcherTest.php @@ -21,7 +21,7 @@ public static function prefixCases(): iterable 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 root' => ['/', '/', true]; + yield 'root matches only root' => ['/', '/', true]; } #[DataProvider('prefixCases')] @@ -37,4 +37,13 @@ public function testMatchesAnyPrefixUsesSameSegmentRules(): void 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')); + } } diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index 856459bd..3db3ec7f 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -141,16 +141,37 @@ public function testItAddsRedactedSubmittedTokenSubjectsForAccountTokenWorkflows $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 testItDoesNotAddSubmittedAccountSubjectsForLookalikePaths(): void diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php index 80ccd0ab..0111d196 100644 --- a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -4,6 +4,8 @@ namespace App\Tests\Security\RateLimit; +use App\Security\Abuse\AbuseSubjectType; +use App\Security\RateLimit\RateLimitEnforcementStage; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; use PHPUnit\Framework\TestCase; @@ -97,6 +99,60 @@ public function testPolicyUsesActionCostsAsCreditMultipliers(): void 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(); From 4ba4bd638c6e93c187081ae5c6ad9a46e9482513 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 02:52:24 +0200 Subject: [PATCH 37/38] Centralize localized request path resolution --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + src/Api/Security/ApiRequestMethodPolicy.php | 10 +- src/Core/Log/AccessRequestMetadata.php | 43 +------- src/Core/Routing/RequestPathResolver.php | 100 ++++++++++++++++++ src/Security/Abuse/AbuseSubjectResolver.php | 59 ++++++++--- .../Abuse/RequestIntentClassifier.php | 41 ++----- .../RateLimit/RateLimitRequestSubscriber.php | 18 ++-- .../RateLimit/RateLimitResponseRenderer.php | 10 +- .../Security/ApiRequestMethodPolicyTest.php | 9 ++ .../Core/Routing/RequestPathResolverTest.php | 70 ++++++++++++ .../Abuse/AbuseSubjectResolverTest.php | 13 +++ .../RateLimitRequestSubscriberTest.php | 23 +++- .../RateLimitResponseRendererTest.php | 17 ++- 14 files changed, 306 insertions(+), 110 deletions(-) create mode 100644 src/Core/Routing/RequestPathResolver.php create mode 100644 tests/Core/Routing/RequestPathResolverTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ea21dbbc..ede58aee 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,7 +60,7 @@ | 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` | Shared segment-bound path-scope matcher for technical route scopes so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, and toolbar paths cannot accidentally match lookalike or localized public content paths. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver` | Shared segment-bound path-scope matchers for technical route scopes and request-aware locale-prefix stripping 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 miss valid localized route contexts. | `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\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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f5aa8c19..8b36bf7e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -96,6 +96,7 @@ - Added shared segment-bound `PathScopeMatcher` routing helper and moved API v1 detection plus rate-limit technical exclusions/JSON response-surface checks onto it 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 locale-prefix stripping and moved API scope detection, rate-limit exclusions/JSON surfaces, access-log surface detection, request-intent classification, scheduler credential scoping, and submitted-account workflow subject detection onto the same exact path-part semantics. ### 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. diff --git a/src/Api/Security/ApiRequestMethodPolicy.php b/src/Api/Security/ApiRequestMethodPolicy.php index dc30c2c7..bec0aae0 100644 --- a/src/Api/Security/ApiRequestMethodPolicy.php +++ b/src/Api/Security/ApiRequestMethodPolicy.php @@ -4,21 +4,21 @@ namespace App\Api\Security; -use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use Symfony\Component\HttpFoundation\Request; final readonly class ApiRequestMethodPolicy { - private PathScopeMatcher $paths; + private RequestPathResolver $requestPaths; - public function __construct(?PathScopeMatcher $paths = null) + public function __construct(?RequestPathResolver $requestPaths = null) { - $this->paths = $paths ?? new PathScopeMatcher(); + $this->requestPaths = $requestPaths ?? new RequestPathResolver(); } public function isApiV1Request(Request $request): bool { - return $this->paths->matchesPrefix($request->getPathInfo(), '/api/v1'); + return $this->requestPaths->matches($request, 'api', 'v1'); } public function isCorsPreflight(Request $request): bool 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/Routing/RequestPathResolver.php b/src/Core/Routing/RequestPathResolver.php new file mode 100644 index 00000000..f49ea125 --- /dev/null +++ b/src/Core/Routing/RequestPathResolver.php @@ -0,0 +1,100 @@ + + */ + private const LOCALIZED_RESERVED_SEGMENTS = ['admin', 'api', 'cron', 'editor', 'setup', '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::LOCALIZED_RESERVED_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/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index 1b3903c7..d1222682 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -5,6 +5,7 @@ namespace App\Security\Abuse; use App\Api\Http\ApiRequestContext; +use App\Core\Routing\RequestPathResolver; use App\Core\Statistics\VisitorIdGenerator; use App\Core\Validation\EmailAddress; use App\Entity\UserAccount; @@ -15,12 +16,15 @@ final readonly class AbuseSubjectResolver { private const PLACEHOLDER = 'n/a'; + private RequestPathResolver $paths; public function __construct( private VisitorIdGenerator $visitorIdGenerator, private TokenStorageInterface $tokenStorage, private string $secret, + ?RequestPathResolver $paths = null, ) { + $this->paths = $paths ?? new RequestPathResolver(); } public function resolve(Request $request): AbuseSubjectResolution @@ -104,8 +108,7 @@ private function submittedApiKeyPrefix(Request $request): ?string private function submittedSchedulerCredential(Request $request): ?AbuseSubject { - $path = rtrim($request->getPathInfo(), '/') ?: '/'; - if ('/cron/run' !== $path) { + if (!$this->paths->matchesExact($request, 'cron', 'run')) { return null; } @@ -130,7 +133,7 @@ private function schedulerCredentialSubject(string $token): ?AbuseSubject private function submittedAccount(Request $request): ?AbuseSubject { - $path = rtrim($request->getPathInfo(), '/') ?: '/'; + $segments = $this->paths->segments($request); $route = $request->attributes->get('_route'); $token = $request->attributes->get('token'); @@ -146,28 +149,28 @@ private function submittedAccount(Request $request): ?AbuseSubject return $this->submittedTokenSubject('security_review_token', $token); } - if ('/user/login' === $path) { + if ($this->matchesExactSegments($segments, 'user', 'login')) { return $this->submittedAccountSubject('login', $request->request->get('username')); } - if ('/user/register' === $path) { + if ($this->matchesExactSegments($segments, 'user', 'register')) { return $this->submittedAccountSubject('registration_email', $request->request->get('email'), email: true); } - if (1 === preg_match('#^/user/invitation/([a-f0-9]{64})$#i', $path, $matches)) { - return $this->submittedAccountSubject('registration_token', $matches[1]); + if ($this->matchesSegments($segments, 'user', 'invitation') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('registration_token', $submittedToken); } - if ('/user/reset-password' === $path) { + if ($this->matchesExactSegments($segments, 'user', 'reset-password')) { return $this->submittedAccountSubject('password_reset_email', $request->request->get('email'), email: true); } - if (1 === preg_match('#^/user/reset-password/([a-f0-9]{64})$#i', $path, $matches)) { - return $this->submittedAccountSubject('password_reset_token', $matches[1]); + if ($this->matchesSegments($segments, 'user', 'reset-password') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('password_reset_token', $submittedToken); } - if (1 === preg_match('#^/user/security-review/([a-f0-9]{64})$#i', $path, $matches)) { - return $this->submittedAccountSubject('security_review_token', $matches[1]); + if ($this->matchesSegments($segments, 'user', 'security-review') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('security_review_token', $submittedToken); } return null; @@ -182,6 +185,38 @@ private function submittedTokenSubject(string $scope, mixed $token): ?AbuseSubje 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)) { diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 51a74c26..66dbb784 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -6,15 +6,20 @@ use App\Api\Security\ApiRequestMethodPolicy; use App\Content\Routing\ContentRouteLocalization; +use App\Core\Routing\RequestPathResolver; use Symfony\Component\HttpFoundation\Request; final readonly class RequestIntentClassifier { + private RequestPathResolver $paths; + public function __construct( private SuspiciousProbePathMatcher $probePathMatcher = new SuspiciousProbePathMatcher(), - private ?ContentRouteLocalization $routeLocalization = null, + ?ContentRouteLocalization $routeLocalization = null, private ApiRequestMethodPolicy $apiMethods = new ApiRequestMethodPolicy(), + ?RequestPathResolver $paths = null, ) { + $this->paths = $paths ?? new RequestPathResolver($routeLocalization); } public function classify(Request $request): AbuseRequestProfile @@ -228,35 +233,7 @@ private function routeTokens(string $route): 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); } private function matchesSegments(array $pathSegments, string ...$segments): bool @@ -293,8 +270,4 @@ private function apiAdminSegments(array $segments): array : $segments; } - private function hasLocalizedReservedPath(array $segments): bool - { - return in_array($segments[1] ?? '', ['admin', 'api', 'cron', 'editor', 'setup', 'user'], true); - } } diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index 137a05ca..f703e319 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -4,7 +4,7 @@ namespace App\Security\RateLimit; -use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -16,7 +16,7 @@ final readonly class RateLimitRequestSubscriber implements EventSubscriberInterface { private SuspiciousProbePathMatcher $probePathMatcher; - private PathScopeMatcher $paths; + private RequestPathResolver $paths; public function __construct( private RateLimitEnforcer $enforcer, @@ -25,10 +25,10 @@ public function __construct( private SetupCompletionMarker $setupCompletionMarker, private string $projectDir, ?SuspiciousProbePathMatcher $probePathMatcher = null, - ?PathScopeMatcher $paths = null, + ?RequestPathResolver $paths = null, ) { $this->probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); - $this->paths = $paths ?? new PathScopeMatcher(); + $this->paths = $paths ?? new RequestPathResolver(); } public static function getSubscribedEvents(): array @@ -72,7 +72,7 @@ public function onKernelRequestOrdinary(RequestEvent $event): void } $request = $event->getRequest(); - if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing')) || $this->excludedPath($request->getPathInfo())) { + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing')) || $this->excludedRequest($request)) { return; } @@ -103,16 +103,16 @@ private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bo : $this->responses->tooManyRequests($request, $result)); } - private function excludedPath(string $path): bool + private function excludedRequest(Request $request): bool { - return $this->paths->matchesAnyPrefix($path, '/api/live', '/assets', '/build', '/_profiler', '/_wdt') - || in_array($path, ['/favicon.ico', '/robots.txt'], true); + return $this->paths->matchesAny($request, ['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()) - && '/setup/review' === $request->getPathInfo() + && $this->paths->matchesExact($request, 'setup', 'review') && 'apply' === (string) $request->request->get('_setup_action', ''); } diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php index f0eeee46..a062382a 100644 --- a/src/Security/RateLimit/RateLimitResponseRenderer.php +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -7,7 +7,7 @@ use App\Api\Http\ApiResponder; use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; -use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use App\View\Http\HttpErrorRenderer; @@ -16,15 +16,15 @@ final readonly class RateLimitResponseRenderer { - private PathScopeMatcher $paths; + private RequestPathResolver $paths; public function __construct( private HttpErrorRenderer $httpError, private ApiResponder $apiResponder, private AccessRequestMetadata $requestMetadata, - ?PathScopeMatcher $paths = null, + ?RequestPathResolver $paths = null, ) { - $this->paths = $paths ?? new PathScopeMatcher(); + $this->paths = $paths ?? new RequestPathResolver(); } public function tooManyRequests(Request $request, RateLimitCheckResult $result): Response @@ -92,6 +92,6 @@ private function noStore(Response $response): Response private function jsonSurface(Request $request): bool { - return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/v1', '/cron'); + return $this->paths->matchesAny($request, ['api', 'v1'], ['cron']); } } diff --git a/tests/Api/Security/ApiRequestMethodPolicyTest.php b/tests/Api/Security/ApiRequestMethodPolicyTest.php index 78bb33e5..1c4f78fd 100644 --- a/tests/Api/Security/ApiRequestMethodPolicyTest.php +++ b/tests/Api/Security/ApiRequestMethodPolicyTest.php @@ -83,4 +83,13 @@ public function testApiPathUsesSegmentBoundaries(string $path, bool $api): void { self::assertSame($api, (new ApiRequestMethodPolicy())->isApiV1Request(Request::create($path))); } + + public function testApiPathUsesLocalizedRequestSegments(): void + { + $request = Request::create('/de/api/v1/status'); + $request->attributes->set('_locale', 'de'); + + self::assertTrue((new ApiRequestMethodPolicy())->isApiV1Request($request)); + self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request(Request::create('/de/api/v1/status'))); + } } diff --git a/tests/Core/Routing/RequestPathResolverTest.php b/tests/Core/Routing/RequestPathResolverTest.php new file mode 100644 index 00000000..863b9d23 --- /dev/null +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -0,0 +1,70 @@ +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 testItMatchesLocalizedTechnicalScopesByExactSegments(): 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::assertTrue($resolver->matches($api, 'api', 'v1')); + self::assertTrue($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/api/v1/status'), 'api', 'v1')); + self::assertTrue($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 3db3ec7f..9ad9bcb0 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -174,6 +174,19 @@ public function testItAddsSubmittedTokenSubjectsFromRouteAttributesForLocalizedA 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'); diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index 6edfe0e1..21cc99f1 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -11,7 +11,7 @@ use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; -use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use App\Core\Statistics\VisitorIdGenerator; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubjectResolver; @@ -96,10 +96,23 @@ public function testExcludedPathUsesSegmentBoundaries(string $path, bool $exclud { $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); - $paths->setValue($subscriber, new PathScopeMatcher()); - $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedPath'); + $paths->setValue($subscriber, new RequestPathResolver()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); - self::assertSame($excluded, $method->invoke($subscriber, $path)); + self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); + } + + public function testExcludedRequestUsesLocalizedPathSegments(): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new RequestPathResolver()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + $localized = Request::create('/de/api/live/status'); + $localized->attributes->set('_locale', 'de'); + + self::assertTrue($method->invoke($subscriber, $localized)); + self::assertFalse($method->invoke($subscriber, Request::create('/de/api/live/status'))); } public function testProbePriorityRunsBeforeResponseProducingGates(): void @@ -174,6 +187,8 @@ public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void { $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new RequestPathResolver()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'setupApplyRequest'); self::assertTrue($method->invoke($subscriber, Request::create('/setup/review', 'POST', [ diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php index ba757fc9..7beb6a01 100644 --- a/tests/Security/RateLimit/RateLimitResponseRendererTest.php +++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php @@ -4,7 +4,7 @@ namespace App\Tests\Security\RateLimit; -use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\RequestPathResolver; use App\Security\RateLimit\RateLimitResponseRenderer; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -30,9 +30,22 @@ public function testJsonSurfaceUsesPathBoundaries(string $path, bool $json): voi { $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths'); - $paths->setValue($renderer, new PathScopeMatcher()); + $paths->setValue($renderer, new RequestPathResolver()); $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface'); self::assertSame($json, $method->invoke($renderer, Request::create($path))); } + + public function testJsonSurfaceUsesLocalizedPathSegments(): void + { + $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths'); + $paths->setValue($renderer, new RequestPathResolver()); + $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface'); + $localized = Request::create('/de/cron/run'); + $localized->attributes->set('_locale', 'de'); + + self::assertTrue($method->invoke($renderer, $localized)); + self::assertFalse($method->invoke($renderer, Request::create('/de/cron/run'))); + } } From c294fefae88f8330645ed8ac9edcfe2f883bfc89 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 03:39:46 +0200 Subject: [PATCH 38/38] Harden technical path rate scopes --- dev/CLASSMAP.md | 6 ++-- dev/WORKLOG.md | 5 +-- .../security-hardening/policy-defaults.md | 3 +- .../security-hardening/rate-enforcement.md | 4 ++- src/Api/Http/ApiTraceHeaderSubscriber.php | 9 +++-- .../Security/ApiAvailabilitySubscriber.php | 3 +- src/Api/Security/ApiContentTypeSubscriber.php | 3 +- .../ApiDatabaseExceptionSubscriber.php | 8 +++-- .../Security/ApiEndpointAccessSubscriber.php | 3 +- .../ApiEndpointPermissionSubscriber.php | 3 +- src/Api/Security/ApiKeyAuthenticator.php | 3 +- .../Security/ApiMaintenanceModeSubscriber.php | 3 +- src/Api/Security/ApiRequestMethodPolicy.php | 10 +++--- .../DeferredMessengerDrainSubscriber.php | 9 +++-- src/Core/Routing/PathScopeMatcher.php | 5 +++ src/Core/Routing/RequestPathResolver.php | 4 +-- src/Debug/RouteRenderer.php | 4 ++- src/Security/Abuse/AbuseSubjectResolver.php | 6 +++- .../Abuse/RequestIntentClassifier.php | 24 ++++++++----- src/Security/Api/SelfServiceApiHandler.php | 4 ++- src/Security/RateLimit/RateLimitEnforcer.php | 9 ++--- .../RateLimit/RateLimitRequestSubscriber.php | 22 +++++------- .../RateLimit/RateLimitResponseRenderer.php | 18 +++++++--- .../Api/Http/ApiTraceHeaderSubscriberTest.php | 11 ++++++ .../ApiAvailabilitySubscriberTest.php | 14 ++++++++ .../Security/ApiRequestMethodPolicyTest.php | 4 +-- .../RateLimitEnforcementControllerTest.php | 3 +- tests/Core/Log/AccessRequestMetadataTest.php | 2 +- .../Messenger/DeferredMessengerDrainTest.php | 25 +++++++++++++ tests/Core/Routing/PathScopeMatcherTest.php | 9 +++++ .../Core/Routing/RequestPathResolverTest.php | 13 +++---- .../Abuse/AbuseSubjectResolverTest.php | 9 +++++ .../Abuse/RequestIntentClassifierTest.php | 17 +++++---- .../RateLimit/RateLimitEnforcerTest.php | 15 ++++++++ .../RateLimitRequestSubscriberTest.php | 36 ++++++++++++++----- .../RateLimitResponseRendererTest.php | 10 +++--- 36 files changed, 242 insertions(+), 94 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ede58aee..c1d07982 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,7 +60,7 @@ | 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 technical route scopes and request-aware locale-prefix stripping 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 miss valid localized route contexts. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver` | 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\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` | @@ -200,8 +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 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 `/api/live/**`, exact `/cron/run` scheduler triggers, exact setup review apply submissions, locale-prefix stripping only for actual route locale attributes or enabled content route prefixes, 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, early suspicious-probe blocking before API availability/setup/maintenance gates and ordinary segment-bound technical exclusions with DB-free static probe matching before setup completion, 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 `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, segment-bounded scheduler JSON response detection, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` and suspicious-probe `400` 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8b36bf7e..c0f8dd68 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -93,10 +93,11 @@ - 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 it so `/api/v10`, `/cronjobs`, `/_wdtfoo`, localized public lookalikes, and similar paths do not inherit protected path behavior by raw prefix accident. +- 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 locale-prefix stripping and moved API scope detection, rate-limit exclusions/JSON surfaces, access-log surface detection, request-intent classification, scheduler credential scoping, and submitted-account workflow subject detection onto the same exact path-part semantics. +- 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. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index afaf66fe..ac8a5a8e 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -141,7 +141,7 @@ Multi-bucket requests must not partially spend earlier buckets when a later buck - 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. 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. @@ -152,6 +152,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index e9f59c7c..1d7b16ff 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -52,6 +52,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. @@ -61,7 +62,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 response-producing API availability, setup redirect, and maintenance gates. Profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. +- 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. @@ -73,6 +74,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. +- 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. 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/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/ApiRequestMethodPolicy.php b/src/Api/Security/ApiRequestMethodPolicy.php index bec0aae0..1fc0f7fd 100644 --- a/src/Api/Security/ApiRequestMethodPolicy.php +++ b/src/Api/Security/ApiRequestMethodPolicy.php @@ -4,21 +4,21 @@ namespace App\Api\Security; -use App\Core\Routing\RequestPathResolver; +use App\Core\Routing\PathScopeMatcher; use Symfony\Component\HttpFoundation\Request; final readonly class ApiRequestMethodPolicy { - private RequestPathResolver $requestPaths; + private PathScopeMatcher $paths; - public function __construct(?RequestPathResolver $requestPaths = null) + public function __construct(?PathScopeMatcher $paths = null) { - $this->requestPaths = $requestPaths ?? new RequestPathResolver(); + $this->paths = $paths ?? new PathScopeMatcher(); } public function isApiV1Request(Request $request): bool { - return $this->requestPaths->matches($request, 'api', 'v1'); + return $this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1'); } public function isCorsPreflight(Request $request): bool 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 index f478728f..ebfb59b6 100644 --- a/src/Core/Routing/PathScopeMatcher.php +++ b/src/Core/Routing/PathScopeMatcher.php @@ -39,6 +39,11 @@ public function matchesSegments(string $path, string ...$segments): bool return [] !== $segments; } + public function matchesExactSegments(string $path, string ...$segments): bool + { + return count($this->segments($path)) === count($segments) && $this->matchesSegments($path, ...$segments); + } + /** * @return list */ diff --git a/src/Core/Routing/RequestPathResolver.php b/src/Core/Routing/RequestPathResolver.php index f49ea125..b9045806 100644 --- a/src/Core/Routing/RequestPathResolver.php +++ b/src/Core/Routing/RequestPathResolver.php @@ -12,7 +12,7 @@ /** * @var list */ - private const LOCALIZED_RESERVED_SEGMENTS = ['admin', 'api', 'cron', 'editor', 'setup', 'user']; + private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user']; public function __construct(private ?ContentRouteLocalization $routeLocalization = null) { @@ -82,7 +82,7 @@ private function localePrefix(Request $request, array $segments): ?string { $firstSegment = $segments[0] ?? ''; - if ('' === $firstSegment || !in_array($segments[1] ?? '', self::LOCALIZED_RESERVED_SEGMENTS, true)) { + if ('' === $firstSegment || !in_array($segments[1] ?? '', self::LOCALE_PREFIX_SCOPED_SEGMENTS, true)) { return null; } diff --git a/src/Debug/RouteRenderer.php b/src/Debug/RouteRenderer.php index 23e358b1..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); } diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index d1222682..ede611c4 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -5,6 +5,7 @@ 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; @@ -17,14 +18,17 @@ { 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 @@ -108,7 +112,7 @@ private function submittedApiKeyPrefix(Request $request): ?string private function submittedSchedulerCredential(Request $request): ?AbuseSubject { - if (!$this->paths->matchesExact($request, 'cron', 'run')) { + if (!$this->rawPaths->matchesExactSegments($request->getPathInfo(), 'cron', 'run')) { return null; } diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 66dbb784..74266528 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -6,20 +6,24 @@ 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(), ?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 @@ -28,7 +32,7 @@ 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); @@ -43,13 +47,15 @@ public function classify(Request $request): AbuseRequestProfile ); } - 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, @@ -70,7 +76,7 @@ private function intent( } if (RequestFamily::Scheduler === $family) { - return $this->schedulerTrigger($segments) + return $this->schedulerTrigger($request) ? RequestIntent::SchedulerTrigger : RequestIntent::BrowserNavigation; } @@ -145,9 +151,9 @@ private function setupApply(Request $request, array $segments): bool && 'apply' === (string) $request->request->get('_setup_action', ''); } - private function schedulerTrigger(array $segments): bool + private function schedulerTrigger(Request $request): bool { - return $this->matchesExactSegments($segments, 'cron', 'run'); + return $this->rawPaths->matchesExactSegments($request->getPathInfo(), 'cron', 'run'); } private function adminMutationIntent(array $segments, string $route): RequestIntent 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/RateLimitEnforcer.php b/src/Security/RateLimit/RateLimitEnforcer.php index d9a4ad6f..ee5db1ba 100644 --- a/src/Security/RateLimit/RateLimitEnforcer.php +++ b/src/Security/RateLimit/RateLimitEnforcer.php @@ -110,17 +110,14 @@ private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $s private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array { $primaryFamily = $this->bucketFamily($cost, $subjects); - $families = [$primaryFamily]; - - if ([] === $this->descriptorsForFamily($primaryFamily, $mode, $stage)) { - return []; - } + $families = []; + $primaryDescriptors = $this->descriptorsForFamily($primaryFamily, $mode, $stage); if ($stage->consumesWebsiteFamily() && $this->shouldConsumeWebsiteFamily($profile, $primaryFamily)) { $families[] = 'website'; } - $descriptors = []; + $descriptors = $primaryDescriptors; foreach (array_values(array_unique($families)) as $family) { array_push($descriptors, ...$this->descriptorsForFamily($family, $mode, $stage)); } diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index f703e319..1e02152c 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -4,7 +4,7 @@ namespace App\Security\RateLimit; -use App\Core\Routing\RequestPathResolver; +use App\Core\Routing\PathScopeMatcher; use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -16,7 +16,7 @@ final readonly class RateLimitRequestSubscriber implements EventSubscriberInterface { private SuspiciousProbePathMatcher $probePathMatcher; - private RequestPathResolver $paths; + private PathScopeMatcher $paths; public function __construct( private RateLimitEnforcer $enforcer, @@ -25,17 +25,17 @@ public function __construct( private SetupCompletionMarker $setupCompletionMarker, private string $projectDir, ?SuspiciousProbePathMatcher $probePathMatcher = null, - ?RequestPathResolver $paths = null, + ?PathScopeMatcher $paths = null, ) { $this->probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); - $this->paths = $paths ?? new RequestPathResolver(); + $this->paths = $paths ?? new PathScopeMatcher(); } public static function getSubscribedEvents(): array { return [ KernelEvents::REQUEST => [ - ['onKernelRequestProbe', 900], + ['onKernelRequestProbe', 4096], ['onKernelRequestOrdinary', 3], ], ]; @@ -56,13 +56,9 @@ public function onKernelRequestProbe(RequestEvent $event): void return; } - if (!$this->setupCompleted()) { - $event->setResponse($this->responses->bare($request, Response::HTTP_BAD_REQUEST)); + $this->enforcer->check($request, RateLimitEnforcementStage::SuspiciousProbe); - return; - } - - $this->apply($event, RateLimitEnforcementStage::SuspiciousProbe); + $event->setResponse($this->responses->invalidRequest($request)); } public function onKernelRequestOrdinary(RequestEvent $event): void @@ -105,14 +101,14 @@ private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bo private function excludedRequest(Request $request): bool { - return $this->paths->matchesAny($request, ['api', 'live'], ['assets'], ['build'], ['_profiler'], ['_wdt']) + 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->matchesExact($request, 'setup', 'review') + && $this->paths->matchesExactSegments($request->getPathInfo(), 'setup', 'review') && 'apply' === (string) $request->request->get('_setup_action', ''); } diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php index a062382a..5437825e 100644 --- a/src/Security/RateLimit/RateLimitResponseRenderer.php +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -7,7 +7,7 @@ use App\Api\Http\ApiResponder; use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; -use App\Core\Routing\RequestPathResolver; +use App\Core\Routing\PathScopeMatcher; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use App\View\Http\HttpErrorRenderer; @@ -16,15 +16,15 @@ final readonly class RateLimitResponseRenderer { - private RequestPathResolver $paths; + private PathScopeMatcher $paths; public function __construct( private HttpErrorRenderer $httpError, private ApiResponder $apiResponder, private AccessRequestMetadata $requestMetadata, - ?RequestPathResolver $paths = null, + ?PathScopeMatcher $paths = null, ) { - $this->paths = $paths ?? new RequestPathResolver(); + $this->paths = $paths ?? new PathScopeMatcher(); } public function tooManyRequests(Request $request, RateLimitCheckResult $result): Response @@ -49,6 +49,13 @@ public function suspiciousProbe(Request $request): Response 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 = []; @@ -92,6 +99,7 @@ private function noStore(Response $response): Response private function jsonSurface(Request $request): bool { - return $this->paths->matchesAny($request, ['api', 'v1'], ['cron']); + return $this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1') + || $this->paths->matchesSegments($request->getPathInfo(), 'cron'); } } 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/ApiRequestMethodPolicyTest.php b/tests/Api/Security/ApiRequestMethodPolicyTest.php index 1c4f78fd..2a7ab3f4 100644 --- a/tests/Api/Security/ApiRequestMethodPolicyTest.php +++ b/tests/Api/Security/ApiRequestMethodPolicyTest.php @@ -84,12 +84,12 @@ public function testApiPathUsesSegmentBoundaries(string $path, bool $api): void self::assertSame($api, (new ApiRequestMethodPolicy())->isApiV1Request(Request::create($path))); } - public function testApiPathUsesLocalizedRequestSegments(): void + public function testApiPathDoesNotUseLocalizedRequestSegments(): void { $request = Request::create('/de/api/v1/status'); $request->attributes->set('_locale', 'de'); - self::assertTrue((new ApiRequestMethodPolicy())->isApiV1Request($request)); + self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request($request)); self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request(Request::create('/de/api/v1/status'))); } } diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index 65f61d96..cc7c07af 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -74,7 +74,8 @@ public function testSuspiciousProbeReturnsGenericBadRequestEvenWhenModeIsOff(): self::assertResponseStatusCodeSame(400); self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); - self::assertStringContainsString('Request ID', $client->getResponse()->getContent()); + self::assertStringContainsString('Invalid Request', $client->getResponse()->getContent()); + self::assertStringContainsString('Request-ID', $client->getResponse()->getContent()); self::assertStringNotContainsString('suspicious.probe', $client->getResponse()->getContent()); } 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/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 index 40361697..29fb0cfd 100644 --- a/tests/Core/Routing/PathScopeMatcherTest.php +++ b/tests/Core/Routing/PathScopeMatcherTest.php @@ -46,4 +46,13 @@ public function testMatchesSegmentsPinsExplicitPathParts(): void 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 index 863b9d23..208b7e5a 100644 --- a/tests/Core/Routing/RequestPathResolverTest.php +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -16,7 +16,7 @@ final class RequestPathResolverTest extends TestCase { - public function testItStripsRouteLocaleOnlyBeforeKnownReservedPathScopes(): void + public function testItStripsRouteLocaleOnlyBeforeKnownLocalePrefixPathScopes(): void { $resolver = new RequestPathResolver(); $admin = Request::create('/de/admin/settings/security'); @@ -28,7 +28,7 @@ public function testItStripsRouteLocaleOnlyBeforeKnownReservedPathScopes(): void self::assertSame(['de', 'about'], $resolver->segments($content)); } - public function testItMatchesLocalizedTechnicalScopesByExactSegments(): void + public function testItDoesNotStripLocalePrefixForPrefixlessTechnicalScopes(): void { $resolver = new RequestPathResolver(); $api = Request::create('/de/api/v1/status'); @@ -36,8 +36,8 @@ public function testItMatchesLocalizedTechnicalScopesByExactSegments(): void $cron = Request::create('/de/cron/run'); $cron->attributes->set('_locale', 'de'); - self::assertTrue($resolver->matches($api, 'api', 'v1')); - self::assertTrue($resolver->matchesExact($cron, 'cron', 'run')); + 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')); } @@ -46,8 +46,9 @@ public function testItUsesEnabledRoutePrefixLanguages(): void { $resolver = new RequestPathResolver($this->routeLocalization(true)); - self::assertTrue($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); - self::assertTrue($resolver->matchesExact(Request::create('/de/cron/run'), 'cron', 'run')); + 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')); } diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index 9ad9bcb0..cc39f68c 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -119,6 +119,15 @@ public function testItAddsRedactedSchedulerCredentialSubjects(): void 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'); diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index d166fd13..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'), @@ -198,6 +198,11 @@ public static function requestCases(): iterable 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, diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index ad49b660..48dd417d 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -415,6 +415,7 @@ 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')); @@ -423,6 +424,20 @@ public function testSchedulerIntervalOnlyAppliesToCronRun(): void 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()); diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index 21cc99f1..8e8afbd2 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -11,7 +11,7 @@ use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; -use App\Core\Routing\RequestPathResolver; +use App\Core\Routing\PathScopeMatcher; use App\Core\Statistics\VisitorIdGenerator; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubjectResolver; @@ -96,22 +96,22 @@ public function testExcludedPathUsesSegmentBoundaries(string $path, bool $exclud { $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); - $paths->setValue($subscriber, new RequestPathResolver()); + $paths->setValue($subscriber, new PathScopeMatcher()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); } - public function testExcludedRequestUsesLocalizedPathSegments(): void + public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): void { $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); - $paths->setValue($subscriber, new RequestPathResolver()); + $paths->setValue($subscriber, new PathScopeMatcher()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); $localized = Request::create('/de/api/live/status'); $localized->attributes->set('_locale', 'de'); - self::assertTrue($method->invoke($subscriber, $localized)); + self::assertFalse($method->invoke($subscriber, $localized)); self::assertFalse($method->invoke($subscriber, Request::create('/de/api/live/status'))); } @@ -119,7 +119,8 @@ public function testProbePriorityRunsBeforeResponseProducingGates(): void { $events = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; - self::assertSame(['onKernelRequestProbe', 900], $events[0]); + 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]); @@ -152,7 +153,7 @@ public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void { unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); putenv(SetupCompletionMarker::KEY); - $subscriber = $this->subscriberWithUninitializedEnforcer(); + $subscriber = $this->subscriberWithRealEnforcer(); $event = new RequestEvent( new RateLimitRequestSubscriberTestKernel(), Request::create('/.env'), @@ -164,10 +165,29 @@ public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void 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]);
@@ -188,7 +208,7 @@ public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void
     {
         $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
         $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
-        $paths->setValue($subscriber, new RequestPathResolver());
+        $paths->setValue($subscriber, new PathScopeMatcher());
         $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'setupApplyRequest');
 
         self::assertTrue($method->invoke($subscriber, Request::create('/setup/review', 'POST', [
diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
index 7beb6a01..1747003b 100644
--- a/tests/Security/RateLimit/RateLimitResponseRendererTest.php
+++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
@@ -4,7 +4,7 @@
 
 namespace App\Tests\Security\RateLimit;
 
-use App\Core\Routing\RequestPathResolver;
+use App\Core\Routing\PathScopeMatcher;
 use App\Security\RateLimit\RateLimitResponseRenderer;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\TestCase;
@@ -30,22 +30,22 @@ public function testJsonSurfaceUsesPathBoundaries(string $path, bool $json): voi
     {
         $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
         $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
-        $paths->setValue($renderer, new RequestPathResolver());
+        $paths->setValue($renderer, new PathScopeMatcher());
         $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
 
         self::assertSame($json, $method->invoke($renderer, Request::create($path)));
     }
 
-    public function testJsonSurfaceUsesLocalizedPathSegments(): void
+    public function testJsonSurfaceDoesNotUseLocalizedTechnicalPathSegments(): void
     {
         $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
         $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
-        $paths->setValue($renderer, new RequestPathResolver());
+        $paths->setValue($renderer, new PathScopeMatcher());
         $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
         $localized = Request::create('/de/cron/run');
         $localized->attributes->set('_locale', 'de');
 
-        self::assertTrue($method->invoke($renderer, $localized));
+        self::assertFalse($method->invoke($renderer, $localized));
         self::assertFalse($method->invoke($renderer, Request::create('/de/cron/run')));
     }
 }