From 91387d9ef1ab168b0ff281a0b8786c4ea1eb9fa5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:19:53 +0200 Subject: [PATCH 001/119] Plan security hardening branches --- dev/WORKLOG.md | 82 +----- dev/WORKLOG_HISTORY.md | 12 +- dev/draft/0.2.x-SecurityAccessControl.md | 22 +- dev/draft/0.2.x-SecurityHardeningPlan.md | 272 ++++++++++++++++++ dev/draft/0.4.x-ApiLayer.md | 2 + dev/draft/0.4.x-ContactMailLogging.md | 6 +- dev/draft/0.4.x-IconCaptcha.md | 16 +- dev/draft/README.md | 3 +- .../security-hardening/abuse-foundation.md | 68 +++++ dev/draft/security-hardening/auto-ban.md | 74 +++++ .../security-hardening/captcha-contract.md | 70 +++++ .../security-hardening/geoip-observability.md | 71 +++++ dev/draft/security-hardening/icon-captcha.md | 71 +++++ .../mailer-account-delivery.md | 69 +++++ dev/draft/security-hardening/policy-docs.md | 64 +++++ .../security-hardening/rate-enforcement.md | 71 +++++ dev/draft/security-hardening/remember-me.md | 73 +++++ 17 files changed, 959 insertions(+), 87 deletions(-) create mode 100644 dev/draft/0.2.x-SecurityHardeningPlan.md create mode 100644 dev/draft/security-hardening/abuse-foundation.md create mode 100644 dev/draft/security-hardening/auto-ban.md create mode 100644 dev/draft/security-hardening/captcha-contract.md create mode 100644 dev/draft/security-hardening/geoip-observability.md create mode 100644 dev/draft/security-hardening/icon-captcha.md create mode 100644 dev/draft/security-hardening/mailer-account-delivery.md create mode 100644 dev/draft/security-hardening/policy-docs.md create mode 100644 dev/draft/security-hardening/rate-enforcement.md create mode 100644 dev/draft/security-hardening/remember-me.md diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c153dfee..19eeb5e0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -75,79 +75,13 @@ ## 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-13 feat-symfony-ux-integration -- Added namespace-aware Twig component primitives for root, frontend, backend, and package-adjacent UI surfaces, then wired shared alert stacks, buttons, page headers, empty states, chart/map wrappers, and form field enhancements without removing the override-friendly partial entry points. -- Added reusable Stimulus/JS foundations for live polling, filter forms, dialog, clipboard, disclosure, tabs, notification-center behavior, Mercure alert streams, and manual one-shot polls; applied the filter/dialog/clipboard pieces to existing Admin logs/statistics/users/package/API-key surfaces where useful. -- Reworked UI alerts into a unified dispatcher and notification center with direct, queued, and low-level push delivery modes; request-time alerts, DB-backed inbox fallback, Mercure best-effort push, polling fallback, titles, actions, loading state, and `auto`/`hidden`/`persistent` presentation now share one public `addAlert()` path. -- Added a package-owned `/api/live/{package_slug}/...` endpoint registry and dispatch boundary for lightweight GET-only polling/manual interactions such as future captcha seed reloads. -- Added a package-extendable cookie-consent foundation with duplicate-name rejection, stateless public CSRF, consent-aware cookie helpers, optional-cookie withdrawal expiry, DNT/GPC-aware defaults, and a reusable overlay that can be reopened from later privacy/footer links. -- Added optional local Mercure tooling with installer/start/stop/health/check commands, fixed versioned Caddy-based release assets, `var/mercure` storage, read-only diagnostics, public subscribe probes, publish probes, setup seeding, scheduler health refresh, and graceful polling fallback when Push is unavailable. -- Switched local Mercure signing to derive from the validated/generated `APP_SECRET` by default, moved local hub secrets out of process arguments, required a 32-byte `APP_SECRET`, and made unsupported short app secrets fail before the app-secret rotation recovery flow can mark them as healed. -- Removed unused Alpine and ApexCharts wiring now covered by Symfony UX packages, kept UX assets lazy, repaired demo package/theme CSS validation, and clarified that `asset-map:compile` is production/release-only. -- Bounded large log reading from the end of the file to avoid Admin log timeouts on large application logs. -- Updated the design-system draft, Mercure web-server notes, class map, and related translation/catalogue entries for the new UI alert, live polling, component, cookie-consent, Mercure, and Symfony UX foundations. - -### 2026-06-14 feat-symfony-ux-integration -- Addressed review findings around live endpoint access and registration by making package live endpoints GET-only, enforcing minimum access levels before handler dispatch, reserving system live slugs, and preferring exact endpoint paths before broad pattern matches. -- Applied the same exact-before-pattern selection to regular API endpoint dispatch and split the oversized API class-map entry into focused API foundation/security, endpoint registry/documentation, admin/settings, content, and package/user rows. -- Folded `ui_alert_inbox` into the pre-`1.0.0` baseline migration and hardened prefixed index/constraint naming for alert-inbox schema objects. -- Hardened alert delivery and storage by scoping notification-center storage by user/session/surface, preserving closed-alert dedupe, giving queued/pushed alerts stable fallback IDs, and making explicit alert-inbox cleanup failures return a failing command status. -- Tightened cookie consent behavior by making rejection work without JavaScript, avoiding anonymous session creation from hidden CSRF tokens, rejecting duplicate consent definitions, and expiring withdrawn optional cookies. -- Kept profile views on cached Mercure availability only, required authenticated publish success for Mercure health, and kept setup/profile paths from starting or installing Mercure implicitly. -- Switched local Mercure downloads from deprecated legacy assets to the Caddy-based `mercure_{OS}_{ARCH}` archives, used the release Caddyfile plus protected env file for secrets, normalized Windows paths, and kept PID plus exact-binary process detection for start/stop/check diagnostics. -- Clarified Mercure public URL/reverse-proxy expectations in the web-server manual while keeping local checks precise enough to distinguish Symfony fallback responses from real Mercure SSE endpoints. -- Replaced committed Mercure JWT defaults with `${APP_SECRET}`, removed setup-time `MERCURE_JWT_SECRET` generation, and kept app-secret rotation naturally coupled to the default Mercure JWT key unless an operator explicitly configures a dedicated JWT secret. -- Deferred Mercure startup out of `bin/init`, made setup stop stale local hubs before health recovery starts them with persisted setup secrets, and coupled app-secret rotation recovery to Mercure stop/health refresh so local hubs do not keep stale signing keys. -- Stripped diagnostic message context from UI alert serialization so UI payloads expose only display-safe alert fields. -- Bound cookie-consent CSRF tokens to the existing visitor identity and hardened package live/API path-pattern guards plus live dispatch route-slug checks so package-owned endpoints cannot escape their namespace through broad regex patterns. -- Hardened late review edge cases for UI alerts, Mercure health, and setup copy by notifying only for newly created alerts, keeping server-rendered flashes visible during storage hydration, retrying transient alert-poll failures, treating disabled Mercure health as a configured success, and deriving setup secret browser constraints from the shared validator. -- Hardened queued alert fallback by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions. -- Hardened follow-up review edges by removing PostgreSQL-sensitive `lastInsertId()` dependency from queued alert appends, making initial server-rendered alerts visible without JavaScript, normalizing colon-only Mercure listen addresses for local probes, and restoring timed removal of transient auto alerts without marking them as manually closed. -- Extended the Mercure colon-only listen hardening to configured hub URLs so `.env`-derived `http://:3000/.well-known/mercure` values normalize before publish/public probes. -- Hardened cookie consent and alert dispatch follow-up edges by clearing all rejected optional cookies even without stored consent, preserving clear-cookie response headers for rejected cookies, enforcing registered cookie identity in the consent jar, skipping topic-specific Mercure publishes while unavailable, and adding a root Twig-component namespace smoke test for `root:*` components. -- Tightened production-readiness edges by SHA256-pinning Mercure release archive downloads, rejecting custom consent cookies that change registered security attributes, signing and TTL-validating consent cookies, and covering safe relative consent redirects. -- Added lightweight native `node --test` JavaScript behavior testing through `bin/jstest` without a `node_modules` dependency tree, with first coverage for alert payload normalization and live polling cursor/retry/error behavior. -- Expanded JavaScript behavior coverage with a small test-only fake DOM and Stimulus controller loader for stable controller contract tests around clipboard/dialog/disclosure/tabs/filter forms, cookie consent, alert stack behavior, alert polling, and Mercure stream reconnect handling. -- Hardened final review edges by verifying stored Mercure PIDs against the exact binary before termination, avoiding parallel alert stream/poll delivery while adding one-shot stream catch-up from the inbox and stable Mercure event IDs, remembering auto-dismissed alert IDs, constraining package-owned necessary cookies to package-scoped host-only names, and marking Mercure unavailable when app-secret rotation cannot safely stop the local hub. -- Hardened cookie-consent package review edges by rejecting duplicate or core-reserved package cookie definitions during package loading and validating optional-cookie privacy links before they can render in the public consent UI. -- Hardened follow-up setup, redirect, alert-inbox, and naming edges by passing the persisted setup `APP_SECRET` as the Mercure setup health JWT secret, rejecting backslash/control-character local redirect targets, storing queued alert topics as bounded HMAC keys, and renaming the consent cookie to a system-owned name. -- Replaced host-derived UI-alert Mercure topic URLs with system-owned URN topics so alert transport identifiers no longer consume HTTP route namespace or depend on `DEFAULT_URI` length. -- Hardened additional review edges by running consent cookie filtering after response cookie writers, standardizing queued user alert topics on canonical account UIDs with username-to-UID normalization when resolvable, and rejecting package live endpoint root paths that cannot be routed by `/api/live/{packageSlug}/{resourcePath}`. -- Hardened follow-up alert edges by preserving username case during username-to-UID topic resolution. -- Removed the native browser notification opt-in and `symfony/ux-notify` dependency because UX Notify only works while a page keeps an active Mercure/EventSource stream; real closed-browser notifications need a separate future Web Push design. -- Completed another broad review pass across URL/link sinks, browser storage naming, and Mercure secret-file handling; hardened alert action links, package metadata URLs, content redirect targets, filter-form storage names, and protected Mercure env-file rewrites. -- Hardened the alert stream fallback so browsers without `EventSource` support or streams that fail before their first open switch to inbox polling without enabling parallel normal stream/poll delivery. -- Kept operation detail overlays dismissible during running operations by showing close controls in non-terminal states and hiding details without stopping the live poller. -- Hardened additional alert/CSS review edges by draining paginated queued-alert catch-up pages after Mercure stream opens, authorizing rendered stream subscriptions for private alert pushes, and suppressing strict-parser CSS limitations for Tailwind directives, generated modern group at-rules, and empty custom-property fallbacks only when a normalized second parse finds no adjacent syntax error. -- Follow-up hardening pass: removed unused legacy CSS-linter wrapper names after broadening the parser-normalization scope, made the generic live poller drain `has_more` pages immediately when cursors advance so polling fallback behaves like stream catch-up, and made Mercure stream views drain queued alerts before connecting while queuing a follow-up drain when the stream opens mid-catch-up. -- Follow-up: when the next feature branch starts, evaluate focused controller foundations for public consent/privacy settings, package live endpoint documentation/navigation, captcha provider/live seed flows, notification preference detail settings, and package-owned webhook/job callback endpoints before adding more broad UI surface. -- Follow-up: add a public privacy/footer trigger for `cookie_consent_trigger_attributes()` so visitors with stored consent can reopen cookie preferences and withdraw or adjust optional-cookie consent. -- Follow-up: evaluate converting high-use backend filters from GET-refresh enhancement to Symfony UX LiveComponent slices with URL-bound writable `LiveProp`s so filter input updates can re-render only the list component while keeping shareable query parameters. -- Follow-up: revisit the full operation overlay controller after the first real UI/UX feature slice; the polling core is now shared, but renderer/storage responsibilities can still be split further when more live consumers exist. - -### 2026-06-12 docs-cleanup -- Refreshed the `.codex` context inventory: marked the branding-neutral naming migration and first readiness audit as completed/historical, removed the obsolete standalone Symfony docs notes, made the framework recap the version-pinned dependency documentation cache, and updated it with current installed-dependency guidance for Symfony 8.1, Doctrine ORM/DBAL, Twig 3.27, Tailwind v4/TailwindBundle, Symfony UX, CommonMark, and PHPUnit 13. -- Extended `bin/lint` into the all-in-one diff linting entry point: it now supports `--diff`, `--diff=`, and `--diff:`, collects staged/unstaged or explicit Git diff files when Git is available, lints extensionless PHP scripts such as `bin/lint`, and runs a non-Markdown Git whitespace check that preserves intentional Markdown hard line breaks. -- Added Markdown parse coverage to `bin/lint` using the existing League CommonMark/GFM dependency so Markdown targets produce a real parse/render smoke-check instead of being reported as unsupported. -- Documented the Git whitespace/Markdown hard-break rule in `AGENTS.md`, updated the `.codex` tool index and class map for the new lint modes, and compacted the old 2026-06-07 API session into `dev/WORKLOG_HISTORY.md`. -- Moved the binding project rules from `.codex/PROJECT_RULES.md` into `AGENTS.md` so architecture, naming, pre-`1.0.0`, database, content-revision, security, and audit rules remain available when Codex project context changes or the `.codex` notes are not loaded. -- Removed the obsolete `.codex/PROJECT_RULES.md` duplicate, updated `.codex/ENVIRONMENT.md` for the new `/Volumes/Projekte/studio` checkout path, and refreshed `.codex/README.md` with active context, historical audit, tool, and cleanup guidance. -- Verified `.codex/resolve_cloud_artifacts.php` reports no cloud conflict artifacts; `.codex/clean_ignored_artifacts.php` dry-run still lists normal ignored generated/dependency directories such as `vendor/`, `var/`, `assets/vendor/`, `translations/runtime/`, and package build outputs, so no deletion was applied. - -### 2026-06-13 docs-cleanup -- Moved route rendering from the `.codex` helper into project code with `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, setup-completion, browser-auth, and API debug context support; removed the obsolete `.codex/render.php` helper and updated render-review references. -- Extended `bin/lint` with `--staged` and `--changed=` while keeping Git-dependent target collection and whitespace checks graceful when Git or a work tree is unavailable. -- Reviewed the newly installed Symfony UX package set, kept optional UX Stimulus controllers lazy, removed generated React/Vue/Icon demo files, and tied committed Mercure defaults to `DEFAULT_URI` and `APP_SECRET` for development while documenting production override expectations. -- Updated the class map, dependency recap, local agent tooling notes, and active worklog/history to reflect the render command, lint modes, Symfony UX baseline, and branch-oriented worklog boundary. -- Changed worklog retention from per-session compaction to branch-scoped archival, restored the current `docs-cleanup` branch context from history, and mirrored the rule in `AGENTS.md`. -- Added Symfony UX icon locking to `bin/init` and the package-aware asset rebuild queue as non-blocking dependency steps, and registered active package template paths for icon/AssetMapper console scans so core and package icon references can be imported locally when Iconify is reachable without breaking offline CI or admin rebuilds. -- Added a local-only Symfony UX icon reference check to `bin/lint` so static Twig icon references fail when the required locked SVG is missing, without running the mutating network-backed `ux:icons:lock` command. -- Documented that locked SVGs in `assets/icons` should be committed as reviewable dependency snapshots while avoiding bulk-locking complete upstream icon sets by default. -- Declared `ext-sodium` as a direct Composer platform requirement and added it to the PR verification runner because the Symfony Mercure/JWT dependency chain requires `lcobucci/jwt`, which requires Sodium. -- Clarified `AGENTS.md` wording around session notes with branch/PR context and the boundary between agent-only `.codex` helpers and project-wide tooling. -- Normalized `AGENTS.md` wording so the document reads as a standalone first-version guide rather than as a patch over earlier agent habits. -- Disabled UX Translator TypeScript type dumps in production because the current AssetMapper setup uses JavaScript, not TypeScript, and recorded the UX Turbo 3.1 stream-listen deprecation in the dependency recap. -- Added cache warmup to `bin/init` and `ux:translator:warm-cache` to the package-aware asset rebuild queue so `var/translations/index.js` exists before AssetMapper resolves `assets/translator.js`. +### 2026-06-15 feat-security-planning +- Added the security hardening implementation plan draft, splitting the next Security work into focused `feat-security-*` branches for policy docs, GeoIP observability, abuse foundations, rate enforcement, auto-ban handling, captcha contracts, IconCaptcha, mailer account delivery, and remember-me. +- Added handoff-ready detail plans under `dev/draft/security-hardening/` for every planned `feat-security-*` branch and linked them from the master hardening plan. +- Documented the Security branch Git policy: Codex may create thematically clear local commits, while pushes require explicit user instruction. +- Recorded planning decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, action-aware limiter costs, scoped `reset()`-based bucket recovery, cross-action abuse signals, TTL auto-bans, Owner lockout protection, and GeoIP as observability before enforcement. +- Aligned the Security, API, Contact/Mail/Logging, and IconCaptcha drafts with the planning branch decisions, including treating live captcha refreshes as passive abuse signals rather than ordinary rate-limit `429` responses. +- Compacted the non-Security `feat-symfony-ux-integration` and `docs-cleanup` active branch logs into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on Security planning. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index b69ed763..61c560ce 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -1,7 +1,7 @@ # Developer Worklog History > **Status**: Active -> **Updated**: 2026-06-13 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Preserve compacted branch/PR history moved out of `dev/WORKLOG.md` at branch boundaries. @@ -9,6 +9,16 @@ 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-13 to 2026-06-14 feat-symfony-ux-integration +- Added the Symfony UX/UI foundation: namespace-aware Twig components, shared alert stacks, reusable Stimulus/live-polling controllers, notification center behavior, package live endpoints, package-aware cookie consent, local Mercure tooling, and lazy UX integrations. +- Hardened live/API/cookie/Mercure boundaries through repeated review passes, including exact-before-pattern dispatch, reserved live slugs, GET-only package live endpoints, alert topic scoping, consent cookie signing, protected Mercure env handling, URL/link sink validation, and safe fallback polling. +- Added JavaScript behavior coverage and UI/operation overlay refinements while recording follow-ups for public privacy triggers, live endpoint docs/navigation, captcha seed flows, notification preferences, package callbacks, and future LiveComponent filter slices. + +### 2026-06-12 to 2026-06-13 docs-cleanup +- Refreshed repository guidance and context docs: moved binding project rules into `AGENTS.md`, updated `.codex` environment/tooling notes, refreshed dependency recap for Symfony 8.1-era packages, and restored branch-oriented worklog archival rules. +- Improved local developer tooling documentation and commands: expanded `bin/lint` with diff/staged/changed modes, Markdown parsing, extensionless PHP checks, Git whitespace handling, route rendering through `php bin/console render:route`, and Symfony UX icon reference checks. +- Recorded Symfony UX integration notes, icon locking behavior, cache warmup/UX Translator expectations, production-only AssetMapper guidance, and ext-sodium platform requirements while archiving obsolete `.codex` helper context. + ### 2026-06-07 - Completed the API foundation and hardening slice: stateless Bearer API-key authentication, endpoint definitions/handlers, OpenAPI 3.2 generation, public/private navigation, admin/user/content/package endpoints, CORS, trace headers, feature policy settings, response/error schemas, and Message-layer localized feedback. - Hardened API access and review boundaries around disabled/setup/maintenance responses, package-owned route patterns, read-only method gates, endpoint permissions, API-key parsing, deleted users, ACL denial status, retained-deleted account mutations, content revisions, package slug identity, pagination/filtering/sorting, and public published-content status leakage. diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 3dcdb5d0..fb428be5 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -1,7 +1,7 @@ # Security and access control (Feature Draft) > **Status**: Draft -> **Updated**: 2026-06-05 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for authentication, authorization, ACLs, rate limiting, captchas, and secure administrative flows. @@ -10,6 +10,7 @@ - Covers roles, ACLs, rate limiting, captcha integration, secrets, and module-provided security extensions. - Depends on core architecture, plugin modules, and editor workflows. - Keeps security Symfony-native while leaving explicit replacement points for captcha and future authentication extensions. +- Splits the next hardening work into focused branches through the [security hardening implementation plan](0.2.x-SecurityHardeningPlan.md). ## Outline Security should use Symfony Security as the primary model. Authentication, authorization, CSRF protection, voters, rate limiting, secrets, and route access rules should remain recognizable Symfony concepts. @@ -49,9 +50,15 @@ Captcha should use a global form field integration. When a workflow includes the - Define exactly one global user role per account, with Public, User, Moderator, Author, Publisher, Curator, Manager, Director, Admin, and Owner mapped to numeric access levels `0` through `9` and inherited Symfony roles. - Use CSRF protection for state-changing browser workflows. - Use Symfony Rate Limiter for application-level throttling such as login attempts, contact forms, API usage, import attempts, and captcha failures. -- Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha refreshes, captcha failures, API usage, import attempts, and suspicious probes. +- Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha failures, API usage, import attempts, and suspicious probes. - Avoid using a single coarse per-IP limiter as the only abuse-control decision. +- Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints should stay cheap, tokenized where necessary, no-store, and safe for live polling, captcha refreshes, or lightweight UI refreshes; clear abuse may still feed passive suspicious-behavior signals. +- Classify Turbo/browser prefetch requests separately from deliberate navigations and submissions. Prefetch should not spend the same global abuse budget as a user-initiated request, and expensive or side-effect-adjacent links may disable Turbo prefetching. +- Route rate-limit decisions through a Studio-owned facade so workflows can assign action costs, reset scoped buckets after clear success, and combine Symfony RateLimiter buckets with cross-action abuse signals without binding controllers to Symfony limiter internals. +- Prefer scoped bucket resets for successful human outcomes before designing partial refunds. For example, a successful login may reset the login-attempt bucket for that subject, and a successful captcha challenge may reset the relevant challenge or form bucket when the workflow explicitly allows it. +- Track global cross-action abuse signals across website and API activity so several separately limited actions can still trigger progressive handling when they occur in suspicious sequence. - Support progressive abuse handling such as allow, throttle, require captcha, temporary block, and hard block. +- Support active punishment such as draining relevant buckets or applying temporary TTL bans for clear suspicious behavior. Auto-bans may be keyed by IP, visitor ID, API key, or safe combined subjects, must be auditable, and must preserve Owner recovery paths. - Document that edge/server-level rate limiting is still needed for DoS protection. - Keep secrets out of manifests, docs, logs, fixtures, and committed config. - Generate `APP_SECRET` during setup and store it in `.env.local.php`. @@ -86,6 +93,7 @@ Captcha should use a global form field integration. When a workflow includes the - Require modules to declare permissions and route sensitivity before enabling admin routes or state-changing actions. - Treat protected database-backed configuration values, such as the MaxMind API key, as administrator-only settings and never expose them through logs, diagnostics, public exports, or unprivileged UI. - Ensure missing optional security configuration, such as a GeoIP provider key, degrades gracefully instead of producing hard runtime failures. +- Use GeoIP only as optional operational metadata for logs, statistics, diagnostics, and later security review unless a dedicated geo-blocking policy is explicitly designed. - Treat API authentication separately from browser authentication once the API draft is implemented. ## Testing & Validation @@ -94,6 +102,10 @@ Captcha should use a global form field integration. When a workflow includes the - Test CSRF protection on state-changing forms. - Test rate-limited workflows with configured thresholds. - Test separate rate-limit buckets do not block unrelated workflows. +- Test `/api/live/**` stays outside ordinary rate-limit responses while remaining protected by route-specific tokens or access checks where applicable. +- Test Turbo/browser prefetch classification so speculative requests do not spend the same budget as deliberate requests. +- Test successful outcomes reset only the intended limiter buckets. +- Test cross-action abuse signals and temporary bans with IP, visitor ID, API key, authenticated user, and Owner recovery cases. - Test progressive abuse handling transitions where implemented. - Test captcha provider selection and fallback behavior. - Test the global captcha form field passes validation when provider is `none` or no captcha provider is available. @@ -127,6 +139,12 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected workflows to protect with captcha. - **Decision recorded:** Abuse handling should be progressive and use workflow-specific limiters instead of relying on one coarse IP bucket. +- **Decision recorded:** The next security work is split through `feat-security-*` branches described in the security hardening implementation plan. Each branch should be feature-complete for its focused concern rather than accumulating one large security review branch. +- **Decision recorded:** `/api/live/**` should not receive ordinary rate-limit enforcement. Live endpoints remain low-cost JSON surfaces for polling, captcha refreshes, and UI refreshes; suspicious access may still feed passive abuse signals. +- **Decision recorded:** Rate and abuse checks should pass through a Studio-owned facade. Symfony RateLimiter remains the token bucket/sliding window implementation where practical, while Studio owns request-intent classification, action costs, cross-action signals, scoped resets, and future temporary ban policy. +- **Decision recorded:** Scoped limiter resets are preferred over partial token refunds when a workflow has a clear successful human outcome. +- **Decision recorded:** Turbo/browser prefetch should be classified separately from deliberate navigation and submission traffic so speculative requests do not unfairly drain user-facing buckets. +- **Decision recorded:** Temporary auto-bans are acceptable for clear suspicious behavior when they are TTL-bound, auditable, reviewable, and preserve Owner recovery paths. - **Decision recorded:** Visitor identity should use a first-party, HMAC-protected, rotatable technical visitor cookie. Avoid machine IDs, advertising IDs, cross-site identifiers, and browser/device fingerprinting. When no valid visitor cookie is present, the request uses an `APP_SECRET`-derived fallback visitor ID from source IP and normalized user-agent so cookie-disabled clients do not create a new unique visitor on every request. The response still receives a fresh random signed cookie token. The short-lived identity store keeps cookie hashes and fallback hashes separate: known valid cookie hashes win, then recent fallback hashes win, and newly issued cookies bind to the chosen visitor ID. This intentionally accepts temporary undercounting for same-IP/same-user-agent visitors so the system does not overcount every page view or first-cookie transition. - **Decision recorded:** Session hardening binds authenticated Symfony sessions to the first-party visitor signal. Login requests and legacy sessions without an existing binding initialize the binding; established sessions with a changed or missing visitor signal are treated as likely session duplication and terminated to prevent copied session cookies from staying usable. - **Decision recorded:** Request IDs, visitor IDs, IP buckets, and related metadata may feed rate limiting and suspicious-behavior detection, but they are signals rather than sole proof of identity. Some enforcement may be IP-based and some visitor-based, depending on the abuse pattern. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md new file mode 100644 index 00000000..2ceac992 --- /dev/null +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -0,0 +1,272 @@ +# Security hardening implementation plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Plan the security hardening feature split so each branch can be implemented, reviewed, and merged as a focused production-ready slice. + +## Overview + +This plan turns the security and access-control decisions into a reviewable branch sequence. The goal is not to create one large security branch. Each branch should ship one coherent security capability with tests, documentation, worklog notes, and narrow integration points. + +The `feat-security` branch is the shared merge base. Feature branches should use the `feat-security-*` prefix and avoid unrelated cleanup. A branch may add extension points needed by later branches, but it should not implement future behavior merely to prove the extension point. + +Detailed handoff plans live in `dev/draft/security-hardening/`. Each branch plan defines the implementation sequence, interfaces, edge cases, tests, documentation updates, non-goals, and acceptance criteria for one reviewable branch. + +## Git handling + +Codex may create local commits for Security planning and implementation work when the commit scope is thematically clear and reviewable. Pushes are never implied by these plans and require an explicit user instruction. + +## Planning decisions + +- Keep Symfony Security, CSRF, Validator, RateLimiter, Messenger, Lock, and existing project message/audit/log layers as the primary foundations. +- Keep `/api/live/**` outside the normal rate-limit enforcement path. Live JSON endpoints should stay cheap, tokenized where needed, no-store, and suitable for operation polling, captcha refreshes, or lightweight UI refreshes. Suspicious live-endpoint traffic may feed passive abuse signals, but live endpoints should not receive ordinary HTML or versioned-API `429` handling. +- Treat Turbo and browser prefetch requests as lower-confidence interaction signals. Prefetch requests should not spend the same global abuse budget as deliberate navigations or submissions, and high-cost links may disable prefetch through `data-turbo-prefetch="false"`. +- Detect Turbo prefetch defensively through `X-Sec-Purpose: prefetch` and browser speculative loading through `Sec-Purpose: prefetch` where available. Missing or spoofable non-`Sec-*` hints must not bypass security checks. +- Use action-aware limiter costs. A failed login, failed captcha, registration submission, password-reset request, API mutation, suspicious probe, or scheduler trigger may consume different costs from different buckets. +- Prefer complete bucket reset for clear success cases before designing partial refunds. For example, successful password login may reset the login-attempt bucket for that subject. Captcha success may reset or improve specific challenge/form buckets when that behavior is explicit and safe. +- Keep room for future positive adjustments through a Studio-owned rate/abuse facade, but do not expose Symfony RateLimiter details directly to controllers or packages. +- Recognize cross-action abuse. Separate buckets remain useful, but repeated activity across different guarded workflows should also feed a global subject budget and suspicious-signal store. +- Add progressive punishment as a first-class concept: observe, throttle, require captcha, temporarily block, and hard-block only when signals justify it. +- Support active punishment such as draining a suspicious subject's relevant buckets when clear bot behavior is detected. +- Add temporary auto-bans with TTL for IP, visitor ID, API key, or combined subjects. Authenticated users should receive softer handling where reasonable, and Owner accounts must never be locked out of all recovery paths. +- Treat GeoIP as operational metadata for logs, statistics, and security review. Missing provider configuration must degrade gracefully. +- Include IconCaptcha in the overall security feature cut, but keep its provider implementation in a dedicated branch after the generic captcha contract. + +## Branch sequence + +### `feat-security-policy-docs` + +Record the product decisions from this plan in the relevant drafts and manuals before implementation starts. + +Detailed plan: [policy-docs](security-hardening/policy-docs.md). + +Scope: + +- Update security, captcha, logging/statistics, API/live, and scheduler notes where their behavior is affected. +- Document the branch plan, expected verification shape, and deferred decisions. +- Do not add runtime code. + +Acceptance: + +- Future branches can cite one planning draft instead of re-litigating the same scope. +- Open decisions are explicit and assigned to the branch where they must be resolved. + +### `feat-security-geoip-observability` + +Make GeoIP useful for logs, statistics, and security diagnostics without using it for enforcement yet. + +Detailed plan: [geoip-observability](security-hardening/geoip-observability.md). + +Scope: + +- Add or complete a MaxMind/GeoIP2 provider behind the existing GeoIP resolver boundary. +- Store provider configuration as protected administrator-only configuration. +- Keep `NullGeoIpResolver` as the safe fallback when configuration or local databases are missing. +- Add scheduled update support if the scheduler branch already exposes the needed task boundary; otherwise add the task definition and leave activation disabled. +- Surface safe GeoIP status in Admin diagnostics. + +Non-goals: + +- No geo-blocking. +- No permanent allow/deny decisions based on country or region. + +Acceptance: + +- Logs and statistics receive normalized GeoIP fields when available and normalized empty fields when unavailable. +- Provider secrets never appear in logs, exports, fixtures, screenshots, or diagnostics. + +### `feat-security-abuse-foundation` + +Introduce the Studio-owned abuse and rate facade without enforcing broad bans. + +Detailed plan: [abuse-foundation](security-hardening/abuse-foundation.md). + +Scope: + +- Add subject resolution for IP, visitor ID, authenticated user, API key, and safe combined keys. +- Add request-intent classification for browser navigation, Turbo prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. +- Add a central action cost catalogue with separate website and API families. +- Add passive suspicious-signal recording with TTL. +- Exempt `/api/live/**` from normal enforcement while allowing passive signal hooks for clearly abusive patterns. +- Add audit/message events for suspicious signals with redacted context. + +Non-goals: + +- No automatic ban yet. +- No provider-specific captcha behavior. + +Acceptance: + +- Controllers and future packages can ask one service for classification/cost decisions instead of calling Symfony RateLimiter directly. +- Prefetch requests are detectable and classified separately from deliberate navigation. + +### `feat-security-rate-enforcement` + +Attach concrete Symfony RateLimiter buckets through the Studio facade. + +Detailed plan: [rate-enforcement](security-hardening/rate-enforcement.md). + +Scope: + +- Add named buckets for login, registration, password reset, contact, captcha failure, website global, API read, API write, scheduler trigger, import, and suspicious probes where the workflow exists. +- Treat captcha refreshes served through `/api/live/**` as passive abuse signals instead of ordinary rejecting rate-limit buckets. +- Use costed `consume(n)` calls for action-aware spending. +- Use `reset()` for clear successful outcomes such as successful login or verified captcha where the reset is scoped and safe. +- Return stable HTML or JSON `429` responses depending on request family. +- Keep `/api/live/**` excluded from ordinary rate-limit responses. + +Non-goals: + +- No auto-ban. +- No partial token refund API unless a concrete workflow needs it after `reset()` is evaluated. + +Acceptance: + +- Different workflows can be limited independently and also contribute to a global budget. +- Successful human completion can clear the relevant local bucket when the product policy allows it. + +### `feat-security-auto-ban` + +Add temporary enforcement for sustained suspicious behavior. + +Detailed plan: [auto-ban](security-hardening/auto-ban.md). + +Scope: + +- Add TTL-based ban records for IP, visitor ID, API key, and combined subjects. +- Add ban reasons, expiry, source signals, actor context, and audit entries. +- Apply softer thresholds or bypasses for authenticated users where appropriate. +- Enforce Owner safety so at least one active Owner retains a documented recovery path. +- Provide Admin review and manual unban tools. + +Non-goals: + +- No country-level blocking. +- No permanent invisible deny list. + +Acceptance: + +- Clear bot/probe behavior can be temporarily blocked without blocking all Owner recovery. +- Operators can understand why a subject is blocked and when the block expires. + +### `feat-security-captcha-contract` + +Add the generic captcha extension model. + +Detailed plan: [captcha-contract](security-hardening/captcha-contract.md). + +Scope: + +- Define `CaptchaProvider` and resolver contracts. +- Add workflow-level provider selection. +- Add a global captcha form field. +- Treat `none`, missing providers, and disabled providers as successful validation unless a later provider-required policy explicitly changes that workflow. +- Allow captcha success to reset or improve the relevant challenge/form buckets through the abuse facade. + +Non-goals: + +- No IconCaptcha provider implementation. +- No hard dependency on package assets. + +Acceptance: + +- Public workflows can include captcha without knowing the provider. +- Captcha remains additive and never replaces CSRF, authentication, ACL, rate limiting, or domain validation. + +### `feat-security-icon-captcha` + +Implement the first-party IconCaptcha provider as a focused provider branch. + +Detailed plan: [icon-captcha](security-hardening/icon-captcha.md). + +Scope: + +- Ship provider-owned assets, templates, JavaScript, translations, services, and validation. +- Use deterministic server-side challenge derivation with one-shot challenge IDs and short TTL. +- Add refresh behavior through `/api/live/**` or another lightweight JSON route. Refresh abuse should feed passive signals, but ordinary live refreshes should not return normal rate-limit `429` responses. +- Keep UI accessible and layout-stable. + +Non-goals: + +- No core-specific IconCaptcha checks in contact, registration, or password forms. +- No copied legacy code, secrets, or hard-coded asset paths from older projects. + +Acceptance: + +- The provider can be enabled, disabled, or replaced without breaking workflows that use the generic captcha field. +- Challenge payloads do not expose secrets, generated answer material, or reusable state. + +### `feat-security-mailer-account-delivery` + +Replace the message-log-only account delivery path with production mail delivery. + +Detailed plan: [mailer-account-delivery](security-hardening/mailer-account-delivery.md). + +Scope: + +- Add provider-based mail-flow registration if it is not already present. +- Add localized Markdown templates, HTML/plain-text rendering, placeholder replacement, and Symfony Messenger queueing. +- Add a conservative transport guard. +- Keep the message-log action-link delivery only as an explicit debug aid with administrative warnings. + +Non-goals: + +- No newsletter or CRM integration. + +Acceptance: + +- Invitation, registration, password reset, password-change review, account closure, and APP_SECRET recovery flows no longer depend on reading clear action URLs from the message log in production. + +### `feat-security-remember-me` + +Add persistent login as an explicit security feature. + +Detailed plan: [remember-me](security-hardening/remember-me.md). + +Scope: + +- Use Symfony remember-me concepts with a server-side persistent token model. +- Store only opaque selector/token material in the browser. +- Bind automatic login to the current visitor signal. +- Revoke tokens on logout, password change, account status changes, APP_SECRET emergency handling, and suspicious reuse. +- Use a seven-day trust window unless implementation evidence supports changing it. + +Non-goals: + +- No long-lived identity cookie without server-side revocation. + +Acceptance: + +- Automatic login is auditable, revocable, visitor-bound, and does not bypass account status or role checks. + +## Cross-branch review rules + +- Every branch must include focused tests for allowed, denied, degraded, and redacted behavior. +- Every branch must update this plan or the owning feature draft when the implementation changes product scope. +- Security-sensitive state changes must emit audit or message entries with safe context. +- Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. +- Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. +- Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. + +## Fixed implementation defaults + +- Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. +- Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. +- Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. +- Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. +- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. + +## Remaining calibration points + +- Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. +- Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. + +## References + +- [Security and access control](0.2.x-SecurityAccessControl.md) +- [IconCaptcha integration](0.4.x-IconCaptcha.md) +- [Contact, mail, and logging](0.4.x-ContactMailLogging.md) +- [API layer](0.4.x-ApiLayer.md) +- [Scheduler](0.4.x-Scheduler.md) +- [Action log and audit snippets](../manual/action-log-audit-snippets.md) diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index f8de64bd..a2e80994 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -79,6 +79,7 @@ The API branch should implement the platform foundation and the first content re - Keep content route identity based on canonical slug hierarchy by default. Internal UIDs may be used by repositories and domain services, but they should not be part of the default public API contract. - Reserve `/api/live/**` for application-owned live JSON flows such as captcha seeds, polling, and operation status checks. This branch is intentionally not part of the stable external versioned API contract. - Keep `/api/live/**` reachable during `APP_MAINTENANCE` so setup, admin, and operational live-log polling can continue while the public site is unavailable. +- Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints are application-owned lightweight JSON flows; clear abuse may feed passive security signals, but live polling, captcha refreshes, and UI refreshes should not receive the normal website/API `429` response path. - Use `/api/live/operations/{operationId}?token=&cursor=` for ActionLog polling fallback responses. Payloads should include `operation_id`, `status`, `cursor`, optional `cursor_max`, `entries`, and `next_poll_ms`. - Treat live-operation cursors as monotonic numeric positions emitted by the log producer, not as stable durable identifiers. - Defer public content DTO and serializer contracts to the API feature branch so entity exposure, pagination, error payloads, and versioning rules are decided together. @@ -192,6 +193,7 @@ The API branch should implement the platform foundation and the first content re - **Decision recorded:** OpenAPI documents should track the current stable OpenAPI Specification unless client tooling compatibility forces a downgrade. The first generator emits OpenAPI `3.2.0`, uses the root `.manifest` `APP_NAME`, `APP_DESCRIPTION`, and `APP_LICENSE` as stable product/API metadata, exposes `$self`, names the `/api/v1` server, emits native OpenAPI 3.2 tag metadata with hierarchy hints, keeps shell/domain scope visible in tags without adding route hierarchy, and keeps user-defined `site.title` reserved for optional instance metadata instead of API contract branding. - **Decision recorded:** Use `/api/live/**` for internal live JSON endpoints that are fetched by system or public UI JavaScript and should not consume top-level content routes. - **Decision recorded:** `/api/live/**` remains outside the stable external API contract. External integrations should use versioned routes such as `/api/v1/**`; live routes are application-owned internal JSON flows. +- **Decision recorded:** `/api/live/**` remains outside ordinary rate-limit enforcement so operation polling, captcha refreshes, and lightweight UI flows stay cheap and resilient. Route-specific tokens, access checks, TTLs, no-store headers, and passive abuse signals remain available where appropriate. - **Decision recorded:** The global maintenance gate bypasses `/api/**`; `/api/v1/**` applies its own API maintenance gate after Bearer authentication, while `/api/live/**` remains reachable for operation status and continuation flows. - **Decision recorded:** ActionLog polling under `/api/live/operations/**` uses a numeric `cursor` query parameter plus optional `cursor_max` progress metadata and a per-run token instead of route authentication. - **Decision recorded:** Defer `ApiResponseContextEvent` until real API response builders exist; it should extend safe diagnostics or debug context, not bypass serialization, permission, or redaction rules. diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index 6b9a0ef9..e41b37d5 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -1,7 +1,7 @@ # Contact, mail, and logging (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-25 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for contact forms, mail delivery, logging, statistics, and GeoIP-aware operational insights. @@ -14,7 +14,7 @@ ## Outline The CMS should provide a reliable contact form and mail delivery foundation for project websites. Contact workflows should be protected by validation, CSRF where applicable, rate limiting, optional captcha, and safe error messages that do not leak delivery internals. -Logging and statistics should help operators understand system behavior, failed workflows, content activity, and abuse patterns. GeoIP enrichment can be useful, but it must be treated as optional operational metadata with privacy and retention controls. If GeoIP is not configured, logs and statistics should continue without hard errors and should use `N/A` or an equivalent empty value for location fields. +Logging and statistics should help operators understand system behavior, failed workflows, content activity, and abuse patterns. GeoIP enrichment can be useful, but it must be treated as optional operational metadata with privacy and retention controls. If GeoIP is not configured, logs and statistics should continue without hard errors and should use `N/A` or an equivalent empty value for location fields. GeoIP belongs in the security hardening plan as an observability feature before it is ever used for enforcement. The first implementation should prefer Symfony Mailer, Monolog, Rate Limiter, Validator, and explicit storage decisions. Module-provided integrations can add newsletter systems, CRM handoff, or alternative logging sinks later. Statistics may evaluate rotated logs over longer time ranges, but rotated logs should anonymize IP addresses while preserving acceptable GeoIP-derived location data. @@ -38,6 +38,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Use GeoIP through a replaceable adapter or optional service boundary. - Use the installed MaxMind/GeoIP2 package as the first GeoIP provider implementation. - Treat GeoIP as a provider-backed service with local database storage, scheduled update support, and clear failure handling. +- Use GeoIP for logs, statistics, diagnostics, and later security review only. Geo-blocking or country-based deny policies require a separate explicit security decision. - Configure the MaxMind API key through a protected admin configuration UI and store it in database-backed configuration with access restricted to authorized administrators. - If no MaxMind API key is configured, disable GeoIP lookup, GeoIP database updates, and Geo-blocking without throwing hard errors. - Use `N/A`, `null`, or an equivalent normalized empty value for GeoIP log/statistic fields when GeoIP is disabled or unavailable. @@ -78,6 +79,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Implement contact forms with Symfony Forms, Validator, Mailer, Rate Limiter, and optional captcha. - **Decision recorded:** Contact form storage is configurable: mail-only by default, with optional stored submissions. - **Decision recorded:** Treat GeoIP as optional and replaceable, with MaxMind/GeoIP2 as the first provider implementation because it is already installed. +- **Decision recorded:** GeoIP should be completed as an observability slice before any abuse-enforcement or blocking behavior depends on it. - **Decision recorded:** GeoIP should use a provider boundary with local database storage, scheduled update support, and safe failure behavior. - **Decision recorded:** The initial GeoIP boundary is `GeoIpResolverInterface` with a `NullGeoIpResolver` that returns normalized `n/a` fields. Access logging and statistics depend on the interface now, while MaxMind/local database lookup remains a later provider implementation. - **Decision recorded:** The MaxMind API key is configured through protected admin configuration and stored in database-backed configuration with restricted access. diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index 8cc59c27..bd447ade 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -1,13 +1,14 @@ # IconCaptcha integration (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for an optional IconCaptcha provider module built on the generic captcha extension contract. ## Overview - Defines the planned IconCaptcha implementation as a replaceable provider module. - Depends on security, setup, error handling, public form workflows, and plugin module extensibility. +- Belongs to the broader security hardening cut, but should be implemented in its own provider branch after the generic captcha contract lands. - Keeps the CMS core focused on a generic captcha contract while allowing IconCaptcha to ship its own services, templates, assets, translations, and validation rules. - Uses the old Grav `sec-lookup` implementation only as inspiration. No old code, secrets, or hard-coded asset paths should be copied into the Symfony project. @@ -53,13 +54,13 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Use fixed-size UI elements so the challenge does not shift the form layout. - Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and non-visual labels through translations where needed. - Provide a refresh endpoint or provider action that returns a fresh challenge without caching. -- Protect refresh endpoints from aggressive abuse with a route-specific limiter, but avoid charging normal human refreshes too heavily. +- Keep refresh endpoints lightweight. If refreshes are served through `/api/live/**`, aggressive abuse should feed passive security signals instead of normal rate-limit `429` responses. - Refresh challenges after browser back-forward cache restores. - Use Symfony Validator constraints, form-level validation, or a shared captcha form field type for user-facing errors. - Send recoverable captcha errors through the normal validation and error-handling flow. - Treat honeypot failures as suspicious signals that may be silent, while normal missing/expired/wrong captcha input should be recoverable for humans. -- Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for captcha refreshes, form posts, captcha failures, and suspicious probe traffic. -- Refund or soften form-post rate-limit costs after successful captcha validation so human users are not unnecessarily penalized. +- Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for form posts, captcha failures, and suspicious probe traffic, while `/api/live/**` challenge refreshes remain passive abuse signals. +- Reset or soften the relevant scoped limiter buckets after successful captcha validation when the workflow policy allows it, so human users are not unnecessarily penalized. - Avoid a single coarse per-IP bucket as the only rate-limit mechanism. - Consider progressive response behavior: allow, throttle, require captcha, then block only for strong signals. - Log provider failures with safe context such as workflow, route, failure code, and anonymized request identifiers where appropriate. @@ -137,7 +138,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test successful and failed captcha validation paths. - Test recoverable validation feedback on public forms. - Test rate-limited captcha failure behavior where applicable. -- Test successful captcha validation refunds or softens form-post rate-limit costs. +- Test successful captcha validation resets or softens relevant scoped limiter buckets. - Test that disabling IconCaptcha or selecting another provider does not break workflows using the generic contract. - Test provider value `none` validates successfully. - Test missing or disabled provider modules validate successfully unless a later provider-required policy is introduced. @@ -147,7 +148,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test used challenge IDs cannot be replayed. - Test expired challenges fail cleanly. - Test context-bound challenges fail when submitted in an invalid workflow or route context. -- Test refresh endpoint responses are uncached and rate-limited separately from form posts. +- Test refresh endpoint responses are uncached and feed passive abuse signals without normal `/api/live/**` rate-limit rejection. - Test browser back-forward cache recovery refreshes stale challenges. - Test SVG/icon asset loading, allowlisting, and sanitization behavior. - Test keyboard interaction and accessible button state where practical. @@ -156,6 +157,7 @@ Recoverable failures should produce translated form errors. Suspicious failures ## Implementation Notes - **Decision recorded:** Do not implement IconCaptcha as part of the first security foundation; implement the generic captcha extension contract first. +- **Decision recorded:** IconCaptcha is part of the overall security hardening feature cut, but the provider implementation should live in a dedicated branch after the generic captcha contract branch. - **Decision recorded:** Implement IconCaptcha as an optional provider module, not as a hard-coded core feature. - **Decision recorded:** Captcha integrates through a global form field that delegates to the configured provider. - **Decision recorded:** Provider selection is configurable. With only the first-party provider installed, choices are `icon_captcha` and `none`. @@ -166,7 +168,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a short TTL. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. - **Decision recorded:** Avoid a single coarse per-IP rate limiter; use workflow-specific limiters and progressive abuse handling. -- **Decision recorded:** Successful captcha validation should refund or soften form-post rate-limit costs for human users. +- **Decision recorded:** Successful captcha validation should reset or soften relevant scoped limiter buckets for human users when the workflow policy explicitly allows it. - **Decision recorded:** Keep provider-specific templates theme-compatible but package-owned. - **Required decision:** Define the exact generic `CaptchaProvider` interface before implementation. - **Required decision:** Define the provider secret storage location and rotation behavior. diff --git a/dev/draft/README.md b/dev/draft/README.md index a08dbb25..61a1dfe7 100644 --- a/dev/draft/README.md +++ b/dev/draft/README.md @@ -1,7 +1,7 @@ # Project Outline and Feature Drafts > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Description of planned features and technical specification drafts for use as guidance alongside implementation. @@ -47,6 +47,7 @@ The feature drafts should be created in dependency order. Start with architectur ### 0.2.x security and extension baseline drafts - [Security and access control](0.2.x-SecurityAccessControl.md) +- [Security hardening implementation plan](0.2.x-SecurityHardeningPlan.md) - [Admin interface and setup UI](0.2.x-AdminInterfaceSetupUi.md) - [Package modules and providers](0.2.x-PluginModules.md) - [Event hooks and buses](0.2.x-EventHooksBuses.md) diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md new file mode 100644 index 00000000..8e07e1f5 --- /dev/null +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -0,0 +1,68 @@ +# Abuse foundation branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-abuse-foundation` implementation plan. + +## Goal + +Introduce Studio-owned request classification, subject resolution, action costs, and passive suspicious-signal recording before any broad enforcement or auto-ban behavior is enabled. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing visitor identity, access logging, audit logging, API-key authentication, Scheduler API authentication, and `/api/live/**` route boundaries. +- Symfony Request data and Turbo/browser prefetch headers. + +## Implementation sequence + +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. +3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, 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 passive suspicious-signal recording with TTL-ready metadata and redacted message/audit reporting. +6. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. + +## Public interfaces and data decisions + +- Controllers and future packages call a Studio-owned abuse facade instead of Symfony RateLimiter directly. +- Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. +- Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. +- No entity schema is required unless passive-signal review needs persistence immediately; cache/file/database choice must be justified in the branch before implementation. + +## Edge cases + +- Missing visitor cookie uses the existing fallback visitor identity. +- Invalid Bearer API keys should still classify as API activity without trusting the key as an authenticated subject. +- Authenticated Owner requests still classify normally; Owner lockout protection is enforced in later branches. +- Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. + +## 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, `/api/live/**`, login, registration, password reset, and suspicious probes. +- Test redaction in passive signal messages. +- Test no limiter or ban enforcement occurs in this branch. + +## Documentation and tracking + +- Update Security draft with facade and classification names if they become stable public extension points. +- Update class map for the facade and value objects only if they are contributor-facing services. +- Record default cost catalogue decisions in the worklog. + +## Non-goals + +- No `429` responses. +- No rate limiter bucket consumption. +- No auto-ban or captcha provider logic. + +## Acceptance criteria + +- Later branches can enforce limits and bans through one facade. +- Passive signal output is useful for review without affecting user traffic. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md new file mode 100644 index 00000000..7f55d694 --- /dev/null +++ b/dev/draft/security-hardening/auto-ban.md @@ -0,0 +1,74 @@ +# Auto-ban branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-auto-ban` implementation plan. + +## Goal + +Add TTL-based temporary bans for sustained suspicious anonymous/IP/visitor/API behavior, while keeping authenticated handling softer and preserving Owner recovery access. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- `feat-security-abuse-foundation`. +- `feat-security-rate-enforcement`. +- Existing Admin, audit, message, user-role, visitor identity, and API-key foundations. + +## Implementation sequence + +1. Add database-backed ban records with subject type, normalized subject key, reason code, source signal summary, status, created/expiry timestamps, actor context where available, and manual unban metadata. +2. Add cleanup for expired bans through command and scheduler-ready task. +3. Add ban-decision checks to the abuse facade after request classification and before expensive workflow handling. +4. Enforce by default for anonymous/IP/visitor/API probe abuse. +5. Apply softer authenticated handling: throttle, captcha, or warning state before hard block unless account compromise signals are explicit. +6. Add Owner safety checks so at least one active Owner retains login and recovery paths. +7. Add compact Admin review/manual unban surface with audit entries. + +## Public interfaces and data decisions + +- First implementation uses database-backed TTL records; cache may be added later as an optimization. +- Ban subject types are IP bucket, visitor ID, API key, combined anonymous subject, and optional authenticated user only for explicit compromise cases. +- Ban reasons use stable message/code catalogues. +- Ban responses use HTML or JSON according to request family and never expose raw signal internals. + +## Edge cases + +- Expired bans must not block while cleanup is pending. +- Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. +- Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. +- Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. + +## Tests and validation + +- Test active, expired, manually revoked, and cleanup states. +- Test anonymous enforcement and softer authenticated behavior. +- Test Owner recovery protection. +- Test HTML/JSON ban responses and redaction. +- Test Admin manual unban writes audit entries. +- Test migration applies on SQLite. + +## Documentation and tracking + +- Update Security draft with final subject types, statuses, and Owner protections. +- Update Admin/security diagnostics notes for review UI. +- Update class map for entity, repository, decision service, cleanup command/task, and Admin routes. +- Record threshold and false-positive assumptions in worklog. + +## Non-goals + +- No permanent invisible deny list. +- No GeoIP/country blocking. +- No machine-learning risk scoring. + +## Acceptance criteria + +- Clear bot/probe behavior can be blocked temporarily and reviewed. +- Operators can understand and reverse bans. +- Owner recovery remains available. diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md new file mode 100644 index 00000000..c2eeddc2 --- /dev/null +++ b/dev/draft/security-hardening/captcha-contract.md @@ -0,0 +1,70 @@ +# Captcha contract branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-captcha-contract` implementation plan. + +## Goal + +Add the generic captcha provider contract, resolver, workflow configuration, and global form integration without implementing IconCaptcha itself. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Abuse facade from `feat-security-abuse-foundation`. +- Rate reset hooks from `feat-security-rate-enforcement` where available. +- Existing Symfony Form, Validator, Translation, package contribution, and settings foundations. + +## Implementation sequence + +1. Define captcha provider and result contracts for render, validate, provider key, label key, and failure reason. +2. Add a resolver that selects a provider by workflow configuration and returns `none` behavior when disabled or unavailable. +3. Add workflow keys for first expected consumers: registration, contact, guest comments, and future login step-up. +4. Add a global captcha form field that delegates rendering/validation to the resolver. +5. Add validation mapping for recoverable failures and suspicious failures. +6. Add success/failure hooks to the abuse facade so success can reset scoped buckets and failure can record signals. + +## Public interfaces and data decisions + +- Provider key `none` always validates successfully. +- Missing or disabled provider validates successfully unless a future provider-required policy is explicitly configured for a workflow. +- Captcha result exposes only stable failure codes and safe context. +- Provider contracts are package-facing extension points and must be documented. + +## Edge cases + +- Captcha must not create anonymous sessions by default. +- Captcha validation never replaces CSRF, authentication, ACL, rate limiting, or domain validation. +- Provider render failures should degrade according to workflow policy and report safe diagnostics. +- Multi-language validation messages use deterministic translation keys. + +## Tests and validation + +- Test `none`, missing provider, disabled provider, success, recoverable failure, and suspicious failure. +- Test form integration does not break workflows with no provider. +- Test abuse hooks are called with safe context. +- Test provider registration rejects duplicate provider keys. +- Test translation catalogue synchronization for user-facing errors. + +## Documentation and tracking + +- Update Security and IconCaptcha drafts with final contract names. +- Update package developer guidance for provider registration. +- Update class map for provider interface, resolver, form type, and validation services. +- Record provider-required policy as deferred unless implemented. + +## Non-goals + +- No IconCaptcha assets, JavaScript, challenge generation, or refresh endpoint. +- No third-party captcha provider. + +## Acceptance criteria + +- Workflows can add captcha once and remain provider-agnostic. +- Future provider branches can implement only the contract without touching core forms. diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md new file mode 100644 index 00000000..39dbe9df --- /dev/null +++ b/dev/draft/security-hardening/geoip-observability.md @@ -0,0 +1,71 @@ +# GeoIP observability branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-geoip-observability` implementation plan. + +## Goal + +Make GeoIP useful for access logs, statistics, diagnostics, and later security review without using it for blocking decisions. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing `GeoIpResolverInterface` and `NullGeoIpResolver` decisions from the logging/statistics draft. +- Existing protected settings, scheduler, message, audit, access-log, and statistics foundations. +- MaxMind/GeoIP2 package already listed as the first provider choice. + +## Implementation sequence + +1. Add a MaxMind-backed resolver behind the existing GeoIP resolver interface. +2. Add protected administrator-only settings for provider selection, database path/status, account/license key, and update policy. +3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. +4. Add a scheduler-ready update task definition for GeoIP database refresh; default inactive unless an existing scheduler policy already enables safe maintenance tasks. +5. Add safe Admin diagnostics for provider status, last update attempt, database freshness, and disabled/unconfigured state. +6. Wire access logs and statistics to consume normalized provider output only through the resolver interface. + +## Public interfaces and data decisions + +- GeoIP output uses normalized nullable or `n/a` fields for country, region, city, latitude/longitude where available, provider status, and lookup status. +- Provider secrets are protected config values and never rendered outside authorized Admin settings. +- Scheduler task identifiers use stable system-owned names and do not expose provider credentials. +- No public API response adds GeoIP data in this branch. + +## Edge cases + +- Missing MaxMind key, unreadable database, expired database, failed download, unsupported IP, private/local IP, and lookup exceptions all degrade to normalized empty fields. +- Diagnostics must not include raw license keys, request IP lists, full provider exceptions, or filesystem paths that expose secrets. +- GeoIP failures must not block the user request that triggered logging/statistics. + +## Tests and validation + +- Unit-test resolver success, null fallback, private/invalid IP handling, and exception fallback. +- Test protected settings visibility and redaction. +- Test access-log/statistics enrichment with provider data and with disabled/missing provider. +- Test scheduler task no-op and failure message behavior. +- Run focused container lint when services/config are added. + +## Documentation and tracking + +- Update Contact/Mail/Logging notes with provider status and Admin diagnostics behavior. +- Update Scheduler notes if a task definition is added. +- Update class map for resolver, task, settings, and diagnostics entry points. +- Add worklog verification notes for redaction and fallback tests. + +## Non-goals + +- No geo-blocking. +- No country allow/deny lists. +- No abuse scoring based on GeoIP. + +## Acceptance criteria + +- Operators can see whether GeoIP is configured and fresh. +- Logs/statistics gain GeoIP fields when available and continue cleanly when unavailable. +- No secret or sensitive provider detail leaks through logs, diagnostics, tests, or exports. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md new file mode 100644 index 00000000..c5bf32e5 --- /dev/null +++ b/dev/draft/security-hardening/icon-captcha.md @@ -0,0 +1,71 @@ +# IconCaptcha branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-icon-captcha` implementation plan. + +## Goal + +Implement the first-party IconCaptcha provider as a replaceable package-owned provider behind the generic captcha contract. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- `feat-security-captcha-contract`. +- Package lifecycle, AssetMapper/Tailwind, translation aggregation, `/api/live/**`, and abuse passive signal foundations. + +## Implementation sequence + +1. Add the first-party provider package skeleton with captcha-provider scope, package-owned services, templates, assets, translations, and JavaScript. +2. Implement deterministic challenge generation from provider secret, challenge ID, timestamp, workflow key, route context, user agent, and optional existing session/visitor signal. +3. Store one-shot challenge IDs and short-lived challenge metadata in Symfony cache or another documented short-lived store. +4. Implement validation for missing, expired, reused, invalid choice, wrong choice, context mismatch, asset error, and provider unavailable. +5. Add lightweight refresh through `/api/live/**` or a provider-owned JSON route with no ordinary rate-limit rejection; record passive abuse signals for aggressive refreshes. +6. Add accessible, layout-stable UI with fixed button grid, translated labels, keyboard support, and back-forward-cache refresh handling. + +## Public interfaces and data decisions + +- Provider key is `icon_captcha`. +- Public challenge payload contains only challenge ID, timestamp, render metadata, and button identifiers needed for display. +- Provider secret is generated/configured outside manifests and public assets. +- SVG/icons must be allowlisted or sanitized before inline rendering. + +## Edge cases + +- Challenge reuse fails after validation regardless of success or failure. +- Expired challenges fail recoverably. +- Context mismatch is suspicious but should not reveal internals. +- Disabled provider falls back according to the generic resolver policy. +- Asset loading failures produce safe diagnostics and recoverable user feedback where possible. + +## Tests and validation + +- Test challenge generation determinism and answer validation. +- Test every failure model. +- Test one-shot replay prevention and TTL expiry. +- Test refresh no-store behavior and passive signal recording. +- Test package asset/template/translation registration. +- Test keyboard/accessibility behavior where practical with JS tests. + +## Documentation and tracking + +- Update IconCaptcha draft with final payload/storage choices. +- Update package developer guidance if provider package layout adds a reusable pattern. +- Update class map for provider, challenge services, controller/live endpoint, assets, and templates. +- Record asset licensing/sanitization notes. + +## Non-goals + +- No workflow-specific IconCaptcha code in contact, registration, or password forms. +- No copied legacy implementation, secrets, or hard-coded old asset paths. + +## Acceptance criteria + +- IconCaptcha can be enabled, disabled, or replaced without changing workflow forms. +- Challenge payloads and logs do not expose secrets or reusable answer material. diff --git a/dev/draft/security-hardening/mailer-account-delivery.md b/dev/draft/security-hardening/mailer-account-delivery.md new file mode 100644 index 00000000..781c2e0c --- /dev/null +++ b/dev/draft/security-hardening/mailer-account-delivery.md @@ -0,0 +1,69 @@ +# Mailer account delivery branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-mailer-account-delivery` implementation plan. + +## Goal + +Replace production reliance on message-log action URLs with real Symfony Mailer/Messenger delivery for account and recovery flows. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing account-link delivery boundary, mail-flow registry, locale resolver, message logging, Messenger, and account-token flows. +- Security settings/admin settings foundation. + +## Implementation sequence + +1. Replace the hard-coded mail-flow registry with provider-backed registration while preserving current built-in account flow keys. +2. Add localized Markdown template storage/editing for built-in account flows. +3. Render Markdown to HTML and plain text with placeholder replacement from allowed parameter keys only. +4. Queue mail delivery through Messenger with a conservative transport guard and safe failure reporting. +5. Keep message-log action-link delivery as an explicit debug aid only, gated by debug configuration and strong Admin warning. +6. Update account, registration, password reset, security review, closure, restore, and APP_SECRET recovery flows to use the real delivery service in production. + +## Public interfaces and data decisions + +- Mail flow/template keys remain stable machine identifiers. +- Allowed replacement keys are defined by the mail-flow registry/provider metadata. +- Clear action URLs may exist in queued mail payloads but must not be duplicated into log context. +- Missing localized templates fall back to default language and record safe warnings. + +## Edge cases + +- Mail transport unavailable should produce recoverable UI/message feedback without exposing SMTP internals. +- Queue failures should not leave account tokens silently unreachable; callers receive a generic delivery failure. +- Debug log delivery must be unavailable or strongly warned in production-like environments. +- APP_SECRET recovery owner links need safe partial-failure reporting. + +## Tests and validation + +- Test registry provider completeness and duplicate key rejection. +- Test template fallback, placeholder replacement, HTML/plain rendering, and unsupported placeholders. +- Test Messenger queue payload redaction. +- Test account flows use real delivery when configured and debug delivery only when allowed. +- Test transport guard behavior and safe failure messages. + +## Documentation and tracking + +- Update Mailer Delivery Contract with final provider and template behavior. +- Update Security draft where the debug stub is replaced/gated. +- Update class map for registry providers, renderer, queued message/handler, and settings UI. +- Update user/admin docs if Mail settings become visible. + +## Non-goals + +- No newsletter, CRM, or campaign tooling. +- No third-party transactional mail provider package. + +## Acceptance criteria + +- Production account operations no longer require reading action URLs from message logs. +- Mail failures are visible, recoverable, and redacted. diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md new file mode 100644 index 00000000..6737a0b4 --- /dev/null +++ b/dev/draft/security-hardening/policy-docs.md @@ -0,0 +1,64 @@ +# Security policy documentation branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the documentation-only baseline for the `feat-security-policy-docs` branch. + +## Goal + +Align all security planning documents before runtime implementation begins. This branch creates the durable review reference for later `feat-security-*` work and keeps the active worklog focused on Security. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing security, API, captcha, scheduler, mailer, and logging drafts. +- Existing worklog archive convention in `dev/WORKLOG_HISTORY.md`. + +## Implementation sequence + +1. Expand the master security hardening plan with links to every detailed branch plan. +2. Add one detail file for each `feat-security-*` branch under `dev/draft/security-hardening/`. +3. Align related drafts only where product decisions changed: `/api/live/**` rate-limit exclusion, GeoIP as observability first, IconCaptcha as a dedicated branch, scoped limiter resets, Turbo/browser prefetch classification, and database-backed auto-bans. +4. Move non-Security active branch logs from `dev/WORKLOG.md` to compact sections in `dev/WORKLOG_HISTORY.md`. +5. Keep global roadmap and global To-Do items in the active worklog. + +## Public interfaces and data decisions + +- No runtime interfaces, routes, entities, configuration, services, commands, migrations, or translations are added in this branch. +- Documentation establishes fixed defaults for later branches: database-backed auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, and ordinary rate-limit exclusion for `/api/live/**`. + +## Edge cases + +- Do not archive the active `feat-security-planning` branch log. +- Preserve deferred follow-ups from old logs in compact archive entries when they are still relevant. +- Do not rewrite unrelated historical entries already archived. + +## Tests and validation + +- Run `bin/lint` for changed Markdown files. +- Run `git diff --check`; Markdown metadata hardbreaks may appear in raw Git output, but project lint is authoritative for Markdown. +- Confirm `dev/WORKLOG.md` has no non-Security active branch sections. +- Confirm every branch detail file links back to the master plan and the master plan links to every detail file. + +## Documentation and tracking + +- Update `dev/draft/README.md` if new draft paths need discoverability. +- Update `dev/WORKLOG.md` with concise planning notes only. +- Update `dev/WORKLOG_HISTORY.md` with compact archived branch summaries. + +## Non-goals + +- No runtime behavior. +- No threshold tuning. +- No branch implementation beyond planning and archive cleanup. + +## Acceptance criteria + +- A future implementer can start any `feat-security-*` branch from its detail plan without inventing product policy. +- The active worklog is short enough to serve as review notes for Security planning. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md new file mode 100644 index 00000000..98bd4312 --- /dev/null +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -0,0 +1,71 @@ +# Rate enforcement branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-rate-enforcement` implementation plan. + +## Goal + +Wire Symfony RateLimiter through the Studio abuse facade so known workflows get action-aware limits, global budgets, scoped resets, and stable HTML/JSON `429` responses. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- `feat-security-abuse-foundation`. +- Existing form, API, scheduler, login, account-token, message, and error-rendering foundations. + +## Implementation sequence + +1. Configure named Symfony limiters for login, registration, password reset, contact, captcha failure, website global, API read, API write, scheduler trigger, import, and suspicious probes where the workflow exists. +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 successful 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. + +## 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. +- Registration and password-reset success do not reset global buckets by default. + +## 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. +- Read-only API keys hitting write routes should still follow API write policy before or alongside authorization failure as decided by the handler order. +- `/api/live/**` operation polling must continue to function during long admin operations. + +## Tests and validation + +- Test each guarded workflow below and above threshold. +- Test global budget catches mixed suspicious actions. +- Test successful login resets only the login bucket. +- Test `/api/live/**` never receives ordinary rate-limit `429`. +- Test browser HTML and API JSON `429` shapes. +- Test configured limiter service wiring with `lint:container`. + +## Documentation and tracking + +- Update Security draft thresholds and reset behavior. +- Update API/Scheduler notes for JSON `429` behavior. +- Update class map for facade/enforcement services. +- Record focused test commands and any threshold changes in the worklog. + +## Non-goals + +- No auto-ban records. +- No partial refund API. +- No IconCaptcha provider. + +## Acceptance criteria + +- Known workflows are rate-limited through one facade. +- Successful human outcomes can clear scoped local buckets without weakening global abuse detection. diff --git a/dev/draft/security-hardening/remember-me.md b/dev/draft/security-hardening/remember-me.md new file mode 100644 index 00000000..6a4fcc45 --- /dev/null +++ b/dev/draft/security-hardening/remember-me.md @@ -0,0 +1,73 @@ +# Remember-me branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-remember-me` implementation plan. + +## Goal + +Add persistent login using server-side revocable tokens that remain bound to account status, visitor identity, and audit policy. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing Symfony Security login, visitor identity/session binding, account lifecycle, audit logging, password change, logout, and APP_SECRET rotation handling. +- Symfony remember-me foundation. + +## Implementation sequence + +1. Add a server-side persistent login token model with selector, hashed token, owning user, visitor binding, issued/last-used timestamps, expiry, status, and revocation reason. +2. Configure Symfony remember-me to use an opaque browser selector/token and the server-side provider. +3. Add a login checkbox that issues a seven-day token only after explicit credential login. +4. On automatic login, validate token status, expiry, user status, visitor binding, and suspicious reuse before creating a fresh Symfony session. +5. Rotate token value on successful automatic login while preserving original expiry unless a full credential login issues a new token. +6. Revoke tokens on manual logout, password change/reset, account inactive/deleted status, security-review dispute, APP_SECRET emergency handling, and suspicious reuse. +7. Add audit entries for issue, auto-login success, logout revocation, mismatch, reuse, and lifecycle revocation. + +## Public interfaces and data decisions + +- Trust window is seven days. +- Browser cookie contains only opaque selector/token material. +- Server stores only hashed token values. +- Remember-me never bypasses `UserAccountChecker`, account status, role checks, or session visitor binding. +- Token list/revocation UI may be added to profile if branch scope remains small; otherwise record as follow-up. + +## Edge cases + +- Copied remember-me cookie with different visitor signal is revoked and audited. +- Reused old rotated token revokes the token family where practical. +- Deleted/inactive users cannot auto-login. +- Owner accounts may use remember-me, but lifecycle revocation and recovery protection still apply. +- APP_SECRET rotation revokes active persistent tokens unless a tested re-encryption/rehash path exists. + +## Tests and validation + +- Test issue, auto-login, rotation, expiry, logout revocation, password/status revocation, and APP_SECRET revocation. +- Test visitor mismatch and token reuse audit. +- Test inactive/deleted user denial. +- Test cookie attributes and absence of raw token storage. +- Test container/security firewall wiring. + +## Documentation and tracking + +- Update Security draft with final remember-me model. +- Update user account docs if UI is exposed. +- Update class map for entity/provider/services/subscribers. +- Record verification around copied-cookie risk. + +## Non-goals + +- No bare identity cookie. +- No indefinite sliding session. +- No MFA/2FA step-up in this branch. + +## Acceptance criteria + +- Persistent login is revocable, auditable, visitor-bound, and bounded to seven days. +- Automatic login cannot revive disabled accounts or bypass existing security checks. From ca133e0797516d369e4a14b753a2179186dc26a7 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:26:33 +0200 Subject: [PATCH 002/119] Refine security hardening branch plans --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 11 ++++++++--- dev/draft/security-hardening/abuse-foundation.md | 8 ++++++-- dev/draft/security-hardening/auto-ban.md | 6 +++++- dev/draft/security-hardening/captcha-contract.md | 5 +++-- dev/draft/security-hardening/geoip-observability.md | 4 +++- dev/draft/security-hardening/icon-captcha.md | 4 +++- .../security-hardening/mailer-account-delivery.md | 4 ++++ dev/draft/security-hardening/policy-docs.md | 4 +++- dev/draft/security-hardening/rate-enforcement.md | 5 ++++- dev/draft/security-hardening/remember-me.md | 8 ++++++-- 11 files changed, 46 insertions(+), 14 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 19eeb5e0..24f02260 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -82,6 +82,7 @@ - Recorded planning decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, action-aware limiter costs, scoped `reset()`-based bucket recovery, cross-action abuse signals, TTL auto-bans, Owner lockout protection, and GeoIP as observability before enforcement. - Aligned the Security, API, Contact/Mail/Logging, and IconCaptcha drafts with the planning branch decisions, including treating live captcha refreshes as passive abuse signals rather than ordinary rate-limit `429` responses. - Compacted the non-Security `feat-symfony-ux-integration` and `docs-cleanup` active branch logs into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on Security planning. +- Re-reviewed every Security detail plan for implementation readiness and tightened unresolved planning language around passive-signal persistence, GeoIP update tasks, rate-limit workflow wiring, auto-ban TTL records, captcha provider policy, IconCaptcha cache/TTL behavior, account-mail delivery guards, and remember-me token management UI. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 2ceac992..24221f24 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -63,7 +63,7 @@ Scope: - Add or complete a MaxMind/GeoIP2 provider behind the existing GeoIP resolver boundary. - Store provider configuration as protected administrator-only configuration. - Keep `NullGeoIpResolver` as the safe fallback when configuration or local databases are missing. -- Add scheduled update support if the scheduler branch already exposes the needed task boundary; otherwise add the task definition and leave activation disabled. +- Add a scheduler-ready GeoIP update task definition and leave it inactive by default until an administrator configures provider credentials and update policy. - Surface safe GeoIP status in Admin diagnostics. Non-goals: @@ -87,7 +87,7 @@ Scope: - Add subject resolution for IP, visitor ID, authenticated user, API key, and safe combined keys. - Add request-intent classification for browser navigation, Turbo prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. - Add a central action cost catalogue with separate website and API families. -- Add passive suspicious-signal recording with TTL. +- Add database-backed passive suspicious-signal recording with TTL cleanup metadata. These records remain observational in this branch and become enforcement inputs only in later branches. - Exempt `/api/live/**` from normal enforcement while allowing passive signal hooks for clearly abusive patterns. - Add audit/message events for suspicious signals with redacted context. @@ -109,7 +109,7 @@ Detailed plan: [rate-enforcement](security-hardening/rate-enforcement.md). Scope: -- Add named buckets for login, registration, password reset, contact, captcha failure, website global, API read, API write, scheduler trigger, import, and suspicious probes where the workflow exists. +- Add named buckets for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure flows. Branches that introduce a later workflow must attach it to the existing policy catalogue instead of inventing a parallel limiter. - Treat captcha refreshes served through `/api/live/**` as passive abuse signals instead of ordinary rejecting rate-limit buckets. - Use costed `consume(n)` calls for action-aware spending. - Use `reset()` for clear successful outcomes such as successful login or verified captcha where the reset is scoped and safe. @@ -231,6 +231,7 @@ Scope: - Bind automatic login to the current visitor signal. - Revoke tokens on logout, password change, account status changes, APP_SECRET emergency handling, and suspicious reuse. - Use a seven-day trust window unless implementation evidence supports changing it. +- Include a minimal account-facing token review/revocation surface so persistent login is operable without database access. Non-goals: @@ -253,9 +254,13 @@ Acceptance: - Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. - Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. +- Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. +- IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is five minutes, with one-shot invalidation after every validation attempt. +- Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. +- Remember-me includes a minimal profile/security UI for listing active persistent tokens and revoking individual tokens or all other tokens. ## Remaining calibration points diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 8e07e1f5..dc72dadf 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -26,7 +26,7 @@ Codex may create local commits for this branch when each commit has a clear them 2. Add subject resolution for IP bucket, visitor ID, authenticated user UID, API key UID/prefix, and safe combined subject keys. 3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, 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 passive suspicious-signal recording with TTL-ready metadata and redacted message/audit reporting. +5. Add database-backed passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. 6. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. ## Public interfaces and data decisions @@ -34,7 +34,8 @@ Codex may create local commits for this branch when each commit has a clear them - Controllers and future packages call a Studio-owned abuse facade instead of Symfony RateLimiter directly. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. -- No entity schema is required unless passive-signal review needs persistence immediately; cache/file/database choice must be justified in the branch before implementation. +- First implementation uses a portable database table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, first-seen timestamp, last-seen timestamp, expiry timestamp, safe context hash, and optional audit reference. +- Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. ## Edge cases @@ -42,18 +43,21 @@ Codex may create local commits for this branch when each commit has a clear them - Invalid Bearer API keys should still classify as API activity without trusting the key as an authenticated subject. - Authenticated Owner requests still classify normally; Owner lockout protection is enforced in later branches. - Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. +- Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. ## 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, `/api/live/**`, login, registration, password reset, and suspicious probes. - Test redaction in passive signal messages. +- Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. - Test no limiter or ban enforcement occurs in this branch. ## Documentation and tracking - Update Security draft with facade and classification names if they become stable public extension points. - Update class map for the facade and value objects only if they are contributor-facing services. +- Update class map for the passive-signal entity/repository/cleanup command if they are added. - Record default cost catalogue decisions in the worklog. ## Non-goals diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 7f55d694..91aa2e6f 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -24,7 +24,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Implementation sequence 1. Add database-backed ban records with subject type, normalized subject key, reason code, source signal summary, status, created/expiry timestamps, actor context where available, and manual unban metadata. -2. Add cleanup for expired bans through command and scheduler-ready task. +2. Add cleanup for expired bans through command and scheduler-ready task with a separate review-retention window for recently expired records. 3. Add ban-decision checks to the abuse facade after request classification and before expensive workflow handling. 4. Enforce by default for anonymous/IP/visitor/API probe abuse. 5. Apply softer authenticated handling: throttle, captcha, or warning state before hard block unless account compromise signals are explicit. @@ -37,6 +37,8 @@ Codex may create local commits for this branch when each commit has a clear them - Ban subject types are IP bucket, visitor ID, API key, combined anonymous subject, and optional authenticated user only for explicit compromise cases. - Ban reasons use stable message/code catalogues. - Ban responses use HTML or JSON according to request family and never expose raw signal internals. +- Suggested record fields are subject type/key, reason code, source signal digest, status, created at, expires at, lifted at, lifted by, lift reason, actor context hash, last matched at, match count, and audit reference. +- Initial TTL defaults should be conservative and test-backed: short anonymous/probe bans first, longer repeat bans only after repeated signals within the review window, and no permanent bans. ## Edge cases @@ -44,6 +46,7 @@ Codex may create local commits for this branch when each commit has a clear them - Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. - Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. - Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. +- Manual unban must take effect immediately even if passive signals that created the ban still exist. ## Tests and validation @@ -52,6 +55,7 @@ Codex may create local commits for this branch when each commit has a clear them - Test Owner recovery protection. - Test HTML/JSON ban responses and redaction. - Test Admin manual unban writes audit entries. +- Test repeat-ban TTL escalation stays bounded and does not create permanent bans. - Test migration applies on SQLite. ## Documentation and tracking diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md index c2eeddc2..b56989f7 100644 --- a/dev/draft/security-hardening/captcha-contract.md +++ b/dev/draft/security-hardening/captcha-contract.md @@ -18,7 +18,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Dependencies - Abuse facade from `feat-security-abuse-foundation`. -- Rate reset hooks from `feat-security-rate-enforcement` where available. +- Rate reset hooks from `feat-security-rate-enforcement`. - Existing Symfony Form, Validator, Translation, package contribution, and settings foundations. ## Implementation sequence @@ -36,6 +36,7 @@ Codex may create local commits for this branch when each commit has a clear them - Missing or disabled provider validates successfully unless a future provider-required policy is explicitly configured for a workflow. - Captcha result exposes only stable failure codes and safe context. - Provider contracts are package-facing extension points and must be documented. +- Provider-required behavior is not enabled in the first contract branch; workflow policy may declare the shape for later enforcement, but default runtime behavior remains graceful success for unavailable providers. ## Edge cases @@ -57,7 +58,7 @@ Codex may create local commits for this branch when each commit has a clear them - Update Security and IconCaptcha drafts with final contract names. - Update package developer guidance for provider registration. - Update class map for provider interface, resolver, form type, and validation services. -- Record provider-required policy as deferred unless implemented. +- Record provider-required policy as deferred design context; do not enforce it in this branch. ## Non-goals diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 39dbe9df..b37435e6 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -26,7 +26,7 @@ Codex may create local commits for this branch when each commit has a clear them 1. Add a MaxMind-backed resolver behind the existing GeoIP resolver interface. 2. Add protected administrator-only settings for provider selection, database path/status, account/license key, and update policy. 3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. -4. Add a scheduler-ready update task definition for GeoIP database refresh; default inactive unless an existing scheduler policy already enables safe maintenance tasks. +4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until provider credentials and update policy are configured by an administrator. 5. Add safe Admin diagnostics for provider status, last update attempt, database freshness, and disabled/unconfigured state. 6. Wire access logs and statistics to consume normalized provider output only through the resolver interface. @@ -35,6 +35,7 @@ Codex may create local commits for this branch when each commit has a clear them - GeoIP output uses normalized nullable or `n/a` fields for country, region, city, latitude/longitude where available, provider status, and lookup status. - Provider secrets are protected config values and never rendered outside authorized Admin settings. - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. +- Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code. - No public API response adds GeoIP data in this branch. ## Edge cases @@ -49,6 +50,7 @@ Codex may create local commits for this branch when each commit has a clear them - Test protected settings visibility and redaction. - Test access-log/statistics enrichment with provider data and with disabled/missing provider. - Test scheduler task no-op and failure message behavior. +- Test that the task remains inactive until provider configuration and update policy are both present. - Run focused container lint when services/config are added. ## Documentation and tracking diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index c5bf32e5..76331766 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -24,7 +24,7 @@ Codex may create local commits for this branch when each commit has a clear them 1. Add the first-party provider package skeleton with captcha-provider scope, package-owned services, templates, assets, translations, and JavaScript. 2. Implement deterministic challenge generation from provider secret, challenge ID, timestamp, workflow key, route context, user agent, and optional existing session/visitor signal. -3. Store one-shot challenge IDs and short-lived challenge metadata in Symfony cache or another documented short-lived store. +3. Store one-shot challenge IDs and short-lived challenge metadata in a dedicated Symfony cache pool where practical, falling back to `cache.app` if the project has no dedicated pool yet. 4. Implement validation for missing, expired, reused, invalid choice, wrong choice, context mismatch, asset error, and provider unavailable. 5. Add lightweight refresh through `/api/live/**` or a provider-owned JSON route with no ordinary rate-limit rejection; record passive abuse signals for aggressive refreshes. 6. Add accessible, layout-stable UI with fixed button grid, translated labels, keyboard support, and back-forward-cache refresh handling. @@ -34,6 +34,7 @@ Codex may create local commits for this branch when each commit has a clear them - Provider key is `icon_captcha`. - Public challenge payload contains only challenge ID, timestamp, render metadata, and button identifiers needed for display. - Provider secret is generated/configured outside manifests and public assets. +- Default challenge TTL is five minutes, and validation invalidates the challenge after every attempt, successful or failed. - SVG/icons must be allowlisted or sanitized before inline rendering. ## Edge cases @@ -49,6 +50,7 @@ Codex may create local commits for this branch when each commit has a clear them - Test challenge generation determinism and answer validation. - Test every failure model. - Test one-shot replay prevention and TTL expiry. +- Test cache-pool fallback and secret absence from cached/public challenge payloads. - Test refresh no-store behavior and passive signal recording. - Test package asset/template/translation registration. - Test keyboard/accessibility behavior where practical with JS tests. diff --git a/dev/draft/security-hardening/mailer-account-delivery.md b/dev/draft/security-hardening/mailer-account-delivery.md index 781c2e0c..029f54e2 100644 --- a/dev/draft/security-hardening/mailer-account-delivery.md +++ b/dev/draft/security-hardening/mailer-account-delivery.md @@ -35,6 +35,8 @@ Codex may create local commits for this branch when each commit has a clear them - Allowed replacement keys are defined by the mail-flow registry/provider metadata. - Clear action URLs may exist in queued mail payloads but must not be duplicated into log context. - Missing localized templates fall back to default language and record safe warnings. +- The first transport guard allows one queued account-flow message per user action, requires configured sender/transport before production delivery, and relies on Messenger retry/backoff instead of controller-level loops. +- Built-in account flows must remain registered by provider metadata even if no third-party provider exists yet. ## Edge cases @@ -42,6 +44,7 @@ Codex may create local commits for this branch when each commit has a clear them - Queue failures should not leave account tokens silently unreachable; callers receive a generic delivery failure. - Debug log delivery must be unavailable or strongly warned in production-like environments. - APP_SECRET recovery owner links need safe partial-failure reporting. +- Duplicate delivery attempts for the same token/action should be idempotent or clearly audited so operators can distinguish retries from new security events. ## Tests and validation @@ -50,6 +53,7 @@ Codex may create local commits for this branch when each commit has a clear them - Test Messenger queue payload redaction. - Test account flows use real delivery when configured and debug delivery only when allowed. - Test transport guard behavior and safe failure messages. +- Test duplicate/retry behavior for token-bearing account messages. ## Documentation and tracking diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md index 6737a0b4..ce0081a7 100644 --- a/dev/draft/security-hardening/policy-docs.md +++ b/dev/draft/security-hardening/policy-docs.md @@ -31,7 +31,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Public interfaces and data decisions - No runtime interfaces, routes, entities, configuration, services, commands, migrations, or translations are added in this branch. -- Documentation establishes fixed defaults for later branches: database-backed auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, and ordinary rate-limit exclusion for `/api/live/**`. +- Documentation establishes fixed defaults for later branches: database-backed passive-signal and auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, ordinary rate-limit exclusion for `/api/live/**`, IconCaptcha challenge cache/TTL behavior, account-mail transport guard expectations, and minimal remember-me token management UI. ## Edge cases @@ -57,8 +57,10 @@ Codex may create local commits for this branch when each commit has a clear them - No runtime behavior. - No threshold tuning. - No branch implementation beyond planning and archive cleanup. +- No new open product questions unless the answer blocks a later branch from being implemented safely. ## Acceptance criteria - A future implementer can start any `feat-security-*` branch from its detail plan without inventing product policy. +- Remaining calibration points are explicitly framed as implementation defaults to be committed and tested in the owning branch, not as unresolved product direction. - The active worklog is short enough to serve as review notes for Security planning. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 98bd4312..3c77bc72 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -22,7 +22,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Implementation sequence -1. Configure named Symfony limiters for login, registration, password reset, contact, captcha failure, website global, API read, API write, scheduler trigger, import, and suspicious probes where the workflow exists. +1. Configure named Symfony limiters for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure 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 successful captcha validation where the workflow explicitly allows it. @@ -35,6 +35,8 @@ Codex may create local commits for this branch when each commit has a clear them - 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. - Registration and password-reset success do not reset global buckets by default. +- The branch must commit initial threshold defaults as named configuration/constants with behavior tests. Later branches may tune those defaults only with matching draft/worklog notes. +- 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. ## Edge cases @@ -50,6 +52,7 @@ Codex may create local commits for this branch when each commit has a clear them - Test successful login resets only the login bucket. - Test `/api/live/**` never receives ordinary rate-limit `429`. - Test browser HTML and API JSON `429` shapes. +- 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 configured limiter service wiring with `lint:container`. ## Documentation and tracking diff --git a/dev/draft/security-hardening/remember-me.md b/dev/draft/security-hardening/remember-me.md index 6a4fcc45..f463fc15 100644 --- a/dev/draft/security-hardening/remember-me.md +++ b/dev/draft/security-hardening/remember-me.md @@ -29,6 +29,7 @@ Codex may create local commits for this branch when each commit has a clear them 5. Rotate token value on successful automatic login while preserving original expiry unless a full credential login issues a new token. 6. Revoke tokens on manual logout, password change/reset, account inactive/deleted status, security-review dispute, APP_SECRET emergency handling, and suspicious reuse. 7. Add audit entries for issue, auto-login success, logout revocation, mismatch, reuse, and lifecycle revocation. +8. Add a minimal profile/security UI for active persistent tokens with revoke-current, revoke-other, and revoke-all actions. ## Public interfaces and data decisions @@ -36,7 +37,8 @@ Codex may create local commits for this branch when each commit has a clear them - Browser cookie contains only opaque selector/token material. - Server stores only hashed token values. - Remember-me never bypasses `UserAccountChecker`, account status, role checks, or session visitor binding. -- Token list/revocation UI may be added to profile if branch scope remains small; otherwise record as follow-up. +- Token records include selector, hashed token, user, visitor binding hash, issued at, last used at, expires at, status, revocation reason, last IP bucket, last user-agent hash, and token family identifier. +- Token list/revocation UI is part of the branch scope so users can operate the feature without direct database access. ## Edge cases @@ -45,6 +47,7 @@ Codex may create local commits for this branch when each commit has a clear them - Deleted/inactive users cannot auto-login. - Owner accounts may use remember-me, but lifecycle revocation and recovery protection still apply. - APP_SECRET rotation revokes active persistent tokens unless a tested re-encryption/rehash path exists. +- Revoking all other tokens must not destroy the current authenticated session unless the user explicitly revokes the current token/session. ## Tests and validation @@ -52,12 +55,13 @@ Codex may create local commits for this branch when each commit has a clear them - Test visitor mismatch and token reuse audit. - Test inactive/deleted user denial. - Test cookie attributes and absence of raw token storage. +- Test profile token list and revoke actions, including current-token and other-token behavior. - Test container/security firewall wiring. ## Documentation and tracking - Update Security draft with final remember-me model. -- Update user account docs if UI is exposed. +- Update user account docs for the persistent-token review/revocation UI. - Update class map for entity/provider/services/subscribers. - Record verification around copied-cookie risk. From c8de6bbaca7f9c6698fbc06296824d3e069693eb Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:29:55 +0200 Subject: [PATCH 003/119] Document legacy security plugin reference --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 4 ++++ dev/draft/security-hardening/abuse-foundation.md | 4 ++++ dev/draft/security-hardening/auto-ban.md | 4 ++++ dev/draft/security-hardening/captcha-contract.md | 4 ++++ dev/draft/security-hardening/geoip-observability.md | 4 ++++ dev/draft/security-hardening/icon-captcha.md | 4 ++++ dev/draft/security-hardening/rate-enforcement.md | 4 ++++ 8 files changed, 29 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 24f02260..4895f5d7 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -83,6 +83,7 @@ - Aligned the Security, API, Contact/Mail/Logging, and IconCaptcha drafts with the planning branch decisions, including treating live captcha refreshes as passive abuse signals rather than ordinary rate-limit `429` responses. - Compacted the non-Security `feat-symfony-ux-integration` and `docs-cleanup` active branch logs into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on Security planning. - Re-reviewed every Security detail plan for implementation readiness and tightened unresolved planning language around passive-signal persistence, GeoIP update tasks, rate-limit workflow wiring, auto-ban TTL records, captcha provider policy, IconCaptcha cache/TTL behavior, account-mail delivery guards, and remember-me token management UI. +- Added the legacy Grav `sec-lookup` plugin at `/Volumes/Projekte/temp/sec-lookup` as an inspiration-only reference for GeoIP, abuse, rate-limit, auto-ban, captcha, and IconCaptcha planning, with current Symfony product decisions taking priority over historical implementation details. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 24221f24..97f49734 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -17,6 +17,10 @@ Detailed handoff plans live in `dev/draft/security-hardening/`. Each branch plan Codex may create local commits for Security planning and implementation work when the commit scope is thematically clear and reviewable. Pushes are never implied by these plans and require an explicit user instruction. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be used as implementation inspiration for GeoIP, abuse-signal, rate-limit, auto-ban, captcha, and IconCaptcha work because similar features were already proven there. The current Symfony product decisions in this plan and the detail plans have absolute priority over the historical implementation. Do not copy old logic, storage models, identifiers, templates, assets, secrets, or framework-specific shortcuts directly; use the plugin only to understand behavior patterns, edge cases, and operational lessons. + ## Planning decisions - Keep Symfony Security, CSRF, Validator, RateLimiter, Messenger, Lock, and existing project message/audit/log layers as the primary foundations. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index dc72dadf..d1e36dd6 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -20,6 +20,10 @@ Codex may create local commits for this branch when each commit has a clear them - Existing visitor identity, access logging, audit logging, API-key authentication, Scheduler API authentication, and `/api/live/**` route boundaries. - Symfony Request data and Turbo/browser prefetch headers. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for suspicious-request categories, passive-signal examples, and diagnostics language. Current subject-resolution, privacy, database-portability, and no-enforcement decisions in this branch have priority. Do not copy legacy logic or framework-specific request handling directly. + ## Implementation sequence 1. Add an abuse namespace with value objects for subject, request family, request intent, action cost, and passive signal. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 91aa2e6f..6545da9f 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -21,6 +21,10 @@ Codex may create local commits for this branch when each commit has a clear them - `feat-security-rate-enforcement`. - Existing Admin, audit, message, user-role, visitor identity, and API-key foundations. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for temporary-block workflows, ban-review ergonomics, and false-positive lessons. Current database-backed TTL records, authenticated soft handling, Owner recovery protection, and Admin audit requirements have priority. Do not copy legacy logic, thresholds, or persistence directly. + ## Implementation sequence 1. Add database-backed ban records with subject type, normalized subject key, reason code, source signal summary, status, created/expiry timestamps, actor context where available, and manual unban metadata. diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md index b56989f7..b08470cb 100644 --- a/dev/draft/security-hardening/captcha-contract.md +++ b/dev/draft/security-hardening/captcha-contract.md @@ -21,6 +21,10 @@ Codex may create local commits for this branch when each commit has a clear them - Rate reset hooks from `feat-security-rate-enforcement`. - Existing Symfony Form, Validator, Translation, package contribution, and settings foundations. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for captcha workflow expectations and human-recovery signals. Current provider-contract, graceful `none` behavior, Symfony Form integration, and package-facing extension rules have priority. Do not copy legacy logic or provider coupling directly. + ## Implementation sequence 1. Define captcha provider and result contracts for render, validate, provider key, label key, and failure reason. diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index b37435e6..dbd42827 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -21,6 +21,10 @@ Codex may create local commits for this branch when each commit has a clear them - Existing protected settings, scheduler, message, audit, access-log, and statistics foundations. - MaxMind/GeoIP2 package already listed as the first provider choice. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for GeoIP provider behavior, update diagnostics, fallback handling, and operator-facing status ideas. Current Symfony product decisions, protected-settings rules, resolver boundaries, and redaction requirements have priority. Do not copy legacy logic or storage shape directly. + ## Implementation sequence 1. Add a MaxMind-backed resolver behind the existing GeoIP resolver interface. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index 76331766..b393e44a 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -20,6 +20,10 @@ Codex may create local commits for this branch when each commit has a clear them - `feat-security-captcha-contract`. - Package lifecycle, AssetMapper/Tailwind, translation aggregation, `/api/live/**`, and abuse passive signal foundations. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for IconCaptcha challenge flow, refresh behavior, accessibility pitfalls, and abuse-signal ideas. Current provider-owned package boundaries, deterministic one-shot challenge policy, cache/TTL decisions, `/api/live/**` behavior, and product decisions in this plan have priority. Do not copy legacy logic, assets, templates, secrets, identifiers, or framework-specific shortcuts directly. + ## Implementation sequence 1. Add the first-party provider package skeleton with captcha-provider scope, package-owned services, templates, assets, translations, and JavaScript. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 3c77bc72..cbfea89e 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -20,6 +20,10 @@ Codex may create local commits for this branch when each commit has a clear them - `feat-security-abuse-foundation`. - Existing form, API, scheduler, login, account-token, message, and error-rendering foundations. +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for rate-limit pressure patterns, bucket naming ideas, and human-recovery behavior. Current Symfony RateLimiter integration, Studio facade boundaries, `/api/live/**` exclusion, and scoped `reset()` policy have priority. Do not copy legacy logic or thresholds directly. + ## Implementation sequence 1. Configure named Symfony limiters for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure flows. From 5e02369c9fd069dc37d75304b45665acf531fa8a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:36:21 +0200 Subject: [PATCH 004/119] Add security PR readiness planning notes --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 14 +++++++++++++ dev/draft/0.4.x-IconCaptcha.md | 17 ++++++++++++--- .../security-hardening/abuse-foundation.md | 1 + dev/draft/security-hardening/auto-ban.md | 1 + .../security-hardening/captcha-contract.md | 1 + .../security-hardening/geoip-observability.md | 1 + dev/draft/security-hardening/icon-captcha.md | 21 +++++++++++++------ .../mailer-account-delivery.md | 1 + dev/draft/security-hardening/policy-docs.md | 1 + .../security-hardening/rate-enforcement.md | 1 + dev/draft/security-hardening/remember-me.md | 1 + 12 files changed, 52 insertions(+), 9 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 4895f5d7..bc8eb13f 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,6 +84,7 @@ - Compacted the non-Security `feat-symfony-ux-integration` and `docs-cleanup` active branch logs into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on Security planning. - Re-reviewed every Security detail plan for implementation readiness and tightened unresolved planning language around passive-signal persistence, GeoIP update tasks, rate-limit workflow wiring, auto-ban TTL records, captcha provider policy, IconCaptcha cache/TTL behavior, account-mail delivery guards, and remember-me token management UI. - Added the legacy Grav `sec-lookup` plugin at `/Volumes/Projekte/temp/sec-lookup` as an inspiration-only reference for GeoIP, abuse, rate-limit, auto-ban, captcha, and IconCaptcha planning, with current Symfony product decisions taking priority over historical implementation details. +- Added IconCaptcha asset/license, inline-rendering, bot-resistance, and neutral accessibility-label requirements, and documented Security PR-readiness checks that must be completed from the actual branch diff before PRs. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 97f49734..193eaa4a 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -253,6 +253,20 @@ Acceptance: - Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. - Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. - Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. +- Each PR must complete the Security PR-readiness checklist below from the actual branch diff. Do not pre-check items from the template without reviewing the changed public entry points, data flows, browser storage, package boundaries, docs, translations, and verification output for that branch. + +## Security PR-readiness checklist + +- [ ] Security/privacy considerations, public entry points, sessions, secrets, and browser storage reviewed. +- [ ] Package/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed. +- [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed. +- [ ] Project-rules, architecture, naming, and documentation drift reviewed; see issue `#57` for the broader drift-audit expectations. +- [ ] Follow-up tasks captured in `dev/WORKLOG.md`. +- [ ] Translations and user-facing copy updated or explicitly confirmed unchanged. +- [ ] `bin/phpunit`: [RESULT] +- [ ] `bin/jstest`: [RESULT] +- [ ] `bin/lint`: [RESULT] +- [ ] Other checks not already covered by the full suites above: [RESULT] ## Fixed implementation defaults diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index bd447ade..ff891ae4 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -21,6 +21,10 @@ The core captcha integration should expose a global form field. When a workflow The provider module should own its icon and emoji assets. These assets should live inside the module package rather than in the application core so IconCaptcha remains removable and replaceable. The core should only know that the selected provider can render a challenge, validate a submitted answer, and report recoverable validation failures. +The asset set must be chosen deliberately before implementation. Prefer permissive open-source-compatible licenses such as MIT, Apache-2.0, CC0, or similarly permissive terms, and record provenance and license notes with the provider package. Unclear asset provenance blocks the provider branch until the asset is replaced or the license is confirmed. + +Inline-rendered graphics and symbol SVGs must be bot-resistant. Do not expose answer-bearing names through file names, SVG IDs, CSS classes, `data-*` attributes, translation keys, titles, descriptions, hidden text, or ARIA labels. Use opaque challenge-local identifiers for rendered options and neutral control labels that describe interaction state without naming the target symbol. If neutral labels are not sufficient for assistive technology, implement a separate accessible fallback flow instead of leaking the answer through labels. + The challenge should be deterministic from server-side state, but unpredictable to the client. A challenge ID, timestamp, request context, and application/module secret can derive the icon pool, target icon, and button order. The server can then recompute the expected answer during validation without persisting the whole challenge payload. The challenge ID must still be marked as used after validation to prevent replay. The frontend should keep the interaction lightweight and accessible: one target icon, a fixed button grid, hidden form fields for challenge metadata, a refresh control, and clear error feedback from the normal form validation layer. A two-slot challenge approach is preferred for smooth refresh behavior: switch immediately to a standby challenge, then refill the hidden slot asynchronously. @@ -30,6 +34,7 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, ## Technical Specifications - Implement IconCaptcha as a first-party package with `captcha-provider` scope, for example `packages/icon-captcha/`. - Store provider assets in the module package, for example `assets/icons/` and `assets/emoji/`. +- Prefer MIT, Apache-2.0, CC0, or similarly permissive asset licenses, and commit package-local provenance/license notes for every bundled icon or graphic set. - Use package-owned templates, styles, JavaScript, translations, and PHP services. - Keep the package compatible with the module manifest rules from the plugin modules draft. - Register IconCaptcha through the generic captcha provider contract, for example a tagged provider service. @@ -50,9 +55,11 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Expire challenges after a short configurable TTL. - Use cache pools or another Symfony-native storage mechanism for used challenge IDs and short-lived challenge metadata. - Sanitize SVG assets before rendering inline SVG in buttons, or serve them through a trusted static asset path with a fixed allowlist. -- Keep icon identifiers stable and non-sensitive. +- Keep internal icon identifiers stable and non-sensitive, but never render answer-bearing names into public DOM, asset URLs, CSS classes, JavaScript state, translation keys, or ARIA labels. +- Generate challenge-local opaque option IDs so a bot cannot infer the answer from semantic asset names or DOM metadata. - Use fixed-size UI elements so the challenge does not shift the form layout. -- Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and non-visual labels through translations where needed. +- Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and neutral non-visual labels through translations where needed. Labels must not name the target symbol or correct answer. +- Provide a documented accessible fallback if neutral labels make the visual challenge unsuitable for assistive technology users. - Provide a refresh endpoint or provider action that returns a fresh challenge without caching. - Keep refresh endpoints lightweight. If refreshes are served through `/api/live/**`, aggressive abuse should feed passive security signals instead of normal rate-limit `429` responses. - Refresh challenges after browser back-forward cache restores. @@ -152,6 +159,8 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test browser back-forward cache recovery refreshes stale challenges. - Test SVG/icon asset loading, allowlisting, and sanitization behavior. - Test keyboard interaction and accessible button state where practical. +- Test rendered DOM, inline SVG, ARIA labels, asset paths, serialized challenge payloads, and frontend state for answer-bearing names or reusable answer material. +- Verify asset license/provenance notes for every bundled graphic and symbol set. - Test translation keys for visible provider labels, buttons, and validation errors. - Test that provider failure logs do not contain secrets, seeds, raw challenge internals, or disallowed personal data. @@ -164,6 +173,8 @@ Recoverable failures should produce translated form errors. Suspicious failures - **Decision recorded:** Provider value `none`, missing providers, and disabled provider modules validate successfully to avoid breaking workflows. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected protected workflows. - **Decision recorded:** Store IconCaptcha SVG and emoji assets inside the IconCaptcha module package when implementation begins. +- **Decision recorded:** Prefer permissive open-source-compatible asset licenses such as MIT, Apache-2.0, and CC0, with provenance/license notes committed in the provider package. +- **Decision recorded:** Inline-rendered graphics, symbol SVGs, DOM metadata, and ARIA labels must not reveal answer-bearing names; use opaque option identifiers and neutral labels. - **Decision recorded:** Use the old Grav `sec-lookup` IconCaptcha only as inspiration; do not copy old code or secrets. - **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a short TTL. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. @@ -172,5 +183,5 @@ Recoverable failures should produce translated form errors. Suspicious failures - **Decision recorded:** Keep provider-specific templates theme-compatible but package-owned. - **Required decision:** Define the exact generic `CaptchaProvider` interface before implementation. - **Required decision:** Define the provider secret storage location and rotation behavior. -- **Required decision:** Define the exact icon asset set, asset license notes, and SVG sanitization policy. +- **Required decision:** Define the exact icon asset set and SVG sanitization policy before implementation. Asset license/provenance notes are required branch output, not optional follow-up. - **Deferred:** Exact challenge payload shape, frontend controller implementation, and UI styling belong to the implementation phase. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index d1e36dd6..8443de97 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -63,6 +63,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update class map for the facade and value objects only if they are contributor-facing services. - Update class map for the passive-signal entity/repository/cleanup command if they are added. - Record default cost catalogue decisions in the worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 6545da9f..6fc47e12 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -68,6 +68,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update Admin/security diagnostics notes for review UI. - Update class map for entity, repository, decision service, cleanup command/task, and Admin routes. - Record threshold and false-positive assumptions in worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md index b08470cb..9de8e8cf 100644 --- a/dev/draft/security-hardening/captcha-contract.md +++ b/dev/draft/security-hardening/captcha-contract.md @@ -63,6 +63,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update package developer guidance for provider registration. - Update class map for provider interface, resolver, form type, and validation services. - Record provider-required policy as deferred design context; do not enforce it in this branch. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index dbd42827..47886000 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -63,6 +63,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update Scheduler notes if a task definition is added. - Update class map for resolver, task, settings, and diagnostics entry points. - Add worklog verification notes for redaction and fallback tests. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index b393e44a..9d2288ba 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -27,11 +27,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Implementation sequence 1. Add the first-party provider package skeleton with captcha-provider scope, package-owned services, templates, assets, translations, and JavaScript. -2. Implement deterministic challenge generation from provider secret, challenge ID, timestamp, workflow key, route context, user agent, and optional existing session/visitor signal. -3. Store one-shot challenge IDs and short-lived challenge metadata in a dedicated Symfony cache pool where practical, falling back to `cache.app` if the project has no dedicated pool yet. -4. Implement validation for missing, expired, reused, invalid choice, wrong choice, context mismatch, asset error, and provider unavailable. -5. Add lightweight refresh through `/api/live/**` or a provider-owned JSON route with no ordinary rate-limit rejection; record passive abuse signals for aggressive refreshes. -6. Add accessible, layout-stable UI with fixed button grid, translated labels, keyboard support, and back-forward-cache refresh handling. +2. Select or create a suitable asset set before challenge implementation. Prefer open-source-compatible licenses such as MIT, Apache-2.0, CC0, or similarly permissive licenses, and record asset provenance/license notes in the provider package. +3. Implement deterministic challenge generation from provider secret, challenge ID, timestamp, workflow key, route context, user agent, and optional existing session/visitor signal. +4. Store one-shot challenge IDs and short-lived challenge metadata in a dedicated Symfony cache pool where practical, falling back to `cache.app` if the project has no dedicated pool yet. +5. Implement validation for missing, expired, reused, invalid choice, wrong choice, context mismatch, asset error, and provider unavailable. +6. Add lightweight refresh through `/api/live/**` or a provider-owned JSON route with no ordinary rate-limit rejection; record passive abuse signals for aggressive refreshes. +7. Add accessible, layout-stable UI with fixed button grid, translated labels, keyboard support, and back-forward-cache refresh handling. ## Public interfaces and data decisions @@ -40,6 +41,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Provider secret is generated/configured outside manifests and public assets. - Default challenge TTL is five minutes, and validation invalidates the challenge after every attempt, successful or failed. - SVG/icons must be allowlisted or sanitized before inline rendering. +- Inline-rendered graphics and symbol SVGs must not expose answer-bearing names through file names, element IDs, CSS classes, `data-*` attributes, titles, descriptions, or translation keys. Use opaque challenge-local identifiers and randomized or non-semantic button identifiers. +- Accessibility labels must describe the control purpose without revealing the visual answer. Prefer neutral labels such as option numbers and state/status text over labels that name the target icon or symbol. If this makes the visual challenge insufficient for assistive technology, document and implement a separate accessible fallback flow instead of leaking the answer through ARIA. ## Edge cases @@ -48,6 +51,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Context mismatch is suspicious but should not reveal internals. - Disabled provider falls back according to the generic resolver policy. - Asset loading failures produce safe diagnostics and recoverable user feedback where possible. +- Asset license gaps or unclear provenance block the provider branch until the asset is replaced or the license is documented as acceptable. +- Browser inspection should not reveal the correct answer through DOM order, source file names, SVG IDs, ARIA labels, visible hidden text, or static asset URLs. ## Tests and validation @@ -58,18 +63,22 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test refresh no-store behavior and passive signal recording. - Test package asset/template/translation registration. - Test keyboard/accessibility behavior where practical with JS tests. +- Test that rendered DOM, inline SVG, ARIA labels, asset paths, and serialized challenge payloads do not expose answer-bearing names or reusable answer material. +- Verify asset licenses/provenance and record the result in branch documentation or package metadata. ## Documentation and tracking - Update IconCaptcha draft with final payload/storage choices. - Update package developer guidance if provider package layout adds a reusable pattern. - Update class map for provider, challenge services, controller/live endpoint, assets, and templates. -- Record asset licensing/sanitization notes. +- Record asset licensing, provenance, sanitization, and bot-resistance notes. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals - No workflow-specific IconCaptcha code in contact, registration, or password forms. - No copied legacy implementation, secrets, or hard-coded old asset paths. +- No answer-bearing asset, DOM, CSS, JavaScript, translation, or ARIA names that make the challenge solvable by simple static heuristics. ## Acceptance criteria diff --git a/dev/draft/security-hardening/mailer-account-delivery.md b/dev/draft/security-hardening/mailer-account-delivery.md index 029f54e2..832fb940 100644 --- a/dev/draft/security-hardening/mailer-account-delivery.md +++ b/dev/draft/security-hardening/mailer-account-delivery.md @@ -61,6 +61,7 @@ Codex may create local commits for this branch when each commit has a clear them - Update Security draft where the debug stub is replaced/gated. - Update class map for registry providers, renderer, queued message/handler, and settings UI. - Update user/admin docs if Mail settings become visible. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md index ce0081a7..3e40e4a7 100644 --- a/dev/draft/security-hardening/policy-docs.md +++ b/dev/draft/security-hardening/policy-docs.md @@ -51,6 +51,7 @@ Codex may create local commits for this branch when each commit has a clear them - Update `dev/draft/README.md` if new draft paths need discoverability. - Update `dev/WORKLOG.md` with concise planning notes only. - Update `dev/WORKLOG_HISTORY.md` with compact archived branch summaries. +- Add or maintain the Security PR-readiness checklist in the master hardening plan when review expectations change. ## Non-goals diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index cbfea89e..7c1175d5 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -65,6 +65,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update API/Scheduler notes for JSON `429` behavior. - Update class map for facade/enforcement services. - Record focused test commands and any threshold changes in the worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/remember-me.md b/dev/draft/security-hardening/remember-me.md index f463fc15..afb35e87 100644 --- a/dev/draft/security-hardening/remember-me.md +++ b/dev/draft/security-hardening/remember-me.md @@ -64,6 +64,7 @@ Codex may create local commits for this branch when each commit has a clear them - Update user account docs for the persistent-token review/revocation UI. - Update class map for entity/provider/services/subscribers. - Record verification around copied-cookie risk. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals From d32772a593ab04ab4beb2a3406d2ef9f475970b4 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:37:44 +0200 Subject: [PATCH 005/119] Document accessible IconCaptcha quiz fallback --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 1 + dev/draft/0.4.x-IconCaptcha.md | 7 ++++++- dev/draft/security-hardening/icon-captcha.md | 4 ++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index bc8eb13f..44f1cb4c 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -85,6 +85,7 @@ - Re-reviewed every Security detail plan for implementation readiness and tightened unresolved planning language around passive-signal persistence, GeoIP update tasks, rate-limit workflow wiring, auto-ban TTL records, captcha provider policy, IconCaptcha cache/TTL behavior, account-mail delivery guards, and remember-me token management UI. - Added the legacy Grav `sec-lookup` plugin at `/Volumes/Projekte/temp/sec-lookup` as an inspiration-only reference for GeoIP, abuse, rate-limit, auto-ban, captcha, and IconCaptcha planning, with current Symfony product decisions taking priority over historical implementation details. - Added IconCaptcha asset/license, inline-rendering, bot-resistance, and neutral accessibility-label requirements, and documented Security PR-readiness checks that must be completed from the actual branch diff before PRs. +- Recorded quiz-style IconCaptcha as the preferred accessible fallback when neutral labels are insufficient, keeping quiz prompts/options under the same one-shot challenge, TTL, context-binding, and answer-leak resistance rules. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 193eaa4a..94d93f1c 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -277,6 +277,7 @@ Acceptance: - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is five minutes, with one-shot invalidation after every validation attempt. +- IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. - Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. - Remember-me includes a minimal profile/security UI for listing active persistent tokens and revoking individual tokens or all other tokens. diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index ff891ae4..d2719d1f 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -25,6 +25,8 @@ The asset set must be chosen deliberately before implementation. Prefer permissi Inline-rendered graphics and symbol SVGs must be bot-resistant. Do not expose answer-bearing names through file names, SVG IDs, CSS classes, `data-*` attributes, translation keys, titles, descriptions, hidden text, or ARIA labels. Use opaque challenge-local identifiers for rendered options and neutral control labels that describe interaction state without naming the target symbol. If neutral labels are not sufficient for assistive technology, implement a separate accessible fallback flow instead of leaking the answer through labels. +The preferred accessible fallback is a quiz-style IconCaptcha mode. Instead of naming the visual icon choices through ARIA, the provider can render a spoken question/task and multiple neutral answer options. The submitted answer is then validated through the same provider challenge model as the visual captcha: one challenge ID, short TTL, one-shot invalidation, context binding, refresh behavior, and passive abuse signals. Quiz prompts and answer options must come from vetted provider-owned pools, avoid stable answer-bearing DOM metadata, and use opaque option IDs just like visual icon choices. + The challenge should be deterministic from server-side state, but unpredictable to the client. A challenge ID, timestamp, request context, and application/module secret can derive the icon pool, target icon, and button order. The server can then recompute the expected answer during validation without persisting the whole challenge payload. The challenge ID must still be marked as used after validation to prevent replay. The frontend should keep the interaction lightweight and accessible: one target icon, a fixed button grid, hidden form fields for challenge metadata, a refresh control, and clear error feedback from the normal form validation layer. A two-slot challenge approach is preferred for smooth refresh behavior: switch immediately to a standby challenge, then refill the hidden slot asynchronously. @@ -59,7 +61,8 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Generate challenge-local opaque option IDs so a bot cannot infer the answer from semantic asset names or DOM metadata. - Use fixed-size UI elements so the challenge does not shift the form layout. - Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and neutral non-visual labels through translations where needed. Labels must not name the target symbol or correct answer. -- Provide a documented accessible fallback if neutral labels make the visual challenge unsuitable for assistive technology users. +- Provide a documented accessible quiz fallback if neutral labels make the visual challenge unsuitable for assistive technology users. +- Keep quiz questions, answer options, and prompt identifiers provider-owned and reviewable, but do not render stable internal prompt keys or correct-answer identifiers into public markup. - Provide a refresh endpoint or provider action that returns a fresh challenge without caching. - Keep refresh endpoints lightweight. If refreshes are served through `/api/live/**`, aggressive abuse should feed passive security signals instead of normal rate-limit `429` responses. - Refresh challenges after browser back-forward cache restores. @@ -160,6 +163,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test SVG/icon asset loading, allowlisting, and sanitization behavior. - Test keyboard interaction and accessible button state where practical. - Test rendered DOM, inline SVG, ARIA labels, asset paths, serialized challenge payloads, and frontend state for answer-bearing names or reusable answer material. +- Test accessible quiz fallback success, wrong answer, expiry, replay prevention, context mismatch, refresh behavior, and answer-leak resistance if quiz mode ships in the branch. - Verify asset license/provenance notes for every bundled graphic and symbol set. - Test translation keys for visible provider labels, buttons, and validation errors. - Test that provider failure logs do not contain secrets, seeds, raw challenge internals, or disallowed personal data. @@ -175,6 +179,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - **Decision recorded:** Store IconCaptcha SVG and emoji assets inside the IconCaptcha module package when implementation begins. - **Decision recorded:** Prefer permissive open-source-compatible asset licenses such as MIT, Apache-2.0, and CC0, with provenance/license notes committed in the provider package. - **Decision recorded:** Inline-rendered graphics, symbol SVGs, DOM metadata, and ARIA labels must not reveal answer-bearing names; use opaque option identifiers and neutral labels. +- **Decision recorded:** If neutral ARIA labels are insufficient, prefer a provider-owned quiz challenge with spoken question/task text and multiple answer options over leaking visual answers through labels. - **Decision recorded:** Use the old Grav `sec-lookup` IconCaptcha only as inspiration; do not copy old code or secrets. - **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a short TTL. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index 9d2288ba..f2140e79 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -43,6 +43,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - SVG/icons must be allowlisted or sanitized before inline rendering. - Inline-rendered graphics and symbol SVGs must not expose answer-bearing names through file names, element IDs, CSS classes, `data-*` attributes, titles, descriptions, or translation keys. Use opaque challenge-local identifiers and randomized or non-semantic button identifiers. - Accessibility labels must describe the control purpose without revealing the visual answer. Prefer neutral labels such as option numbers and state/status text over labels that name the target icon or symbol. If this makes the visual challenge insufficient for assistive technology, document and implement a separate accessible fallback flow instead of leaking the answer through ARIA. +- The preferred accessible fallback is a provider-owned quiz challenge using a spoken question/task and multiple answer options. The quiz mode must share the same challenge ID, TTL, one-shot invalidation, context binding, failure codes, refresh handling, and passive abuse signals as the visual IconCaptcha mode. ## Edge cases @@ -53,6 +54,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Asset loading failures produce safe diagnostics and recoverable user feedback where possible. - Asset license gaps or unclear provenance block the provider branch until the asset is replaced or the license is documented as acceptable. - Browser inspection should not reveal the correct answer through DOM order, source file names, SVG IDs, ARIA labels, visible hidden text, or static asset URLs. +- Quiz-mode questions and answer options must be generated from vetted provider-owned prompt pools and opaque option IDs so the correct answer is not inferable from stable DOM metadata or static translation keys. ## Tests and validation @@ -64,6 +66,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test package asset/template/translation registration. - Test keyboard/accessibility behavior where practical with JS tests. - Test that rendered DOM, inline SVG, ARIA labels, asset paths, and serialized challenge payloads do not expose answer-bearing names or reusable answer material. +- Test accessible quiz-mode success, wrong answer, expiry, replay prevention, context mismatch, refresh behavior, and answer-leak resistance. - Verify asset licenses/provenance and record the result in branch documentation or package metadata. ## Documentation and tracking @@ -72,6 +75,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update package developer guidance if provider package layout adds a reusable pattern. - Update class map for provider, challenge services, controller/live endpoint, assets, and templates. - Record asset licensing, provenance, sanitization, and bot-resistance notes. +- Document whether the first implementation ships neutral-label-only visual mode, quiz fallback, or both, and record the accessibility/security trade-off. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals From 1808a2c3d4ffba9c6c3d61c3f6f846267b7de3fa Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:40:34 +0200 Subject: [PATCH 006/119] Add security planning edge-case guardrails --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 5 +++++ dev/draft/security-hardening/abuse-foundation.md | 6 +++++- dev/draft/security-hardening/auto-ban.md | 5 +++++ dev/draft/security-hardening/geoip-observability.md | 5 ++++- dev/draft/security-hardening/icon-captcha.md | 3 +++ dev/draft/security-hardening/rate-enforcement.md | 4 ++++ dev/draft/security-hardening/remember-me.md | 3 +++ 8 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 44f1cb4c..71d54ce7 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -86,6 +86,7 @@ - Added the legacy Grav `sec-lookup` plugin at `/Volumes/Projekte/temp/sec-lookup` as an inspiration-only reference for GeoIP, abuse, rate-limit, auto-ban, captcha, and IconCaptcha planning, with current Symfony product decisions taking priority over historical implementation details. - Added IconCaptcha asset/license, inline-rendering, bot-resistance, and neutral accessibility-label requirements, and documented Security PR-readiness checks that must be completed from the actual branch diff before PRs. - Recorded quiz-style IconCaptcha as the preferred accessible fallback when neutral labels are insufficient, keeping quiz prompts/options under the same one-shot challenge, TTL, context-binding, and answer-leak resistance rules. +- Added final cross-cutting planning guardrails for shared client identity/trusted-proxy handling, injectable time boundaries, degraded storage behavior, and race/idempotency review across Security branches. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 94d93f1c..a81fd1b4 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -253,6 +253,10 @@ Acceptance: - Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. - Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. - Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. +- Client identity must come from one reviewed resolver that respects Symfony trusted-proxy configuration. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, or package-owned client identity logic. +- TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. +- Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. +- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual unban, mail token delivery, and remember-me token rotation. - Each PR must complete the Security PR-readiness checklist below from the actual branch diff. Do not pre-check items from the template without reviewing the changed public entry points, data flows, browser storage, package boundaries, docs, translations, and verification output for that branch. ## Security PR-readiness checklist @@ -273,6 +277,7 @@ Acceptance: - Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. - Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. - Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. +- Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 8443de97..90e0046b 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -27,7 +27,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Implementation sequence 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. +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, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, 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 passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. @@ -36,10 +36,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Public interfaces and data decisions - Controllers and future packages call a Studio-owned abuse facade instead of Symfony RateLimiter directly. +- Client identity must respect Symfony trusted-proxy configuration and must not trust raw forwarding headers outside that configuration. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - First implementation uses a portable database table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, first-seen timestamp, last-seen timestamp, expiry timestamp, safe context hash, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. +- TTL and expiry use an injectable clock/time boundary for deterministic tests. ## Edge cases @@ -48,6 +50,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Authenticated Owner requests still classify normally; Owner lockout protection is enforced in later branches. - Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. - Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. +- Passive-signal storage failure records a safe diagnostic and must not change request outcome in this foundation branch. ## Tests and validation @@ -55,6 +58,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test intent classification for browser, prefetch, API read/write, `/api/live/**`, login, registration, password reset, and suspicious probes. - Test redaction in passive signal messages. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. +- Test trusted-proxy/client-identity behavior and storage-failure degradation. - Test no limiter or ban enforcement occurs in this branch. ## Documentation and tracking diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 6fc47e12..228aedc4 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -43,6 +43,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Ban responses use HTML or JSON according to request family and never expose raw signal internals. - Suggested record fields are subject type/key, reason code, source signal digest, status, created at, expires at, lifted at, lifted by, lift reason, actor context hash, last matched at, match count, and audit reference. - Initial TTL defaults should be conservative and test-backed: short anonymous/probe bans first, longer repeat bans only after repeated signals within the review window, and no permanent bans. +- Ban keys come only from the shared subject/client-identity resolver. Raw IP strings, raw API keys, and raw forwarding headers must never be stored as ban keys. +- Expiry and cleanup use an injectable clock/time boundary. ## Edge cases @@ -51,6 +53,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. - Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. - Manual unban must take effect immediately even if passive signals that created the ban still exist. +- Concurrent ban creation, expiry cleanup, and manual unban must be idempotent and auditable. +- Ban-store degradation must not create an invisible permanent block or lock out Owner recovery. ## Tests and validation @@ -60,6 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test HTML/JSON ban responses and redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. +- Test trusted-proxy/client-identity behavior, ban-store degradation, and concurrent create/unban/cleanup behavior. - Test migration applies on SQLite. ## Documentation and tracking diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 47886000..20cfde19 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -32,11 +32,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. 4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until provider credentials and update policy are configured by an administrator. 5. Add safe Admin diagnostics for provider status, last update attempt, database freshness, and disabled/unconfigured state. -6. Wire access logs and statistics to consume normalized provider output only through the resolver interface. +6. Wire access logs and statistics to consume normalized provider output only through the resolver interface and the shared client-identity resolver. ## Public interfaces and data decisions - GeoIP output uses normalized nullable or `n/a` fields for country, region, city, latitude/longitude where available, provider status, and lookup status. +- Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. - Provider secrets are protected config values and never rendered outside authorized Admin settings. - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. - Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code. @@ -47,12 +48,14 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Missing MaxMind key, unreadable database, expired database, failed download, unsupported IP, private/local IP, and lookup exceptions all degrade to normalized empty fields. - Diagnostics must not include raw license keys, request IP lists, full provider exceptions, or filesystem paths that expose secrets. - GeoIP failures must not block the user request that triggered logging/statistics. +- GeoIP update/download failures must leave the previous usable database in place when possible and record only redacted diagnostics. ## Tests and validation - Unit-test resolver success, null fallback, private/invalid IP handling, and exception fallback. - Test protected settings visibility and redaction. - Test access-log/statistics enrichment with provider data and with disabled/missing provider. +- Test trusted-proxy/client-identity behavior for lookup input. - Test scheduler task no-op and failure message behavior. - Test that the task remains inactive until provider configuration and update policy are both present. - Run focused container lint when services/config are added. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index f2140e79..218eedc1 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -40,6 +40,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Public challenge payload contains only challenge ID, timestamp, render metadata, and button identifiers needed for display. - Provider secret is generated/configured outside manifests and public assets. - Default challenge TTL is five minutes, and validation invalidates the challenge after every attempt, successful or failed. +- Challenge expiry and one-shot state use an injectable clock/time boundary where practical for deterministic tests. - SVG/icons must be allowlisted or sanitized before inline rendering. - Inline-rendered graphics and symbol SVGs must not expose answer-bearing names through file names, element IDs, CSS classes, `data-*` attributes, titles, descriptions, or translation keys. Use opaque challenge-local identifiers and randomized or non-semantic button identifiers. - Accessibility labels must describe the control purpose without revealing the visual answer. Prefer neutral labels such as option numbers and state/status text over labels that name the target icon or symbol. If this makes the visual challenge insufficient for assistive technology, document and implement a separate accessible fallback flow instead of leaking the answer through ARIA. @@ -55,6 +56,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Asset license gaps or unclear provenance block the provider branch until the asset is replaced or the license is documented as acceptable. - Browser inspection should not reveal the correct answer through DOM order, source file names, SVG IDs, ARIA labels, visible hidden text, or static asset URLs. - Quiz-mode questions and answer options must be generated from vetted provider-owned prompt pools and opaque option IDs so the correct answer is not inferable from stable DOM metadata or static translation keys. +- Concurrent double-submit validation must remain one-shot: one attempt wins, later attempts fail recoverably or suspiciously according to the failure model. ## Tests and validation @@ -67,6 +69,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test keyboard/accessibility behavior where practical with JS tests. - Test that rendered DOM, inline SVG, ARIA labels, asset paths, and serialized challenge payloads do not expose answer-bearing names or reusable answer material. - Test accessible quiz-mode success, wrong answer, expiry, replay prevention, context mismatch, refresh behavior, and answer-leak resistance. +- Test concurrent double-submit/replay behavior against the cache store. - Verify asset licenses/provenance and record the result in branch documentation or package metadata. ## Documentation and tracking diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 7c1175d5..ff0a6a9b 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -41,6 +41,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Registration and password-reset success do not reset global buckets by default. - The branch must commit initial threshold defaults as named configuration/constants with behavior tests. Later branches may tune those defaults only with matching draft/worklog notes. - 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. ## Edge cases @@ -48,6 +50,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Failed login consumes login and global website budget; successful login resets only the login-attempt bucket for that subject. - Read-only API keys hitting write routes should still follow API write policy before or alongside authorization failure as decided by the handler order. - `/api/live/**` operation polling must continue to function during long admin operations. +- Concurrent failures and immediate success/reset sequences must not accidentally reset unrelated global buckets or hide suspicious mixed-action behavior. ## Tests and validation @@ -57,6 +60,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test `/api/live/**` never receives ordinary rate-limit `429`. - Test browser HTML and API JSON `429` shapes. - 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 configured limiter service wiring with `lint:container`. ## Documentation and tracking diff --git a/dev/draft/security-hardening/remember-me.md b/dev/draft/security-hardening/remember-me.md index afb35e87..63107ea9 100644 --- a/dev/draft/security-hardening/remember-me.md +++ b/dev/draft/security-hardening/remember-me.md @@ -39,6 +39,7 @@ Codex may create local commits for this branch when each commit has a clear them - Remember-me never bypasses `UserAccountChecker`, account status, role checks, or session visitor binding. - Token records include selector, hashed token, user, visitor binding hash, issued at, last used at, expires at, status, revocation reason, last IP bucket, last user-agent hash, and token family identifier. - Token list/revocation UI is part of the branch scope so users can operate the feature without direct database access. +- Token expiry and rotation use an injectable clock/time boundary for deterministic tests. ## Edge cases @@ -48,6 +49,7 @@ Codex may create local commits for this branch when each commit has a clear them - Owner accounts may use remember-me, but lifecycle revocation and recovery protection still apply. - APP_SECRET rotation revokes active persistent tokens unless a tested re-encryption/rehash path exists. - Revoking all other tokens must not destroy the current authenticated session unless the user explicitly revokes the current token/session. +- Concurrent automatic logins with the same selector/token must rotate or revoke deterministically and audit reuse/mismatch without creating two valid successor tokens. ## Tests and validation @@ -56,6 +58,7 @@ Codex may create local commits for this branch when each commit has a clear them - Test inactive/deleted user denial. - Test cookie attributes and absence of raw token storage. - Test profile token list and revoke actions, including current-token and other-token behavior. +- Test concurrent auto-login/rotation behavior and deterministic expiry through the time boundary. - Test container/security firewall wiring. ## Documentation and tracking From 4fa2be734f2b90f02a44a10f9fe1b6f8c80689f5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:48:04 +0200 Subject: [PATCH 007/119] Document security log projection question --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 1 + dev/draft/0.4.x-ContactMailLogging.md | 3 +++ dev/draft/security-hardening/abuse-foundation.md | 2 ++ 4 files changed, 7 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 71d54ce7..a8eb1fb2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -87,6 +87,7 @@ - Added IconCaptcha asset/license, inline-rendering, bot-resistance, and neutral accessibility-label requirements, and documented Security PR-readiness checks that must be completed from the actual branch diff before PRs. - Recorded quiz-style IconCaptcha as the preferred accessible fallback when neutral labels are insufficient, keeping quiz prompts/options under the same one-shot challenge, TTL, context-binding, and answer-leak resistance rules. - Added final cross-cutting planning guardrails for shared client identity/trusted-proxy handling, injectable time boundaries, degraded storage behavior, and race/idempotency review across Security branches. +- Recorded the open logging architecture question of keeping 30-day rotating file logs as the durable raw source while evaluating a parallel database-backed security event projection for query-heavy Security review and abuse correlation. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index a81fd1b4..eec6e340 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -290,6 +290,7 @@ Acceptance: - Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. - Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. +- Evaluate whether Security events should be projected into database-backed query tables alongside the 30-day rotating file logs. File logs remain the durable raw source; the open question is whether DB projection should become the preferred read model for Admin review, abuse correlation, and Security dashboards. ## References diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index e41b37d5..c0e07024 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -31,6 +31,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Do not expose mail transport errors directly to users. - Log delivery failures with safe context. - Prefer filesystem-backed structured logs over database-backed operational logs for access, error, and security events. +- Keep rotating filesystem logs as the durable raw operational source with a 30-day default retention, but evaluate a parallel database-backed security event projection for query-heavy Security features. - Use stable structured log records that can be emitted as JSONL or converted to JSONL for UI filtering, scripted analysis, and retention jobs. - Start with separate channels for `access`, `error`, and `security`; keep anonymized statistics output separate from raw request-derived logs. - Define a mail template structure that themes may style but not silently break. @@ -72,6 +73,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Test GeoIP update failure handling once scheduled updates are implemented. - Test suspicious-event aggregation for probe paths, repeated 404s, and rate-limit hits. - Test access/security logs remain separable from anonymized aggregate statistics. +- Test any future database-backed security event projection against the same redaction, retention, and disabled-feature fallback rules as the file logs. - Test audit-worthy events are emitted with actor, action, target, timestamp, and safe redaction where implemented. - Run translation comparison after user-facing copy changes. @@ -96,3 +98,4 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** First audit-worthy events should include configuration, protected setting, provider, package lifecycle, schema, import, backup restore, user/ACL, API token, and high-impact maintenance changes. The first implemented events are login success, login failure, logout, backend maintenance action starts/completions, package ZIP verification starts, and package lifecycle action starts/completions. - **Decision recorded:** Defer a generic operations-message event until after the logger is implemented; start with explicit recorder/service boundaries and keep audit/access/security logs separate from the live ActionLog overlay. - **Decision recorded:** Built-in raw log channels use 30-day rotating files by default. Long-term statistics stay separate and may move to database-backed aggregates. +- **Open decision:** Evaluate whether Security features should write a parallel database-backed security event projection in addition to the 30-day rotating file logs. File logs should remain the durable raw operational fallback because they are simple, inspectable, and safer during database degradation. A DB projection may be better for Security review, rate/abuse correlation, Admin filtering, cleanup jobs, and avoiding file scans, but must duplicate only redacted/minimized fields, define retention explicitly, and degrade without weakening enforcement or hiding diagnostics. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 90e0046b..71fadc13 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -41,6 +41,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - First implementation uses a portable database table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, first-seen timestamp, last-seen timestamp, expiry timestamp, safe context hash, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. +- Keep passive signals separate from raw file logs. If a broader database-backed security event projection is introduced later, this branch's signal store should either feed it through a documented boundary or remain the focused enforcement-oriented read model. - TTL and expiry use an injectable clock/time boundary for deterministic tests. ## Edge cases @@ -67,6 +68,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update class map for the facade and value objects only if they are contributor-facing services. - Update class map for the passive-signal entity/repository/cleanup command if they are added. - Record default cost catalogue decisions in the worklog. +- Record whether the branch keeps only the passive-signal store or also introduces/reuses a broader security event projection. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals From 04adeaa6b55bd80870cb21b81dd7e157d4a137d2 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 21:52:37 +0200 Subject: [PATCH 008/119] Document IP retention security limits --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 3 +++ dev/draft/0.4.x-ContactMailLogging.md | 4 ++++ dev/draft/security-hardening/abuse-foundation.md | 3 +++ dev/draft/security-hardening/auto-ban.md | 3 +++ 5 files changed, 14 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index a8eb1fb2..1cba43a8 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -88,6 +88,7 @@ - Recorded quiz-style IconCaptcha as the preferred accessible fallback when neutral labels are insufficient, keeping quiz prompts/options under the same one-shot challenge, TTL, context-binding, and answer-leak resistance rules. - Added final cross-cutting planning guardrails for shared client identity/trusted-proxy handling, injectable time boundaries, degraded storage behavior, and race/idempotency review across Security branches. - Recorded the open logging architecture question of keeping 30-day rotating file logs as the durable raw source while evaluating a parallel database-backed security event projection for query-heavy Security review and abuse correlation. +- Recorded the privacy rule that raw IPs, IP buckets, and stable IP-derived hashes remain queryable for at most 30 days, with longer-term Security/statistics correlation handled through internal visitor IDs or other non-IP subjects. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index eec6e340..78daead1 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -276,8 +276,10 @@ Acceptance: - Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. - Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. +- IP-based enforcement is secondary and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. - Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. - Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. +- Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. @@ -291,6 +293,7 @@ Acceptance: - Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. - Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. - Evaluate whether Security events should be projected into database-backed query tables alongside the 30-day rotating file logs. File logs remain the durable raw source; the open question is whether DB projection should become the preferred read model for Admin review, abuse correlation, and Security dashboards. +- Define backup/export handling for short-retention security data before enabling any database-backed security projection, so restore and support workflows do not reintroduce expired IP-derived records. ## References diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index c0e07024..17896581 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -46,8 +46,10 @@ Audit logging should start with a simple proposed event set and remain easy to a - Never write the MaxMind API key to logs, exported diagnostics, screenshots, fixtures, or public configuration. - Define retention controls before storing request-derived statistics. - Keep raw access logs with retraceable IP addresses for at most 30 days by default. +- Keep raw IP addresses, IP buckets, and stable IP-derived hashes queryable for at most 30 days across file logs, database projections, exports, diagnostics, and backup/restore workflows. - Anonymize IP addresses during rotation when logs are retained beyond the raw retention window, or write a parallel anonymized statistics log from the start. - Use log rotation for longer-running statistics. Rotated logs should anonymize IP addresses while retaining non-sensitive aggregate or GeoIP location data where allowed. +- Use the internal first-party visitor ID for longer-term statistics and correlation instead of extending IP retention. - Keep access/security event logs separate from aggregated statistics where practical. - Use a separate `audit` channel for high-impact administrator actions, starting with authentication events, backend maintenance/package lifecycle events, and settings saves that log changed keys but not submitted values. Keep audit logging configurable through Security settings with an enabled production default and selectable event categories. - Draft first audit-worthy events: global configuration changes, protected configuration changes, provider selection changes, theme lifecycle actions, module lifecycle actions, schema changes, import apply, backup restore, user/ACL changes, API token changes, and high-impact maintenance actions. @@ -66,6 +68,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Test mail dispatch with Symfony test transports. - Test logs do not include secrets or full sensitive payloads. - Test rotated logs anonymize IP addresses while preserving allowed aggregate/GeoIP data. +- Test raw IP, IP bucket, and stable IP-derived hash retention does not exceed 30 days in file logs, database projections, exports, diagnostics, and cleanup jobs. - Test GeoIP adapter behavior with missing or invalid data. - Test missing MaxMind API key disables GeoIP lookup and Geo-blocking without hard errors. - Test GeoIP-disabled logs use normalized empty location values such as `N/A`. @@ -99,3 +102,4 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Defer a generic operations-message event until after the logger is implemented; start with explicit recorder/service boundaries and keep audit/access/security logs separate from the live ActionLog overlay. - **Decision recorded:** Built-in raw log channels use 30-day rotating files by default. Long-term statistics stay separate and may move to database-backed aggregates. - **Open decision:** Evaluate whether Security features should write a parallel database-backed security event projection in addition to the 30-day rotating file logs. File logs should remain the durable raw operational fallback because they are simple, inspectable, and safer during database degradation. A DB projection may be better for Security review, rate/abuse correlation, Admin filtering, cleanup jobs, and avoiding file scans, but must duplicate only redacted/minimized fields, define retention explicitly, and degrade without weakening enforcement or hiding diagnostics. +- **Decision recorded:** IP addresses, IP buckets, and stable IP-derived hashes are short-retention security data and must remain queryable for at most 30 days. Longer-term statistics and security correlation should use the internal first-party visitor ID, authenticated user IDs where applicable, API key fingerprints where applicable, or aggregated anonymized dimensions. Backups, exports, diagnostics bundles, and database projections must not silently extend IP retention beyond the raw-log window. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 71fadc13..a2bcd404 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -42,6 +42,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - First implementation uses a portable database table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, first-seen timestamp, last-seen timestamp, expiry timestamp, safe context hash, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs. If a broader database-backed security event projection is introduced later, this branch's signal store should either feed it through a documented boundary or remain the focused enforcement-oriented read model. +- IP subjects and stable IP-derived hashes must expire within 30 days. Longer-lived passive signals must use visitor ID, authenticated user ID, API key fingerprint, or aggregate keys without retaining the IP-derived subject. - TTL and expiry use an injectable clock/time boundary for deterministic tests. ## Edge cases @@ -52,6 +53,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. - Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. - Passive-signal storage failure records a safe diagnostic and must not change request outcome in this foundation branch. +- Cleanup must remove or anonymize expired IP-derived signal keys before any Admin export, support bundle, or statistics projection can expose them. ## Tests and validation @@ -59,6 +61,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test intent classification for browser, prefetch, API read/write, `/api/live/**`, login, registration, password reset, and suspicious probes. - Test redaction in passive signal messages. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. +- Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. - Test trusted-proxy/client-identity behavior and storage-failure degradation. - Test no limiter or ban enforcement occurs in this branch. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 228aedc4..f25021af 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -43,6 +43,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Ban responses use HTML or JSON according to request family and never expose raw signal internals. - Suggested record fields are subject type/key, reason code, source signal digest, status, created at, expires at, lifted at, lifted by, lift reason, actor context hash, last matched at, match count, and audit reference. - Initial TTL defaults should be conservative and test-backed: short anonymous/probe bans first, longer repeat bans only after repeated signals within the review window, and no permanent bans. +- Prefer Visitor-ID-backed bans for continuity. Add IP-bucket bans as a shorter secondary layer to reduce cookie-reset bypasses, and keep every IP-derived ban TTL below 30 days. - Ban keys come only from the shared subject/client-identity resolver. Raw IP strings, raw API keys, and raw forwarding headers must never be stored as ban keys. - Expiry and cleanup use an injectable clock/time boundary. @@ -52,6 +53,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. - Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. - Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. +- IP-derived bans must expire and be cleaned up before the 30-day IP retention limit; expired IP bans must not remain searchable as historical Admin records with recoverable IP material. - Manual unban must take effect immediately even if passive signals that created the ban still exist. - Concurrent ban creation, expiry cleanup, and manual unban must be idempotent and auditable. - Ban-store degradation must not create an invisible permanent block or lock out Owner recovery. @@ -64,6 +66,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test HTML/JSON ban responses and redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. +- Test IP-derived ban TTL validation rejects or clamps values at 30 days and cleanup removes expired IP-derived records from review/export surfaces. - Test trusted-proxy/client-identity behavior, ban-store degradation, and concurrent create/unban/cleanup behavior. - Test migration applies on SQLite. From c4c7a9e248aab4f34b41c3c1c30d129bbb81d33b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:19:22 +0200 Subject: [PATCH 009/119] Add security policy defaults --- dev/WORKLOG.md | 18 +-- dev/WORKLOG_HISTORY.md | 5 + dev/draft/0.2.x-SecurityAccessControl.md | 4 + dev/draft/0.2.x-SecurityHardeningPlan.md | 6 + dev/draft/0.4.x-ApiLayer.md | 2 + dev/draft/0.4.x-ContactMailLogging.md | 1 + dev/draft/README.md | 1 + .../security-hardening/abuse-foundation.md | 2 + dev/draft/security-hardening/auto-ban.md | 4 +- .../security-hardening/captcha-contract.md | 2 + dev/draft/security-hardening/icon-captcha.md | 2 + .../security-hardening/policy-defaults.md | 110 ++++++++++++++++++ dev/draft/security-hardening/policy-docs.md | 12 +- .../security-hardening/rate-enforcement.md | 4 +- 14 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 dev/draft/security-hardening/policy-defaults.md diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 1cba43a8..00ca017e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -75,20 +75,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-15 feat-security-planning -- Added the security hardening implementation plan draft, splitting the next Security work into focused `feat-security-*` branches for policy docs, GeoIP observability, abuse foundations, rate enforcement, auto-ban handling, captcha contracts, IconCaptcha, mailer account delivery, and remember-me. -- Added handoff-ready detail plans under `dev/draft/security-hardening/` for every planned `feat-security-*` branch and linked them from the master hardening plan. -- Documented the Security branch Git policy: Codex may create thematically clear local commits, while pushes require explicit user instruction. -- Recorded planning decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, action-aware limiter costs, scoped `reset()`-based bucket recovery, cross-action abuse signals, TTL auto-bans, Owner lockout protection, and GeoIP as observability before enforcement. -- Aligned the Security, API, Contact/Mail/Logging, and IconCaptcha drafts with the planning branch decisions, including treating live captcha refreshes as passive abuse signals rather than ordinary rate-limit `429` responses. -- Compacted the non-Security `feat-symfony-ux-integration` and `docs-cleanup` active branch logs into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on Security planning. -- Re-reviewed every Security detail plan for implementation readiness and tightened unresolved planning language around passive-signal persistence, GeoIP update tasks, rate-limit workflow wiring, auto-ban TTL records, captcha provider policy, IconCaptcha cache/TTL behavior, account-mail delivery guards, and remember-me token management UI. -- Added the legacy Grav `sec-lookup` plugin at `/Volumes/Projekte/temp/sec-lookup` as an inspiration-only reference for GeoIP, abuse, rate-limit, auto-ban, captcha, and IconCaptcha planning, with current Symfony product decisions taking priority over historical implementation details. -- Added IconCaptcha asset/license, inline-rendering, bot-resistance, and neutral accessibility-label requirements, and documented Security PR-readiness checks that must be completed from the actual branch diff before PRs. -- Recorded quiz-style IconCaptcha as the preferred accessible fallback when neutral labels are insufficient, keeping quiz prompts/options under the same one-shot challenge, TTL, context-binding, and answer-leak resistance rules. -- Added final cross-cutting planning guardrails for shared client identity/trusted-proxy handling, injectable time boundaries, degraded storage behavior, and race/idempotency review across Security branches. -- Recorded the open logging architecture question of keeping 30-day rotating file logs as the durable raw source while evaluating a parallel database-backed security event projection for query-heavy Security review and abuse correlation. -- Recorded the privacy rule that raw IPs, IP buckets, and stable IP-derived hashes remain queryable for at most 30 days, with longer-term Security/statistics correlation handled through internal visitor IDs or other non-IP subjects. +### 2026-06-15 feat-security-policy-docs +- Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. +- Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch detail plans so later implementation branches can cite one policy reference. +- Compacted the completed `feat-security-planning` worklog entry into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on the policy-docs branch. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index 61c560ce..1b83c5c9 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -9,6 +9,11 @@ Move completed branch or PR logs from `dev/WORKLOG.md` into this file when switching branches or after a PR is merged. Keep the active worklog focused on the current branch so reviewers can see the full PR context while older project history stays available. ## Archived Branches +### 2026-06-15 feat-security-planning +- Created the Security hardening planning package: master branch-tree plan plus detailed `feat-security-*` plans for policy docs, GeoIP observability, abuse foundations, rate enforcement, auto-ban, captcha contracts, IconCaptcha, mailer account delivery, and remember-me. +- Recorded core product decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, scoped `reset()` behavior, database-backed passive signals/auto-bans, Owner recovery protection, GeoIP observability, IconCaptcha asset/accessibility rules, PR-readiness checks, and the inspiration-only `sec-lookup` legacy reference. +- Tightened privacy and architecture guardrails around shared client identity/trusted proxies, injectable clocks, degraded storage, race/idempotency, database-backed security event projection as an open question, and a 30-day maximum for queryable IP-derived data. + ### 2026-06-13 to 2026-06-14 feat-symfony-ux-integration - Added the Symfony UX/UI foundation: namespace-aware Twig components, shared alert stacks, reusable Stimulus/live-polling controllers, notification center behavior, package live endpoints, package-aware cookie consent, local Mercure tooling, and lazy UX integrations. - Hardened live/API/cookie/Mercure boundaries through repeated review passes, including exact-before-pattern dispatch, reserved live slugs, GET-only package live endpoints, alert topic scoping, consent cookie signing, protected Mercure env handling, URL/link sink validation, and safe fallback polling. diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index fb428be5..bd6b0da7 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -11,6 +11,7 @@ - Depends on core architecture, plugin modules, and editor workflows. - Keeps security Symfony-native while leaving explicit replacement points for captcha and future authentication extensions. - Splits the next hardening work into focused branches through the [security hardening implementation plan](0.2.x-SecurityHardeningPlan.md). +- Uses [security policy defaults](security-hardening/policy-defaults.md) as the first source for Security hardening TTLs, threshold defaults, privacy ceilings, and review requirements. ## Outline Security should use Symfony Security as the primary model. Authentication, authorization, CSRF protection, voters, rate limiting, secrets, and route access rules should remain recognizable Symfony concepts. @@ -51,6 +52,7 @@ Captcha should use a global form field integration. When a workflow includes the - Use CSRF protection for state-changing browser workflows. - Use Symfony Rate Limiter for application-level throttling such as login attempts, contact forms, API usage, import attempts, and captcha failures. - Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha failures, API usage, import attempts, and suspicious probes. +- Use the Security policy defaults for first limiter thresholds, auto-ban TTLs, captcha TTLs, and IP-retention ceilings. Implementation branches may tune those defaults only with tests, draft updates, and worklog notes. - Avoid using a single coarse per-IP limiter as the only abuse-control decision. - Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints should stay cheap, tokenized where necessary, no-store, and safe for live polling, captcha refreshes, or lightweight UI refreshes; clear abuse may still feed passive suspicious-behavior signals. - Classify Turbo/browser prefetch requests separately from deliberate navigations and submissions. Prefetch should not spend the same global abuse budget as a user-initiated request, and expensive or side-effect-adjacent links may disable Turbo prefetching. @@ -59,6 +61,7 @@ Captcha should use a global form field integration. When a workflow includes the - Track global cross-action abuse signals across website and API activity so several separately limited actions can still trigger progressive handling when they occur in suspicious sequence. - Support progressive abuse handling such as allow, throttle, require captcha, temporary block, and hard block. - Support active punishment such as draining relevant buckets or applying temporary TTL bans for clear suspicious behavior. Auto-bans may be keyed by IP, visitor ID, API key, or safe combined subjects, must be auditable, and must preserve Owner recovery paths. +- Prefer visitor-ID-backed temporary bans for continuity and use shorter IP-bucket bans only as a secondary layer against cookie-reset bypasses. IP-derived records must remain queryable for less than 30 days. - Document that edge/server-level rate limiting is still needed for DoS protection. - Keep secrets out of manifests, docs, logs, fixtures, and committed config. - Generate `APP_SECRET` during setup and store it in `.env.local.php`. @@ -140,6 +143,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected workflows to protect with captcha. - **Decision recorded:** Abuse handling should be progressive and use workflow-specific limiters instead of relying on one coarse IP bucket. - **Decision recorded:** The next security work is split through `feat-security-*` branches described in the security hardening implementation plan. Each branch should be feature-complete for its focused concern rather than accumulating one large security review branch. +- **Decision recorded:** First Security hardening thresholds, TTLs, privacy ceilings, and configuration posture live in the Security policy defaults document and must be updated when an implementation branch changes those policies. - **Decision recorded:** `/api/live/**` should not receive ordinary rate-limit enforcement. Live endpoints remain low-cost JSON surfaces for polling, captcha refreshes, and UI refreshes; suspicious access may still feed passive abuse signals. - **Decision recorded:** Rate and abuse checks should pass through a Studio-owned facade. Symfony RateLimiter remains the token bucket/sliding window implementation where practical, while Studio owns request-intent classification, action costs, cross-action signals, scoped resets, and future temporary ban policy. - **Decision recorded:** Scoped limiter resets are preferred over partial token refunds when a workflow has a clear successful human outcome. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 78daead1..e31bbd2c 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -13,6 +13,8 @@ The `feat-security` branch is the shared merge base. Feature branches should use Detailed handoff plans live in `dev/draft/security-hardening/`. Each branch plan defines the implementation sequence, interfaces, edge cases, tests, documentation updates, non-goals, and acceptance criteria for one reviewable branch. +First policy defaults live in [security policy defaults](security-hardening/policy-defaults.md). Implementation branches should treat that document as the source for first TTLs, threshold defaults, privacy ceilings, and review requirements until a branch updates it with tested evidence. + ## Git handling Codex may create local commits for Security planning and implementation work when the commit scope is thematically clear and reviewable. Pushes are never implied by these plans and require an explicit user instruction. @@ -44,10 +46,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be u Record the product decisions from this plan in the relevant drafts and manuals before implementation starts. Detailed plan: [policy-docs](security-hardening/policy-docs.md). +Policy defaults: [security policy defaults](security-hardening/policy-defaults.md). Scope: - Update security, captcha, logging/statistics, API/live, and scheduler notes where their behavior is affected. +- Record first implementation defaults for retention, limiter thresholds, auto-ban TTLs, captcha TTLs, privacy ceilings, and configuration posture. - Document the branch plan, expected verification shape, and deferred decisions. - Do not add runtime code. @@ -55,6 +59,7 @@ Acceptance: - Future branches can cite one planning draft instead of re-litigating the same scope. - Open decisions are explicit and assigned to the branch where they must be resolved. +- First implementation branches have a single policy-defaults reference for initial TTLs and thresholds. ### `feat-security-geoip-observability` @@ -298,6 +303,7 @@ Acceptance: ## References - [Security and access control](0.2.x-SecurityAccessControl.md) +- [Security policy defaults](security-hardening/policy-defaults.md) - [IconCaptcha integration](0.4.x-IconCaptcha.md) - [Contact, mail, and logging](0.4.x-ContactMailLogging.md) - [API layer](0.4.x-ApiLayer.md) diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index a2e80994..842771bd 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -10,6 +10,7 @@ - Covers REST conventions, authentication, serialization, permissions, versioning, and module-provided endpoints. - Depends on the content model, security, and import/export drafts. - Starts with content interaction, a reusable endpoint registry, and package-ready extension contracts. +- Uses the Security hardening policy defaults for API rate-limit and `/api/live/**` abuse-signal policy. ## Outline The first API should expose content interaction, not internal process control. External clients should be able to read content and, where explicitly allowed, create or update content through content-oriented operations. Content API responses should preserve the canonical slug hierarchy, language, generic variant, version, and schema field identifiers where applicable. Internal content UIDs, Doctrine identifiers, audit metadata, ACL group details, and editor-only state should not be exposed by default. API access requires user-owned API tokens generated, revoked, and reset by users. Administrative internals such as package lifecycle, update flows, backup/restore, release packaging, and general command execution should stay outside the initial API. @@ -80,6 +81,7 @@ The API branch should implement the platform foundation and the first content re - Reserve `/api/live/**` for application-owned live JSON flows such as captcha seeds, polling, and operation status checks. This branch is intentionally not part of the stable external versioned API contract. - Keep `/api/live/**` reachable during `APP_MAINTENANCE` so setup, admin, and operational live-log polling can continue while the public site is unavailable. - Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints are application-owned lightweight JSON flows; clear abuse may feed passive security signals, but live polling, captcha refreshes, and UI refreshes should not receive the normal website/API `429` response path. +- Apply versioned API read/write rate-limit defaults from the Security policy defaults through the Security hardening rate-enforcement branch. - Use `/api/live/operations/{operationId}?token=&cursor=` for ActionLog polling fallback responses. Payloads should include `operation_id`, `status`, `cursor`, optional `cursor_max`, `entries`, and `next_poll_ms`. - Treat live-operation cursors as monotonic numeric positions emitted by the log producer, not as stable durable identifiers. - Defer public content DTO and serializer contracts to the API feature branch so entity exposure, pagination, error payloads, and versioning rules are decided together. diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index 17896581..b0b6b4be 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -10,6 +10,7 @@ - Covers logging, statistics, GeoIP enrichment, privacy considerations, and operational visibility. - Depends on error handling, security, and configuration decisions. - Provides practical public interaction and operational insight without turning analytics into a surveillance feature. +- Uses the Security policy defaults for IP-derived data retention, security event projection posture, and abuse-correlation privacy ceilings. ## Outline The CMS should provide a reliable contact form and mail delivery foundation for project websites. Contact workflows should be protected by validation, CSRF where applicable, rate limiting, optional captcha, and safe error messages that do not leak delivery internals. diff --git a/dev/draft/README.md b/dev/draft/README.md index 61a1dfe7..4cd989a7 100644 --- a/dev/draft/README.md +++ b/dev/draft/README.md @@ -48,6 +48,7 @@ The feature drafts should be created in dependency order. Start with architectur - [Security and access control](0.2.x-SecurityAccessControl.md) - [Security hardening implementation plan](0.2.x-SecurityHardeningPlan.md) +- [Security policy defaults](security-hardening/policy-defaults.md) - [Admin interface and setup UI](0.2.x-AdminInterfaceSetupUi.md) - [Package modules and providers](0.2.x-PluginModules.md) - [Event hooks and buses](0.2.x-EventHooksBuses.md) diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index a2bcd404..e96ac3ed 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -17,6 +17,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Dependencies +- [Security policy defaults](policy-defaults.md). - Existing visitor identity, access logging, audit logging, API-key authentication, Scheduler API authentication, and `/api/live/**` route boundaries. - Symfony Request data and Turbo/browser prefetch headers. @@ -71,6 +72,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update class map for the facade and value objects only if they are contributor-facing services. - Update class map for the passive-signal entity/repository/cleanup command if they are added. - Record default cost catalogue decisions in the worklog. +- Update Security policy defaults if implementation evidence changes signal retention, subject composition, or suspicious-intent weighting. - Record whether the branch keeps only the passive-signal store or also introduces/reuses a broader security event projection. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index f25021af..81d50d2b 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -19,6 +19,7 @@ Codex may create local commits for this branch when each commit has a clear them - `feat-security-abuse-foundation`. - `feat-security-rate-enforcement`. +- [Security policy defaults](policy-defaults.md). - Existing Admin, audit, message, user-role, visitor identity, and API-key foundations. ## Legacy inspiration @@ -42,7 +43,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Ban reasons use stable message/code catalogues. - Ban responses use HTML or JSON according to request family and never expose raw signal internals. - Suggested record fields are subject type/key, reason code, source signal digest, status, created at, expires at, lifted at, lifted by, lift reason, actor context hash, last matched at, match count, and audit reference. -- Initial TTL defaults should be conservative and test-backed: short anonymous/probe bans first, longer repeat bans only after repeated signals within the review window, and no permanent bans. +- Initial TTL defaults come from the Security policy defaults and must stay test-backed: short anonymous/probe bans first, longer repeat bans only after repeated signals within the review window, and no permanent bans. - Prefer Visitor-ID-backed bans for continuity. Add IP-bucket bans as a shorter secondary layer to reduce cookie-reset bypasses, and keep every IP-derived ban TTL below 30 days. - Ban keys come only from the shared subject/client-identity resolver. Raw IP strings, raw API keys, and raw forwarding headers must never be stored as ban keys. - Expiry and cleanup use an injectable clock/time boundary. @@ -73,6 +74,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Documentation and tracking - Update Security draft with final subject types, statuses, and Owner protections. +- Update Security policy defaults if implementation evidence changes ban TTLs, maximums, subject types, or authenticated/Owner handling. - Update Admin/security diagnostics notes for review UI. - Update class map for entity, repository, decision service, cleanup command/task, and Admin routes. - Record threshold and false-positive assumptions in worklog. diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md index 9de8e8cf..90d0e5bb 100644 --- a/dev/draft/security-hardening/captcha-contract.md +++ b/dev/draft/security-hardening/captcha-contract.md @@ -19,6 +19,7 @@ Codex may create local commits for this branch when each commit has a clear them - Abuse facade from `feat-security-abuse-foundation`. - Rate reset hooks from `feat-security-rate-enforcement`. +- [Security policy defaults](policy-defaults.md). - Existing Symfony Form, Validator, Translation, package contribution, and settings foundations. ## Legacy inspiration @@ -60,6 +61,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Documentation and tracking - Update Security and IconCaptcha drafts with final contract names. +- Update Security policy defaults if provider-required workflow behavior or captcha success reset policy changes. - Update package developer guidance for provider registration. - Update class map for provider interface, resolver, form type, and validation services. - Record provider-required policy as deferred design context; do not enforce it in this branch. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index 218eedc1..cf4a8d21 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -18,6 +18,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Dependencies - `feat-security-captcha-contract`. +- [Security policy defaults](policy-defaults.md). - Package lifecycle, AssetMapper/Tailwind, translation aggregation, `/api/live/**`, and abuse passive signal foundations. ## Legacy inspiration @@ -75,6 +76,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Documentation and tracking - Update IconCaptcha draft with final payload/storage choices. +- Update Security policy defaults if challenge TTL, quiz fallback policy, refresh handling, or captcha reset behavior changes. - Update package developer guidance if provider package layout adds a reusable pattern. - Update class map for provider, challenge services, controller/live endpoint, assets, and templates. - Record asset licensing, provenance, sanitization, and bot-resistance notes. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md new file mode 100644 index 00000000..41771bdc --- /dev/null +++ b/dev/draft/security-hardening/policy-defaults.md @@ -0,0 +1,110 @@ +# Security policy defaults + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define first implementation defaults for Security hardening branches before runtime work begins. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Goal + +This document records the first testable Security policy defaults for the `feat-security-*` branch tree. These defaults should be implemented as named constants or configuration values in the owning branch, covered by behavior tests, and updated here whenever implementation evidence changes the policy. + +The defaults are not an Admin UI requirement. Admin-configurable policy can be added later where the owning branch explicitly introduces settings, validation, documentation, and safe bounds. + +## Policy Precedence + +- Current product decisions in the Security hardening plan and this policy file take priority over historical `sec-lookup` behavior. +- Repository privacy rules take priority over convenience: IP-derived data remains short-retention data even when it would be useful for longer investigations. +- Runtime code should use one shared subject/client-identity resolver, one injectable clock/time boundary, and one abuse/rate facade instead of reimplementing policy in controllers. +- Any policy that would block authentication, Owner recovery, scheduler access, or Admin diagnostics must include an explicit recovery path and tests. + +## Identity And Privacy + +- Raw IP addresses, IP buckets, and stable IP-derived hashes may be queryable for at most 30 days across file logs, database projections, diagnostics, exports, support bundles, and backups. +- Longer-term correlation uses internal visitor ID, authenticated user UID, API key fingerprint/prefix, or aggregate dimensions. +- Visitor-ID-backed policy is preferred for continuity. IP-backed policy is a short-lived secondary layer to reduce cookie-reset bypasses and shared-host abuse. +- Raw credentials, raw API keys, raw visitor-cookie tokens, session IDs, full user agents, and captcha answer material must not be stored in policy records. +- GeoIP values are operational metadata. They may support diagnostics and aggregate statistics, but they do not create allow/deny decisions in this policy slice. + +## Retention Defaults + +- Raw access/security file logs: 30 days by default. +- Queryable IP-derived records in database projections or passive-signal stores: maximum 30 days. +- IP-derived auto-ban records: maximum 7 days, even though the privacy ceiling is 30 days. +- Visitor-ID auto-ban records: maximum 30 days unless a later policy explicitly defines longer visitor retention and user-facing privacy copy. +- Passive suspicious signals: default 7 days for visitor/user/API subjects; default 24 hours for IP-only subjects; maximum 30 days for any IP-derived subject. +- Captcha challenge state: five minutes, one-shot invalidation after every validation attempt. +- Remember-me trust window: seven days. +- Account invitation/registration links: 24 hours by default. Password-reset links: one hour. + +## Rate-Limit Defaults + +These are first implementation defaults. Branches may adjust them only with tests and a worklog note explaining the review reason. + +| 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 | +| 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 | +| Captcha failures | 5 failures per 10 minutes | Challenge subject plus visitor ID | Successful captcha may reset the scoped challenge/form bucket only | +| Website global budget | 120 ordinary requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | +| 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 30 per hour | API key fingerprint plus scheduler endpoint subject | No success reset | +| Suspicious probes | 10 high-signal probes per 10 minutes | Visitor ID plus IP bucket | No success reset; may drain suspicious buckets | + +`/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. + +Turbo/browser prefetch for safe `GET` requests should not spend the same budget as deliberate navigation. Use lower-confidence passive signal weighting; do not let spoofable prefetch headers bypass authentication, authorization, CSRF, or domain validation. + +## Auto-Ban Defaults + +- Visitor-ID bans are the preferred continuity mechanism: + - first temporary ban: 1 hour; + - repeated ban within 24 hours: 24 hours; + - severe repeated anonymous abuse: up to 7 days; + - maximum: 30 days unless a later policy extends visitor retention. +- IP-bucket bans are secondary and shorter: + - first temporary IP ban: 15 minutes; + - repeated IP ban within 24 hours: 6 hours; + - severe repeated IP abuse: up to 24 hours; + - maximum: 7 days. +- API-key bans use key fingerprint/prefix only: + - invalid-key probe ban: 15 minutes; + - repeated invalid-key probe ban: 1 hour; + - compromised or revoked-key replay review may escalate to 24 hours. +- Authenticated users start with softer handling such as throttling, captcha, warnings, or session/token review unless explicit compromise signals justify a hard block. +- Owner accounts must retain at least one documented recovery path. A policy that could deny all Owners is invalid. +- Manual unban takes effect immediately and must be audited. + +## Captcha Defaults + +- `none`, missing provider, and disabled provider validate successfully until a workflow explicitly introduces provider-required policy. +- IconCaptcha challenge TTL is five minutes. +- Every validation attempt consumes the challenge ID, successful or failed. +- Captcha success may reset only the scoped challenge/form bucket when the workflow policy allows it. +- Visual IconCaptcha must not expose answer-bearing names through DOM, SVG, asset paths, translation keys, hidden text, or ARIA labels. +- If neutral labels are not sufficient for assistive technology, the preferred fallback is a provider-owned quiz challenge that shares the same one-shot, TTL, context-binding, refresh, and abuse-signal rules. + +## Logging And Projection Policy + +- Rotating file logs remain the durable raw operational source. +- A database-backed security event projection is an open read-model decision for query-heavy review and abuse correlation. +- If introduced, the projection must duplicate only minimized/redacted fields, keep IP-derived data within the 30-day limit, and degrade without weakening enforcement or hiding diagnostics. +- Backups, exports, diagnostics, and support bundles must not silently extend IP retention. + +## Configuration Posture + +- First implementations may ship policy defaults as code-level constants or configuration values with tests. +- Admin-configurable settings require bounded validation, safe defaults, documentation, and tests for disabled/missing settings. +- User-facing copy is required whenever a configurable policy affects public behavior, recovery, captcha, mail delivery, remember-me, account access, or data retention. + +## Review Requirements + +- Every branch that implements one of these policies must update this file when thresholds, TTLs, subject types, or retention rules change. +- Every branch must complete the Security PR-readiness checklist in the master hardening plan from the actual branch diff. +- Follow-up decisions that remain after implementation must be recorded in `dev/WORKLOG.md`. diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md index 3e40e4a7..02313c72 100644 --- a/dev/draft/security-hardening/policy-docs.md +++ b/dev/draft/security-hardening/policy-docs.md @@ -24,14 +24,16 @@ Codex may create local commits for this branch when each commit has a clear them 1. Expand the master security hardening plan with links to every detailed branch plan. 2. Add one detail file for each `feat-security-*` branch under `dev/draft/security-hardening/`. -3. Align related drafts only where product decisions changed: `/api/live/**` rate-limit exclusion, GeoIP as observability first, IconCaptcha as a dedicated branch, scoped limiter resets, Turbo/browser prefetch classification, and database-backed auto-bans. -4. Move non-Security active branch logs from `dev/WORKLOG.md` to compact sections in `dev/WORKLOG_HISTORY.md`. -5. Keep global roadmap and global To-Do items in the active worklog. +3. Add a policy-defaults reference for first implementation TTLs, rate thresholds, retention ceilings, auto-ban defaults, captcha defaults, logging projection posture, and configuration rules. +4. Align related drafts only where product decisions changed: `/api/live/**` rate-limit exclusion, GeoIP as observability first, IconCaptcha as a dedicated branch, scoped limiter resets, Turbo/browser prefetch classification, database-backed auto-bans, and IP-retention privacy limits. +5. Move non-Security active branch logs from `dev/WORKLOG.md` to compact sections in `dev/WORKLOG_HISTORY.md`. +6. Keep global roadmap and global To-Do items in the active worklog. ## Public interfaces and data decisions - No runtime interfaces, routes, entities, configuration, services, commands, migrations, or translations are added in this branch. -- Documentation establishes fixed defaults for later branches: database-backed passive-signal and auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, ordinary rate-limit exclusion for `/api/live/**`, IconCaptcha challenge cache/TTL behavior, account-mail transport guard expectations, and minimal remember-me token management UI. +- Documentation establishes fixed defaults for later branches: database-backed passive-signal and auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, ordinary rate-limit exclusion for `/api/live/**`, IconCaptcha challenge cache/TTL behavior, account-mail transport guard expectations, minimal remember-me token management UI, and privacy-first IP retention ceilings. +- `policy-defaults.md` is the first implementation source for thresholds and TTLs until an owning branch updates it with tested evidence. ## Edge cases @@ -52,6 +54,7 @@ Codex may create local commits for this branch when each commit has a clear them - Update `dev/WORKLOG.md` with concise planning notes only. - Update `dev/WORKLOG_HISTORY.md` with compact archived branch summaries. - Add or maintain the Security PR-readiness checklist in the master hardening plan when review expectations change. +- Link `policy-defaults.md` from the master plan and relevant draft indexes. ## Non-goals @@ -64,4 +67,5 @@ Codex may create local commits for this branch when each commit has a clear them - A future implementer can start any `feat-security-*` branch from its detail plan without inventing product policy. - Remaining calibration points are explicitly framed as implementation defaults to be committed and tested in the owning branch, not as unresolved product direction. +- The first thresholds and TTLs are discoverable in one policy-defaults document. - The active worklog is short enough to serve as review notes for Security planning. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index ff0a6a9b..beeb84d4 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -18,6 +18,7 @@ Codex may create local commits for this branch when each commit has a clear them ## Dependencies - `feat-security-abuse-foundation`. +- [Security policy defaults](policy-defaults.md). - Existing form, API, scheduler, login, account-token, message, and error-rendering foundations. ## Legacy inspiration @@ -39,7 +40,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - Registration and password-reset success do not reset global buckets by default. -- The branch must commit initial threshold defaults as named configuration/constants with behavior tests. Later branches may tune those defaults only with matching draft/worklog notes. +- 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. - 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. @@ -66,6 +67,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Documentation and tracking - Update Security draft thresholds and reset behavior. +- Update Security policy defaults if implementation evidence changes any threshold, subject, or reset policy. - Update API/Scheduler notes for JSON `429` behavior. - Update class map for facade/enforcement services. - Record focused test commands and any threshold changes in the worklog. From 7ec6a220db297a885abad2853257df7fe82d875f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:23:10 +0200 Subject: [PATCH 010/119] Adjust captcha challenge TTL default --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 4 ++-- dev/draft/0.4.x-IconCaptcha.md | 6 +++--- dev/draft/security-hardening/icon-captcha.md | 3 ++- dev/draft/security-hardening/policy-defaults.md | 5 +++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 00ca017e..fed18ffe 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -79,6 +79,7 @@ - Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. - Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch detail plans so later implementation branches can cite one policy reference. - Compacted the completed `feat-security-planning` worklog entry into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on the policy-docs branch. +- Raised the first IconCaptcha challenge TTL default to 15 minutes for realistic form completion time while keeping one-shot validation, scoped failure buckets, context binding, refresh abuse signals, and answer-leak checks as required bot-protection controls. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index e31bbd2c..9c577f79 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -192,7 +192,7 @@ Detailed plan: [icon-captcha](security-hardening/icon-captcha.md). Scope: - Ship provider-owned assets, templates, JavaScript, translations, services, and validation. -- Use deterministic server-side challenge derivation with one-shot challenge IDs and short TTL. +- Use deterministic server-side challenge derivation with one-shot challenge IDs and the bounded TTL from the Security policy defaults. - Add refresh behavior through `/api/live/**` or another lightweight JSON route. Refresh abuse should feed passive signals, but ordinary live refreshes should not return normal rate-limit `429` responses. - Keep UI accessible and layout-stable. @@ -288,7 +288,7 @@ Acceptance: - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. -- IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is five minutes, with one-shot invalidation after every validation attempt. +- IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. - IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. - Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. - Remember-me includes a minimal profile/security UI for listing active persistent tokens and revoking individual tokens or all other tokens. diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index d2719d1f..40c2947d 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -25,7 +25,7 @@ The asset set must be chosen deliberately before implementation. Prefer permissi Inline-rendered graphics and symbol SVGs must be bot-resistant. Do not expose answer-bearing names through file names, SVG IDs, CSS classes, `data-*` attributes, translation keys, titles, descriptions, hidden text, or ARIA labels. Use opaque challenge-local identifiers for rendered options and neutral control labels that describe interaction state without naming the target symbol. If neutral labels are not sufficient for assistive technology, implement a separate accessible fallback flow instead of leaking the answer through labels. -The preferred accessible fallback is a quiz-style IconCaptcha mode. Instead of naming the visual icon choices through ARIA, the provider can render a spoken question/task and multiple neutral answer options. The submitted answer is then validated through the same provider challenge model as the visual captcha: one challenge ID, short TTL, one-shot invalidation, context binding, refresh behavior, and passive abuse signals. Quiz prompts and answer options must come from vetted provider-owned pools, avoid stable answer-bearing DOM metadata, and use opaque option IDs just like visual icon choices. +The preferred accessible fallback is a quiz-style IconCaptcha mode. Instead of naming the visual icon choices through ARIA, the provider can render a spoken question/task and multiple neutral answer options. The submitted answer is then validated through the same provider challenge model as the visual captcha: one challenge ID, bounded configured TTL, one-shot invalidation, context binding, refresh behavior, and passive abuse signals. Quiz prompts and answer options must come from vetted provider-owned pools, avoid stable answer-bearing DOM metadata, and use opaque option IDs just like visual icon choices. The challenge should be deterministic from server-side state, but unpredictable to the client. A challenge ID, timestamp, request context, and application/module secret can derive the icon pool, target icon, and button order. The server can then recompute the expected answer during validation without persisting the whole challenge payload. The challenge ID must still be marked as used after validation to prevent replay. @@ -54,7 +54,7 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Bind challenge derivation to server-side secret, challenge ID, timestamp, session identifier where available, user agent, and route or workflow key. - Keep the public challenge payload limited to data needed for rendering and validation submission. - Mark challenge IDs as one-shot values in a Symfony cache pool after validation, regardless of success or failure. -- Expire challenges after a short configurable TTL. +- Expire challenges after the bounded TTL defined in the Security policy defaults. - Use cache pools or another Symfony-native storage mechanism for used challenge IDs and short-lived challenge metadata. - Sanitize SVG assets before rendering inline SVG in buttons, or serve them through a trusted static asset path with a fixed allowlist. - Keep internal icon identifiers stable and non-sensitive, but never render answer-bearing names into public DOM, asset URLs, CSS classes, JavaScript state, translation keys, or ARIA labels. @@ -181,7 +181,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - **Decision recorded:** Inline-rendered graphics, symbol SVGs, DOM metadata, and ARIA labels must not reveal answer-bearing names; use opaque option identifiers and neutral labels. - **Decision recorded:** If neutral ARIA labels are insufficient, prefer a provider-owned quiz challenge with spoken question/task text and multiple answer options over leaking visual answers through labels. - **Decision recorded:** Use the old Grav `sec-lookup` IconCaptcha only as inspiration; do not copy old code or secrets. -- **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a short TTL. +- **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a 15-minute TTL. The longer TTL is intended to support longer form completion time and relies on one-shot invalidation, context binding, captcha-failure buckets, refresh abuse signals, and answer-leak tests to prevent brute-force guessing. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. - **Decision recorded:** Avoid a single coarse per-IP rate limiter; use workflow-specific limiters and progressive abuse handling. - **Decision recorded:** Successful captcha validation should reset or soften relevant scoped limiter buckets for human users when the workflow policy explicitly allows it. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index cf4a8d21..6bfcbd15 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -40,7 +40,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Provider key is `icon_captcha`. - Public challenge payload contains only challenge ID, timestamp, render metadata, and button identifiers needed for display. - Provider secret is generated/configured outside manifests and public assets. -- Default challenge TTL is five minutes, and validation invalidates the challenge after every attempt, successful or failed. +- Default challenge TTL is 15 minutes, and validation invalidates the challenge after every attempt, successful or failed. - Challenge expiry and one-shot state use an injectable clock/time boundary where practical for deterministic tests. - SVG/icons must be allowlisted or sanitized before inline rendering. - Inline-rendered graphics and symbol SVGs must not expose answer-bearing names through file names, element IDs, CSS classes, `data-*` attributes, titles, descriptions, or translation keys. Use opaque challenge-local identifiers and randomized or non-semantic button identifiers. @@ -58,6 +58,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Browser inspection should not reveal the correct answer through DOM order, source file names, SVG IDs, ARIA labels, visible hidden text, or static asset URLs. - Quiz-mode questions and answer options must be generated from vetted provider-owned prompt pools and opaque option IDs so the correct answer is not inferable from stable DOM metadata or static translation keys. - Concurrent double-submit validation must remain one-shot: one attempt wins, later attempts fail recoverably or suspiciously according to the failure model. +- The 15-minute TTL must not permit brute-force attempts. A submitted challenge is consumed after the first validation attempt, captcha failures feed the scoped failure bucket, and aggressive refresh behavior records passive abuse signals. ## Tests and validation diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 41771bdc..1e2cc402 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -35,7 +35,7 @@ The defaults are not an Admin UI requirement. Admin-configurable policy can be a - IP-derived auto-ban records: maximum 7 days, even though the privacy ceiling is 30 days. - Visitor-ID auto-ban records: maximum 30 days unless a later policy explicitly defines longer visitor retention and user-facing privacy copy. - Passive suspicious signals: default 7 days for visitor/user/API subjects; default 24 hours for IP-only subjects; maximum 30 days for any IP-derived subject. -- Captcha challenge state: five minutes, one-shot invalidation after every validation attempt. +- Captcha challenge state: 15 minutes, one-shot invalidation after every validation attempt. - Remember-me trust window: seven days. - Account invitation/registration links: 24 hours by default. Password-reset links: one hour. @@ -84,8 +84,9 @@ Turbo/browser prefetch for safe `GET` requests should not spend the same budget ## Captcha Defaults - `none`, missing provider, and disabled provider validate successfully until a workflow explicitly introduces provider-required policy. -- IconCaptcha challenge TTL is five minutes. +- IconCaptcha challenge TTL is 15 minutes so humans can complete longer forms without unnecessary expiry. - Every validation attempt consumes the challenge ID, successful or failed. +- The longer challenge TTL is safe only when repeated guesses against the same challenge are impossible. Keep one-shot invalidation, context binding, tight captcha-failure buckets, refresh abuse signals, and answer-leak tests in place. - Captcha success may reset only the scoped challenge/form bucket when the workflow policy allows it. - Visual IconCaptcha must not expose answer-bearing names through DOM, SVG, asset paths, translation keys, hidden text, or ARIA labels. - If neutral labels are not sufficient for assistive technology, the preferred fallback is a provider-owned quiz challenge that shares the same one-shot, TTL, context-binding, refresh, and abuse-signal rules. From 94c77ba3db49b0a0c6a548d600323db321aae066 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:26:41 +0200 Subject: [PATCH 011/119] Refine website global rate defaults --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 1 + dev/draft/security-hardening/policy-defaults.md | 8 ++++++-- dev/draft/security-hardening/rate-enforcement.md | 4 +++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index fed18ffe..f93543b3 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -80,6 +80,7 @@ - Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch detail plans so later implementation branches can cite one policy reference. - Compacted the completed `feat-security-planning` worklog entry into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on the policy-docs branch. - Raised the first IconCaptcha challenge TTL default to 15 minutes for realistic form completion time while keeping one-shot validation, scoped failure buckets, context binding, refresh abuse signals, and answer-leak checks as required bot-protection controls. +- Split the website global rate-limit default into deliberate burst and sustained buckets, with Turbo/browser prefetch tracked through a separate lower-confidence observation path so speculative requests do not drain user-facing navigation budgets. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 9c577f79..c7a3a5a0 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -286,6 +286,7 @@ Acceptance: - Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. - Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. +- Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 1e2cc402..4dd9f2b0 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -50,16 +50,20 @@ These are first implementation defaults. Branches may adjust them only with test | 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 | | Captcha failures | 5 failures per 10 minutes | Challenge subject plus visitor ID | Successful captcha may reset the scoped challenge/form bucket only | -| Website global budget | 120 ordinary requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | +| 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 | | 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 30 per hour | API key fingerprint plus scheduler endpoint subject | No success reset | | Suspicious probes | 10 high-signal probes per 10 minutes | Visitor ID plus IP bucket | No success reset; 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. -Turbo/browser prefetch for safe `GET` requests should not spend the same budget as deliberate navigation. Use lower-confidence passive signal weighting; do not let spoofable prefetch headers bypass authentication, authorization, CSRF, or domain validation. +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. ## Auto-Ban Defaults diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index beeb84d4..8fdc47ea 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -41,6 +41,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. - 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. +- 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. - 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. @@ -56,7 +57,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Tests and validation - Test each guarded workflow below and above threshold. -- Test global budget catches mixed suspicious actions. +- 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 successful login resets only the login bucket. - Test `/api/live/**` never receives ordinary rate-limit `429`. - Test browser HTML and API JSON `429` shapes. From 3e84f72f26926d00ddc13e760e12ea3b8942a234 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:42:52 +0200 Subject: [PATCH 012/119] Tighten scheduler and probe security defaults --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityAccessControl.md | 3 +++ dev/draft/0.2.x-SecurityHardeningPlan.md | 4 ++++ .../security-hardening/abuse-foundation.md | 3 +++ dev/draft/security-hardening/auto-ban.md | 5 ++++ .../security-hardening/policy-defaults.md | 23 ++++++++++++++++--- .../security-hardening/rate-enforcement.md | 4 ++++ 7 files changed, 40 insertions(+), 3 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f93543b3..8d5a5c1f 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -81,6 +81,7 @@ - Compacted the completed `feat-security-planning` worklog entry into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on the policy-docs branch. - Raised the first IconCaptcha challenge TTL default to 15 minutes for realistic form completion time while keeping one-shot validation, scoped failure buckets, context binding, refresh abuse signals, and answer-leak checks as required bot-protection controls. - Split the website global rate-limit default into deliberate burst and sustained buckets, with Turbo/browser prefetch tracked through a separate lower-confidence observation path so speculative requests do not drain user-facing navigation budgets. +- Adjusted scheduler and probe policies: scheduler trigger limits now support minutely cron, high-signal probes are limited to one per 10 minutes with generic `400` handling, probe paths are configurable with broad defaults, auto-ban defaults to on, and active Admin/Owner recovery protections are explicit. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index bd6b0da7..69619fb2 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -62,6 +62,9 @@ Captcha should use a global form field integration. When a workflow includes the - Support progressive abuse handling such as allow, throttle, require captcha, temporary block, and hard block. - Support active punishment such as draining relevant buckets or applying temporary TTL bans for clear suspicious behavior. Auto-bans may be keyed by IP, visitor ID, API key, or safe combined subjects, must be auditable, and must preserve Owner recovery paths. - Prefer visitor-ID-backed temporary bans for continuity and use shorter IP-bucket bans only as a secondary layer against cookie-reset bypasses. IP-derived records must remain queryable for less than 30 days. +- Registered users should receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define an explicit stricter bucket. Active Owner sessions and Owner-owned API keys are exempt from ordinary application rate-limit rejection. +- Suspicious probe paths should be configurable with broad defaults. High-signal probes such as `.env`, VCS metadata, backup/database dumps, common foreign admin panels, and shell probes should return a generic `400` and feed suspicious-signal handling instead of being treated like normal 404 traffic. +- Auto-ban is enabled by default once implemented, but active Admin/Owner session Visitor IDs and IP buckets must not be banned. A recovery login route must remain reachable through active Visitor/IP bans so a successful login can re-evaluate the subject under authenticated/Admin/Owner policy. - Document that edge/server-level rate limiting is still needed for DoS protection. - Keep secrets out of manifests, docs, logs, fixtures, and committed config. - Generate `APP_SECRET` during setup and store it in `.env.local.php`. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index c7a3a5a0..ca8fea44 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -281,12 +281,16 @@ Acceptance: - Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. - Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. +- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to active Admin or Owner sessions must not be banned. - IP-based enforcement is secondary and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. - Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. - Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. - Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. +- Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. +- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. +- A recovery login route must remain reachable even when the current Visitor ID or IP bucket is banned; successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index e96ac3ed..324ee14e 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -40,6 +40,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Client identity must respect Symfony trusted-proxy configuration and must not trust raw forwarding headers outside that configuration. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. +- Probe-path detection is configurable and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. - First implementation uses a portable database table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, first-seen timestamp, last-seen timestamp, expiry timestamp, safe context hash, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs. If a broader database-backed security event projection is introduced later, this branch's signal store should either feed it through a documented boundary or remain the focused enforcement-oriented read model. @@ -51,6 +52,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Missing visitor cookie uses the existing fallback visitor identity. - Invalid Bearer API keys should still classify as API activity without trusting the key as an authenticated subject. - Authenticated Owner requests still classify normally; Owner lockout protection is enforced in later branches. +- High-signal probe paths are suspicious even when the route does not exist or is only a honeypot; later enforcement should return a generic `400` without revealing route existence. - Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. - Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. - Passive-signal storage failure records a safe diagnostic and must not change request outcome in this foundation branch. @@ -60,6 +62,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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, `/api/live/**`, login, registration, password reset, and suspicious probes. +- Test configurable probe-path defaults and high-signal probe classification. - Test redaction in passive signal messages. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. - Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 81d50d2b..7171f79f 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -39,6 +39,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Public interfaces and data decisions - First implementation uses database-backed TTL records; cache may be added later as an optimization. +- Auto-ban is enabled by default, with bounded configuration to disable it when the auto-ban branch introduces Security settings. - Ban subject types are IP bucket, visitor ID, API key, combined anonymous subject, and optional authenticated user only for explicit compromise cases. - Ban reasons use stable message/code catalogues. - Ban responses use HTML or JSON according to request family and never expose raw signal internals. @@ -51,6 +52,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Edge cases - Expired bans must not block while cleanup is pending. +- 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 must not be banned or rate-limited by ordinary application buckets. +- A recovery login route must render even when the current Visitor ID or IP bucket is banned, then re-evaluate the ban after successful credential login under authenticated policies. - Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. - Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. - Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. @@ -64,6 +68,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test active, expired, manually revoked, and cleanup states. - Test anonymous enforcement and softer authenticated behavior. - Test Owner recovery protection. +- Test active Admin/Owner session ban protection, Owner API-key protection, and recovery-login re-evaluation. - Test HTML/JSON ban responses and redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 4dd9f2b0..75dc6b95 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -56,8 +56,8 @@ These are first implementation defaults. Branches may adjust them only with test | 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 30 per hour | API key fingerprint plus scheduler endpoint subject | No success reset | -| Suspicious probes | 10 high-signal probes per 10 minutes | Visitor ID plus IP bucket | No success reset; may drain suspicious buckets | +| Scheduler trigger | 5 trigger attempts per minute and 60 per hour | API key fingerprint plus scheduler endpoint subject | No success reset | +| Suspicious probes | 1 high-signal probe per 10 minutes | Visitor ID plus IP bucket | No success reset; return generic `400`; may drain suspicious buckets | 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. @@ -65,8 +65,22 @@ 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. + +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. + +## Probe Path Policy + +- Probe paths are configurable and ship with extensive defaults for 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. +- 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. + ## Auto-Ban Defaults +- Auto-ban is enabled by default and can be disabled through Security policy/settings once the auto-ban branch introduces bounded configuration. - Visitor-ID bans are the preferred continuity mechanism: - first temporary ban: 1 hour; - repeated ban within 24 hours: 24 hours; @@ -81,8 +95,11 @@ Turbo/browser prefetch for safe `GET` requests should not spend the same budget - invalid-key probe ban: 15 minutes; - repeated invalid-key probe ban: 1 hour; - compromised or revoked-key replay review may escalate to 24 hours. -- Authenticated users start with softer handling such as throttling, captcha, warnings, or session/token review unless explicit compromise signals justify a hard block. +- Authenticated users start with higher limits and softer handling such as throttling, captcha, warnings, or session/token review unless explicit compromise signals justify a hard block. +- 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 route that renders the login form even when the current Visitor ID or IP bucket is banned. Successful credential login re-evaluates the current limiter/ban state under authenticated policies, including Admin ban protection and Owner rate-limit exemption. - 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 8fdc47ea..9b7f801b 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -42,6 +42,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - Website global policy uses separate deliberate burst and sustained buckets. Turbo/browser prefetch uses a separate lower-confidence observation path so speculative `GET` requests do not exhaust user-facing navigation budgets. +- Scheduler trigger policy must allow normal once-per-minute external cron calls; task due-state logic, locks, and task policies decide whether work actually runs. +- Authenticated users receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define its own explicit bucket. Owner-owned API keys and subjects tied to an active Owner session are exempt from ordinary rate-limit rejection. - 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. @@ -59,6 +61,8 @@ 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 authenticated-user higher limits and Owner ordinary-rate-limit exemptions for active sessions and Owner-owned API keys. - Test successful login resets only the login bucket. - Test `/api/live/**` never receives ordinary rate-limit `429`. - Test browser HTML and API JSON `429` shapes. From 25fdf406466ccc9ec052168b6a2e4d94771b75ab Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:46:42 +0200 Subject: [PATCH 013/119] Define recovery login bypass policy --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityAccessControl.md | 2 +- dev/draft/0.2.x-SecurityHardeningPlan.md | 2 +- dev/draft/security-hardening/auto-ban.md | 3 ++- dev/draft/security-hardening/policy-defaults.md | 4 +++- dev/draft/security-hardening/rate-enforcement.md | 2 ++ 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8d5a5c1f..2abfa6c2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -82,6 +82,7 @@ - Raised the first IconCaptcha challenge TTL default to 15 minutes for realistic form completion time while keeping one-shot validation, scoped failure buckets, context binding, refresh abuse signals, and answer-leak checks as required bot-protection controls. - Split the website global rate-limit default into deliberate burst and sustained buckets, with Turbo/browser prefetch tracked through a separate lower-confidence observation path so speculative requests do not drain user-facing navigation budgets. - Adjusted scheduler and probe policies: scheduler trigger limits now support minutely cron, high-signal probes are limited to one per 10 minutes with generic `400` handling, probe paths are configurable with broad defaults, auto-ban defaults to on, and active Admin/Owner recovery protections are explicit. +- Documented recovery login bypass policy using the normal login route plus a bypass flag, guarded by a dedicated 2/minute and 10/hour bucket with 30-minute retry behavior and no bypass of CSRF, credential checks, login-failure accounting, or audit logging. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 69619fb2..688369ba 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -64,7 +64,7 @@ Captcha should use a global form field integration. When a workflow includes the - Prefer visitor-ID-backed temporary bans for continuity and use shorter IP-bucket bans only as a secondary layer against cookie-reset bypasses. IP-derived records must remain queryable for less than 30 days. - Registered users should receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define an explicit stricter bucket. Active Owner sessions and Owner-owned API keys are exempt from ordinary application rate-limit rejection. - Suspicious probe paths should be configurable with broad defaults. High-signal probes such as `.env`, VCS metadata, backup/database dumps, common foreign admin panels, and shell probes should return a generic `400` and feed suspicious-signal handling instead of being treated like normal 404 traffic. -- Auto-ban is enabled by default once implemented, but active Admin/Owner session Visitor IDs and IP buckets must not be banned. A recovery login route must remain reachable through active Visitor/IP bans so a successful login can re-evaluate the subject under authenticated/Admin/Owner policy. +- Auto-ban is enabled by default once implemented, but active Admin/Owner session Visitor IDs and IP buckets must not be banned. A recovery login path such as `/user/login?bypass=1` must remain reachable through active Visitor/IP bans, guarded by a dedicated recovery bucket, so a successful login can re-evaluate the subject under authenticated/Admin/Owner policy. - Document that edge/server-level rate limiting is still needed for DoS protection. - Keep secrets out of manifests, docs, logs, fixtures, and committed config. - Generate `APP_SECRET` during setup and store it in `.env.local.php`. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index ca8fea44..4e221bd5 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -290,7 +290,7 @@ Acceptance: - Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. - Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. - Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. -- A recovery login route must remain reachable even when the current Visitor ID or IP bucket is banned; successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. +- A recovery login path such as `/user/login?bypass=1` must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. - Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 7171f79f..a48540ca 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -54,7 +54,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Expired bans must not block while cleanup is pending. - 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 must not be banned or rate-limited by ordinary application buckets. -- A recovery login route must render even when the current Visitor ID or IP bucket is banned, then re-evaluate the ban after successful credential login under authenticated policies. +- A recovery login route, for example `/user/login?bypass=1`, must render the normal login form even when the current Visitor ID or IP bucket is banned, then re-evaluate the ban after successful credential login under authenticated policies. - Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. - Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. - Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. @@ -69,6 +69,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test anonymous enforcement and softer authenticated behavior. - Test Owner recovery protection. - Test active Admin/Owner session ban protection, Owner API-key protection, and recovery-login re-evaluation. +- Test that recovery-login bypass does not bypass CSRF, credential validation, the dedicated recovery-login bucket, or audit logging. - Test HTML/JSON ban responses and redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 75dc6b95..362b9cbc 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -46,6 +46,7 @@ These are first implementation defaults. Branches may adjust them only with test | Policy | Default | Subject | Success reset | | --- | --- | --- | --- | | Login failures | 5 failed attempts per 15 minutes | Visitor ID plus username/email hash where safe; IP bucket as secondary signal | Successful credential login resets only the login-attempt bucket | +| Recovery login bypass | 2 credential attempts per minute, 10 per hour, retry after 30 minutes once exhausted | Visitor ID plus username/email hash where safe; IP bucket as secondary signal | Successful credential login re-evaluates active bans/limits under authenticated policy | | Registration submissions | 3 submissions per hour and 10 per day | Visitor ID; IP bucket as secondary signal | No automatic global reset | | Password-reset requests | 3 requests per hour and 10 per day | Visitor ID plus normalized email hash where safe; IP bucket as secondary signal | No automatic global reset | | Contact form submissions | 3 submissions per 10 minutes and 20 per day | Visitor ID; IP bucket as secondary signal | No automatic global reset | @@ -99,7 +100,8 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner - 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 route that renders the login form even when the current Visitor ID or IP bucket is banned. Successful credential login re-evaluates the current limiter/ban state under authenticated policies, including Admin ban protection and Owner rate-limit exemption. +- 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. - 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 9b7f801b..34d52d57 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -44,6 +44,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. - 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. - 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. @@ -63,6 +64,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 and still records passive signals for excessive speculative traffic. - Test scheduler triggers allow normal minutely cron calls while still limiting obvious trigger storms. - 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 successful login resets only the login bucket. - Test `/api/live/**` never receives ordinary rate-limit `429`. - Test browser HTML and API JSON `429` shapes. From ed589d86b576a3664c707766986a176007ad2f72 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:53:12 +0200 Subject: [PATCH 014/119] Clarify captcha auto-success limits --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityAccessControl.md | 5 +++-- dev/draft/0.2.x-SecurityHardeningPlan.md | 9 +++++---- dev/draft/0.4.x-IconCaptcha.md | 9 +++++---- dev/draft/security-hardening/captcha-contract.md | 7 ++++++- dev/draft/security-hardening/policy-defaults.md | 7 +++++-- dev/draft/security-hardening/rate-enforcement.md | 6 +++++- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 2abfa6c2..2acc24c6 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -83,6 +83,7 @@ - Split the website global rate-limit default into deliberate burst and sustained buckets, with Turbo/browser prefetch tracked through a separate lower-confidence observation path so speculative requests do not drain user-facing navigation budgets. - Adjusted scheduler and probe policies: scheduler trigger limits now support minutely cron, high-signal probes are limited to one per 10 minutes with generic `400` handling, probe paths are configurable with broad defaults, auto-ban defaults to on, and active Admin/Owner recovery protections are explicit. - Documented recovery login bypass policy using the normal login route plus a bypass flag, guarded by a dedicated 2/minute and 10/hour bucket with 30-minute retry behavior and no bypass of CSRF, credential checks, login-failure accounting, or audit logging. +- Clarified captcha auto-success policy: provider `none`, missing providers, and disabled providers keep workflows graceful but never reset/refill rate-limit buckets, clear bans, or satisfy captcha-based `429` recovery. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 688369ba..2a928826 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -57,7 +57,7 @@ Captcha should use a global form field integration. When a workflow includes the - Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints should stay cheap, tokenized where necessary, no-store, and safe for live polling, captcha refreshes, or lightweight UI refreshes; clear abuse may still feed passive suspicious-behavior signals. - Classify Turbo/browser prefetch requests separately from deliberate navigations and submissions. Prefetch should not spend the same global abuse budget as a user-initiated request, and expensive or side-effect-adjacent links may disable Turbo prefetching. - Route rate-limit decisions through a Studio-owned facade so workflows can assign action costs, reset scoped buckets after clear success, and combine Symfony RateLimiter buckets with cross-action abuse signals without binding controllers to Symfony limiter internals. -- Prefer scoped bucket resets for successful human outcomes before designing partial refunds. For example, a successful login may reset the login-attempt bucket for that subject, and a successful captcha challenge may reset the relevant challenge or form bucket when the workflow explicitly allows it. +- Prefer scoped bucket resets for successful human outcomes before designing partial refunds. For example, a successful login may reset the login-attempt bucket for that subject, and a verified provider-backed captcha challenge may reset the relevant challenge or form bucket when the workflow explicitly allows it. - Track global cross-action abuse signals across website and API activity so several separately limited actions can still trigger progressive handling when they occur in suspicious sequence. - Support progressive abuse handling such as allow, throttle, require captcha, temporary block, and hard block. - Support active punishment such as draining relevant buckets or applying temporary TTL bans for clear suspicious behavior. Auto-bans may be keyed by IP, visitor ID, API key, or safe combined subjects, must be auditable, and must preserve Owner recovery paths. @@ -77,6 +77,7 @@ Captcha should use a global form field integration. When a workflow includes the - Make captcha usage configurable per workflow, with contact forms, registration forms, and guest comments as the first expected protected workflows. - Allow provider selection through configuration. With only the first-party module installed, the default choices should be `icon_captcha` and `none`. - Treat provider value `none` and missing provider modules as successful captcha validation so configured forms do not break. +- Treat provider value `none` and missing/disabled provider success as graceful workflow success only. It must not reset rate limits, refill budgets, clear bans, or satisfy captcha-based `429` recovery. - Ensure captcha never replaces CSRF, ACL checks, authentication, or business validation. - Use audit logging for high-impact actions such as publish, delete, import apply, module enable, migration run, backup restore, and update. - Audit schema Twig changes, content-query/list field changes, variant availability changes, and localization routing changes. @@ -142,7 +143,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** `APP_SECRET` is generated during setup and stored in `.env.local.php`. - **Decision recorded:** Captcha extensibility belongs in core through `CaptchaProvider` and resolver contracts, while IconCaptcha behavior is tracked in its own feature draft before implementation. - **Decision recorded:** Captcha integrates through a global form field that uses the configured captcha provider resolver. -- **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows. +- **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows, but this graceful success is not verified human success and must not reset rate limits or satisfy captcha-based `429` recovery. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected workflows to protect with captcha. - **Decision recorded:** Abuse handling should be progressive and use workflow-specific limiters instead of relying on one coarse IP bucket. - **Decision recorded:** The next security work is split through `feat-security-*` branches described in the security hardening implementation plan. Each branch should be feature-complete for its focused concern rather than accumulating one large security review branch. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 4e221bd5..81ad4469 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -30,7 +30,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be u - Treat Turbo and browser prefetch requests as lower-confidence interaction signals. Prefetch requests should not spend the same global abuse budget as deliberate navigations or submissions, and high-cost links may disable prefetch through `data-turbo-prefetch="false"`. - Detect Turbo prefetch defensively through `X-Sec-Purpose: prefetch` and browser speculative loading through `Sec-Purpose: prefetch` where available. Missing or spoofable non-`Sec-*` hints must not bypass security checks. - Use action-aware limiter costs. A failed login, failed captcha, registration submission, password-reset request, API mutation, suspicious probe, or scheduler trigger may consume different costs from different buckets. -- Prefer complete bucket reset for clear success cases before designing partial refunds. For example, successful password login may reset the login-attempt bucket for that subject. Captcha success may reset or improve specific challenge/form buckets when that behavior is explicit and safe. +- Prefer complete bucket reset for clear success cases before designing partial refunds. For example, successful password login may reset the login-attempt bucket for that subject. Verified provider-backed captcha success may reset or improve specific challenge/form buckets when that behavior is explicit and safe. - Keep room for future positive adjustments through a Studio-owned rate/abuse facade, but do not expose Symfony RateLimiter details directly to controllers or packages. - Recognize cross-action abuse. Separate buckets remain useful, but repeated activity across different guarded workflows should also feed a global subject budget and suspicious-signal store. - Add progressive punishment as a first-class concept: observe, throttle, require captcha, temporarily block, and hard-block only when signals justify it. @@ -121,7 +121,7 @@ Scope: - Add named buckets for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure flows. Branches that introduce a later workflow must attach it to the existing policy catalogue instead of inventing a parallel limiter. - Treat captcha refreshes served through `/api/live/**` as passive abuse signals instead of ordinary rejecting rate-limit buckets. - Use costed `consume(n)` calls for action-aware spending. -- Use `reset()` for clear successful outcomes such as successful login or verified captcha where the reset is scoped and safe. +- Use `reset()` for clear successful outcomes such as successful login or verified provider-backed captcha where the reset is scoped and safe. - Return stable HTML or JSON `429` responses depending on request family. - Keep `/api/live/**` excluded from ordinary rate-limit responses. @@ -171,7 +171,7 @@ Scope: - Add workflow-level provider selection. - Add a global captcha form field. - Treat `none`, missing providers, and disabled providers as successful validation unless a later provider-required policy explicitly changes that workflow. -- Allow captcha success to reset or improve the relevant challenge/form buckets through the abuse facade. +- Distinguish graceful captcha auto-success from verified provider-backed challenge success. Only verified provider-backed success may reset/improve buckets or satisfy captcha-based `429` recovery. Non-goals: @@ -291,7 +291,8 @@ Acceptance: - Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. - Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. - A recovery login path such as `/user/login?bypass=1` must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. -- Successful login and successful captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. +- Successful login and verified provider-backed captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. +- Captcha-based reset or `429` recovery requires an active provider-backed challenge. Provider `none`, missing-provider, or disabled-provider auto-success is never human proof and must not reset limits. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. - IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index 40c2947d..52d4d256 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -31,7 +31,7 @@ The challenge should be deterministic from server-side state, but unpredictable The frontend should keep the interaction lightweight and accessible: one target icon, a fixed button grid, hidden form fields for challenge metadata, a refresh control, and clear error feedback from the normal form validation layer. A two-slot challenge approach is preferred for smooth refresh behavior: switch immediately to a standby challenge, then refill the hidden slot asynchronously. -IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, route-specific rate limits, form validation, suspicious-request tracking, and optional GeoIP/security policy all remain separate signals. Captcha success should not bypass ACLs, CSRF, or business validation. +IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, route-specific rate limits, form validation, suspicious-request tracking, and optional GeoIP/security policy all remain separate signals. Captcha success should not bypass ACLs, CSRF, or business validation. Rate-limit resets, budget recovery, or captcha-based `429` recovery require verified success from an active provider-backed challenge; provider `none`, missing-provider, or disabled-provider auto-success must not be treated as human proof. ## Technical Specifications - Implement IconCaptcha as a first-party package with `captcha-provider` scope, for example `packages/icon-captcha/`. @@ -70,7 +70,7 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Send recoverable captcha errors through the normal validation and error-handling flow. - Treat honeypot failures as suspicious signals that may be silent, while normal missing/expired/wrong captcha input should be recoverable for humans. - Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for form posts, captcha failures, and suspicious probe traffic, while `/api/live/**` challenge refreshes remain passive abuse signals. -- Reset or soften the relevant scoped limiter buckets after successful captcha validation when the workflow policy allows it, so human users are not unnecessarily penalized. +- Reset or soften the relevant scoped limiter buckets after verified provider-backed captcha validation when the workflow policy allows it, so human users are not unnecessarily penalized. Provider `none`, missing-provider, or disabled-provider auto-success never resets buckets. - Avoid a single coarse per-IP bucket as the only rate-limit mechanism. - Consider progressive response behavior: allow, throttle, require captcha, then block only for strong signals. - Log provider failures with safe context such as workflow, route, failure code, and anonymized request identifiers where appropriate. @@ -148,7 +148,8 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test successful and failed captcha validation paths. - Test recoverable validation feedback on public forms. - Test rate-limited captcha failure behavior where applicable. -- Test successful captcha validation resets or softens relevant scoped limiter buckets. +- Test verified captcha validation resets or softens relevant scoped limiter buckets. +- Test provider `none`, missing-provider, and disabled-provider auto-success never resets or softens limiter buckets. - Test that disabling IconCaptcha or selecting another provider does not break workflows using the generic contract. - Test provider value `none` validates successfully. - Test missing or disabled provider modules validate successfully unless a later provider-required policy is introduced. @@ -184,7 +185,7 @@ Recoverable failures should produce translated form errors. Suspicious failures - **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a 15-minute TTL. The longer TTL is intended to support longer form completion time and relies on one-shot invalidation, context binding, captcha-failure buckets, refresh abuse signals, and answer-leak tests to prevent brute-force guessing. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. - **Decision recorded:** Avoid a single coarse per-IP rate limiter; use workflow-specific limiters and progressive abuse handling. -- **Decision recorded:** Successful captcha validation should reset or soften relevant scoped limiter buckets for human users when the workflow policy explicitly allows it. +- **Decision recorded:** Successful provider-backed captcha validation should reset or soften relevant scoped limiter buckets for human users when the workflow policy explicitly allows it. Provider `none`, missing-provider, or disabled-provider auto-success does not count as human proof and must not reset buckets. - **Decision recorded:** Keep provider-specific templates theme-compatible but package-owned. - **Required decision:** Define the exact generic `CaptchaProvider` interface before implementation. - **Required decision:** Define the provider secret storage location and rotation behavior. diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md index 90d0e5bb..de675290 100644 --- a/dev/draft/security-hardening/captcha-contract.md +++ b/dev/draft/security-hardening/captcha-contract.md @@ -33,13 +33,15 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 3. Add workflow keys for first expected consumers: registration, contact, guest comments, and future login step-up. 4. Add a global captcha form field that delegates rendering/validation to the resolver. 5. Add validation mapping for recoverable failures and suspicious failures. -6. Add success/failure hooks to the abuse facade so success can reset scoped buckets and failure can record signals. +6. Add success/failure hooks to the abuse facade so verified provider success can reset scoped buckets and failure can record signals. ## Public interfaces and data decisions - Provider key `none` always validates successfully. - Missing or disabled provider validates successfully unless a future provider-required policy is explicitly configured for a workflow. +- Successful validation from `none`, a missing provider, or a disabled provider is graceful workflow success, not verified human success. It must not trigger rate-limit resets, ban relief, budget refill, or `429` recovery behavior. - Captcha result exposes only stable failure codes and safe context. +- Captcha result must distinguish graceful unavailable-provider success from verified challenge success. - Provider contracts are package-facing extension points and must be documented. - Provider-required behavior is not enabled in the first contract branch; workflow policy may declare the shape for later enforcement, but default runtime behavior remains graceful success for unavailable providers. @@ -47,12 +49,15 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Captcha must not create anonymous sessions by default. - Captcha validation never replaces CSRF, authentication, ACL, rate limiting, or domain validation. +- Captcha-on-`429` recovery is available only when an active provider can render and validate a real challenge. - Provider render failures should degrade according to workflow policy and report safe diagnostics. - Multi-language validation messages use deterministic translation keys. ## Tests and validation - Test `none`, missing provider, disabled provider, success, recoverable failure, and suspicious failure. +- Test `none`, missing provider, and disabled provider do not call reset/refill/recovery hooks. +- Test verified provider success is the only captcha result that may call scoped reset hooks. - Test form integration does not break workflows with no provider. - Test abuse hooks are called with safe context. - Test provider registration rejects duplicate provider keys. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 362b9cbc..ddb9ac49 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -50,7 +50,7 @@ These are first implementation defaults. Branches may adjust them only with test | 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 | -| Captcha failures | 5 failures per 10 minutes | Challenge subject plus visitor ID | Successful captcha may reset the scoped challenge/form bucket only | +| 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 | @@ -107,10 +107,13 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner ## Captcha Defaults - `none`, missing provider, and disabled provider validate successfully until a workflow explicitly introduces provider-required policy. +- `none`, missing-provider, and disabled-provider success means "do not block the workflow because captcha is unavailable"; it is not verified human challenge success. - IconCaptcha challenge TTL is 15 minutes so humans can complete longer forms without unnecessary expiry. - Every validation attempt consumes the challenge ID, successful or failed. - The longer challenge TTL is safe only when repeated guesses against the same challenge are impossible. Keep one-shot invalidation, context binding, tight captcha-failure buckets, refresh abuse signals, and answer-leak tests in place. -- Captcha success may reset only the scoped challenge/form bucket when the workflow policy allows it. +- Captcha success may reset only the scoped challenge/form bucket when the workflow policy allows it and when a real provider validated a real challenge. +- Provider `none`, missing-provider, or disabled-provider auto-success must never reset rate-limit buckets, refill budgets, clear bans, or satisfy a captcha-based `429` recovery step. +- Rendering captcha on a `429` recovery/error page is allowed only when the workflow has an active captcha provider that can validate a real challenge. Without such a provider, the response must fall back to ordinary retry-after behavior. - Visual IconCaptcha must not expose answer-bearing names through DOM, SVG, asset paths, translation keys, hidden text, or ARIA labels. - If neutral labels are not sufficient for assistive technology, the preferred fallback is a provider-owned quiz challenge that shares the same one-shot, TTL, context-binding, refresh, and abuse-signal rules. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 34d52d57..df844e7e 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -30,7 +30,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 1. Configure named Symfony limiters for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure 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 successful captcha validation where the workflow explicitly allows it. +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. @@ -41,6 +41,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. - Registration and password-reset success do not reset global buckets by default. - The branch must commit initial threshold defaults from the Security policy defaults as named configuration/constants with behavior tests. Later branches may tune those defaults only with matching draft/worklog notes. +- Captcha-triggered limiter resets or `429` recovery require verified provider-backed challenge success. Provider `none`, missing-provider, and disabled-provider auto-success must not reset or refill any bucket. - Website global policy uses separate deliberate burst and sustained buckets. Turbo/browser prefetch uses a separate lower-confidence observation path so speculative `GET` requests do not exhaust user-facing navigation budgets. - Scheduler trigger policy must allow normal once-per-minute external cron calls; task due-state logic, locks, and task policies decide whether work actually runs. - Authenticated users receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define its own explicit bucket. Owner-owned API keys and subjects tied to an active Owner session are exempt from ordinary rate-limit rejection. @@ -56,6 +57,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Read-only API keys hitting write routes should still follow API write policy before or alongside authorization failure as decided by the handler order. - `/api/live/**` operation polling must continue to function during long admin operations. - 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. ## Tests and validation @@ -66,6 +68,8 @@ 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 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 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 that non-existing optional workflows are not wired as dead routes/services and that later workflow branches have a clear catalogue attachment point. From a47a1c915b4e60790516640b88f9b3d9f258863f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:57:12 +0200 Subject: [PATCH 015/119] Document security enforcement policy order --- dev/WORKLOG.md | 1 + .../security-hardening/abuse-foundation.md | 3 ++ dev/draft/security-hardening/auto-ban.md | 3 ++ .../security-hardening/policy-defaults.md | 32 +++++++++++++++++++ .../security-hardening/rate-enforcement.md | 3 ++ 5 files changed, 42 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 2acc24c6..f1a4d701 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,6 +84,7 @@ - Adjusted scheduler and probe policies: scheduler trigger limits now support minutely cron, high-signal probes are limited to one per 10 minutes with generic `400` handling, probe paths are configurable with broad defaults, auto-ban defaults to on, and active Admin/Owner recovery protections are explicit. - Documented recovery login bypass policy using the normal login route plus a bypass flag, guarded by a dedicated 2/minute and 10/hour bucket with 30-minute retry behavior and no bypass of CSRF, credential checks, login-failure accounting, or audit logging. - Clarified captcha auto-success policy: provider `none`, missing providers, and disabled providers keep workflows graceful but never reset/refill rate-limit buckets, clear bans, or satisfy captcha-based `429` recovery. +- Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, and auditable Owner/Admin exemptions. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 324ee14e..2059af47 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -46,6 +46,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Keep passive signals separate from raw file logs. If a broader database-backed security event projection is introduced later, this branch's signal store should either feed it through a documented boundary or remain the focused enforcement-oriented read model. - IP subjects and stable IP-derived hashes must expire within 30 days. Longer-lived passive signals must use visitor ID, authenticated user ID, API key fingerprint, or aggregate keys without retaining the IP-derived subject. - TTL and expiry use an injectable clock/time boundary for deterministic tests. +- Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. +- Probe-path configuration uses anchored, normalized patterns and must be tested against normal app/package/media/editor routes to avoid false positives. ## Edge cases @@ -63,6 +65,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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, `/api/live/**`, login, registration, password reset, and suspicious probes. - Test configurable probe-path defaults and high-signal probe classification. +- Test probe-pattern normalization and false-positive avoidance for ordinary application routes. - Test redaction in passive signal messages. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. - Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index a48540ca..deeb2f76 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -48,6 +48,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Prefer Visitor-ID-backed bans for continuity. Add IP-bucket bans as a shorter secondary layer to reduce cookie-reset bypasses, and keep every IP-derived ban TTL below 30 days. - Ban keys come only from the shared subject/client-identity resolver. Raw IP strings, raw API keys, and raw forwarding headers must never be stored as ban keys. - Expiry and cleanup use an injectable clock/time boundary. +- Ban decisions follow the Security policy enforcement order so Admin/Owner context and recovery-login rendering are resolved before visitor/IP bans can deny access. +- Active temporary ban responses default to generic `403` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. ## Edge cases @@ -71,6 +73,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test active Admin/Owner session ban protection, Owner API-key protection, and recovery-login re-evaluation. - Test that recovery-login bypass does not bypass CSRF, credential validation, the dedicated recovery-login bucket, or audit logging. - Test HTML/JSON ban responses and redaction. +- Test ban response status, retry metadata, cache headers, and route-existence redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. - Test IP-derived ban TTL validation rejects or clamps values at 30 days and cleanup removes expired IP-derived records from review/export surfaces. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index ddb9ac49..d9ccc335 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -39,6 +39,24 @@ The defaults are not an Admin UI requirement. Admin-configurable policy can be a - Remember-me trust window: seven days. - Account invitation/registration links: 24 hours by default. Password-reset links: one hour. +## Enforcement Order + +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. +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. +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. +7. Consume rate buckets in a stable order: workflow-specific bucket, request-family/global bucket, then suspicious/abuse bucket where applicable. +8. When multiple buckets fail, report the most user-actionable policy to the client and keep internal bucket names in diagnostics only. +9. Run the guarded workflow only after the decision is allowed. +10. Apply scoped resets only after clear success, such as successful credential login or verified provider-backed captcha success. +11. Record audit events and passive signals with redacted context regardless of whether the request was allowed or blocked. + +Owner/Admin protection does not bypass authentication validity, account status, role checks, ACL decisions, CSRF validation, API-key revocation, or explicit workflow authorization. Exempted requests should still record diagnostics so unusual administrative traffic remains reviewable. + ## Rate-Limit Defaults These are first implementation defaults. Branches may adjust them only with tests and a worklog note explaining the review reason. @@ -78,6 +96,17 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner - 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. - 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. + +## Response Semantics + +- 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. +- 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. +- `/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. ## Auto-Ban Defaults @@ -128,6 +157,9 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner - First implementations may ship policy defaults as code-level constants or configuration values with tests. - Admin-configurable settings require bounded validation, safe defaults, documentation, and tests for disabled/missing settings. +- Security policy bounds must prevent accidental lockout and privacy drift. Configuration must not allow IP-derived retention above 30 days, IP-ban TTLs above the documented maximum, disabling Owner recovery, disabling the recovery-login bypass without an equivalent path, or treating captcha `none` auto-success as verified human success. +- More permissive settings for public entry points should require an explicit policy update, not only a local configuration change. +- More restrictive settings that affect login, account recovery, scheduler operation, captcha, mail delivery, or Owner/Admin access need tests for recovery behavior and false-positive handling. - User-facing copy is required whenever a configurable policy affects public behavior, recovery, captcha, mail delivery, remember-me, account access, or data retention. ## Review Requirements diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index df844e7e..6ec2de5e 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -49,6 +49,8 @@ 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 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. +- 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`. ## Edge cases @@ -72,6 +74,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 response cache headers and redaction for browser/API/scheduler limit failures. - 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 configured limiter service wiring with `lint:container`. From 3340d58bf94a46c6a13a9492fc1247cb509757cc Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 22:59:29 +0200 Subject: [PATCH 016/119] Document security configuration surfaces --- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 2 ++ .../security-hardening/captcha-contract.md | 2 ++ .../security-hardening/geoip-observability.md | 2 ++ dev/draft/security-hardening/icon-captcha.md | 3 ++ .../security-hardening/policy-defaults.md | 29 +++++++++++++++++++ .../security-hardening/rate-enforcement.md | 2 ++ 7 files changed, 41 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f1a4d701..6b8868c7 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -85,6 +85,7 @@ - Documented recovery login bypass policy using the normal login route plus a bypass flag, guarded by a dedicated 2/minute and 10/hour bucket with 30-minute retry behavior and no bypass of CSRF, credential checks, login-failure accounting, or audit logging. - Clarified captcha auto-success policy: provider `none`, missing providers, and disabled providers keep workflows graceful but never reset/refill rate-limit buckets, clear bans, or satisfy captcha-based `429` recovery. - Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, and auditable Owner/Admin exemptions. +- Added a first configuration-surface matrix that separates fixed policy, code/config defaults, protected secrets, bounded Admin settings, and later-tunable thresholds for follow-up Security branches. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index deeb2f76..d93b68cf 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -50,6 +50,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Expiry and cleanup use an injectable clock/time boundary. - Ban decisions follow the Security policy enforcement order so Admin/Owner context and recovery-login rendering are resolved before visitor/IP bans can deny access. - Active temporary ban responses default to generic `403` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. +- Auto-ban enablement, TTLs, and escalation windows should use named bounded policy descriptors. Disabling auto-ban must not disable passive signal recording, audit, manual review, or recovery protections. ## Edge cases @@ -76,6 +77,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test ban response status, retry metadata, cache headers, and route-existence redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. +- Test disabling auto-ban preserves passive signals, diagnostics, and recovery behavior. - Test IP-derived ban TTL validation rejects or clamps values at 30 days and cleanup removes expired IP-derived records from review/export surfaces. - Test trusted-proxy/client-identity behavior, ban-store degradation, and concurrent create/unban/cleanup behavior. - Test migration applies on SQLite. diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md index de675290..fade4b9c 100644 --- a/dev/draft/security-hardening/captcha-contract.md +++ b/dev/draft/security-hardening/captcha-contract.md @@ -44,6 +44,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Captcha result must distinguish graceful unavailable-provider success from verified challenge success. - Provider contracts are package-facing extension points and must be documented. - Provider-required behavior is not enabled in the first contract branch; workflow policy may declare the shape for later enforcement, but default runtime behavior remains graceful success for unavailable providers. +- Provider selection and workflow mapping should be represented as audited configuration descriptors. Reset/recovery eligibility is not an ordinary setting: only verified provider-backed success may trigger scoped rate-limit reset or captcha-based recovery hooks. ## Edge cases @@ -61,6 +62,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test form integration does not break workflows with no provider. - Test abuse hooks are called with safe context. - Test provider registration rejects duplicate provider keys. +- Test configuration descriptor behavior for provider `none`, missing provider, disabled provider, and provider-required policy declarations where introduced. - Test translation catalogue synchronization for user-facing errors. ## Documentation and tracking diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 20cfde19..6f3969ea 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -42,6 +42,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. - Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code. - No public API response adds GeoIP data in this branch. +- Provider selection, database path/status, and update policy are protected/audited configuration surfaces; account and license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. ## Edge cases @@ -58,6 +59,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test trusted-proxy/client-identity behavior for lookup input. - Test scheduler task no-op and failure message behavior. - Test that the task remains inactive until provider configuration and update policy are both present. +- Test protected configuration redaction and null fallback for disabled, missing, invalid, and expired provider states. - Run focused container lint when services/config are added. ## Documentation and tracking diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md index 6bfcbd15..ea9ae0d2 100644 --- a/dev/draft/security-hardening/icon-captcha.md +++ b/dev/draft/security-hardening/icon-captcha.md @@ -46,6 +46,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Inline-rendered graphics and symbol SVGs must not expose answer-bearing names through file names, element IDs, CSS classes, `data-*` attributes, titles, descriptions, or translation keys. Use opaque challenge-local identifiers and randomized or non-semantic button identifiers. - Accessibility labels must describe the control purpose without revealing the visual answer. Prefer neutral labels such as option numbers and state/status text over labels that name the target icon or symbol. If this makes the visual challenge insufficient for assistive technology, document and implement a separate accessible fallback flow instead of leaking the answer through ARIA. - The preferred accessible fallback is a provider-owned quiz challenge using a spoken question/task and multiple answer options. The quiz mode must share the same challenge ID, TTL, one-shot invalidation, context binding, failure codes, refresh handling, and passive abuse signals as the visual IconCaptcha mode. +- Challenge TTL may become bounded configuration, but starts at 15 minutes. The recommended range is 10-30 minutes; longer TTLs require explicit policy review plus brute-force, one-shot, refresh, and replay tests. +- Provider secrets are secret/protected configuration only and must never be stored in package metadata, public assets, serialized challenge payloads, cache payloads, logs, or diagnostics. ## Edge cases @@ -66,6 +68,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test every failure model. - Test one-shot replay prevention and TTL expiry. - Test cache-pool fallback and secret absence from cached/public challenge payloads. +- Test configured TTL bounds if challenge TTL becomes configurable. - Test refresh no-store behavior and passive signal recording. - Test package asset/template/translation registration. - Test keyboard/accessibility behavior where practical with JS tests. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index d9ccc335..5719f98a 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -162,6 +162,35 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner - More restrictive settings that affect login, account recovery, scheduler operation, captcha, mail delivery, or Owner/Admin access need tests for recovery behavior and false-positive handling. - User-facing copy is required whenever a configurable policy affects public behavior, recovery, captcha, mail delivery, remember-me, account access, or data retention. +## Configuration Surface Defaults + +These are first soft decisions for which values should stay fixed, become protected environment/config values, or become audited Admin settings later. A branch may choose code constants for its first implementation, but it should keep the target surface in mind so later configuration does not require redesign. + +| Area | First implementation surface | Later configurable? | Boundaries | +| --- | --- | --- | --- | +| Enforcement order, Owner recovery, Admin/Owner lockout protection | Code-level policy and tests | No ordinary Admin setting | Requires a policy update and explicit recovery tests to change | +| IP privacy ceiling and raw-secret redaction | Code-level policy and tests | No increase allowed | IP-derived data max 30 days; raw credentials, API keys, visitor tokens, session IDs, captcha answers, and full user agents are never policy records | +| Raw file-log retention | Existing log configuration or code default | Yes, bounded | Default 30 days; IP-bearing logs must not become queryable beyond 30 days through archives, projections, exports, or support bundles | +| Database security event projection | Feature branch decision | Yes, bounded | Stores minimized/redacted read-model data only; must degrade without hiding diagnostics or weakening enforcement | +| GeoIP provider selection, database path, and update policy | Protected config/Admin setting with null fallback | Yes, protected and audited | Provider secrets never public; disabled/unconfigured state uses `NullGeoIpResolver`; no geo-blocking | +| GeoIP account/license key | Secret/protected setting | Yes, protected only | Never rendered, exported, logged, or included in diagnostics | +| 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 | +| Authenticated-user multiplier | Code/config default | Yes, bounded | Applies only to ordinary navigation/public-read usage, not explicit workflow buckets | +| Owner ordinary-rate-limit exemption | Code-level policy and tests | No ordinary Admin setting | Does not bypass authentication, authorization, API-key revocation, CSRF, audit, or diagnostics | +| Recovery-login bypass path and bucket | Code/config default | Path and thresholds may be bounded later | Must keep an equivalent Owner/Admin recovery path; bypass never skips credential, CSRF, audit, or failure accounting | +| Captcha provider selection and workflow map | Contract-level configuration | Yes, audited | Provider `none`/missing/disabled remains graceful success only, not verified human success | +| Captcha challenge TTL | Code/config default | Yes, bounded | Default 15 minutes; recommended range 10-30 minutes; longer values need policy review and brute-force/refresh tests | +| Captcha reset/recovery eligibility | Code-level policy and tests | No ordinary Admin setting | Only verified provider-backed success may reset scoped buckets or satisfy captcha-based recovery | +| IconCaptcha provider secret | Secret/protected setting | Yes, protected only | Never stored in package metadata, public assets, cache payloads, logs, or diagnostics | +| Remember-me trust window | Code/config default | Possibly later | Default seven days; longer windows require explicit token-rotation, revocation, visitor-binding, and privacy review | +| Scheduler trigger thresholds | Named code/config defaults | Yes, bounded | Must support minutely external cron; task due-state and locks remain authoritative | +| Mailer transport and debug log delivery | Environment/protected config | Yes, protected | Production must not depend on message-log action URLs; debug log delivery remains debug-gated | + +Config descriptors should include the setting key, unit, default, minimum, maximum, disabled behavior, source priority, audit behavior, and safe diagnostics shape. Invalid security configuration should fail early in development/test and degrade to the safest documented behavior in production only when that degradation cannot lock out Owners or hide a security failure. + ## Review Requirements - Every branch that implements one of these policies must update this file when thresholds, TTLs, subject types, or retention rules change. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 6ec2de5e..d8ea3c9e 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -51,6 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Limiter storage degradation must be explicit and tested, including safe diagnostics and Owner recovery behavior. - 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`. +- 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. ## Edge cases @@ -69,6 +70,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 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 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 captcha-on-`429` is unavailable without an active provider and falls back to retry-after behavior. From a279eba7a367ef0cfa065ad3a2821feddb346c39 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 23:08:41 +0200 Subject: [PATCH 017/119] Document security surface coverage gaps --- dev/WORKLOG.md | 3 ++ dev/draft/0.2.x-SecurityAccessControl.md | 5 +++ dev/draft/0.2.x-SecurityHardeningPlan.md | 4 +++ .../security-hardening/abuse-foundation.md | 10 ++++-- dev/draft/security-hardening/auto-ban.md | 2 ++ .../security-hardening/policy-defaults.md | 33 +++++++++++++++++++ dev/draft/security-hardening/policy-docs.md | 1 + .../security-hardening/rate-enforcement.md | 8 ++++- 8 files changed, 63 insertions(+), 3 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 6b8868c7..f1cd2c90 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -69,6 +69,7 @@ - [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding intentionally covers visitor changes, not complete cookie-pair duplication. - [ ] 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. - [ ] 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. @@ -86,6 +87,8 @@ - Clarified captcha auto-success policy: provider `none`, missing providers, and disabled providers keep workflows graceful but never reset/refill rate-limit buckets, clear bans, or satisfy captcha-based `429` recovery. - Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, and auditable Owner/Admin exemptions. - Added a first configuration-surface matrix that separates fixed policy, code/config defaults, protected secrets, bounded Admin settings, and later-tunable thresholds for follow-up Security branches. +- Scanned feature drafts and code surfaces for remaining Security planning gaps; added coverage notes for setup/install, CORS preflight, high-impact admin operations, uploads/archives, exports/downloads, diagnostic bundles, trusted proxy identity, browser storage, and deferred HTTP security-header policy. +- Added Admin-vs-Owner authority policy so non-user-management Admin features can distinguish delegated Admin visibility/mutation from Owner-only site-control actions. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 2a928826..08212a07 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -89,6 +89,9 @@ Captcha should use a global form field integration. When a workflow includes the - Allow users to belong to multiple ACL groups independently of their exact global role; anonymous visitors use Public access level `0`. - Require every active or inactive user account to keep at least the User role, and ensure at least one active Owner account always remains available. - Owners may manage all roles and groups. Admins may enter Admin but cannot manage peer Admin users, Owner users, or groups at or above their own role level. +- Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. +- Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency operational controls. +- Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. - Allow entities and schema versions to override inherited ACL behavior with separate capability fields: min-level plus optional group identifiers. Content uses `view`, `edit`, and `manage`; schemas use `use`, `edit`, and `manage`. @@ -129,6 +132,7 @@ Captcha should use a global form field integration. When a workflow includes the - Test schema Twig editing and activation permissions. - Test the shared ACL resolver for anonymous, level-based, group-based, inherited, and denied decisions. - Test administrative hierarchy guardrails for editing users, assigning groups, creating groups, updating group minimum roles, deleting groups, self-lockout, last-owner protection, and role/group assignment boundaries. +- Test Admin/Owner action authority for settings, package lifecycle, scheduler controls, backup/restore, imports/exports, diagnostics, security policy, and emergency operational controls as those surfaces become implemented. - Test ACL group impact review and cleanup of deleted identifiers across all known ACL-bearing entities. - Test content-query/list field permissions. - Test ACL-restricted content visibility in public rendering, API output, content-query/list fields, and search results. @@ -162,6 +166,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** Require module-declared permissions before module admin routes become active. - **Decision recorded:** Split operational route surfaces into `admin/` for system administration and `editor/` for content authoring, review, publishing, and content-management workflows. `admin/` starts at Admin, while `editor/` starts at Author and should use ACL capability checks so authors, managers, and administrators can access only the authoring functions they are allowed to use. - **Decision recorded:** Backend area checks expose an `AccessRule` boundary so future admin exceptions can add configured ACL groups without changing controller flow. The first router slice keeps admin at the Admin role boundary and keeps Owner as the unrestricted site-owner role. +- **Decision recorded:** Admin and Owner are separate operational authority levels. Admins are delegated operators; Owners control site-wide and recovery-sensitive actions. High-impact Admin features must use an action authority matrix enforced in the service/API/live-operation boundary, not only route prefixes or hidden navigation. - **Decision recorded:** Editing or activating database-backed schema Twig and content-query/list fields is security-sensitive and requires explicit permissions plus audit logging. - **Decision recorded:** Content ACL restrictions may limit visibility to specific ACL groups and must be enforced consistently across rendering, API output, content-query/list fields, search, and import/export previews. - **Decision recorded:** Implement ACL foundations early, including `/admin` access protection and content-record ACL restrictions, to avoid later structural rewrites. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 81ad4469..28ed5895 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -38,6 +38,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be u - Add temporary auto-bans with TTL for IP, visitor ID, API key, or combined subjects. Authenticated users should receive softer handling where reasonable, and Owner accounts must never be locked out of all recovery paths. - Treat GeoIP as operational metadata for logs, statistics, and security review. Missing provider configuration must degrade gracefully. - Include IconCaptcha in the overall security feature cut, but keep its provider implementation in a dedicated branch after the generic captcha contract. +- Cover adjacent security surfaces through the abuse/rate policy catalogue instead of local ad hoc checks: setup apply, CORS preflight, high-impact admin operations, package lifecycle, backup/restore, import/export, uploads/archives, diagnostic downloads, and support bundles. +- Separate delegated Admin authority from Owner-only site-control actions through a shared action policy before broadening package, scheduler, backup, settings, diagnostics, update, and security-management workflows. +- Track HTTP security headers as a production-hardening follow-up if they are not implemented inside an existing response or frontend-delivery branch. ## Branch sequence @@ -95,6 +98,7 @@ Scope: - Add subject resolution for IP, visitor ID, authenticated user, API key, and safe combined keys. - Add request-intent classification for browser navigation, Turbo prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. +- Include additional intents for setup apply, CORS preflight, package/admin operations, upload/archive validation, export/download, backup/restore, self-update, and diagnostics/support-bundle generation where those routes already exist or are introduced by later branches. - Add a central action cost catalogue with separate website and API families. - Add database-backed passive suspicious-signal recording with TTL cleanup metadata. These records remain observational in this branch and become enforcement inputs only in later branches. - Exempt `/api/live/**` from normal enforcement while allowing passive signal hooks for clearly abusive patterns. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 2059af47..c7aedf50 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, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. +3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, CORS preflight, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, setup apply, package/admin operation, upload/archive validation, export/download, import, 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 passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. 6. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. @@ -48,6 +48,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - TTL and expiry use an injectable clock/time boundary for deterministic tests. - Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. - Probe-path configuration uses anchored, normalized patterns and must be tested against normal app/package/media/editor routes to avoid false positives. +- High-impact operation intents must exist even when their first implementation only records passive signals: setup apply, settings mutation, user/ACL mutation, package lifecycle, backup/restore, import apply, export/download, self-update, scheduler run-now, diagnostics/support bundles, and upload/archive validation. +- Classification should include the resolved Admin/Owner authority outcome for high-impact operations so rate/ban diagnostics can distinguish a denied delegated Admin action from anonymous/API abuse. +- CORS preflight classification must distinguish allowed preflights from invalid origin/method/header combinations so the API layer can stay cheap for valid browser clients while still recording suspicious probing. ## Edge cases @@ -56,6 +59,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Authenticated Owner requests still classify normally; Owner lockout protection is enforced in later branches. - High-signal probe paths are suspicious even when the route does not exist or is only a honeypot; later enforcement should return a generic `400` without revealing route existence. - Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. +- Setup/install requests happen before an Owner session exists, so classification must not depend on authenticated recovery context for pre-setup protection. +- Upload, package, import, backup, and restore paths must not be classified as high-signal probes solely because their filenames resemble archive/database defaults; failed validation results should emit separate upload/archive signals. - Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. - Passive-signal storage failure records a safe diagnostic and must not change request outcome in this foundation branch. - Cleanup must remove or anonymize expired IP-derived signal keys before any Admin export, support bundle, or statistics projection can expose them. @@ -63,8 +68,9 @@ 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, `/api/live/**`, login, registration, password reset, and suspicious probes. +- 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 configurable probe-path defaults 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. - Test redaction in passive signal messages. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index d93b68cf..bf10aac9 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -51,6 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Ban decisions follow the Security policy enforcement order so Admin/Owner context and recovery-login rendering are resolved before visitor/IP bans can deny access. - Active temporary ban responses default to generic `403` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. - Auto-ban enablement, TTLs, and escalation windows should use named bounded policy descriptors. Disabling auto-ban must not disable passive signal recording, audit, manual review, or recovery protections. +- Invalid CORS/API probing, repeated failed setup apply attempts, upload/archive abuse, and repeated diagnostic/export probing may feed auto-ban decisions for anonymous or API subjects when the underlying signals are high confidence. ## Edge cases @@ -60,6 +61,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - A recovery login route, for example `/user/login?bypass=1`, must render the normal login form even when the current Visitor ID or IP bucket is banned, then re-evaluate the ban after successful credential login under authenticated policies. - Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. - Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. +- Setup/install abuse happens before Owner identity may exist. Auto-ban must avoid turning setup into an unrecoverable installer lockout; allow documented manual/CLI recovery where no authenticated recovery path exists yet. - Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. - IP-derived bans must expire and be cleaned up before the 30-day IP retention limit; expired IP bans must not remain searchable as historical Admin records with recoverable IP material. - Manual unban must take effect immediately even if passive signals that created the ban still exist. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 5719f98a..96c82340 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -27,6 +27,7 @@ The defaults are not an Admin UI requirement. Admin-configurable policy can be a - Visitor-ID-backed policy is preferred for continuity. IP-backed policy is a short-lived secondary layer to reduce cookie-reset bypasses and shared-host abuse. - Raw credentials, raw API keys, raw visitor-cookie tokens, session IDs, full user agents, and captcha answer material must not be stored in policy records. - GeoIP values are operational metadata. They may support diagnostics and aggregate statistics, but they do not create allow/deny decisions in this policy slice. +- Browser storage may hold only transient UI state, such as operation overlay resume data. It must not hold raw credentials, API keys, captcha answers, remember-me token material, CSRF secrets beyond Symfony's intended browser-side double-submit flow, or live-operation polling tokens longer than the underlying operation TTL. ## Retention Defaults @@ -57,6 +58,21 @@ Runtime enforcement must use one deterministic order so the same request is not Owner/Admin protection does not bypass authentication validity, account status, role checks, ACL decisions, CSRF validation, API-key revocation, or explicit workflow authorization. Exempted requests should still record diagnostics so unusual administrative traffic remains reviewable. +## Admin And Owner Authority + +`Admin` is a delegated operations role. `Owner` is the site-control role. Admins may enter the Admin area, but high-impact actions need an explicit action policy instead of inheriting blanket mutation rights from the `/admin` route prefix. + +Default authority policy: + +- Admins may view normal Admin dashboards, package/theme overviews, scheduler status, redacted log/audit/security diagnostics, non-secret settings, user review queues, and operational summaries. +- Admins may mutate non-owner user accounts, ACL groups below their own role level, registration approvals, password-reset link creation, bounded non-secret settings, cache/asset rebuilds, and clearly non-destructive scheduler run-now actions when the owning workflow allows it. +- Owners are required for Owner/Admin account promotion or demotion, peer Admin changes, last-Owner-sensitive actions, protected secret configuration, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, backup/download/export of full system data, self-update/release actions, destructive package/data purge, and emergency operational controls that can affect global runtime state. +- Admins may perform manual unban or abuse review for ordinary anonymous/user subjects, but Owner/Admin subject relief, disabling auto-ban, weakening recovery protections, or changing privacy ceilings remains Owner-only. +- Protected values remain write-only or status-only even for Owners unless a workflow explicitly implements a reveal flow with re-authentication, audit, and redaction rules. +- Permission-aware navigation is not the security boundary. Controllers, API handlers, live-operation starters, scheduler triggers, and service-layer workflows must all call the same action policy before mutating or revealing high-impact data. + +The first implementation should introduce a route/action authority matrix or policy service for existing Admin surfaces instead of relying on ad hoc controller checks. Existing user-management guardrails remain the model for peer-role and last-Owner protection, but they are not sufficient for package, scheduler, backup, settings, diagnostics, and update workflows. + ## Rate-Limit Defaults These are first implementation defaults. Branches may adjust them only with tests and a worklog note explaining the review reason. @@ -108,6 +124,18 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner - Security block, recovery, captcha, login, and bypass responses are `no-store` by default. - `/api/live/**` should return cheap JSON, token/access checks where needed, `no-store`, and passive signals; it should not enter ordinary website/API `429` rendering. +## Additional Security Surface Coverage + +The codebase and other feature drafts expose several security-relevant surfaces beyond login, captcha, API, scheduler, and probes. The first Security branches should cover them through classification, cost catalogues, diagnostics, or explicit deferred follow-ups rather than inventing separate local policies later. + +- Setup/install mode is its own request family. Before setup completion, final setup apply attempts should receive a workflow bucket and passive signals because no Owner session exists yet. After setup completion, setup routes must not become public alternative admin entry points. Setup ActionLog/live-operation payloads must stay tokenized, `no-store`, and redacted. +- CORS preflight and API metadata requests are API-family traffic, not write attempts. Successful allowed `OPTIONS` preflights should be cheap and must not spend mutating API budget; invalid origin/method/header combinations may record passive signals. Invalid Bearer credentials remain authentication failures and must never fall back to anonymous public reads. +- 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. +- 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. +- Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. +- Trusted-proxy configuration is part of the security boundary. Client identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use one trusted client-identity resolver and must not parse raw forwarding headers directly. +- HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. + ## Auto-Ban Defaults - Auto-ban is enabled by default and can be disabled through Security policy/settings once the auto-ban branch introduces bounded configuration. @@ -178,6 +206,9 @@ These are first soft decisions for which values should stay fixed, become protec | 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 | +| 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-level policy and tests | Possibly later | Navigation is not enforcement; Owner-only actions require service/API/live-operation checks | | Authenticated-user multiplier | Code/config default | Yes, bounded | Applies only to ordinary navigation/public-read usage, not explicit workflow buckets | | Owner ordinary-rate-limit exemption | Code-level policy and tests | No ordinary Admin setting | Does not bypass authentication, authorization, API-key revocation, CSRF, audit, or diagnostics | | Recovery-login bypass path and bucket | Code/config default | Path and thresholds may be bounded later | Must keep an equivalent Owner/Admin recovery path; bypass never skips credential, CSRF, audit, or failure accounting | @@ -188,6 +219,8 @@ These are first soft decisions for which values should stay fixed, become protec | Remember-me trust window | Code/config default | Possibly later | Default seven days; longer windows require explicit token-rotation, revocation, visitor-binding, and privacy review | | Scheduler trigger thresholds | Named code/config defaults | Yes, bounded | Must support minutely external cron; task due-state and locks remain authoritative | | Mailer transport and debug log delivery | Environment/protected config | Yes, protected | Production must not depend on message-log action URLs; debug log delivery remains debug-gated | +| HTTP security-header policy | Code/config defaults | Possibly later | Defaults must be production-safe; route exceptions need tests and documentation | +| Trusted proxy/client identity | Symfony/deployment config plus resolver tests | Yes, deployment-level only | Raw forwarding headers are never trusted outside configured proxies | Config descriptors should include the setting key, unit, default, minimum, maximum, disabled behavior, source priority, audit behavior, and safe diagnostics shape. Invalid security configuration should fail early in development/test and degrade to the safest documented behavior in production only when that degradation cannot lock out Owners or hide a security failure. diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md index 02313c72..25677533 100644 --- a/dev/draft/security-hardening/policy-docs.md +++ b/dev/draft/security-hardening/policy-docs.md @@ -34,6 +34,7 @@ Codex may create local commits for this branch when each commit has a clear them - No runtime interfaces, routes, entities, configuration, services, commands, migrations, or translations are added in this branch. - Documentation establishes fixed defaults for later branches: database-backed passive-signal and auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, ordinary rate-limit exclusion for `/api/live/**`, IconCaptcha challenge cache/TTL behavior, account-mail transport guard expectations, minimal remember-me token management UI, and privacy-first IP retention ceilings. - `policy-defaults.md` is the first implementation source for thresholds and TTLs until an owning branch updates it with tested evidence. +- The planning baseline also records adjacent coverage for setup/install, CORS preflight, high-impact admin operations, Admin-vs-Owner authority, uploads/archives, exports/downloads, diagnostic bundles, trusted-proxy identity, browser storage, and deferred HTTP security-header policy. ## Edge cases diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index d8ea3c9e..69c229d6 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -27,7 +27,7 @@ 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, and any already-present contact/import/captcha-failure flows. +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. @@ -52,13 +52,18 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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`. - 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. +- 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. +- 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. - Failed login consumes login and global website budget; successful login resets only the login-attempt bucket for that subject. - 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. +- 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. @@ -68,6 +73,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 and still records passive signals for excessive speculative traffic. - Test scheduler triggers allow normal minutely cron calls while still limiting obvious trigger storms. +- Test setup apply, CORS preflight, high-impact admin operation, Admin-vs-Owner authority outcomes, export/download, and upload/archive validation classification attach to the expected buckets when those workflows exist. - Test authenticated-user higher limits and Owner ordinary-rate-limit exemptions for active sessions and Owner-owned API keys. - Test recovery-login bypass rendering, dedicated recovery bucket exhaustion, retry-after behavior, and successful-login policy re-evaluation. - Test policy descriptor validation for invalid, missing, overly permissive, and overly restrictive threshold/window values where configuration is introduced. From 823a39f23a0af223064227c9b4b799caa16da466 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 23:10:14 +0200 Subject: [PATCH 018/119] Add admin ACL enforcement branch plan --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 24 +++++ dev/draft/README.md | 1 + .../admin-acl-enforcement.md | 91 +++++++++++++++++++ dev/draft/security-hardening/policy-docs.md | 2 +- 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 dev/draft/security-hardening/admin-acl-enforcement.md diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f1cd2c90..c7cd0331 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -89,6 +89,7 @@ - Added a first configuration-surface matrix that separates fixed policy, code/config defaults, protected secrets, bounded Admin settings, and later-tunable thresholds for follow-up Security branches. - Scanned feature drafts and code surfaces for remaining Security planning gaps; added coverage notes for setup/install, CORS preflight, high-impact admin operations, uploads/archives, exports/downloads, diagnostic bundles, trusted proxy identity, browser storage, and deferred HTTP security-header policy. - Added Admin-vs-Owner authority policy so non-user-management Admin features can distinguish delegated Admin visibility/mutation from Owner-only site-control actions. +- Added `feat-security-admin-acl-enforcement` as a dedicated branch plan for shared Admin-vs-Owner action authority before package, scheduler, backup, settings, diagnostics, update, and security-management workflows expand. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 28ed5895..63f60d38 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -114,6 +114,30 @@ Acceptance: - Controllers and future packages can ask one service for classification/cost decisions instead of calling Symfony RateLimiter directly. - Prefetch requests are detectable and classified separately from deliberate navigation. +### `feat-security-admin-acl-enforcement` + +Introduce one shared authority policy for Admin-vs-Owner action decisions before broadening high-impact Admin features. + +Detailed plan: [admin-acl-enforcement](security-hardening/admin-acl-enforcement.md). + +Scope: + +- Inventory existing Admin UI/API/live-operation/scheduler surfaces and assign stable action identifiers. +- Encode the first static authority matrix for delegated Admin actions, Owner-only actions, and deny-by-default unknown actions. +- Enforce the matrix in service/API/live-operation boundaries, not only in navigation or controllers. +- Preserve existing user-management guardrails for peer roles, ACL group assignment, self-lockout, and last-Owner protection. +- Keep protected values write-only or status-only unless a dedicated reveal flow adds re-authentication and audit. + +Non-goals: + +- No configurable Admin permission UI. +- No package marketplace permission model. + +Acceptance: + +- High-impact Admin workflows can distinguish "Admin may view", "Admin may mutate", and "Owner-only". +- Later package, scheduler, backup, settings, diagnostics, update, and security-management branches can consume one policy instead of inventing local role checks. + ### `feat-security-rate-enforcement` Attach concrete Symfony RateLimiter buckets through the Studio facade. diff --git a/dev/draft/README.md b/dev/draft/README.md index 4cd989a7..51b994d0 100644 --- a/dev/draft/README.md +++ b/dev/draft/README.md @@ -49,6 +49,7 @@ The feature drafts should be created in dependency order. Start with architectur - [Security and access control](0.2.x-SecurityAccessControl.md) - [Security hardening implementation plan](0.2.x-SecurityHardeningPlan.md) - [Security policy defaults](security-hardening/policy-defaults.md) +- [Security Admin ACL enforcement plan](security-hardening/admin-acl-enforcement.md) - [Admin interface and setup UI](0.2.x-AdminInterfaceSetupUi.md) - [Package modules and providers](0.2.x-PluginModules.md) - [Event hooks and buses](0.2.x-EventHooksBuses.md) diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md new file mode 100644 index 00000000..d710e496 --- /dev/null +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -0,0 +1,91 @@ +# Admin ACL enforcement branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-admin-acl-enforcement` implementation plan. + +## Goal + +Introduce a shared Admin action authority policy that separates delegated Admin capabilities from Owner-only site-control actions across Admin UI, API handlers, live operations, scheduler/admin controls, and service-layer workflows. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing Symfony role hierarchy and `AccessLevel` model. +- Existing `AdminUserAccessPolicy` guardrails for peer roles, group assignment, self-lockout, and last-Owner protection. +- Existing backend access guard/controller context, Admin controllers, API access guard, live-operation starter/provider boundaries, and settings/package/scheduler foundations. +- [Security policy defaults](policy-defaults.md). + +## Implementation sequence + +1. Inventory current Admin surfaces, including settings, users/groups, package/theme management, scheduler, operations, logs/audit, backups, diagnostics, API management, and future security settings. +2. Define stable Admin action identifiers grouped by domain, for example `system.admin.settings.security.update`, `system.admin.packages.activate`, or `system.admin.scheduler.web_trigger.update`. +3. Add a shared Admin action authority policy service that evaluates actor access level, action identifier, target context, and optional subject data. +4. Encode the first static matrix in code: delegated Admin read/mutate actions, Owner-only actions, and denied/unknown actions. +5. Wire enforcement at service/API/live-operation boundaries for implemented high-impact Admin workflows, starting with the existing user-management policy as the model and avoiding duplicated controller-only checks. +6. Update Admin navigation/read models to hide or disable forbidden actions using the same policy, while keeping backend enforcement authoritative. +7. Add audit/message context for denied high-impact actions without leaking protected values or action internals. +8. Add extension points for future package-owned Admin actions only after core action identifiers and collision rules are stable. + +## Public interfaces and data decisions + +- Admin action identifiers are stable, English, machine-readable strings and are not localized. +- The first matrix can be code-owned and test-backed; database-configurable Admin ACLs are a later feature only if product need appears. +- `Admin` is a delegated operations role. `Owner` remains the site-control role. +- Owner-only defaults include protected secrets, Security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, full-data exports/downloads, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency global operational controls. +- Delegated Admin defaults include normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, non-secret settings, user review queues, operational summaries, non-owner user management, ACL groups below the actor role level, bounded non-secret settings, and non-destructive cache/asset rebuilds where workflow policy allows them. +- Navigation visibility is not enforcement. Controllers, API handlers, live-operation starters, scheduler/admin triggers, and service-layer workflows must enforce the same policy before mutation or sensitive reveal. +- Protected values remain write-only or status-only even for Owners unless a dedicated reveal flow adds re-authentication, audit, and redaction tests. +- Package-owned Admin actions must use package-scoped identifiers and must not override system action identifiers. + +## Edge cases + +- Existing user-management guardrails must stay intact and may be adapted behind the shared policy only when behavior remains equivalent or tighter. +- Owner ordinary-rate-limit exemption does not imply Owner bypass of action authorization, confirmations, audit, CSRF, account status, API-key revocation, or protected-value redaction. +- Admins may need read access to redacted diagnostics without write access to the setting or secret that produced the diagnostic. +- A denied delegated Admin action should produce a stable forbidden response/message, not fall through to "not found" unless hiding existence is an explicit policy for that resource. +- Live operations must check authority before queueing and again before continuation descriptors start a follow-up operation. +- API handlers must enforce the matrix using the API key owner's role and account status, not the key prefix or token label. +- Scheduler run-now and web-trigger controls need separate actions because status viewing, manual run, trigger enablement, and GET-token fallback are different risk levels. +- Backup/export/download actions must distinguish redacted summaries from full-data artifacts. +- If an action identifier is unknown, deny by default and record safe diagnostics. + +## Tests and validation + +- Test the static matrix for Admin, Owner, lower-role users, anonymous users, inactive/deleted users, and API-key actors. +- Test existing user/ACL management behavior remains at least as strict as `AdminUserAccessPolicy`. +- Test Admin can view allowed summaries but cannot mutate Owner-only package, scheduler, security, backup, update, or protected-secret surfaces. +- Test Owner can perform Owner-only actions when the workflow's own confirmation, CSRF, status, and domain validation pass. +- Test navigation/read-model visibility and backend enforcement use the same policy. +- Test Admin API handlers use the API key owner's authority. +- Test live-operation queueing and continuation re-check authority. +- Test denied actions produce stable redacted messages and audit context. +- Test package-scoped action identifier validation rejects collisions with system actions. +- Run focused controller/API/live-operation tests and `lint:container` when services are added. + +## Documentation and tracking + +- Update the Security and Admin UI drafts with the final action identifier naming convention and first matrix. +- Update Security policy defaults if the Admin/Owner split changes. +- Update the class map for the authority policy, action catalogue, voters/subscribers, controllers, API handlers, and live-operation integration points. +- Record implemented action identifiers, denied-by-default behavior, and verification commands in the worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No full configurable Admin permission UI. +- No package permission marketplace or manifest permission model. +- No replacement for content/editor ACL rules. +- No weakening of existing Owner recovery and last-Owner protections. + +## Acceptance criteria + +- Admin and Owner authority are enforced through one shared policy for implemented Admin actions. +- Existing user-management restrictions remain intact. +- High-impact Admin workflows can be reviewed without guessing whether `ROLE_ADMIN` was intended to be enough. diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md index 25677533..09a722a7 100644 --- a/dev/draft/security-hardening/policy-docs.md +++ b/dev/draft/security-hardening/policy-docs.md @@ -34,7 +34,7 @@ Codex may create local commits for this branch when each commit has a clear them - No runtime interfaces, routes, entities, configuration, services, commands, migrations, or translations are added in this branch. - Documentation establishes fixed defaults for later branches: database-backed passive-signal and auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, ordinary rate-limit exclusion for `/api/live/**`, IconCaptcha challenge cache/TTL behavior, account-mail transport guard expectations, minimal remember-me token management UI, and privacy-first IP retention ceilings. - `policy-defaults.md` is the first implementation source for thresholds and TTLs until an owning branch updates it with tested evidence. -- The planning baseline also records adjacent coverage for setup/install, CORS preflight, high-impact admin operations, Admin-vs-Owner authority, uploads/archives, exports/downloads, diagnostic bundles, trusted-proxy identity, browser storage, and deferred HTTP security-header policy. +- The planning baseline also records adjacent coverage for setup/install, CORS preflight, high-impact admin operations, Admin-vs-Owner authority through a dedicated Admin ACL enforcement branch, uploads/archives, exports/downloads, diagnostic bundles, trusted-proxy identity, browser storage, and deferred HTTP security-header policy. ## Edge cases From 852c1e2a1fe7a4d550334d18e11c4373f8755772 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 23:16:20 +0200 Subject: [PATCH 019/119] Detail admin ACL enforcement plan --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityAccessControl.md | 1 + .../admin-acl-enforcement.md | 61 ++++++++++++++++--- .../security-hardening/policy-defaults.md | 2 +- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c7cd0331..d77f31db 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -90,6 +90,7 @@ - Scanned feature drafts and code surfaces for remaining Security planning gaps; added coverage notes for setup/install, CORS preflight, high-impact admin operations, uploads/archives, exports/downloads, diagnostic bundles, trusted proxy identity, browser storage, and deferred HTTP security-header policy. - Added Admin-vs-Owner authority policy so non-user-management Admin features can distinguish delegated Admin visibility/mutation from Owner-only site-control actions. - Added `feat-security-admin-acl-enforcement` as a dedicated branch plan for shared Admin-vs-Owner action authority before package, scheduler, backup, settings, diagnostics, update, and security-management workflows expand. +- Expanded the Admin ACL enforcement plan with a default authority matrix, bounded Owner-only configurability policy, concrete Admin/Owner domain defaults, enforcement boundaries, and test expectations. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 08212a07..58fd7f2e 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -91,6 +91,7 @@ Captcha should use a global form field integration. When a workflow includes the - Owners may manage all roles and groups. Admins may enter Admin but cannot manage peer Admin users, Owner users, or groups at or above their own role level. - Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. - Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency operational controls. +- Treat this matrix as code-owned defaults first. A later Owner-only configuration UI may delegate selected Admin actions only through bounded descriptors, audit, safe fallback, and tests; it must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, or remove Owner recovery protections. - Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index d710e496..5fda7c32 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -11,6 +11,8 @@ Introduce a shared Admin action authority policy that separates delegated Admin Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). +This branch should make it obvious which Admin features are operational delegation and which are site-control powers. The first implementation should ship safe code-owned defaults, then leave room for a later Owner-only configuration UI where that is useful and safe. + ## Git handling Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. @@ -26,23 +28,63 @@ Codex may create local commits for this branch when each commit has a clear them 1. Inventory current Admin surfaces, including settings, users/groups, package/theme management, scheduler, operations, logs/audit, backups, diagnostics, API management, and future security settings. 2. Define stable Admin action identifiers grouped by domain, for example `system.admin.settings.security.update`, `system.admin.packages.activate`, or `system.admin.scheduler.web_trigger.update`. -3. Add a shared Admin action authority policy service that evaluates actor access level, action identifier, target context, and optional subject data. -4. Encode the first static matrix in code: delegated Admin read/mutate actions, Owner-only actions, and denied/unknown actions. -5. Wire enforcement at service/API/live-operation boundaries for implemented high-impact Admin workflows, starting with the existing user-management policy as the model and avoiding duplicated controller-only checks. -6. Update Admin navigation/read models to hide or disable forbidden actions using the same policy, while keeping backend enforcement authoritative. -7. Add audit/message context for denied high-impact actions without leaking protected values or action internals. -8. Add extension points for future package-owned Admin actions only after core action identifiers and collision rules are stable. +3. Add an Admin action catalogue with metadata: identifier, domain, title/description translation keys, default minimum role, sensitivity, mutation/read flag, configurable flag, audit category, and optional confirmation requirement. +4. Add a shared Admin action authority policy service that evaluates actor access level, action identifier, target context, target subject, account status, and optional workflow metadata. +5. Encode the first static default matrix in code: delegated Admin read/mutate actions, Owner-only actions, and denied/unknown actions. +6. Add a narrow Owner-only configuration descriptor shape for later tuning, but do not build a broad permission UI unless this branch can keep validation, audit, docs, and rollback small enough for review. +7. Wire enforcement at service/API/live-operation boundaries for implemented high-impact Admin workflows, starting with the existing user-management policy as the model and avoiding duplicated controller-only checks. +8. Update Admin navigation/read models to hide or disable forbidden actions using the same policy, while keeping backend enforcement authoritative. +9. Add audit/message context for denied high-impact actions without leaking protected values or action internals. +10. Add extension points for future package-owned Admin actions only after core action identifiers and collision rules are stable. + +## Default authority matrix + +The first matrix should use conservative defaults. "View" means the actor may open a redacted screen or summary. "Mutate" means the actor may trigger the workflow after its own CSRF, confirmation, domain validation, and audit checks also pass. + +| Area | Admin default | Owner default | Notes | +| --- | --- | --- | --- | +| Dashboard/system status | View | View | Admin-visible status must be redacted and avoid secrets, raw env dumps, cookies, and request payloads. | +| General site settings | View and mutate bounded site-specific values | View and mutate | Examples: site title, footer text, default language, home path, simple dashboard preferences. | +| User management | View and mutate non-owner/non-peer-admin users within existing role/group guardrails | View and mutate all users within last-Owner guardrails | Existing `AdminUserAccessPolicy` stays authoritative for peer-role, group, self-lockout, and last-Owner rules. | +| ACL groups | View and mutate groups below actor role | View and mutate all groups within system constraints | Group impact review remains mandatory for broad updates/deletes. | +| Registration/user-flow settings | View and mutate low-risk workflow settings | View and mutate | TTLs and notification addresses are Admin-mutable only if they do not affect Owner recovery, security policy, or protected secrets. | +| Mail settings | View and mutate non-secret sender settings | View and mutate, including protected transport status/config where implemented | Mail transport secrets remain protected/write-only. Production delivery guards remain enforced. | +| Security settings | View redacted status only | View and mutate | Captcha provider selection may be Admin-mutable only if it cannot disable required protection or verified recovery policy. Auto-ban disablement, privacy ceilings, recovery protections, and rate/security policy bounds are Owner-only. | +| Access/audit/security logs | View redacted summaries | View redacted summaries and broader review tools | Raw secrets, raw tokens, full request payloads, and IP-derived data beyond retention are never exposed. Full diagnostic/export actions are Owner-only. | +| Statistics and GeoIP status | View summaries | View and mutate provider/update settings | MaxMind/license material is protected/write-only. GeoIP cannot become blocking policy in this slice. | +| API settings | View status and own/user-token surfaces where already allowed | View and mutate global API settings | Enabling public API/CORS expansion, wildcard-like origins, or broad anonymous access is Owner-only. | +| Package/theme overview | View installed/available status | View and mutate | Installing, activating, deactivating, updating, purging, and running package lifecycle actions are Owner-only by default. | +| Package settings | View and mutate simple non-sensitive package settings if the package declares them Admin-safe | View and mutate all package settings within package policy | Package settings that alter routes, permissions, external credentials, data access, or runtime code are Owner-only. | +| Scheduler | View status and run safe non-destructive tasks when allowed | View and mutate | Enabling scheduler web trigger, GET-token fallback, package ActionQueue tasks, or destructive tasks is Owner-only. | +| Operations/action logs | View redacted operation summaries | View redacted summaries and emergency controls | Clearing stale locks may be Admin-safe; emergency stop/kill, retrying destructive operations, and continuation of Owner-only workflows are Owner-only. | +| Cache/asset rebuild | Mutate when non-destructive and recoverable | Mutate | Rebuilds remain audited and use the live-operation shell. | +| Backup/export/download | View redacted status | View and mutate | Backup creation may later be Admin-mutable if it produces protected storage-only artifacts; full download/export/restore stays Owner-only by default. | +| Import/apply | View previews where editor/admin permissions allow | View and mutate Owner-sensitive imports | Applying imports that change configuration, packages, ACLs, users, or system data is Owner-only. Content imports may belong to editor/content ACL policy. | +| Self-update/release | No access by default | View and mutate | Update checks that only show available versions may become Admin-viewable; apply/update/rollback remains Owner-only. | +| Diagnostics/support bundles | View redacted status | Generate/download redacted bundles | Bundle generation/download is Owner-only unless a later policy creates a strictly redacted Admin-safe bundle. | +| Setup/recovery/emergency controls | No runtime access after setup | Owner-only or CLI/manual recovery | Setup routes must not become alternate Admin entry points after setup completion. | + +## Configurability policy + +- The first implementation should be code-owned and test-backed. This avoids shipping a confusing half-permission UI while the Admin surface is still changing. +- A later Owner-only settings UI may relax or tighten selected Admin capabilities only through bounded descriptors. Each configurable action must define default minimum role, allowed role range, whether it may be disabled, audit behavior, affected routes/API/live operations, and safe rollback. +- Some actions are not ordinary configurable settings: last-Owner protection, Owner recovery, protected secret redaction, privacy ceilings, raw-token exposure, `APP_SECRET` emergency handling, and unknown-action deny-by-default. +- Owner configuration may delegate additional read or mutation actions to Admins, but it must not allow Admins to grant themselves Owner role, change Owner-only recovery/security boundaries, reveal secrets without a dedicated reveal flow, or bypass domain confirmations/audit. +- Configured changes to Admin action authority must be audited with actor, old/new policy summary, affected action identifiers, and redacted context. +- If configuration is missing, corrupt, or references an unknown action identifier, runtime must fall back to safe code defaults and record diagnostics. ## Public interfaces and data decisions - Admin action identifiers are stable, English, machine-readable strings and are not localized. -- The first matrix can be code-owned and test-backed; database-configurable Admin ACLs are a later feature only if product need appears. +- The first matrix is code-owned and test-backed. Database-configurable Admin ACLs are a later Owner-only feature only if product need appears and the bounded descriptor model is implemented. - `Admin` is a delegated operations role. `Owner` remains the site-control role. - Owner-only defaults include protected secrets, Security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, full-data exports/downloads, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency global operational controls. - Delegated Admin defaults include normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, non-secret settings, user review queues, operational summaries, non-owner user management, ACL groups below the actor role level, bounded non-secret settings, and non-destructive cache/asset rebuilds where workflow policy allows them. - Navigation visibility is not enforcement. Controllers, API handlers, live-operation starters, scheduler/admin triggers, and service-layer workflows must enforce the same policy before mutation or sensitive reveal. - Protected values remain write-only or status-only even for Owners unless a dedicated reveal flow adds re-authentication, audit, and redaction tests. - Package-owned Admin actions must use package-scoped identifiers and must not override system action identifiers. +- Denied decisions should return structured messages compatible with browser, API JSON, and live-operation feedback. +- The action catalogue should be discoverable for Admin navigation/read models, tests, and future documentation generation. ## Edge cases @@ -55,11 +97,14 @@ Codex may create local commits for this branch when each commit has a clear them - Scheduler run-now and web-trigger controls need separate actions because status viewing, manual run, trigger enablement, and GET-token fallback are different risk levels. - Backup/export/download actions must distinguish redacted summaries from full-data artifacts. - If an action identifier is unknown, deny by default and record safe diagnostics. +- Configurable policy must not create a state where no Owner can recover or where Admins can silently promote themselves to Owner-equivalent authority. +- Downgrading an Owner session to Admin authority through configuration is invalid; role hierarchy remains the foundation. ## Tests and validation - Test the static matrix for Admin, Owner, lower-role users, anonymous users, inactive/deleted users, and API-key actors. - Test existing user/ACL management behavior remains at least as strict as `AdminUserAccessPolicy`. +- Test every row of the default authority matrix where the underlying surface exists, using at least one allowed Admin case, one denied Admin case, and one Owner case per domain. - Test Admin can view allowed summaries but cannot mutate Owner-only package, scheduler, security, backup, update, or protected-secret surfaces. - Test Owner can perform Owner-only actions when the workflow's own confirmation, CSRF, status, and domain validation pass. - Test navigation/read-model visibility and backend enforcement use the same policy. @@ -67,11 +112,13 @@ Codex may create local commits for this branch when each commit has a clear them - Test live-operation queueing and continuation re-check authority. - Test denied actions produce stable redacted messages and audit context. - Test package-scoped action identifier validation rejects collisions with system actions. +- If configurability is implemented, test missing/corrupt/unknown configuration fallback, Owner-only mutation of the matrix, audit of matrix changes, and rejection of unsafe delegation. - Run focused controller/API/live-operation tests and `lint:container` when services are added. ## Documentation and tracking - Update the Security and Admin UI drafts with the final action identifier naming convention and first matrix. +- Update any affected feature draft when a domain action is classified as Admin-safe or Owner-only. - Update Security policy defaults if the Admin/Owner split changes. - Update the class map for the authority policy, action catalogue, voters/subscribers, controllers, API handlers, and live-operation integration points. - Record implemented action identifiers, denied-by-default behavior, and verification commands in the worklog. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 96c82340..f59ece55 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -208,7 +208,7 @@ These are first soft decisions for which values should stay fixed, become protec | 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 | | 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-level policy and tests | Possibly later | Navigation is not enforcement; Owner-only actions require service/API/live-operation checks | +| Admin/Owner action authority matrix | Code-level policy and tests | Possibly later as Owner-only bounded settings | Navigation is not enforcement; Owner-only actions require service/API/live-operation checks; unsafe delegation remains invalid | | Authenticated-user multiplier | Code/config default | Yes, bounded | Applies only to ordinary navigation/public-read usage, not explicit workflow buckets | | Owner ordinary-rate-limit exemption | Code-level policy and tests | No ordinary Admin setting | Does not bypass authentication, authorization, API-key revocation, CSRF, audit, or diagnostics | | Recovery-login bypass path and bucket | Code/config default | Path and thresholds may be bounded later | Must keep an equivalent Owner/Admin recovery path; bypass never skips credential, CSRF, audit, or failure accounting | From 510e578cf5326323a71986d0f5621f869f792efd Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:02:50 +0200 Subject: [PATCH 020/119] Add GeoIP resolver foundation --- config/services.yaml | 10 +- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 19 +-- dev/WORKLOG_HISTORY.md | 6 + .../security-hardening/geoip-observability.md | 3 + src/Core/Geo/GeoIpProviderInterface.php | 14 ++ src/Core/Geo/GeoIpProviderStatus.php | 90 +++++++++++ src/Core/Geo/GeoIpResolver.php | 77 ++++++++++ src/Core/Geo/GeoIpResolverInterface.php | 2 + src/Core/Geo/GeoIpResult.php | 10 +- src/Core/Geo/NullGeoIpProvider.php | 25 ++++ src/Core/Geo/NullGeoIpResolver.php | 5 + tests/Core/Geo/GeoIpResolverTest.php | 140 ++++++++++++++++++ tests/Core/Geo/NullGeoIpResolverTest.php | 8 + 14 files changed, 390 insertions(+), 21 deletions(-) create mode 100644 src/Core/Geo/GeoIpProviderInterface.php create mode 100644 src/Core/Geo/GeoIpProviderStatus.php create mode 100644 src/Core/Geo/GeoIpResolver.php create mode 100644 src/Core/Geo/NullGeoIpProvider.php create mode 100644 tests/Core/Geo/GeoIpResolverTest.php diff --git a/config/services.yaml b/config/services.yaml index da508fcb..8e475153 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -41,6 +41,9 @@ services: App\Core\Event\EventHookDescriptorProviderInterface: tags: - { name: 'system.event_hook_provider', priority: 0 } + App\Core\Geo\GeoIpProviderInterface: + tags: + - { name: 'system.geoip_provider', priority: 0 } App\Backend\BackendViewProviderInterface: tags: - { name: 'system.backend_view_provider', priority: 0 } @@ -372,7 +375,12 @@ services: alias: App\Core\Log\AccessLogger App\Core\Geo\GeoIpResolverInterface: - alias: App\Core\Geo\NullGeoIpResolver + alias: App\Core\Geo\GeoIpResolver + + App\Core\Geo\GeoIpResolver: + arguments: + $providers: !tagged_iterator { tag: system.geoip_provider } + $fallbackProvider: '@App\Core\Geo\NullGeoIpProvider' App\Core\Log\OperationLoggerInterface: alias: App\Core\Log\OperationLogger diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 8bbacd39..00effd81 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -194,7 +194,7 @@ | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | | Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpResolver` | Replaceable GeoIP lookup boundary used by access logging and access statistics; the initial null provider returns normalized `n/a` location values without external dependencies or hard failures. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Geo/NullGeoIpResolverTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver` | Replaceable provider-neutral GeoIP lookup boundary used by access logging and access statistics; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while safe status data prepares Admin diagnostics for later providers. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; 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/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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index d77f31db..8f807639 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -76,21 +76,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-15 feat-security-policy-docs -- Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. -- Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch detail plans so later implementation branches can cite one policy reference. -- Compacted the completed `feat-security-planning` worklog entry into `dev/WORKLOG_HISTORY.md` so the active worklog stays focused on the policy-docs branch. -- Raised the first IconCaptcha challenge TTL default to 15 minutes for realistic form completion time while keeping one-shot validation, scoped failure buckets, context binding, refresh abuse signals, and answer-leak checks as required bot-protection controls. -- Split the website global rate-limit default into deliberate burst and sustained buckets, with Turbo/browser prefetch tracked through a separate lower-confidence observation path so speculative requests do not drain user-facing navigation budgets. -- Adjusted scheduler and probe policies: scheduler trigger limits now support minutely cron, high-signal probes are limited to one per 10 minutes with generic `400` handling, probe paths are configurable with broad defaults, auto-ban defaults to on, and active Admin/Owner recovery protections are explicit. -- Documented recovery login bypass policy using the normal login route plus a bypass flag, guarded by a dedicated 2/minute and 10/hour bucket with 30-minute retry behavior and no bypass of CSRF, credential checks, login-failure accounting, or audit logging. -- Clarified captcha auto-success policy: provider `none`, missing providers, and disabled providers keep workflows graceful but never reset/refill rate-limit buckets, clear bans, or satisfy captcha-based `429` recovery. -- Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, and auditable Owner/Admin exemptions. -- Added a first configuration-surface matrix that separates fixed policy, code/config defaults, protected secrets, bounded Admin settings, and later-tunable thresholds for follow-up Security branches. -- Scanned feature drafts and code surfaces for remaining Security planning gaps; added coverage notes for setup/install, CORS preflight, high-impact admin operations, uploads/archives, exports/downloads, diagnostic bundles, trusted proxy identity, browser storage, and deferred HTTP security-header policy. -- Added Admin-vs-Owner authority policy so non-user-management Admin features can distinguish delegated Admin visibility/mutation from Owner-only site-control actions. -- Added `feat-security-admin-acl-enforcement` as a dedicated branch plan for shared Admin-vs-Owner action authority before package, scheduler, backup, settings, diagnostics, update, and security-management workflows expand. -- Expanded the Admin ACL enforcement plan with a default authority matrix, bounded Owner-only configurability policy, concrete Admin/Owner domain defaults, enforcement boundaries, and test expectations. +### 2026-06-15 feat-security-geoip-observability +- Started the GeoIP observability branch by compacting the completed `feat-security-policy-docs` notes into `dev/WORKLOG_HISTORY.md`. +- Added a narrow provider-neutral GeoIP resolver foundation so access logs and access statistics keep using normalized `n/a` fallback fields until a real provider returns data. +- Verified the foundation with focused GeoIP/access-log/statistics PHPUnit coverage, PHP syntax checks, container linting, focused linting for changed files, and Git whitespace checks. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index 1b83c5c9..9efb59fa 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -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-15 feat-security-policy-docs +- Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation policy source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. +- Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch plans; then refined captcha TTLs, website burst/sustained budgets, scheduler trigger limits, high-signal probe limits, recovery login bypass behavior, captcha auto-success policy, and Admin/Owner protections. +- Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, auditable exemptions, configuration-surface ownership, privacy/storage ceilings, and follow-up coverage for setup/install, CORS preflight, uploads, archives, exports, diagnostics, trusted proxy identity, browser storage, and HTTP security headers. +- Added Admin-vs-Owner authority policy plus the detailed `feat-security-admin-acl-enforcement` branch plan, including default authority matrix, bounded Owner configurability, concrete domain defaults, enforcement boundaries, and test expectations. + ### 2026-06-15 feat-security-planning - Created the Security hardening planning package: master branch-tree plan plus detailed `feat-security-*` plans for policy docs, GeoIP observability, abuse foundations, rate enforcement, auto-ban, captcha contracts, IconCaptcha, mailer account delivery, and remember-me. - Recorded core product decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, scoped `reset()` behavior, database-backed passive signals/auto-bans, Owner recovery protection, GeoIP observability, IconCaptcha asset/accessibility rules, PR-readiness checks, and the inspiration-only `sec-lookup` legacy reference. diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 6f3969ea..3e4dbb26 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -27,6 +27,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Implementation sequence +0. Establish the provider-neutral foundation: `GeoIpProviderInterface`, `GeoIpProviderStatus`, a delegating `GeoIpResolver`, and a null provider that keeps current log/statistic placeholders as the default output. 1. Add a MaxMind-backed resolver behind the existing GeoIP resolver interface. 2. Add protected administrator-only settings for provider selection, database path/status, account/license key, and update policy. 3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. @@ -37,6 +38,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Public interfaces and data decisions - GeoIP output uses normalized nullable or `n/a` fields for country, region, city, latitude/longitude where available, provider status, and lookup status. +- The foundation keeps `n/a` placeholders as the stable default for access logs and statistics whenever lookup input is missing, providers are disabled/unconfigured/unavailable, or a provider throws. +- Providers expose only safe status fields: provider key, coarse status, database edition/build date, update timestamps, next suggested update, and redacted failure code. No raw paths, IP inputs, license/account data, or full exception messages belong in provider status. - Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. - Provider secrets are protected config values and never rendered outside authorized Admin settings. - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. diff --git a/src/Core/Geo/GeoIpProviderInterface.php b/src/Core/Geo/GeoIpProviderInterface.php new file mode 100644 index 00000000..c7c8c70f --- /dev/null +++ b/src/Core/Geo/GeoIpProviderInterface.php @@ -0,0 +1,14 @@ +status; + } + + /** + * @return array{ + * provider_key: string, + * status: string, + * database_edition: ?string, + * database_build_date: ?string, + * last_update_attempt_at: ?string, + * last_update_success_at: ?string, + * next_suggested_update_at: ?string, + * failure_code: ?string + * } + */ + public function toSafeArray(): array + { + return [ + 'provider_key' => $this->providerKey, + 'status' => $this->status, + 'database_edition' => $this->databaseEdition, + 'database_build_date' => $this->databaseBuildDate, + 'last_update_attempt_at' => $this->lastUpdateAttemptAt, + 'last_update_success_at' => $this->lastUpdateSuccessAt, + 'next_suggested_update_at' => $this->nextSuggestedUpdateAt, + 'failure_code' => $this->failureCode, + ]; + } +} diff --git a/src/Core/Geo/GeoIpResolver.php b/src/Core/Geo/GeoIpResolver.php new file mode 100644 index 00000000..35dc05fa --- /dev/null +++ b/src/Core/Geo/GeoIpResolver.php @@ -0,0 +1,77 @@ + */ + private array $providers; + + /** + * @param iterable $providers + */ + public function __construct( + iterable $providers = [], + private GeoIpProviderInterface $fallbackProvider = new NullGeoIpProvider(), + ) { + $this->providers = $this->normalizeProviders($providers); + } + + public function resolve(?string $ipAddress): GeoIpResult + { + if (null === $ipAddress || '' === trim($ipAddress)) { + return $this->fallbackProvider->resolve($ipAddress); + } + + $provider = $this->activeProvider(); + if (null === $provider) { + return $this->fallbackProvider->resolve($ipAddress); + } + + try { + return $provider->resolve($ipAddress); + } catch (Throwable) { + return $this->fallbackProvider->resolve($ipAddress); + } + } + + public function status(): GeoIpProviderStatus + { + return $this->activeProvider()?->status() ?? $this->fallbackProvider->status(); + } + + private function activeProvider(): ?GeoIpProviderInterface + { + foreach ($this->providers as $provider) { + if ($provider === $this->fallbackProvider) { + continue; + } + + if ($provider->status()->isReady()) { + return $provider; + } + } + + return null; + } + + /** + * @param iterable $providers + * + * @return list + */ + private function normalizeProviders(iterable $providers): array + { + $normalized = []; + + foreach ($providers as $provider) { + $normalized[] = $provider; + } + + return $normalized; + } +} diff --git a/src/Core/Geo/GeoIpResolverInterface.php b/src/Core/Geo/GeoIpResolverInterface.php index 1e293c03..f127188b 100644 --- a/src/Core/Geo/GeoIpResolverInterface.php +++ b/src/Core/Geo/GeoIpResolverInterface.php @@ -7,4 +7,6 @@ interface GeoIpResolverInterface { public function resolve(?string $ipAddress): GeoIpResult; + + public function status(): GeoIpProviderStatus; } diff --git a/src/Core/Geo/GeoIpResult.php b/src/Core/Geo/GeoIpResult.php index 3a225f91..46d4dae8 100644 --- a/src/Core/Geo/GeoIpResult.php +++ b/src/Core/Geo/GeoIpResult.php @@ -6,11 +6,13 @@ final readonly class GeoIpResult { + public const PLACEHOLDER = 'n/a'; + public function __construct( - public string $city = 'n/a', - public string $state = 'n/a', - public string $country = 'n/a', - public string $continent = 'n/a', + public string $city = self::PLACEHOLDER, + public string $state = self::PLACEHOLDER, + public string $country = self::PLACEHOLDER, + public string $continent = self::PLACEHOLDER, ) { } diff --git a/src/Core/Geo/NullGeoIpProvider.php b/src/Core/Geo/NullGeoIpProvider.php new file mode 100644 index 00000000..6fbe984a --- /dev/null +++ b/src/Core/Geo/NullGeoIpProvider.php @@ -0,0 +1,25 @@ + 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('203.0.113.10')->toArray()); + + self::assertSame('none', $resolver->status()->providerKey); + self::assertSame('disabled', $resolver->status()->status); + } + + public function testItUsesFirstReadyProvider(): void + { + $resolver = new GeoIpResolver([ + new FakeGeoIpProvider(GeoIpProviderStatus::unconfigured('disabled')), + new FakeGeoIpProvider( + GeoIpProviderStatus::ready('maxmind', databaseEdition: 'GeoLite2-City'), + new GeoIpResult('Berlin', 'Berlin', 'Germany', 'Europe'), + ), + ], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country' => 'Germany', + 'continent' => 'Europe', + ], $resolver->resolve('203.0.113.10')->toArray()); + self::assertSame('maxmind', $resolver->status()->providerKey); + self::assertSame('GeoLite2-City', $resolver->status()->databaseEdition); + } + + public function testItFallsBackWhenReadyProviderThrows(): void + { + $resolver = new GeoIpResolver([ + new FakeGeoIpProvider(GeoIpProviderStatus::ready('maxmind'), throws: true), + ], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('203.0.113.10')->toArray()); + } + + public function testItDoesNotCallProvidersWithoutAnIpAddress(): void + { + $provider = new FakeGeoIpProvider( + GeoIpProviderStatus::ready('maxmind'), + new GeoIpResult('Berlin', 'Berlin', 'Germany', 'Europe'), + ); + $resolver = new GeoIpResolver([$provider], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('')->toArray()); + self::assertSame(0, $provider->lookupCount); + } + + public function testProviderStatusContainsOnlySafeDiagnosticFields(): void + { + $status = GeoIpProviderStatus::ready( + 'maxmind', + databaseEdition: 'GeoLite2-City', + databaseBuildDate: '2026-06-15', + lastUpdateAttemptAt: '2026-06-15T10:00:00+00:00', + lastUpdateSuccessAt: '2026-06-15T10:00:01+00:00', + nextSuggestedUpdateAt: '2026-06-22T10:00:00+00:00', + ); + + self::assertSame([ + 'provider_key' => 'maxmind', + 'status' => 'ready', + 'database_edition' => 'GeoLite2-City', + 'database_build_date' => '2026-06-15', + 'last_update_attempt_at' => '2026-06-15T10:00:00+00:00', + 'last_update_success_at' => '2026-06-15T10:00:01+00:00', + 'next_suggested_update_at' => '2026-06-22T10:00:00+00:00', + 'failure_code' => null, + ], $status->toSafeArray()); + } +} + +final class FakeGeoIpProvider implements GeoIpProviderInterface +{ + public int $lookupCount = 0; + + public function __construct( + private readonly GeoIpProviderStatus $status, + private readonly GeoIpResult $result = new GeoIpResult(), + private readonly bool $throws = false, + ) { + } + + public function key(): string + { + return $this->status->providerKey; + } + + public function status(): GeoIpProviderStatus + { + return $this->status; + } + + public function resolve(?string $ipAddress): GeoIpResult + { + ++$this->lookupCount; + + if ($this->throws) { + throw new RuntimeException('GeoIP provider failure'); + } + + return $this->result; + } +} diff --git a/tests/Core/Geo/NullGeoIpResolverTest.php b/tests/Core/Geo/NullGeoIpResolverTest.php index fff7b10e..e8df0a54 100644 --- a/tests/Core/Geo/NullGeoIpResolverTest.php +++ b/tests/Core/Geo/NullGeoIpResolverTest.php @@ -20,4 +20,12 @@ public function testItReturnsNormalizedPlaceholders(): void 'continent' => 'n/a', ], $result->toArray()); } + + public function testItReportsDisabledStatus(): void + { + $status = (new NullGeoIpResolver())->status(); + + self::assertSame('none', $status->providerKey); + self::assertSame('disabled', $status->status); + } } From 8378ab4dd2958fd5af86d6c59287fc6d9a5af15c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:17:58 +0200 Subject: [PATCH 021/119] Add MaxMind GeoIP provider --- config/services.yaml | 7 + dev/CLASSMAP.md | 8 +- dev/WORKLOG.md | 1 + .../security-hardening/geoip-observability.md | 2 + src/Core/Config/Api/SettingsApiReadModel.php | 32 ++- .../Settings/CoreSettingsFormHandler.php | 21 +- .../Config/Settings/CoreSettingsRegistry.php | 16 ++ src/Core/Geo/GeoIp2MaxMindDatabaseReader.php | 26 +++ .../GeoIp2MaxMindDatabaseReaderFactory.php | 15 ++ src/Core/Geo/MaxMindGeoIpConfig.php | 89 ++++++++ ...indGeoIpDatabaseReaderFactoryInterface.php | 13 ++ .../MaxMindGeoIpDatabaseReaderInterface.php | 15 ++ src/Core/Geo/MaxMindGeoIpProvider.php | 136 ++++++++++++ src/Form/FormInputType.php | 1 + src/View/Twig/AdminViewTwigExtension.php | 7 +- .../partials/forms/_dynamic_fields.html.twig | 2 +- tests/Core/Config/ConfigTest.php | 18 ++ .../Config/CoreSettingsFormHandlerTest.php | 70 ++++++ .../Core/Config/CoreSettingsRegistryTest.php | 17 ++ .../Core/Config/SettingsApiReadModelTest.php | 53 +++++ tests/Core/Geo/MaxMindGeoIpConfigTest.php | 51 +++++ tests/Core/Geo/MaxMindGeoIpProviderTest.php | 205 ++++++++++++++++++ translations/languages/de/admin.yaml | 25 +++ translations/languages/en/admin.yaml | 25 +++ 24 files changed, 842 insertions(+), 13 deletions(-) create mode 100644 src/Core/Geo/GeoIp2MaxMindDatabaseReader.php create mode 100644 src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php create mode 100644 src/Core/Geo/MaxMindGeoIpConfig.php create mode 100644 src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php create mode 100644 src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php create mode 100644 src/Core/Geo/MaxMindGeoIpProvider.php create mode 100644 tests/Core/Config/CoreSettingsFormHandlerTest.php create mode 100644 tests/Core/Config/SettingsApiReadModelTest.php create mode 100644 tests/Core/Geo/MaxMindGeoIpConfigTest.php create mode 100644 tests/Core/Geo/MaxMindGeoIpProviderTest.php diff --git a/config/services.yaml b/config/services.yaml index 8e475153..648f7c8c 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -382,6 +382,13 @@ services: $providers: !tagged_iterator { tag: system.geoip_provider } $fallbackProvider: '@App\Core\Geo\NullGeoIpProvider' + App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface: + alias: App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory + + App\Core\Geo\MaxMindGeoIpProvider: + arguments: + $projectDir: '%kernel.project_dir%' + App\Core\Log\OperationLoggerInterface: alias: App\Core\Log\OperationLogger diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 00effd81..e23111e1 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -63,7 +63,7 @@ | 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 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, 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 package lifecycle review/confirmation endpoints that start LiveOperation runs. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.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, 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 package lifecycle review/confirmation endpoints that start LiveOperation runs. | `dev/draft/0.4.x-ApiLayer.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` | | Package/user API | `App\Core\Package\Api\PackageApiEndpointProvider`, `App\Core\Package\Api\PackageApiHandler`, `App\Core\Package\Api\PackageApiNavigationHandler`, `App\Core\Package\Api\PackageApiReadModel`, package API contributions through `App\Core\Package\PackageContributions` and `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackagePathPatternScope`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel` | Provides package-owned endpoint/handler contributions below `/api/v1/packages/{package_slug}/...`, package read/navigation resources, package API path-pattern validation below the owned package namespace without top-level alternation escapes, user-facing self-service profile and own API-key resources with prefix validation before key material is generated, user detail updates for one role plus multiple groups, ACL group CRUD with impact review and optional LiveOperation execution, membership relationship mutations, registration/invitation approval/reissue/denial actions, and disputed-account security-review confirm/deny actions. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageApiContributionGuardTest.php` | | Service | `App\Core\Lint\CssLinter` | Reusable string-based CSS syntax linter using the strict Sabberworm CSS parser. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | @@ -128,9 +128,9 @@ |------|--------|---------|------|-------| | Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | -| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, and Security audit policy controls. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php` | +| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit policy controls, and GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | -| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | +| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, password inputs for sensitive settings, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | | Entity | `App\Entity\PackageSettingEntry` | Doctrine entity for package-scoped settings stored separately from global configuration so purge can remove package-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | | Value object | `App\Core\Message\Message` | Universal message value object carrying log level, code, translation key, parameters, and context for logs, output, validation, future localization, and invalid-argument diagnostics. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageTest.php` | | Registry | `App\Core\Message\MessageCode`, domain-owned `*MessageCode` catalogues | Aggregates core-owned machine-readable message code catalogues while keeping constants close to their owning domains and leaving room for validated package-owned catalogues. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageCodeTest.php` | @@ -194,7 +194,7 @@ | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | | Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver` | Replaceable provider-neutral GeoIP lookup boundary used by access logging and access statistics; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while safe status data prepares Admin diagnostics for later providers. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory` | Replaceable provider-neutral GeoIP lookup boundary used by access logging and access statistics; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file and exposes safe status data for later Admin diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php` | | 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; 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/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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8f807639..c1db7085 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -80,6 +80,7 @@ - Started the GeoIP observability branch by compacting the completed `feat-security-policy-docs` notes into `dev/WORKLOG_HISTORY.md`. - Added a narrow provider-neutral GeoIP resolver foundation so access logs and access statistics keep using normalized `n/a` fallback fields until a real provider returns data. - Verified the foundation with focused GeoIP/access-log/statistics PHPUnit coverage, PHP syntax checks, container linting, focused linting for changed files, and Git whitespace checks. +- Added the MaxMind GeoIP2 provider slice on top of the foundation: local `.mmdb` lookups via the installed `geoip2/geoip2` dependency, safe provider status, project-relative database path config, provider/update settings, sensitive credential preservation/redaction, password-form support for secret fields, and hermetic fake-reader tests without real MaxMind credentials or network access. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 3e4dbb26..33ae1880 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -46,6 +46,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code. - No public API response adds GeoIP data in this branch. - Provider selection, database path/status, and update policy are protected/audited configuration surfaces; account and license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. +- The first MaxMind implementation uses the installed `geoip2/geoip2` package against a configured local `.mmdb` database. Request-time lookups must not download databases or require outbound network access. +- Account ID and license key configuration are sensitive values. Empty sensitive form submissions preserve existing stored values, API/settings read models return redacted display values, and PHPUnit coverage must use fakes or dummy strings rather than real MaxMind credentials. ## Edge cases diff --git a/src/Core/Config/Api/SettingsApiReadModel.php b/src/Core/Config/Api/SettingsApiReadModel.php index fdce31b4..5493459e 100644 --- a/src/Core/Config/Api/SettingsApiReadModel.php +++ b/src/Core/Config/Api/SettingsApiReadModel.php @@ -69,8 +69,8 @@ public function settings(?string $section = null): array 'attributes' => [ 'section' => $definition->section(), 'key' => $field->name(), - 'value' => $this->config->get($field->name(), $field->defaultValue()), - 'default_value' => $field->defaultValue(), + 'value' => $this->apiValue($field->metadata(), $this->config->get($field->name(), $field->defaultValue())), + 'default_value' => $this->apiValue($field->metadata(), $field->defaultValue()), 'value_type' => $field->valueType()->value, 'input_type' => $field->inputType()->value, 'label_key' => $field->label(), @@ -93,13 +93,33 @@ public function values(string $section): array { $values = []; - foreach ($this->settings($section) as $resource) { - $id = $resource['id'] ?? null; - if (is_string($id)) { - $values[$id] = $resource['attributes']['value'] ?? null; + foreach ($this->settings->allDefinitions() as $definition) { + if ($definition->section() !== $section) { + continue; + } + + $field = $definition->formField(); + if (false === ($field->metadata()['persist'] ?? true)) { + continue; } + + $values[$field->name()] = true === ($field->metadata()['sensitive'] ?? false) + ? '' + : $this->config->get($field->name(), $field->defaultValue()); } return $values; } + + /** + * @param array $metadata + */ + private function apiValue(array $metadata, mixed $value): mixed + { + if (true !== ($metadata['sensitive'] ?? false)) { + return $value; + } + + return is_string($value) && '' !== trim($value) ? '[protected]' : ''; + } } diff --git a/src/Core/Config/Settings/CoreSettingsFormHandler.php b/src/Core/Config/Settings/CoreSettingsFormHandler.php index db9353b3..7175bde8 100644 --- a/src/Core/Config/Settings/CoreSettingsFormHandler.php +++ b/src/Core/Config/Settings/CoreSettingsFormHandler.php @@ -50,7 +50,21 @@ public function submit(string $section, array $submitted, ?string $modifiedBy = continue; } - if (!$this->config->set($definition->key(), $result->value($definition->key()), $definition->valueType(), modifiedBy: $modifiedBy)) { + $metadata = $definition->metadata(); + if ( + true === ($metadata['sensitive'] ?? false) + && $this->isEmptySensitiveValue($result->value($definition->key())) + ) { + continue; + } + + if (!$this->config->set( + $definition->key(), + $result->value($definition->key()), + $definition->valueType(), + sensitive: true === ($metadata['sensitive'] ?? false), + modifiedBy: $modifiedBy, + )) { return new FormSubmissionResult($result->values(), [ '__form' => [FormErrorKey::SAVE_FAILED], ]); @@ -156,4 +170,9 @@ private function isValidOptionalEmail(mixed $email): bool return is_string($email) && ('' === trim($email) || EmailAddress::isValid($email)); } + + private function isEmptySensitiveValue(mixed $value): bool + { + return null === $value || (is_string($value) && '' === trim($value)); + } } diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index c5a9f109..5dbb959b 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -6,6 +6,7 @@ use App\Api\ApiFeaturePolicy; use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; @@ -82,6 +83,21 @@ public function allDefinitions(): array ConfigAuditLogPolicy::CATEGORY_SETTINGS => 'admin.settings.options.audit.settings', ConfigAuditLogPolicy::CATEGORY_OTHER => 'admin.settings.options.audit.other', ], sortOrder: 50), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', sortOrder: 60), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, 'admin.settings.fields.geoip_provider.label', MaxMindGeoIpConfig::PROVIDER_KEY, ConfigValueType::String, FormInputType::Select, options: [ + 'none' => 'admin.settings.options.geoip.none', + MaxMindGeoIpConfig::PROVIDER_KEY => 'admin.settings.options.geoip.maxmind', + ], validation: ['required' => true], sortOrder: 70), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], sortOrder: 80), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::LOCALES_KEY, 'admin.settings.fields.geoip_locales.label', MaxMindGeoIpConfig::DEFAULT_LOCALES, ConfigValueType::Json, FormInputType::Textarea, help: 'admin.settings.fields.geoip_locales.help', sortOrder: 90), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::UPDATE_ENABLED_KEY, 'admin.settings.fields.geoip_update_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_update_enabled.help', sortOrder: 100), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY, 'admin.settings.fields.geoip_update_interval.label', MaxMindGeoIpConfig::DEFAULT_UPDATE_INTERVAL, ConfigValueType::String, FormInputType::Select, options: [ + 'manual' => 'admin.settings.options.interval.manual', + 'daily' => 'admin.settings.options.interval.daily', + 'weekly' => 'admin.settings.options.interval.weekly', + ], validation: ['required' => true], sortOrder: 110), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::ACCOUNT_ID_KEY, 'admin.settings.fields.geoip_account_id.label', '', ConfigValueType::String, help: 'admin.settings.fields.geoip_account_id.help', validation: ['max_length' => 80], metadata: ['sensitive' => true], sortOrder: 120), + new CoreSettingDefinition('security', MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'admin.settings.fields.geoip_license_key.label', '', ConfigValueType::String, FormInputType::Password, help: 'admin.settings.fields.geoip_license_key.help', validation: ['max_length' => 180], metadata: ['sensitive' => true], sortOrder: 130), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), diff --git a/src/Core/Geo/GeoIp2MaxMindDatabaseReader.php b/src/Core/Geo/GeoIp2MaxMindDatabaseReader.php new file mode 100644 index 00000000..20050585 --- /dev/null +++ b/src/Core/Geo/GeoIp2MaxMindDatabaseReader.php @@ -0,0 +1,26 @@ +reader->city($ipAddress); + } + + public function metadata(): Metadata + { + return $this->reader->metadata(); + } +} diff --git a/src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php b/src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php new file mode 100644 index 00000000..f2aa91e5 --- /dev/null +++ b/src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php @@ -0,0 +1,15 @@ +config->get(self::ENABLED_KEY, false) + && self::PROVIDER_KEY === $this->provider(); + } + + public function provider(): string + { + $provider = $this->config->get(self::SELECTED_PROVIDER_KEY, self::PROVIDER_KEY); + + return is_string($provider) && '' !== trim($provider) ? trim($provider) : self::PROVIDER_KEY; + } + + public function databasePath(): string + { + $path = $this->config->get(self::DATABASE_PATH_KEY, self::DEFAULT_DATABASE_PATH); + + return is_string($path) && '' !== trim($path) ? trim($path) : self::DEFAULT_DATABASE_PATH; + } + + /** + * @return list + */ + public function locales(): array + { + $locales = $this->config->get(self::LOCALES_KEY, self::DEFAULT_LOCALES); + $locales = is_array($locales) ? $locales : self::DEFAULT_LOCALES; + $normalized = []; + + foreach ($locales as $locale) { + if (!is_string($locale)) { + continue; + } + + $locale = trim($locale); + if (1 !== preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $locale)) { + continue; + } + + $normalized[$locale] = $locale; + } + + $normalized['en'] ??= 'en'; + + return array_slice(array_values($normalized), 0, 8); + } + + public function updateEnabled(): bool + { + return true === $this->config->get(self::UPDATE_ENABLED_KEY, false); + } + + public function updateInterval(): string + { + $interval = $this->config->get(self::UPDATE_INTERVAL_KEY, self::DEFAULT_UPDATE_INTERVAL); + + return is_string($interval) && in_array($interval, ['manual', 'daily', 'weekly'], true) + ? $interval + : self::DEFAULT_UPDATE_INTERVAL; + } +} diff --git a/src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php b/src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php new file mode 100644 index 00000000..8fe4a26c --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php @@ -0,0 +1,13 @@ + $locales + */ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface; +} diff --git a/src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php b/src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php new file mode 100644 index 00000000..0ade426d --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php @@ -0,0 +1,15 @@ +status) { + return $this->status; + } + + if (!$this->config->enabled()) { + return $this->status = GeoIpProviderStatus::disabled($this->key()); + } + + $databasePath = $this->databasePath(); + if (null === $databasePath) { + return $this->status = GeoIpProviderStatus::unconfigured($this->key(), 'invalid_database_path'); + } + + if (!is_file($databasePath) || !is_readable($databasePath)) { + return $this->status = GeoIpProviderStatus::unconfigured($this->key(), 'database_missing'); + } + + try { + $metadata = $this->reader()->metadata(); + } catch (Throwable) { + return $this->status = GeoIpProviderStatus::unavailable($this->key(), 'database_unreadable'); + } + + return $this->status = GeoIpProviderStatus::ready( + $this->key(), + databaseEdition: is_string($metadata->databaseType) ? $metadata->databaseType : null, + databaseBuildDate: is_int($metadata->buildEpoch) ? $this->formatBuildDate($metadata->buildEpoch) : null, + ); + } + + public function resolve(?string $ipAddress): GeoIpResult + { + if (!$this->status()->isReady() || !$this->isPublicIp($ipAddress)) { + return new GeoIpResult(); + } + + try { + return $this->resultFromCity($this->reader()->city((string) $ipAddress)); + } catch (Throwable) { + return new GeoIpResult(); + } + } + + private function reader(): MaxMindGeoIpDatabaseReaderInterface + { + if (null !== $this->reader) { + return $this->reader; + } + + $databasePath = $this->databasePath(); + if (null === $databasePath) { + throw new \RuntimeException('GeoIP database path is unavailable.'); + } + + return $this->reader = $this->readerFactory->open($databasePath, $this->config->locales()); + } + + private function databasePath(): ?string + { + $relativePath = str_replace('\\', '/', $this->config->databasePath()); + + if ( + '' === trim($relativePath) + || str_contains($relativePath, "\0") + || str_starts_with($relativePath, '/') + || str_starts_with($relativePath, '//') + || str_starts_with($relativePath, '\\\\') + || 1 === preg_match('/^[A-Za-z]:\//', $relativePath) + || str_contains('/'.$relativePath.'/', '/../') + ) { + return null; + } + + return rtrim($this->projectDir, DIRECTORY_SEPARATOR.'/\\') + .DIRECTORY_SEPARATOR + .str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/')); + } + + private function isPublicIp(?string $ipAddress): bool + { + return is_string($ipAddress) + && false !== filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + } + + private function resultFromCity(City $city): GeoIpResult + { + return new GeoIpResult( + $this->locationValue($city->city->name), + $this->locationValue($city->mostSpecificSubdivision->name ?? $city->mostSpecificSubdivision->isoCode), + $this->locationValue($city->country->name ?? $city->country->isoCode ?? $city->registeredCountry->name ?? $city->registeredCountry->isoCode), + $this->locationValue($city->continent->name ?? $city->continent->code), + ); + } + + private function locationValue(?string $value): string + { + return is_string($value) && '' !== trim($value) ? trim($value) : GeoIpResult::PLACEHOLDER; + } + + private function formatBuildDate(int $buildEpoch): string + { + return (new DateTimeImmutable('@'.$buildEpoch)) + ->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d'); + } +} diff --git a/src/Form/FormInputType.php b/src/Form/FormInputType.php index e296943c..ad60a0d8 100644 --- a/src/Form/FormInputType.php +++ b/src/Form/FormInputType.php @@ -15,6 +15,7 @@ enum FormInputType: string case Checkbox = 'checkbox'; case Number = 'number'; case Color = 'color'; + case Password = 'password'; case Captcha = 'captcha'; /** diff --git a/src/View/Twig/AdminViewTwigExtension.php b/src/View/Twig/AdminViewTwigExtension.php index 0c89e7c2..6d41cd16 100644 --- a/src/View/Twig/AdminViewTwigExtension.php +++ b/src/View/Twig/AdminViewTwigExtension.php @@ -119,7 +119,12 @@ public function coreSettingsForm(string $section): array $request = $this->requestStack->getCurrentRequest(); foreach ($definitions as $definition) { - $values[$definition->key()] = $this->config->get($definition->key()) ?? $definition->defaultValue(); + $value = $this->config->get($definition->key()) ?? $definition->defaultValue(); + if (true === ($definition->metadata()['sensitive'] ?? false)) { + $value = ''; + } + + $values[$definition->key()] = $value; } $values = array_replace($values, $this->requestFormValues($request)); diff --git a/templates/backend/partials/forms/_dynamic_fields.html.twig b/templates/backend/partials/forms/_dynamic_fields.html.twig index f9b523f5..02c7469a 100644 --- a/templates/backend/partials/forms/_dynamic_fields.html.twig +++ b/templates/backend/partials/forms/_dynamic_fields.html.twig @@ -65,7 +65,7 @@ id: field.id, name: field.name, label: field_label, - type: field.input_type == 'number' ? 'number' : (field.input_type == 'color' ? 'color' : 'text'), + type: field.input_type == 'number' ? 'number' : (field.input_type == 'color' ? 'color' : (field.input_type == 'password' ? 'password' : 'text')), value: field.value, required: field.required, attributes: field.attributes, diff --git a/tests/Core/Config/ConfigTest.php b/tests/Core/Config/ConfigTest.php index ce868618..b35c80d4 100644 --- a/tests/Core/Config/ConfigTest.php +++ b/tests/Core/Config/ConfigTest.php @@ -122,6 +122,24 @@ public function testItSetsConfigurationValues(): void self::assertSame('test', $row['modified_by']); } + public function testItStoresSensitiveConfigurationFlag(): void + { + $connection = $this->connection(); + $config = new Config($connection); + + self::assertTrue($config->set('security.geoip.maxmind.license_key', 'secret-value', ConfigValueType::String, sensitive: true, modifiedBy: 'test')); + + $row = $connection->fetchAssociative('SELECT value, value_type, sensitive, modified_by FROM config_entry WHERE config_key = ?', [ + 'security.geoip.maxmind.license_key', + ]); + + self::assertIsArray($row); + self::assertSame('"secret-value"', $row['value']); + self::assertSame('string', $row['value_type']); + self::assertSame(1, (int) $row['sensitive']); + self::assertSame('test', $row['modified_by']); + } + public function testItReportsInvalidConfigurationKeys(): void { $reporter = new RecordingConfigMessageReporter(); diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php new file mode 100644 index 00000000..330d9835 --- /dev/null +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -0,0 +1,70 @@ +connection()); + $config->set(MaxMindGeoIpConfig::ACCOUNT_ID_KEY, '123456', ConfigValueType::String, sensitive: true); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); + + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + ); + + $result = $handler->submit('security', [ + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + ConfigAuditLogPolicy::ENABLED_KEY => '1', + ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + MaxMindGeoIpConfig::ENABLED_KEY => '0', + MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY => MaxMindGeoIpConfig::PROVIDER_KEY, + MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, + MaxMindGeoIpConfig::LOCALES_KEY => '["en"]', + MaxMindGeoIpConfig::UPDATE_ENABLED_KEY => '0', + MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY => MaxMindGeoIpConfig::DEFAULT_UPDATE_INTERVAL, + MaxMindGeoIpConfig::ACCOUNT_ID_KEY => '', + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '', + ], 'test'); + + self::assertTrue($result->isValid()); + self::assertSame('123456', $config->get(MaxMindGeoIpConfig::ACCOUNT_ID_KEY)); + self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + + private function registry(): CoreSettingsRegistry + { + $projectDir = dirname(__DIR__, 3); + + return new CoreSettingsRegistry(new TranslationLanguageCatalog($projectDir), new SystemPackageMetadataProvider($projectDir)); + } + + 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/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 7ed2b3b7..17cc3225 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -8,6 +8,7 @@ use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreConfigDefaultProvider; use App\Core\Config\Settings\CoreSettingsRegistry; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; @@ -58,10 +59,23 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.preview', ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, + MaxMindGeoIpConfig::ENABLED_KEY, + MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, + MaxMindGeoIpConfig::DATABASE_PATH_KEY, + MaxMindGeoIpConfig::LOCALES_KEY, + MaxMindGeoIpConfig::UPDATE_ENABLED_KEY, + MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY, + MaxMindGeoIpConfig::ACCOUNT_ID_KEY, + MaxMindGeoIpConfig::LICENSE_KEY_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(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $security[7]->defaultValue()); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_LOCALES, $security[8]->defaultValue()); + self::assertSame(['sensitive' => true], $security[11]->metadata()); + self::assertSame(['sensitive' => true], $security[12]->metadata()); + self::assertSame(FormInputType::Password, $security[12]->formField()->inputType()); self::assertSame([ AccessStatisticsPolicy::ENABLED_KEY, @@ -100,6 +114,9 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void self::assertTrue($provider->defaultValue(ApiFeaturePolicy::ENABLED_KEY)); self::assertFalse($provider->defaultValue(ApiFeaturePolicy::CORS_ENABLED_KEY)); self::assertSame([], $provider->defaultValue(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY)); + self::assertFalse($provider->defaultValue(MaxMindGeoIpConfig::ENABLED_KEY)); + self::assertSame(MaxMindGeoIpConfig::PROVIDER_KEY, $provider->defaultValue(MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY)); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $provider->defaultValue(MaxMindGeoIpConfig::DATABASE_PATH_KEY)); self::assertFalse($provider->hasDefault('security.captcha.preview')); self::assertNull($provider->defaultValue('security.captcha.preview')); } diff --git a/tests/Core/Config/SettingsApiReadModelTest.php b/tests/Core/Config/SettingsApiReadModelTest.php new file mode 100644 index 00000000..61dd5af9 --- /dev/null +++ b/tests/Core/Config/SettingsApiReadModelTest.php @@ -0,0 +1,53 @@ +connection()); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); + + $readModel = new SettingsApiReadModel($this->registry(), $config); + + $licenseSetting = null; + foreach ($readModel->settings('security') as $setting) { + if (MaxMindGeoIpConfig::LICENSE_KEY_KEY === $setting['id']) { + $licenseSetting = $setting; + } + } + + self::assertIsArray($licenseSetting); + self::assertSame('[protected]', $licenseSetting['attributes']['value']); + self::assertSame('', $readModel->values('security')[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); + } + + private function registry(): CoreSettingsRegistry + { + $projectDir = dirname(__DIR__, 3); + + return new CoreSettingsRegistry(new TranslationLanguageCatalog($projectDir), new SystemPackageMetadataProvider($projectDir)); + } + + 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/Core/Geo/MaxMindGeoIpConfigTest.php b/tests/Core/Geo/MaxMindGeoIpConfigTest.php new file mode 100644 index 00000000..db7ba649 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpConfigTest.php @@ -0,0 +1,51 @@ +connection())); + + self::assertFalse($config->enabled()); + self::assertSame(MaxMindGeoIpConfig::PROVIDER_KEY, $config->provider()); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $config->databasePath()); + self::assertSame(['en'], $config->locales()); + self::assertFalse($config->updateEnabled()); + self::assertSame('weekly', $config->updateInterval()); + } + + public function testItNormalizesLocalesAndUpdateInterval(): void + { + $connection = $this->connection(); + $store = new Config($connection); + $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); + $store->set(MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, MaxMindGeoIpConfig::PROVIDER_KEY, ConfigValueType::String); + $store->set(MaxMindGeoIpConfig::LOCALES_KEY, ['de', 'invalid', 'en', 'de', 'fr-FR'], ConfigValueType::Json); + $store->set(MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY, 'hourly', ConfigValueType::String); + + $config = new MaxMindGeoIpConfig($store); + + self::assertTrue($config->enabled()); + self::assertSame(['de', 'en', 'fr-FR'], $config->locales()); + self::assertSame('weekly', $config->updateInterval()); + } + + 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/Core/Geo/MaxMindGeoIpProviderTest.php b/tests/Core/Geo/MaxMindGeoIpProviderTest.php new file mode 100644 index 00000000..ce3bfc80 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpProviderTest.php @@ -0,0 +1,205 @@ +projectDir = $this->createTemporaryDirectory('maxmind-geoip-provider'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testItStaysDisabledByDefault(): void + { + $factory = new RecordingMaxMindReaderFactory($this->reader()); + $provider = new MaxMindGeoIpProvider(new MaxMindGeoIpConfig(new Config($this->connection())), $factory, $this->projectDir); + + self::assertSame('maxmind', $provider->key()); + self::assertSame('disabled', $provider->status()->status); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(0, $factory->openCount); + } + + public function testItReportsMissingConfiguredDatabaseAsUnconfigured(): void + { + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($this->reader()), $this->projectDir); + + self::assertSame('unconfigured', $provider->status()->status); + self::assertSame('database_missing', $provider->status()->failureCode); + } + + public function testItReadsLocalDatabaseThroughGeoIp2Boundary(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader(); + $factory = new RecordingMaxMindReaderFactory($reader); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), $factory, $this->projectDir); + + self::assertSame('ready', $provider->status()->status); + self::assertSame('GeoLite2-City', $provider->status()->databaseEdition); + self::assertSame('2026-06-15', $provider->status()->databaseBuildDate); + self::assertSame([ + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country' => 'Germany', + 'continent' => 'Europe', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(1, $factory->openCount); + self::assertSame($this->projectDir.'/'.MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $factory->lastDatabasePath); + self::assertSame(['en'], $factory->lastLocales); + self::assertSame(1, $reader->cityLookupCount); + } + + public function testItDoesNotLookupPrivateOrInvalidIpAddresses(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader(); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($reader), $this->projectDir); + + self::assertSame('ready', $provider->status()->status); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('127.0.0.1')->toArray()); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('not-an-ip')->toArray()); + self::assertSame(0, $reader->cityLookupCount); + } + + public function testItReportsUnreadableDatabaseWithoutLeakingPath(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new ThrowingMaxMindReaderFactory(), $this->projectDir); + + $status = $provider->status(); + + self::assertSame('unavailable', $status->status); + self::assertSame('database_unreadable', $status->failureCode); + self::assertStringNotContainsString($this->projectDir, json_encode($status->toSafeArray(), JSON_THROW_ON_ERROR)); + } + + private function enabledConfig(): MaxMindGeoIpConfig + { + $store = new Config($this->connection()); + $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); + $store->set(MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, MaxMindGeoIpConfig::PROVIDER_KEY, ConfigValueType::String); + + return new MaxMindGeoIpConfig($store); + } + + 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; + } + + private function reader(): RecordingMaxMindReader + { + return new RecordingMaxMindReader(new City([ + 'city' => ['names' => ['en' => 'Berlin']], + 'subdivisions' => [ + ['names' => ['en' => 'Berlin'], 'iso_code' => 'BE'], + ], + 'country' => ['names' => ['en' => 'Germany'], 'iso_code' => 'DE'], + 'continent' => ['names' => ['en' => 'Europe'], 'code' => 'EU'], + ])); + } +} + +final class RecordingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public int $openCount = 0; + public ?string $lastDatabasePath = null; + /** @var list */ + public array $lastLocales = []; + + public function __construct(private readonly RecordingMaxMindReader $reader) + { + } + + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + ++$this->openCount; + $this->lastDatabasePath = $databasePath; + $this->lastLocales = $locales; + + return $this->reader; + } +} + +final class ThrowingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + throw new RuntimeException('Cannot open local test database.'); + } +} + +final class RecordingMaxMindReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public int $cityLookupCount = 0; + + public function __construct(private readonly City $city) + { + } + + public function city(string $ipAddress): City + { + ++$this->cityLookupCount; + + return $this->city; + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index eef80e73..e88d4cea 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -790,6 +790,28 @@ admin: label: 'Audit-Logging aktivieren' audit_events: label: 'Audit-Ereigniskategorien' + geoip_enabled: + label: 'GeoIP-Lookups aktivieren' + help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' + geoip_provider: + label: 'GeoIP-Provider' + geoip_database_path: + label: 'MaxMind-Datenbankpfad' + help: 'Projektrelativer Pfad zur lokalen .mmdb-Datenbank. Request-Lookups laden Datenbanken niemals automatisch herunter.' + geoip_locales: + label: 'GeoIP-Locales' + help: 'JSON-Array von Locale-Codes, sortiert von bevorzugt bis Fallback, zum Beispiel ["en", "de"].' + geoip_update_enabled: + label: 'GeoIP-Datenbankupdates aktivieren' + help: 'Für den Scheduler-Update-Task reserviert. Lookups verwenden nur eine vorhandene lokale Datenbank.' + geoip_update_interval: + label: 'GeoIP-Update-Intervall' + geoip_account_id: + label: 'MaxMind-Account-ID' + help: 'Wird als sensitive Konfiguration gespeichert und nur vom zukünftigen Update-Task verwendet.' + geoip_license_key: + label: 'MaxMind-License-Key' + help: 'Wird als sensitive Konfiguration gespeichert und niemals für PHPUnit-Tests benötigt.' statistics_enabled: label: 'Zugriffsstatistiken aktivieren' statistics_respect_dnt: @@ -826,6 +848,9 @@ admin: setup_warnings: 'Setup-Warnungen' captcha: none: 'Kein Captcha-Provider' + geoip: + none: 'Kein GeoIP-Provider' + maxmind: 'MaxMind GeoIP2' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index c9bf50b3..cba3f74a 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -790,6 +790,28 @@ admin: label: 'Enable audit logging' audit_events: label: 'Audit event categories' + geoip_enabled: + label: 'Enable GeoIP lookups' + help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' + geoip_provider: + label: 'GeoIP provider' + geoip_database_path: + label: 'MaxMind database path' + help: 'Project-relative path to the local .mmdb database. Request lookups never download databases automatically.' + geoip_locales: + label: 'GeoIP locales' + help: 'JSON array of locale codes, ordered from most preferred to least preferred, for example ["en", "de"].' + geoip_update_enabled: + label: 'Enable GeoIP database updates' + help: 'Reserved for the scheduler update task. Lookups only use an existing local database.' + geoip_update_interval: + label: 'GeoIP update interval' + geoip_account_id: + label: 'MaxMind account ID' + help: 'Stored as sensitive configuration and used only by the future update task.' + geoip_license_key: + label: 'MaxMind license key' + help: 'Stored as sensitive configuration and never required for PHPUnit tests.' statistics_enabled: label: 'Enable access statistics' statistics_respect_dnt: @@ -826,6 +848,9 @@ admin: setup_warnings: 'Setup warnings' captcha: none: 'No captcha provider' + geoip: + none: 'No GeoIP provider' + maxmind: 'MaxMind GeoIP2' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' From 958f8332265a94ca992aeae4896e78f7b3fd9c84 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:38:00 +0200 Subject: [PATCH 022/119] Add GeoIP database update foundation --- config/services.yaml | 10 + dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 3 +- .../admin-acl-enforcement.md | 2 +- .../security-hardening/geoip-observability.md | 19 +- .../security-hardening/policy-defaults.md | 4 +- src/Backend/AdminViewContextProvider.php | 7 + src/Backend/BackendActions.php | 13 ++ .../Config/Settings/CoreSettingsRegistry.php | 26 +-- src/Core/Geo/GeoIpMessageCode.php | 17 ++ src/Core/Geo/GeoIpMessageKey.php | 18 ++ .../Geo/HttpMaxMindGeoIpDownloadClient.php | 98 ++++++++ src/Core/Geo/MaxMindGeoIpArchiveExtractor.php | 89 +++++++ .../MaxMindGeoIpArchiveExtractorInterface.php | 15 ++ src/Core/Geo/MaxMindGeoIpConfig.php | 88 +++---- src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php | 219 ++++++++++++++++++ .../MaxMindGeoIpDownloadClientInterface.php | 15 ++ src/Core/Geo/MaxMindGeoIpProvider.php | 18 +- .../Geo/MaxMindGeoIpSchedulerProvider.php | 59 +++++ src/Core/Geo/MaxMindGeoIpUpdateAction.php | 41 ++++ .../Live/LiveOperationQueueFactory.php | 1 + .../MaxMindGeoIpLiveOperationProvider.php | 63 +++++ .../backend/admin/settings/section.html.twig | 6 + .../partials/forms/_dynamic_fields.html.twig | 8 + tests/Controller/BackendControllerTest.php | 18 ++ tests/Core/Config/ConfigTest.php | 4 +- .../Config/CoreSettingsFormHandlerTest.php | 16 +- .../Core/Config/CoreSettingsRegistryTest.php | 21 +- tests/Core/Geo/MaxMindGeoIpConfigTest.php | 30 ++- .../Geo/MaxMindGeoIpDatabaseUpdaterTest.php | 183 +++++++++++++++ tests/Core/Geo/MaxMindGeoIpProviderTest.php | 1 - .../Geo/MaxMindGeoIpSchedulerProviderTest.php | 115 +++++++++ .../LiveOperationQueueFactoryTest.php | 7 + translations/languages/de/admin.yaml | 29 +-- translations/languages/de/message.yaml | 11 + translations/languages/en/admin.yaml | 29 +-- translations/languages/en/message.yaml | 11 + 37 files changed, 1150 insertions(+), 166 deletions(-) create mode 100644 src/Core/Geo/GeoIpMessageCode.php create mode 100644 src/Core/Geo/GeoIpMessageKey.php create mode 100644 src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php create mode 100644 src/Core/Geo/MaxMindGeoIpArchiveExtractor.php create mode 100644 src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php create mode 100644 src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php create mode 100644 src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php create mode 100644 src/Core/Geo/MaxMindGeoIpSchedulerProvider.php create mode 100644 src/Core/Geo/MaxMindGeoIpUpdateAction.php create mode 100644 src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php create mode 100644 tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php create mode 100644 tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php diff --git a/config/services.yaml b/config/services.yaml index 648f7c8c..d457cc2d 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -385,10 +385,20 @@ services: App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface: alias: App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory + App\Core\Geo\MaxMindGeoIpDownloadClientInterface: + alias: App\Core\Geo\HttpMaxMindGeoIpDownloadClient + + App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface: + alias: App\Core\Geo\MaxMindGeoIpArchiveExtractor + App\Core\Geo\MaxMindGeoIpProvider: arguments: $projectDir: '%kernel.project_dir%' + App\Core\Geo\MaxMindGeoIpDatabaseUpdater: + arguments: + $projectDir: '%kernel.project_dir%' + App\Core\Log\OperationLoggerInterface: alias: App\Core\Log\OperationLogger diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index e23111e1..5bc178c6 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -194,7 +194,7 @@ | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | | Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory` | Replaceable provider-neutral GeoIP lookup boundary used by access logging and access statistics; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file and exposes safe status data for later Admin diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging and access statistics; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, write atomically to `var/geoip2`, preserve the previous database on failure where possible, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; 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/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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c1db7085..5dbce309 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -80,7 +80,8 @@ - Started the GeoIP observability branch by compacting the completed `feat-security-policy-docs` notes into `dev/WORKLOG_HISTORY.md`. - Added a narrow provider-neutral GeoIP resolver foundation so access logs and access statistics keep using normalized `n/a` fallback fields until a real provider returns data. - Verified the foundation with focused GeoIP/access-log/statistics PHPUnit coverage, PHP syntax checks, container linting, focused linting for changed files, and Git whitespace checks. -- Added the MaxMind GeoIP2 provider slice on top of the foundation: local `.mmdb` lookups via the installed `geoip2/geoip2` dependency, safe provider status, project-relative database path config, provider/update settings, sensitive credential preservation/redaction, password-form support for secret fields, and hermetic fake-reader tests without real MaxMind credentials or network access. +- Added the MaxMind GeoIP2 provider slice on top of the foundation: local `.mmdb` lookups via the installed `geoip2/geoip2` dependency, safe provider status, project-relative database path config, sensitive credential preservation/redaction, password-form support for secret fields, and hermetic fake-reader tests without real MaxMind credentials or network access. +- Added the narrow GeoIP2 update foundation: moved the intentionally small GeoIP settings surface to Statistics, changed the default database path to `var/geoip2/GeoLite2-City.mmdb`, derived MaxMind lookup locales from the site default language with `en` fallback, exposed a MaxMind signup help link, added an Admin Operations-backed database download action with non-JS POST fallback, added a daily scheduler callable, and added hermetic updater/scheduler tests that do not use real MaxMind credentials or network access. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index 5fda7c32..503ebec4 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -51,7 +51,7 @@ The first matrix should use conservative defaults. "View" means the actor may op | Mail settings | View and mutate non-secret sender settings | View and mutate, including protected transport status/config where implemented | Mail transport secrets remain protected/write-only. Production delivery guards remain enforced. | | Security settings | View redacted status only | View and mutate | Captcha provider selection may be Admin-mutable only if it cannot disable required protection or verified recovery policy. Auto-ban disablement, privacy ceilings, recovery protections, and rate/security policy bounds are Owner-only. | | Access/audit/security logs | View redacted summaries | View redacted summaries and broader review tools | Raw secrets, raw tokens, full request payloads, and IP-derived data beyond retention are never exposed. Full diagnostic/export actions are Owner-only. | -| Statistics and GeoIP status | View summaries | View and mutate provider/update settings | MaxMind/license material is protected/write-only. GeoIP cannot become blocking policy in this slice. | +| Statistics and GeoIP status | View summaries | View and mutate GeoIP enablement, database path, license key, and update task | MaxMind license material is protected/write-only. GeoIP cannot become blocking policy in this slice. | | API settings | View status and own/user-token surfaces where already allowed | View and mutate global API settings | Enabling public API/CORS expansion, wildcard-like origins, or broad anonymous access is Owner-only. | | Package/theme overview | View installed/available status | View and mutate | Installing, activating, deactivating, updating, purging, and running package lifecycle actions are Owner-only by default. | | Package settings | View and mutate simple non-sensitive package settings if the package declares them Admin-safe | View and mutate all package settings within package policy | Package settings that alter routes, permissions, external credentials, data access, or runtime code are Owner-only. | diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 33ae1880..52f81a84 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -29,9 +29,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 0. Establish the provider-neutral foundation: `GeoIpProviderInterface`, `GeoIpProviderStatus`, a delegating `GeoIpResolver`, and a null provider that keeps current log/statistic placeholders as the default output. 1. Add a MaxMind-backed resolver behind the existing GeoIP resolver interface. -2. Add protected administrator-only settings for provider selection, database path/status, account/license key, and update policy. +2. Add protected administrator-only Statistics settings for GeoIP enablement, the local database path, and the MaxMind license key. 3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. -4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until provider credentials and update policy are configured by an administrator. +4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until an administrator enables the task and stores a MaxMind license key. 5. Add safe Admin diagnostics for provider status, last update attempt, database freshness, and disabled/unconfigured state. 6. Wire access logs and statistics to consume normalized provider output only through the resolver interface and the shared client-identity resolver. @@ -42,12 +42,15 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Providers expose only safe status fields: provider key, coarse status, database edition/build date, update timestamps, next suggested update, and redacted failure code. No raw paths, IP inputs, license/account data, or full exception messages belong in provider status. - Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. - Provider secrets are protected config values and never rendered outside authorized Admin settings. -- Scheduler task identifiers use stable system-owned names and do not expose provider credentials. -- Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code. +- Scheduler task identifiers use stable system-owned names and do not expose provider credentials. The MaxMind database update task is a trusted callable scheduled daily by default and remains inactive until an operator activates it in Scheduler. +- Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code when persistent update-state storage is added. The first foundation reports equivalent context through Operations, Scheduler runs, and the Message layer. - No public API response adds GeoIP data in this branch. -- Provider selection, database path/status, and update policy are protected/audited configuration surfaces; account and license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. +- GeoIP enablement, database path/status, and the MaxMind license key are protected/audited Statistics configuration surfaces; license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. - The first MaxMind implementation uses the installed `geoip2/geoip2` package against a configured local `.mmdb` database. Request-time lookups must not download databases or require outbound network access. -- Account ID and license key configuration are sensitive values. Empty sensitive form submissions preserve existing stored values, API/settings read models return redacted display values, and PHPUnit coverage must use fakes or dummy strings rather than real MaxMind credentials. +- The first production settings surface intentionally avoids a provider dropdown, Account ID field, and explicit GeoIP locale field until the product has a concrete need for them. MaxMind Reader locales are derived from `localization.default_language` with `en` as stable fallback. +- License key configuration is sensitive. Empty sensitive form submissions preserve existing stored values, API/settings read models return redacted display values, and PHPUnit coverage must use fakes or dummy strings rather than real MaxMind credentials. +- The default local database path is `var/geoip2/GeoLite2-City.mmdb`. Admin-triggered downloads and scheduler downloads must write through a temporary workspace and atomically replace the configured target where the platform supports atomic rename. +- The Statistics settings page may link operators to the official MaxMind GeoLite signup page for a free license key. A saved key reveals the database download action; missing keys make the scheduler callable fail with a translated Message-layer diagnostic so normal scheduler failure policy can disable repeatedly failing tasks. ## Edge cases @@ -62,8 +65,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test protected settings visibility and redaction. - Test access-log/statistics enrichment with provider data and with disabled/missing provider. - Test trusted-proxy/client-identity behavior for lookup input. -- Test scheduler task no-op and failure message behavior. -- Test that the task remains inactive until provider configuration and update policy are both present. +- Test scheduler task definition and missing-key failure message behavior. +- Test that the task remains inactive until explicitly activated by an operator and that missing credentials produce clear failure context. - Test protected configuration redaction and null fallback for disabled, missing, invalid, and expired provider states. - Run focused container lint when services/config are added. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index f59ece55..0ad6d55a 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -200,8 +200,8 @@ These are first soft decisions for which values should stay fixed, become protec | IP privacy ceiling and raw-secret redaction | Code-level policy and tests | No increase allowed | IP-derived data max 30 days; raw credentials, API keys, visitor tokens, session IDs, captcha answers, and full user agents are never policy records | | Raw file-log retention | Existing log configuration or code default | Yes, bounded | Default 30 days; IP-bearing logs must not become queryable beyond 30 days through archives, projections, exports, or support bundles | | Database security event projection | Feature branch decision | Yes, bounded | Stores minimized/redacted read-model data only; must degrade without hiding diagnostics or weakening enforcement | -| GeoIP provider selection, database path, and update policy | Protected config/Admin setting with null fallback | Yes, protected and audited | Provider secrets never public; disabled/unconfigured state uses `NullGeoIpResolver`; no geo-blocking | -| GeoIP account/license key | Secret/protected setting | Yes, protected only | Never rendered, exported, logged, or included in diagnostics | +| GeoIP enablement, database path, license key, and update task | Protected config/Admin setting with null fallback | Yes, protected and audited | License key never public; disabled/unconfigured state uses `NullGeoIpResolver`; no geo-blocking | +| GeoIP license key | Secret/protected setting | Yes, protected only | Never rendered, exported, logged, or included in diagnostics | | 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 | diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index 7898ea65..a361dc44 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -5,6 +5,7 @@ namespace App\Backend; use App\Core\Diagnostics\SystemInfoProvider; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\LogFileBrowser; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Statistics\AccessStatisticsSnapshotProvider; @@ -17,6 +18,7 @@ public function __construct( private LogFileBrowser $logFileBrowser, private AccessStatisticsSnapshotProvider $accessStatisticsSnapshotProvider, private SystemInfoProvider $systemInfoProvider, + private MaxMindGeoIpConfig $maxMindGeoIpConfig, ) { } @@ -41,6 +43,11 @@ public function variables(Request $request, ?BackendViewDefinition $view): array 'backend-admin-settings-system-info' => [ 'system_info' => $this->systemInfoProvider->report($request->server->all()), ], + 'backend-admin-settings-statistics' => [ + 'geoip_settings' => [ + 'has_license_key' => $this->maxMindGeoIpConfig->hasLicenseKey(), + ], + ], default => [], }; } diff --git a/src/Backend/BackendActions.php b/src/Backend/BackendActions.php index 564246d5..297b7507 100644 --- a/src/Backend/BackendActions.php +++ b/src/Backend/BackendActions.php @@ -24,6 +24,7 @@ public const PACKAGE_DISCOVERY = 'package_discovery'; public const ASSET_REBUILD = 'asset_rebuild'; public const CACHE_CLEAR = 'cache_clear'; + public const GEOIP_DATABASE_UPDATE = 'geoip_database_update'; public function __construct( private KernelInterface $kernel, @@ -61,6 +62,12 @@ public function definitions(array $ids = []): array 'variant' => 'secondary', 'live' => true, ], + self::GEOIP_DATABASE_UPDATE => [ + 'id' => self::GEOIP_DATABASE_UPDATE, + 'label_key' => 'admin.actions.geoip_database_update.label', + 'variant' => 'secondary', + 'live' => true, + ], ]; if ([] === $ids) { @@ -81,6 +88,7 @@ public function run(string $action): WorkflowResult self::PACKAGE_DISCOVERY => ($this->packageDiscoveryRunner)('admin_ui'), self::ASSET_REBUILD => $this->assetRebuildDispatcher->dispatch($this->kernel->getEnvironment(), 'admin_ui'), self::CACHE_CLEAR => $this->clearCache(), + self::GEOIP_DATABASE_UPDATE => $this->startLive($action), default => WorkflowResult::invalid([ Message::warning( BackendMessageCode::BACKEND_ACTION_UNKNOWN, @@ -113,6 +121,11 @@ public function startLive(string $action): WorkflowResult ['environment' => $this->kernel->getEnvironment(), 'trigger' => 'admin_ui'], 'Cache clear', ), + self::GEOIP_DATABASE_UPDATE => $this->liveOperationStarter->start( + LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE, + ['environment' => $this->kernel->getEnvironment(), 'trigger' => 'admin_ui'], + 'GeoIP2 database update', + ), default => WorkflowResult::invalid([ Message::warning( BackendMessageCode::BACKEND_ACTION_UNKNOWN, diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 5dbb959b..b0e004d2 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -83,24 +83,18 @@ public function allDefinitions(): array ConfigAuditLogPolicy::CATEGORY_SETTINGS => 'admin.settings.options.audit.settings', ConfigAuditLogPolicy::CATEGORY_OTHER => 'admin.settings.options.audit.other', ], sortOrder: 50), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', sortOrder: 60), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, 'admin.settings.fields.geoip_provider.label', MaxMindGeoIpConfig::PROVIDER_KEY, ConfigValueType::String, FormInputType::Select, options: [ - 'none' => 'admin.settings.options.geoip.none', - MaxMindGeoIpConfig::PROVIDER_KEY => 'admin.settings.options.geoip.maxmind', - ], validation: ['required' => true], sortOrder: 70), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], sortOrder: 80), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::LOCALES_KEY, 'admin.settings.fields.geoip_locales.label', MaxMindGeoIpConfig::DEFAULT_LOCALES, ConfigValueType::Json, FormInputType::Textarea, help: 'admin.settings.fields.geoip_locales.help', sortOrder: 90), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::UPDATE_ENABLED_KEY, 'admin.settings.fields.geoip_update_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_update_enabled.help', sortOrder: 100), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY, 'admin.settings.fields.geoip_update_interval.label', MaxMindGeoIpConfig::DEFAULT_UPDATE_INTERVAL, ConfigValueType::String, FormInputType::Select, options: [ - 'manual' => 'admin.settings.options.interval.manual', - 'daily' => 'admin.settings.options.interval.daily', - 'weekly' => 'admin.settings.options.interval.weekly', - ], validation: ['required' => true], sortOrder: 110), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::ACCOUNT_ID_KEY, 'admin.settings.fields.geoip_account_id.label', '', ConfigValueType::String, help: 'admin.settings.fields.geoip_account_id.help', validation: ['max_length' => 80], metadata: ['sensitive' => true], sortOrder: 120), - new CoreSettingDefinition('security', MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'admin.settings.fields.geoip_license_key.label', '', ConfigValueType::String, FormInputType::Password, help: 'admin.settings.fields.geoip_license_key.help', validation: ['max_length' => 180], metadata: ['sensitive' => true], sortOrder: 130), - new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ + 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', + 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', + ], sortOrder: 30), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], sortOrder: 40), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'admin.settings.fields.geoip_license_key.label', '', ConfigValueType::String, FormInputType::Password, help: 'admin.settings.fields.geoip_license_key.help', validation: ['max_length' => 180], metadata: [ + 'sensitive' => true, + 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', + 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', + ], sortOrder: 50), new CoreSettingDefinition('api', ApiFeaturePolicy::ENABLED_KEY, 'admin.settings.fields.api_enabled.label', true, ConfigValueType::Boolean, help: 'admin.settings.fields.api_enabled.help', sortOrder: 10), new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ENABLED_KEY, 'admin.settings.fields.api_cors_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.api_cors_enabled.help', sortOrder: 20), diff --git a/src/Core/Geo/GeoIpMessageCode.php b/src/Core/Geo/GeoIpMessageCode.php new file mode 100644 index 00000000..5cbaeb24 --- /dev/null +++ b/src/Core/Geo/GeoIpMessageCode.php @@ -0,0 +1,17 @@ +failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + ['stage' => 'download'], + ); + } + + try { + $response = $this->httpClient()->request('GET', $url, [ + 'timeout' => 60.0, + 'max_duration' => 180.0, + 'buffer' => $target, + ]); + $status = $response->getStatusCode(); + $response->getContent(false); + } catch (TransportExceptionInterface) { + fclose($target); + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_SERVER_UNREACHABLE, + GeoIpMessageKey::GEOIP_DOWNLOAD_SERVER_UNREACHABLE, + ['stage' => 'download'], + ); + } + + fclose($target); + + if (401 === $status || 403 === $status) { + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + ['stage' => 'download', 'http_status' => $status], + ); + } + + if ($status < 200 || $status >= 300) { + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_FAILED, + ['stage' => 'download', 'http_status' => $status], + ); + } + + if (!is_file($targetPath) || 0 === filesize($targetPath)) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_FAILED, + ['stage' => 'download'], + ); + } + + return WorkflowResult::success(null, ['stage' => 'download']); + } + + private function httpClient(): HttpClientInterface + { + return $this->httpClient ?? HttpClient::create(); + } + + /** + * @param array $context + * + * @return WorkflowResult + */ + private function failure(string $code, string $key, array $context): WorkflowResult + { + return WorkflowResult::failed([ + Message::error($code, $key, context: $context), + ], $context); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php new file mode 100644 index 00000000..f5f6db94 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php @@ -0,0 +1,89 @@ +failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + ['stage' => 'extract'], + ); + } + + try { + if (is_file($tarPath)) { + @unlink($tarPath); + } + + $archive = new PharData($archivePath); + $archive->decompress(); + $tar = new PharData($tarPath); + $tar->extractTo($extractDir, null, true); + } catch (Throwable $error) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + ['stage' => 'extract', 'exception' => $error::class], + ); + } finally { + if (is_file($tarPath)) { + @unlink($tarPath); + } + } + + $databasePath = $this->findDatabase($extractDir); + if (null === $databasePath) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_DATABASE_MISSING, + GeoIpMessageKey::GEOIP_DOWNLOAD_DATABASE_MISSING, + ['stage' => 'extract'], + ); + } + + return WorkflowResult::success(['database_path' => $databasePath], ['stage' => 'extract']); + } + + private function findDatabase(string $extractDir): ?string + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + $path = $file->getPathname(); + if ($file->isFile() && str_ends_with($path, '.mmdb') && is_readable($path)) { + return $path; + } + } + + return null; + } + + /** + * @param array $context + * + * @return WorkflowResult + */ + private function failure(string $code, string $key, array $context): WorkflowResult + { + return WorkflowResult::failed([ + Message::error($code, $key, context: $context), + ], $context); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php new file mode 100644 index 00000000..9e710469 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php @@ -0,0 +1,15 @@ + + */ + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult; +} diff --git a/src/Core/Geo/MaxMindGeoIpConfig.php b/src/Core/Geo/MaxMindGeoIpConfig.php index 1c2b5294..04e90bae 100644 --- a/src/Core/Geo/MaxMindGeoIpConfig.php +++ b/src/Core/Geo/MaxMindGeoIpConfig.php @@ -9,18 +9,12 @@ final readonly class MaxMindGeoIpConfig { public const PROVIDER_KEY = 'maxmind'; - public const ENABLED_KEY = 'security.geoip.enabled'; - public const SELECTED_PROVIDER_KEY = 'security.geoip.provider'; - public const DATABASE_PATH_KEY = 'security.geoip.maxmind.database_path'; - public const LOCALES_KEY = 'security.geoip.maxmind.locales'; - public const UPDATE_ENABLED_KEY = 'security.geoip.maxmind.update_enabled'; - public const UPDATE_INTERVAL_KEY = 'security.geoip.maxmind.update_interval'; - public const ACCOUNT_ID_KEY = 'security.geoip.maxmind.account_id'; - public const LICENSE_KEY_KEY = 'security.geoip.maxmind.license_key'; - - public const DEFAULT_DATABASE_PATH = 'var/geoip/GeoLite2-City.mmdb'; - public const DEFAULT_LOCALES = ['en']; - public const DEFAULT_UPDATE_INTERVAL = 'weekly'; + public const DATABASE_EDITION = 'GeoLite2-City'; + public const ENABLED_KEY = 'statistics.geoip.enabled'; + public const DATABASE_PATH_KEY = 'statistics.geoip.maxmind.database_path'; + public const LICENSE_KEY_KEY = 'statistics.geoip.maxmind.license_key'; + + public const DEFAULT_DATABASE_PATH = 'var/geoip2/GeoLite2-City.mmdb'; public function __construct(private Config $config) { @@ -28,15 +22,7 @@ public function __construct(private Config $config) public function enabled(): bool { - return true === $this->config->get(self::ENABLED_KEY, false) - && self::PROVIDER_KEY === $this->provider(); - } - - public function provider(): string - { - $provider = $this->config->get(self::SELECTED_PROVIDER_KEY, self::PROVIDER_KEY); - - return is_string($provider) && '' !== trim($provider) ? trim($provider) : self::PROVIDER_KEY; + return true === $this->config->get(self::ENABLED_KEY, false); } public function databasePath(): string @@ -51,39 +37,55 @@ public function databasePath(): string */ public function locales(): array { - $locales = $this->config->get(self::LOCALES_KEY, self::DEFAULT_LOCALES); - $locales = is_array($locales) ? $locales : self::DEFAULT_LOCALES; - $normalized = []; + $defaultLanguage = $this->config->get('localization.default_language', 'en'); + $defaultLanguage = is_string($defaultLanguage) ? trim($defaultLanguage) : 'en'; - foreach ($locales as $locale) { - if (!is_string($locale)) { - continue; - } + if (1 !== preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $defaultLanguage)) { + $defaultLanguage = 'en'; + } - $locale = trim($locale); - if (1 !== preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $locale)) { - continue; - } + return 'en' === $defaultLanguage ? ['en'] : [$defaultLanguage, 'en']; + } - $normalized[$locale] = $locale; + public function databaseAbsolutePath(string $projectDir): ?string + { + $relativePath = str_replace('\\', '/', $this->databasePath()); + + if ( + '' === trim($relativePath) + || str_contains($relativePath, "\0") + || str_starts_with($relativePath, '/') + || str_starts_with($relativePath, '//') + || str_starts_with($relativePath, '\\\\') + || 1 === preg_match('/^[A-Za-z]:\//', $relativePath) + || str_contains('/'.$relativePath.'/', '/../') + ) { + return null; } - $normalized['en'] ??= 'en'; - - return array_slice(array_values($normalized), 0, 8); + return rtrim($projectDir, DIRECTORY_SEPARATOR.'/\\') + .DIRECTORY_SEPARATOR + .str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/')); } - public function updateEnabled(): bool + public function licenseKey(): string { - return true === $this->config->get(self::UPDATE_ENABLED_KEY, false); + $licenseKey = $this->config->get(self::LICENSE_KEY_KEY, ''); + + return is_string($licenseKey) ? trim($licenseKey) : ''; } - public function updateInterval(): string + public function hasLicenseKey(): bool { - $interval = $this->config->get(self::UPDATE_INTERVAL_KEY, self::DEFAULT_UPDATE_INTERVAL); + return '' !== $this->licenseKey(); + } - return is_string($interval) && in_array($interval, ['manual', 'daily', 'weekly'], true) - ? $interval - : self::DEFAULT_UPDATE_INTERVAL; + public function downloadUrl(): string + { + return sprintf( + 'https://download.maxmind.com/app/geoip_download?edition_id=%s&license_key=%s&suffix=tar.gz', + rawurlencode(self::DATABASE_EDITION), + rawurlencode($this->licenseKey()), + ); } } diff --git a/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php b/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php new file mode 100644 index 00000000..28401286 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php @@ -0,0 +1,219 @@ + + */ + public function update(string $trigger): WorkflowResult + { + $context = [ + 'edition' => MaxMindGeoIpConfig::DATABASE_EDITION, + 'database_path' => $this->config->databasePath(), + 'trigger' => $this->trigger($trigger), + ]; + + if (!$this->config->hasLicenseKey()) { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, + context: ['stage' => 'preflight', ...$context], + ), + ], ['stage' => 'preflight', ...$context]); + } + + $targetPath = $this->config->databaseAbsolutePath($this->projectDir); + if (null === $targetPath) { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + context: ['stage' => 'preflight', 'reason' => 'invalid_database_path', ...$context], + ), + ], ['stage' => 'preflight', 'reason' => 'invalid_database_path', ...$context]); + } + + $targetDir = dirname($targetPath); + if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { + return $this->writeFailure(['stage' => 'preflight', 'reason' => 'directory_create_failed', ...$context]); + } + + $workspace = $this->workspace($targetDir); + if (!is_dir($workspace) && !mkdir($workspace, 0775, true) && !is_dir($workspace)) { + return $this->writeFailure(['stage' => 'preflight', 'reason' => 'workspace_create_failed', ...$context]); + } + + try { + $archivePath = $workspace.DIRECTORY_SEPARATOR.'maxmind.tar.gz'; + $download = $this->downloadClient->download($this->config->downloadUrl(), $archivePath); + if (!$download->isSuccess()) { + return $this->forwardFailure($download, $context); + } + + $extract = $this->archiveExtractor->extractDatabase($archivePath, $workspace); + if (!$extract->isSuccess()) { + return $this->forwardFailure($extract, $context); + } + + $candidate = $extract->value()['database_path'] ?? null; + if (!is_string($candidate) || !$this->databaseLooksValid($candidate)) { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_DATABASE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_DATABASE_INVALID, + context: ['stage' => 'validate', ...$context], + ), + ], ['stage' => 'validate', ...$context]); + } + + if (!$this->replaceDatabase($candidate, $targetPath)) { + return $this->writeFailure(['stage' => 'replace', ...$context]); + } + } finally { + $this->removeDirectory($workspace); + } + + return WorkflowResult::success(['database_path' => $this->config->databasePath()], $context, [ + Message::success(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, [ + '%path%' => $this->config->databasePath(), + ], $context), + ]); + } + + private function databaseLooksValid(string $candidate): bool + { + if (!is_file($candidate) || !is_readable($candidate)) { + return false; + } + + try { + $metadata = $this->readerFactory->open($candidate, $this->config->locales())->metadata(); + } catch (Throwable) { + return false; + } + + return is_string($metadata->databaseType) + && str_contains($metadata->databaseType, 'City'); + } + + private function replaceDatabase(string $candidate, string $targetPath): bool + { + $targetDir = dirname($targetPath); + $temporaryTarget = tempnam($targetDir, '.geoip2-'); + if (!is_string($temporaryTarget)) { + return false; + } + + if (!@copy($candidate, $temporaryTarget)) { + @unlink($temporaryTarget); + + return false; + } + + if (@rename($temporaryTarget, $targetPath)) { + return true; + } + + if (!is_file($targetPath)) { + @unlink($temporaryTarget); + + return false; + } + + $backupPath = $targetPath.'.previous-'.bin2hex(random_bytes(4)); + if (!@rename($targetPath, $backupPath)) { + @unlink($temporaryTarget); + + return false; + } + + if (@rename($temporaryTarget, $targetPath)) { + @unlink($backupPath); + + return true; + } + + @rename($backupPath, $targetPath); + @unlink($temporaryTarget); + + return false; + } + + /** + * @param WorkflowResult $result + * @param array $context + * + * @return WorkflowResult + */ + private function forwardFailure(WorkflowResult $result, array $context): WorkflowResult + { + return WorkflowResult::failed($result->issues(), [ + ...$context, + ...$result->context(), + ], $result->messages()); + } + + /** + * @param array $context + * + * @return WorkflowResult + */ + private function writeFailure(array $context): WorkflowResult + { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + context: $context, + ), + ], $context); + } + + private function workspace(string $targetDir): string + { + return $targetDir.DIRECTORY_SEPARATOR.'.update-'.bin2hex(random_bytes(8)); + } + + private function trigger(string $trigger): string + { + return '' !== trim($trigger) ? trim($trigger) : 'system'; + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + $item->isDir() && !$item->isLink() + ? @rmdir($item->getPathname()) + : @unlink($item->getPathname()); + } + + @rmdir($path); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php b/src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php new file mode 100644 index 00000000..a798cdf5 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php @@ -0,0 +1,15 @@ + + */ + public function download(string $url, string $targetPath): WorkflowResult; +} diff --git a/src/Core/Geo/MaxMindGeoIpProvider.php b/src/Core/Geo/MaxMindGeoIpProvider.php index 379f9b2b..8282fd76 100644 --- a/src/Core/Geo/MaxMindGeoIpProvider.php +++ b/src/Core/Geo/MaxMindGeoIpProvider.php @@ -87,23 +87,7 @@ private function reader(): MaxMindGeoIpDatabaseReaderInterface private function databasePath(): ?string { - $relativePath = str_replace('\\', '/', $this->config->databasePath()); - - if ( - '' === trim($relativePath) - || str_contains($relativePath, "\0") - || str_starts_with($relativePath, '/') - || str_starts_with($relativePath, '//') - || str_starts_with($relativePath, '\\\\') - || 1 === preg_match('/^[A-Za-z]:\//', $relativePath) - || str_contains('/'.$relativePath.'/', '/../') - ) { - return null; - } - - return rtrim($this->projectDir, DIRECTORY_SEPARATOR.'/\\') - .DIRECTORY_SEPARATOR - .str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/')); + return $this->config->databaseAbsolutePath($this->projectDir); } private function isPublicIp(?string $ipAddress): bool diff --git a/src/Core/Geo/MaxMindGeoIpSchedulerProvider.php b/src/Core/Geo/MaxMindGeoIpSchedulerProvider.php new file mode 100644 index 00000000..aacdea27 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpSchedulerProvider.php @@ -0,0 +1,59 @@ + + */ + public function schedulerTasks(): array + { + return [ + new SchedulerTaskDefinition( + self::TASK_IDENTIFIER, + 'admin.scheduler.tasks.geoip2_database_update.label', + 'admin.scheduler.tasks.geoip2_database_update.description', + 'system', + SchedulerTaskType::Callable, + self::CALLABLE_TARGET, + '0 3 * * *', + true, + ), + ]; + } + + public function schedulerCallable(string $target): ?callable + { + if (self::CALLABLE_TARGET !== $target) { + return null; + } + + return function (): SchedulerTaskExecution { + $result = $this->updater->update('scheduler'); + $messages = [ + ...$result->issues(), + ...$result->messages(), + ]; + + return $result->isSuccess() + ? SchedulerTaskExecution::success($result->context(), $messages) + : SchedulerTaskExecution::failed($result->context(), $messages); + }; + } +} diff --git a/src/Core/Geo/MaxMindGeoIpUpdateAction.php b/src/Core/Geo/MaxMindGeoIpUpdateAction.php new file mode 100644 index 00000000..98cfb5f6 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpUpdateAction.php @@ -0,0 +1,41 @@ +type(), $this->label(), DryRunRisk::Medium, context: [ + 'trigger' => $this->trigger, + ]); + } + + public function execute(): WorkflowResult + { + return $this->updater->update($this->trigger); + } +} diff --git a/src/Core/Operation/Live/LiveOperationQueueFactory.php b/src/Core/Operation/Live/LiveOperationQueueFactory.php index e42f4e8a..a068d82e 100644 --- a/src/Core/Operation/Live/LiveOperationQueueFactory.php +++ b/src/Core/Operation/Live/LiveOperationQueueFactory.php @@ -20,6 +20,7 @@ public const PACKAGE_INSTALL_APPLY = 'package.install.apply'; public const ACL_GROUP_APPLY = 'acl.group.apply'; public const SETUP_APPLY = 'setup.apply'; + public const GEOIP_DATABASE_UPDATE = 'geoip.database_update'; /** * @param iterable $providers diff --git a/src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php b/src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php new file mode 100644 index 00000000..fc3fa54f --- /dev/null +++ b/src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php @@ -0,0 +1,63 @@ + $payload + * + * @return WorkflowResult + */ + public function create(array $payload = []): WorkflowResult + { + $trigger = $this->trigger($payload); + + return WorkflowResult::success(ActionQueue::create('geoip database update', [ + new MaxMindGeoIpUpdateAction($this->updater, $trigger), + ], context: [ + 'operation' => $this->operation(), + 'environment' => $this->environment($payload), + 'trigger' => $trigger, + ])); + } + + /** + * @param array $payload + */ + private function environment(array $payload): string + { + $environment = $payload['environment'] ?? $this->kernel->getEnvironment(); + + return is_string($environment) && '' !== trim($environment) ? trim($environment) : $this->kernel->getEnvironment(); + } + + /** + * @param array $payload + */ + private function trigger(array $payload): string + { + $trigger = $payload['trigger'] ?? 'live_operation'; + + return is_string($trigger) && '' !== trim($trigger) ? trim($trigger) : 'live_operation'; + } +} diff --git a/templates/backend/admin/settings/section.html.twig b/templates/backend/admin/settings/section.html.twig index 4885f3e3..019b5651 100644 --- a/templates/backend/admin/settings/section.html.twig +++ b/templates/backend/admin/settings/section.html.twig @@ -26,5 +26,11 @@ {% if settings_form %} {% include '@backend/partials/forms/_dynamic.html.twig' with {form: settings_form} only %} {% endif %} + {% if settings_section == 'statistics' and geoip_settings.has_license_key|default(false) %} + {% include '@backend/admin/partials/_backend-actions.html.twig' with { + actions: backend_actions(['geoip_database_update']), + class: 'system-settings-actions', + } only %} + {% endif %} {% endblock %} diff --git a/templates/backend/partials/forms/_dynamic_fields.html.twig b/templates/backend/partials/forms/_dynamic_fields.html.twig index 02c7469a..9988ed57 100644 --- a/templates/backend/partials/forms/_dynamic_fields.html.twig +++ b/templates/backend/partials/forms/_dynamic_fields.html.twig @@ -73,4 +73,12 @@ errors: field.errors, } only %} {% endif %} + {% set help_link_url = field.metadata.help_link_url|default(null) %} + {% if help_link_url starts with 'https://' and field.metadata.help_link_label|default(null) %} +

+ + {{ field.metadata.help_link_label|trans }} + +

+ {% endif %} {% endfor %} diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index f575fcc6..f5dbf888 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -935,6 +935,24 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists(sprintf('input[name="%s"]', ConfigAuditLogPolicy::ENABLED_KEY)); self::assertSelectorExists(sprintf('input[name="%s[]"]', ConfigAuditLogPolicy::EVENTS_KEY)); + $client->request('GET', '/admin/settings/statistics'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Statistics settings'); + self::assertSelectorExists('form#admin-settings-statistics'); + self::assertSelectorExists('input[name="statistics.geoip.enabled"]'); + self::assertSelectorExists('input[name="statistics.geoip.maxmind.license_key"][type="password"]'); + self::assertSelectorExists('a[href="https://www.maxmind.com/en/geolite2/signup"]'); + self::assertSelectorNotExists('input[name="_backend_action"][value="geoip_database_update"]'); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set('statistics.geoip.maxmind.license_key', 'saved-test-key', ConfigValueType::String, sensitive: true); + $client->request('GET', '/admin/settings/statistics'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="_backend_action"][value="geoip_database_update"]'); + $client->request('GET', '/admin/settings/scheduler'); self::assertResponseIsSuccessful(); diff --git a/tests/Core/Config/ConfigTest.php b/tests/Core/Config/ConfigTest.php index b35c80d4..786b1551 100644 --- a/tests/Core/Config/ConfigTest.php +++ b/tests/Core/Config/ConfigTest.php @@ -127,10 +127,10 @@ public function testItStoresSensitiveConfigurationFlag(): void $connection = $this->connection(); $config = new Config($connection); - self::assertTrue($config->set('security.geoip.maxmind.license_key', 'secret-value', ConfigValueType::String, sensitive: true, modifiedBy: 'test')); + self::assertTrue($config->set('statistics.geoip.maxmind.license_key', 'secret-value', ConfigValueType::String, sensitive: true, modifiedBy: 'test')); $row = $connection->fetchAssociative('SELECT value, value_type, sensitive, modified_by FROM config_entry WHERE config_key = ?', [ - 'security.geoip.maxmind.license_key', + 'statistics.geoip.maxmind.license_key', ]); self::assertIsArray($row); diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index 330d9835..c9cc5291 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -9,7 +9,6 @@ use App\Core\Config\Settings\CoreSettingsFormHandler; use App\Core\Config\Settings\CoreSettingsRegistry; use App\Core\Geo\MaxMindGeoIpConfig; -use App\Core\Log\ConfigAuditLogPolicy; use App\Form\FormSubmissionHandler; use App\Localization\TranslationLanguageCatalog; use App\View\SystemPackageMetadataProvider; @@ -23,7 +22,6 @@ final class CoreSettingsFormHandlerTest extends TestCase public function testItPreservesExistingSensitiveSettingsWhenSubmittedEmpty(): void { $config = new Config($this->connection()); - $config->set(MaxMindGeoIpConfig::ACCOUNT_ID_KEY, '123456', ConfigValueType::String, sensitive: true); $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); $handler = new CoreSettingsFormHandler( @@ -33,23 +31,15 @@ public function testItPreservesExistingSensitiveSettingsWhenSubmittedEmpty(): vo $this->createStub(EntityManagerInterface::class), ); - $result = $handler->submit('security', [ - 'security.captcha.enabled' => '0', - 'security.captcha.provider' => 'none', - ConfigAuditLogPolicy::ENABLED_KEY => '1', - ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + $result = $handler->submit('statistics', [ + 'statistics.enabled' => '1', + 'statistics.respect_dnt' => '1', MaxMindGeoIpConfig::ENABLED_KEY => '0', - MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY => MaxMindGeoIpConfig::PROVIDER_KEY, MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, - MaxMindGeoIpConfig::LOCALES_KEY => '["en"]', - MaxMindGeoIpConfig::UPDATE_ENABLED_KEY => '0', - MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY => MaxMindGeoIpConfig::DEFAULT_UPDATE_INTERVAL, - MaxMindGeoIpConfig::ACCOUNT_ID_KEY => '', MaxMindGeoIpConfig::LICENSE_KEY_KEY => '', ], 'test'); self::assertTrue($result->isValid()); - self::assertSame('123456', $config->get(MaxMindGeoIpConfig::ACCOUNT_ID_KEY)); self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); } diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 17cc3225..bd16d4fe 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -59,30 +59,24 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.preview', ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, - MaxMindGeoIpConfig::ENABLED_KEY, - MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, - MaxMindGeoIpConfig::DATABASE_PATH_KEY, - MaxMindGeoIpConfig::LOCALES_KEY, - MaxMindGeoIpConfig::UPDATE_ENABLED_KEY, - MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY, - MaxMindGeoIpConfig::ACCOUNT_ID_KEY, - MaxMindGeoIpConfig::LICENSE_KEY_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(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $security[7]->defaultValue()); - self::assertSame(MaxMindGeoIpConfig::DEFAULT_LOCALES, $security[8]->defaultValue()); - self::assertSame(['sensitive' => true], $security[11]->metadata()); - self::assertSame(['sensitive' => true], $security[12]->metadata()); - self::assertSame(FormInputType::Password, $security[12]->formField()->inputType()); self::assertSame([ AccessStatisticsPolicy::ENABLED_KEY, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, + MaxMindGeoIpConfig::ENABLED_KEY, + MaxMindGeoIpConfig::DATABASE_PATH_KEY, + MaxMindGeoIpConfig::LICENSE_KEY_KEY, ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $statistics)); self::assertTrue($statistics[0]->defaultValue()); self::assertTrue($statistics[1]->defaultValue()); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $statistics[3]->defaultValue()); + self::assertTrue($statistics[4]->metadata()['sensitive']); + self::assertSame('https://www.maxmind.com/en/geolite2/signup', $statistics[4]->metadata()['help_link_url']); + self::assertSame(FormInputType::Password, $statistics[4]->formField()->inputType()); self::assertSame([ ApiFeaturePolicy::ENABLED_KEY, @@ -115,7 +109,6 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void self::assertFalse($provider->defaultValue(ApiFeaturePolicy::CORS_ENABLED_KEY)); self::assertSame([], $provider->defaultValue(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY)); self::assertFalse($provider->defaultValue(MaxMindGeoIpConfig::ENABLED_KEY)); - self::assertSame(MaxMindGeoIpConfig::PROVIDER_KEY, $provider->defaultValue(MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY)); self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $provider->defaultValue(MaxMindGeoIpConfig::DATABASE_PATH_KEY)); self::assertFalse($provider->hasDefault('security.captcha.preview')); self::assertNull($provider->defaultValue('security.captcha.preview')); diff --git a/tests/Core/Geo/MaxMindGeoIpConfigTest.php b/tests/Core/Geo/MaxMindGeoIpConfigTest.php index db7ba649..45733f91 100644 --- a/tests/Core/Geo/MaxMindGeoIpConfigTest.php +++ b/tests/Core/Geo/MaxMindGeoIpConfigTest.php @@ -18,27 +18,39 @@ public function testItUsesSafeDefaults(): void $config = new MaxMindGeoIpConfig(new Config($this->connection())); self::assertFalse($config->enabled()); - self::assertSame(MaxMindGeoIpConfig::PROVIDER_KEY, $config->provider()); self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $config->databasePath()); self::assertSame(['en'], $config->locales()); - self::assertFalse($config->updateEnabled()); - self::assertSame('weekly', $config->updateInterval()); + self::assertSame('', $config->licenseKey()); + self::assertFalse($config->hasLicenseKey()); } - public function testItNormalizesLocalesAndUpdateInterval(): void + public function testItUsesConfiguredDefaultLanguageForLocalesAndNormalizesSensitiveSettings(): void { $connection = $this->connection(); $store = new Config($connection); $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); - $store->set(MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, MaxMindGeoIpConfig::PROVIDER_KEY, ConfigValueType::String); - $store->set(MaxMindGeoIpConfig::LOCALES_KEY, ['de', 'invalid', 'en', 'de', 'fr-FR'], ConfigValueType::Json); - $store->set(MaxMindGeoIpConfig::UPDATE_INTERVAL_KEY, 'hourly', ConfigValueType::String); + $store->set('localization.default_language', 'de', ConfigValueType::String); + $store->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, ' test-license ', ConfigValueType::String, sensitive: true); $config = new MaxMindGeoIpConfig($store); self::assertTrue($config->enabled()); - self::assertSame(['de', 'en', 'fr-FR'], $config->locales()); - self::assertSame('weekly', $config->updateInterval()); + self::assertSame(['de', 'en'], $config->locales()); + self::assertSame('test-license', $config->licenseKey()); + self::assertTrue($config->hasLicenseKey()); + self::assertStringContainsString('license_key=test-license', $config->downloadUrl()); + } + + public function testItResolvesSafeProjectRelativeDatabasePath(): void + { + $store = new Config($this->connection()); + $config = new MaxMindGeoIpConfig($store); + + self::assertSame('/project/var/geoip2/GeoLite2-City.mmdb', $config->databaseAbsolutePath('/project')); + + $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, '../secret.mmdb', ConfigValueType::String); + + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath('/project')); } private function connection(): Connection diff --git a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php new file mode 100644 index 00000000..280c0fd5 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php @@ -0,0 +1,183 @@ +projectDir = $this->createTemporaryDirectory('maxmind-geoip-updater'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testItFailsWithoutLicenseKey(): void + { + $updater = new MaxMindGeoIpDatabaseUpdater( + new MaxMindGeoIpConfig(new Config($this->connection())), + new SuccessfulGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + + $result = $updater->update('scheduler'); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $result->firstIssue()?->code()); + self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $result->firstIssue()?->translationKey()); + } + + public function testItDownloadsValidatesAndReplacesDatabase(): void + { + $config = $this->configuredConfig('test-license'); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new SuccessfulGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor('new database'), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'old database'); + + $result = $updater->update('admin_ui'); + + self::assertTrue($result->isSuccess()); + self::assertSame(['database_path' => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH], $result->value()); + self::assertSame('new database', file_get_contents($this->projectDir.'/'.MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH)); + self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, $result->messages()[0]->translationKey()); + } + + public function testItForwardsDownloadFailureWithoutLeakingLicenseKey(): void + { + $config = $this->configuredConfig('sensitive-test-license'); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new FailingGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + + $result = $updater->update('admin_ui'); + $encoded = json_encode($result->toArray(), JSON_THROW_ON_ERROR); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, $result->firstIssue()?->code()); + self::assertStringNotContainsString('sensitive-test-license', $encoded); + } + + private function configuredConfig(string $licenseKey): MaxMindGeoIpConfig + { + $store = new Config($this->connection()); + $store->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, $licenseKey, ConfigValueType::String, sensitive: true); + + return new MaxMindGeoIpConfig($store); + } + + 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 readonly class SuccessfulGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + file_put_contents($targetPath, 'archive'); + + return WorkflowResult::success(); + } +} + +final readonly class FailingGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + context: ['stage' => 'download', 'http_status' => 401], + ), + ], ['stage' => 'download', 'http_status' => 401]); + } +} + +final readonly class SuccessfulGeoIpArchiveExtractor implements MaxMindGeoIpArchiveExtractorInterface +{ + public function __construct(private string $databaseContents = 'database') + { + } + + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult + { + $databasePath = $workspaceDir.'/GeoLite2-City.mmdb'; + file_put_contents($databasePath, $this->databaseContents); + + return WorkflowResult::success(['database_path' => $databasePath]); + } +} + +final readonly class ValidGeoIpReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + return new ValidGeoIpReader(); + } +} + +final readonly class ValidGeoIpReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public function city(string $ipAddress): City + { + return new City([]); + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpProviderTest.php b/tests/Core/Geo/MaxMindGeoIpProviderTest.php index ce3bfc80..8e2069a0 100644 --- a/tests/Core/Geo/MaxMindGeoIpProviderTest.php +++ b/tests/Core/Geo/MaxMindGeoIpProviderTest.php @@ -118,7 +118,6 @@ private function enabledConfig(): MaxMindGeoIpConfig { $store = new Config($this->connection()); $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); - $store->set(MaxMindGeoIpConfig::SELECTED_PROVIDER_KEY, MaxMindGeoIpConfig::PROVIDER_KEY, ConfigValueType::String); return new MaxMindGeoIpConfig($store); } diff --git a/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php new file mode 100644 index 00000000..72697b87 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php @@ -0,0 +1,115 @@ +updater()); + $task = $provider->schedulerTasks()[0]; + + self::assertSame('system.geoip2_database_update', $task->identifier()); + self::assertSame(SchedulerTaskType::Callable, $task->type()); + self::assertSame('system.geoip2.database_update', $task->target()); + self::assertSame('0 3 * * *', $task->defaultCronExpression()); + self::assertTrue($task->trusted()); + } + + public function testCallableReportsMissingLicenseKeyAsFailure(): void + { + $provider = new MaxMindGeoIpSchedulerProvider($this->updater()); + $callable = $provider->schedulerCallable('system.geoip2.database_update'); + + self::assertNotNull($callable); + + $execution = $callable(); + + self::assertFalse($execution->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $execution->messages()[0]->code()); + } + + private function updater(): MaxMindGeoIpDatabaseUpdater + { + $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 new MaxMindGeoIpDatabaseUpdater( + new MaxMindGeoIpConfig(new Config($connection)), + new SchedulerGeoIpDownloadClient(), + new SchedulerGeoIpArchiveExtractor(), + new SchedulerGeoIpReaderFactory(), + sys_get_temp_dir(), + ); + } +} + +final readonly class SchedulerGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + file_put_contents($targetPath, 'archive'); + + return WorkflowResult::success(); + } +} + +final readonly class SchedulerGeoIpArchiveExtractor implements MaxMindGeoIpArchiveExtractorInterface +{ + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult + { + $databasePath = $workspaceDir.'/GeoLite2-City.mmdb'; + file_put_contents($databasePath, 'database'); + + return WorkflowResult::success(['database_path' => $databasePath]); + } +} + +final readonly class SchedulerGeoIpReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + return new SchedulerGeoIpReader(); + } +} + +final readonly class SchedulerGeoIpReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public function city(string $ipAddress): City + { + return new City([]); + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Operation/LiveOperationQueueFactoryTest.php b/tests/Core/Operation/LiveOperationQueueFactoryTest.php index a36ff193..8b4c0a49 100644 --- a/tests/Core/Operation/LiveOperationQueueFactoryTest.php +++ b/tests/Core/Operation/LiveOperationQueueFactoryTest.php @@ -54,6 +54,10 @@ public function testItCreatesSupportedQueues(): void 'package' => 'demo-module', 'trigger' => 'admin_ui', ]); + $geoIpUpdate = $factory->create(LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE, [ + 'environment' => 'test', + 'trigger' => 'admin_ui', + ]); self::assertTrue($backendCacheClear->isSuccess()); self::assertSame('backend cache clear', $backendCacheClear->value()?->name()); @@ -71,6 +75,9 @@ public function testItCreatesSupportedQueues(): void self::assertSame('package install verification', $packageInstallVerify->value()?->name()); self::assertTrue($packageInstallApply->isSuccess()); self::assertSame('demo-module', $packageInstallApply->value()?->context()['package']); + self::assertTrue($geoIpUpdate->isSuccess()); + self::assertSame('geoip database update', $geoIpUpdate->value()?->name()); + self::assertSame('admin_ui', $geoIpUpdate->value()?->context()['trigger']); } public function testItRejectsUnknownOperationsAndInvalidPayloads(): void diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index e88d4cea..56f67b9f 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -41,6 +41,8 @@ admin: label: 'Assets neu bauen' cache_clear: label: 'Cache leeren' + geoip_database_update: + label: 'GeoIP2-Datenbank laden' dashboard: title: 'Admin-Dashboard' foundation_title: 'Backend-Fundament' @@ -484,6 +486,9 @@ admin: mercure_health: label: 'Mercure-Health' description: 'Prüft den Mercure-Hub und startet den lokalen Hub, sofern unterstützt.' + geoip2_database_update: + label: 'GeoIP2-Datenbankupdate' + description: 'Lädt die lokale MaxMind GeoIP2-City-Datenbank herunter und ersetzt sie atomar, wenn ein License-Key konfiguriert ist.' table: job: 'Job' source: 'Quelle' @@ -700,7 +705,7 @@ admin: statistics: title: 'Statistik-Einstellungen' foundation_title: 'Statistik-Konfiguration' - foundation_text: 'Aufzeichnung und Anzeige von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' + foundation_text: 'Aufzeichnung, Anzeige und GeoIP-Anreicherung von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' api: title: 'API-Einstellungen' foundation_title: 'API-Verfügbarkeit' @@ -793,25 +798,14 @@ admin: geoip_enabled: label: 'GeoIP-Lookups aktivieren' help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' - geoip_provider: - label: 'GeoIP-Provider' + geoip_license_link: + label: 'Kostenlosen GeoLite2-License-Key bei MaxMind erhalten' geoip_database_path: label: 'MaxMind-Datenbankpfad' - help: 'Projektrelativer Pfad zur lokalen .mmdb-Datenbank. Request-Lookups laden Datenbanken niemals automatisch herunter.' - geoip_locales: - label: 'GeoIP-Locales' - help: 'JSON-Array von Locale-Codes, sortiert von bevorzugt bis Fallback, zum Beispiel ["en", "de"].' - geoip_update_enabled: - label: 'GeoIP-Datenbankupdates aktivieren' - help: 'Für den Scheduler-Update-Task reserviert. Lookups verwenden nur eine vorhandene lokale Datenbank.' - geoip_update_interval: - label: 'GeoIP-Update-Intervall' - geoip_account_id: - label: 'MaxMind-Account-ID' - help: 'Wird als sensitive Konfiguration gespeichert und nur vom zukünftigen Update-Task verwendet.' + help: 'Projektrelativer Pfad zur lokalen .mmdb-Datenbank. Downloads werden atomar in diesen Pfad geschrieben.' geoip_license_key: label: 'MaxMind-License-Key' - help: 'Wird als sensitive Konfiguration gespeichert und niemals für PHPUnit-Tests benötigt.' + help: 'Wird als sensitive Konfiguration gespeichert. Ein gespeicherter Key aktiviert die GeoIP2-Download-Aktion auf dieser Seite.' statistics_enabled: label: 'Zugriffsstatistiken aktivieren' statistics_respect_dnt: @@ -848,9 +842,6 @@ admin: setup_warnings: 'Setup-Warnungen' captcha: none: 'Kein Captcha-Provider' - geoip: - none: 'Kein GeoIP-Provider' - maxmind: 'MaxMind GeoIP2' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 75db020e..9c4e9c74 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -106,6 +106,17 @@ message: snapshot_store_failed: 'Speicherung des Zugriffsstatistik-Snapshots ist fehlgeschlagen.' cleanup_failed: 'Aufbewahrungs-Cleanup der Zugriffsstatistiken ist fehlgeschlagen.' trace_id_invalid: 'Zugriffsstatistiken haben eine ungültige %label% erhalten.' + geoip: + download: + missing_license_key: 'Der GeoIP2-Datenbankdownload benötigt einen MaxMind-License-Key.' + invalid_license_key: 'MaxMind hat den GeoIP2-Datenbankdownload abgelehnt. Prüfe den konfigurierten License-Key.' + server_unreachable: 'Der MaxMind-Downloadserver konnte nicht erreicht werden. Versuche es später erneut.' + failed: 'Der GeoIP2-Datenbankdownload ist fehlgeschlagen. Prüfe den Diagnosekontext für den HTTP-Status.' + archive_invalid: 'Das heruntergeladene GeoIP2-Archiv konnte nicht entpackt werden.' + database_missing: 'Das heruntergeladene GeoIP2-Archiv enthielt keine Datenbankdatei.' + database_invalid: 'Die heruntergeladene GeoIP2-Datenbank konnte nicht validiert werden.' + write_failed: 'Die GeoIP2-Datenbank konnte nicht geschrieben werden.' + 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.' event: diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index cba3f74a..75de6c19 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -41,6 +41,8 @@ admin: label: 'Rebuild assets' cache_clear: label: 'Clear cache' + geoip_database_update: + label: 'Download GeoIP2 database' dashboard: title: 'Admin dashboard' foundation_title: 'Backend foundation' @@ -484,6 +486,9 @@ admin: mercure_health: label: 'Mercure health' description: 'Checks the Mercure hub and starts the local hub when supported.' + geoip2_database_update: + label: 'GeoIP2 database update' + description: 'Downloads and atomically replaces the local MaxMind GeoIP2 City database when a license key is configured.' table: job: 'Job' source: 'Source' @@ -700,7 +705,7 @@ admin: statistics: title: 'Statistics settings' foundation_title: 'Statistics configuration' - foundation_text: 'Access statistics recording and display can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' + foundation_text: 'Access statistics recording, display, and GeoIP enrichment can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' api: title: 'API settings' foundation_title: 'API availability' @@ -793,25 +798,14 @@ admin: geoip_enabled: label: 'Enable GeoIP lookups' help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' - geoip_provider: - label: 'GeoIP provider' + geoip_license_link: + label: 'Get a free GeoLite2 license key from MaxMind' geoip_database_path: label: 'MaxMind database path' - help: 'Project-relative path to the local .mmdb database. Request lookups never download databases automatically.' - geoip_locales: - label: 'GeoIP locales' - help: 'JSON array of locale codes, ordered from most preferred to least preferred, for example ["en", "de"].' - geoip_update_enabled: - label: 'Enable GeoIP database updates' - help: 'Reserved for the scheduler update task. Lookups only use an existing local database.' - geoip_update_interval: - label: 'GeoIP update interval' - geoip_account_id: - label: 'MaxMind account ID' - help: 'Stored as sensitive configuration and used only by the future update task.' + help: 'Project-relative path to the local .mmdb database. Downloads are written atomically to this path.' geoip_license_key: label: 'MaxMind license key' - help: 'Stored as sensitive configuration and never required for PHPUnit tests.' + help: 'Stored as sensitive configuration. Saving a key enables the GeoIP2 database download action on this page.' statistics_enabled: label: 'Enable access statistics' statistics_respect_dnt: @@ -848,9 +842,6 @@ admin: setup_warnings: 'Setup warnings' captcha: none: 'No captcha provider' - geoip: - none: 'No GeoIP provider' - maxmind: 'MaxMind GeoIP2' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index d88650cc..8e0526ea 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -106,6 +106,17 @@ message: snapshot_store_failed: 'Access statistics snapshot storage failed.' cleanup_failed: 'Access statistics retention cleanup failed.' trace_id_invalid: 'Access statistics received an invalid %label%.' + geoip: + download: + missing_license_key: 'GeoIP2 database download needs a MaxMind license key.' + invalid_license_key: 'MaxMind rejected the GeoIP2 database download. Check the configured license key.' + server_unreachable: 'The MaxMind download server could not be reached. Try again later.' + failed: 'The GeoIP2 database download failed. Check the diagnostic context for the HTTP status.' + archive_invalid: 'The downloaded GeoIP2 archive could not be extracted.' + database_missing: 'The downloaded GeoIP2 archive did not contain a database file.' + database_invalid: 'The downloaded GeoIP2 database could not be validated.' + write_failed: 'The GeoIP2 database could not be written.' + 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.' event: From 3343fc215e779c8baab3cd80e63b65bdd80b6b85 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:41:10 +0200 Subject: [PATCH 023/119] Prevent GeoIP license key logging --- config/services.yaml | 4 ++++ dev/WORKLOG.md | 1 + dev/draft/security-hardening/geoip-observability.md | 1 + src/Core/Log/AccessLogger.php | 2 +- src/Core/Log/AuditLogger.php | 2 +- src/Core/Log/MonologMessageLogger.php | 2 +- tests/Core/Log/MonologMessageLoggerTest.php | 3 ++- 7 files changed, 11 insertions(+), 4 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index d457cc2d..1f6fe6e1 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -391,6 +391,10 @@ services: App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface: alias: App\Core\Geo\MaxMindGeoIpArchiveExtractor + App\Core\Geo\HttpMaxMindGeoIpDownloadClient: + arguments: + $httpClient: null + App\Core\Geo\MaxMindGeoIpProvider: arguments: $projectDir: '%kernel.project_dir%' diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 5dbce309..e78c7325 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -82,6 +82,7 @@ - Verified the foundation with focused GeoIP/access-log/statistics PHPUnit coverage, PHP syntax checks, container linting, focused linting for changed files, and Git whitespace checks. - Added the MaxMind GeoIP2 provider slice on top of the foundation: local `.mmdb` lookups via the installed `geoip2/geoip2` dependency, safe provider status, project-relative database path config, sensitive credential preservation/redaction, password-form support for secret fields, and hermetic fake-reader tests without real MaxMind credentials or network access. - Added the narrow GeoIP2 update foundation: moved the intentionally small GeoIP settings surface to Statistics, changed the default database path to `var/geoip2/GeoLite2-City.mmdb`, derived MaxMind lookup locales from the site default language with `en` fallback, exposed a MaxMind signup help link, added an Admin Operations-backed database download action with non-JS POST fallback, added a daily scheduler callable, and added hermetic updater/scheduler tests that do not use real MaxMind credentials or network access. +- Hardened GeoIP2 download logging: the MaxMind download client now bypasses the autowired Symfony HTTP client service so the license-key query string cannot be captured by HttpClient logging/profiling, and shared log redaction treats `license_key` as sensitive context. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 52f81a84..bbc520b3 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -51,6 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - License key configuration is sensitive. Empty sensitive form submissions preserve existing stored values, API/settings read models return redacted display values, and PHPUnit coverage must use fakes or dummy strings rather than real MaxMind credentials. - The default local database path is `var/geoip2/GeoLite2-City.mmdb`. Admin-triggered downloads and scheduler downloads must write through a temporary workspace and atomically replace the configured target where the platform supports atomic rename. - The Statistics settings page may link operators to the official MaxMind GeoLite signup page for a free license key. A saved key reveals the database download action; missing keys make the scheduler callable fail with a translated Message-layer diagnostic so normal scheduler failure policy can disable repeatedly failing tasks. +- Because MaxMind authenticates the database download through a license-key query parameter, the download client must not use a logger/profiler-wrapped HTTP client service and must never include the request URL in Operation, Scheduler, Message, audit, or access-log context. Shared log redaction treats `license_key` as sensitive defense in depth. ## Edge cases diff --git a/src/Core/Log/AccessLogger.php b/src/Core/Log/AccessLogger.php index 01029999..11929c08 100644 --- a/src/Core/Log/AccessLogger.php +++ b/src/Core/Log/AccessLogger.php @@ -113,6 +113,6 @@ private function isSensitiveKey(string $key): bool $normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $key)); return 'auth' === $normalized - || 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|code|signature|signed|session|csrf|nonce|reset|invite)/', $normalized); + || 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|license_key|code|signature|signed|session|csrf|nonce|reset|invite)/', $normalized); } } diff --git a/src/Core/Log/AuditLogger.php b/src/Core/Log/AuditLogger.php index ab474ea9..b592955e 100644 --- a/src/Core/Log/AuditLogger.php +++ b/src/Core/Log/AuditLogger.php @@ -94,6 +94,6 @@ private function isSensitiveKey(string $key): bool { $normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $key)); - return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key)/', $normalized); + return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|license_key)/', $normalized); } } diff --git a/src/Core/Log/MonologMessageLogger.php b/src/Core/Log/MonologMessageLogger.php index a6188771..d87fc727 100644 --- a/src/Core/Log/MonologMessageLogger.php +++ b/src/Core/Log/MonologMessageLogger.php @@ -157,6 +157,6 @@ private function isSensitiveKey(string $key): bool { $normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $key)); - return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key)/', $normalized); + return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|license_key)/', $normalized); } } diff --git a/tests/Core/Log/MonologMessageLoggerTest.php b/tests/Core/Log/MonologMessageLoggerTest.php index 3bf0837e..432a26a3 100644 --- a/tests/Core/Log/MonologMessageLoggerTest.php +++ b/tests/Core/Log/MonologMessageLoggerTest.php @@ -41,7 +41,7 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void ProcessMessageCode::PROCESS_COMMAND_FAILED, ProcessMessageKey::PROCESS_COMMAND_FAILED, ['%command%' => 'bin/console demo'], - ['exit_code' => 1, 'database_password' => 'secret'], + ['exit_code' => 1, 'database_password' => 'secret', 'license_key' => 'maxmind-secret'], ); $message = Message::info( PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, @@ -74,6 +74,7 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void self::assertSame('process.command_failed', $records[0]->context['code']); self::assertSame('demo', $records[0]->context['queue']); self::assertSame('[redacted]', $records[0]->context['message_context']['database_password']); + self::assertSame('[redacted]', $records[0]->context['message_context']['license_key']); self::assertSame('[redacted]', $records[0]->context['app_secret']); self::assertSame(Level::Info, $records[1]->level); self::assertSame('[redacted]', $records[1]->context['message_context']['api_token']); From 496530b7f5becdf58eed937be0cfa116bd4b430b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:44:47 +0200 Subject: [PATCH 024/119] Assert GeoIP operations keep license key private --- .../Geo/MaxMindGeoIpDatabaseUpdaterTest.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php index 280c0fd5..7e4c7e80 100644 --- a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php +++ b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php @@ -14,7 +14,11 @@ use App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface; use App\Core\Geo\MaxMindGeoIpDatabaseUpdater; use App\Core\Geo\MaxMindGeoIpDownloadClientInterface; +use App\Core\Geo\MaxMindGeoIpUpdateAction; use App\Core\Message\Message; +use App\Core\Message\WorkflowResultMessageReporterInterface; +use App\Core\Operation\ActionQueue; +use App\Core\Operation\OperationExecutor; use App\Core\Workflow\WorkflowResult; use App\Tests\Support\FilesystemTestHelper; use Doctrine\DBAL\Connection; @@ -95,6 +99,34 @@ public function testItForwardsDownloadFailureWithoutLeakingLicenseKey(): void self::assertStringNotContainsString('sensitive-test-license', $encoded); } + public function testOperationExecutionStateDoesNotLeakLicenseKey(): void + { + $licenseKey = 'sensitive-operation-license'; + $config = $this->configuredConfig($licenseKey); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new FailingGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + $executor = new OperationExecutor(new SilentWorkflowResultMessageReporter()); + $queue = ActionQueue::create('geoip database update', [ + new MaxMindGeoIpUpdateAction($updater, 'admin_ui'), + ], context: [ + 'operation' => 'geoip.database_update', + 'environment' => 'test', + 'trigger' => 'admin_ui', + ]); + + $execution = $executor->executeQueue($queue); + $encoded = json_encode($execution->toArray(), JSON_THROW_ON_ERROR); + + self::assertFalse($execution->result()->isSuccess()); + self::assertStringNotContainsString($licenseKey, $encoded); + self::assertStringNotContainsString('license_key=', $encoded); + } + private function configuredConfig(string $licenseKey): MaxMindGeoIpConfig { $store = new Config($this->connection()); @@ -112,6 +144,14 @@ private function connection(): Connection } } +final readonly class SilentWorkflowResultMessageReporter implements WorkflowResultMessageReporterInterface +{ + public function report(WorkflowResult $result, array $operationContext = []): WorkflowResult + { + return $result; + } +} + final readonly class SuccessfulGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface { public function download(string $url, string $targetPath): WorkflowResult From 25b0b7f7f6acdcd05192c3d4234a2d2bda88490d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:47:33 +0200 Subject: [PATCH 025/119] Seed GeoIP defaults during setup --- dev/CLASSMAP.md | 4 ++-- dev/WORKLOG.md | 1 + src/Setup/SetupConfigSeeder.php | 2 +- src/Setup/SetupDefaultSeed.php | 6 +++++- tests/Setup/SetupDefaultSeedTest.php | 7 +++++++ tests/Setup/SetupRunnerTest.php | 6 ++++++ 6 files changed, 22 insertions(+), 4 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 5bc178c6..7ab034ab 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -216,8 +216,8 @@ | Interface/service | `App\Setup\SetupCommandExecutorInterface`, `App\Setup\ProcessSetupCommandExecutor` | Abstraction and default Symfony Process executor for setup subprocess calls so CLI and web setup share command execution behavior, including local Composer environment fallback values and web/CGI environment filtering. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/ProcessSetupCommandExecutorTest.php` | | Value object | `App\Setup\SetupCommandResult` | Value object for setup subprocess exit code, output, and error output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupDatabaseConnectionFactory` | Creates DBAL setup connections from Doctrine database URLs across SQLite, MySQL/MariaDB, and PostgreSQL, including setup-time Symfony placeholder resolution and platform-safe SQLite path handling. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDatabaseConnectionFactoryTest.php`, `tests/Setup/SetupPasswordResetRunnerTest.php`, `tests/Setup/SetupRunnerTest.php` | -| Service | `App\Setup\SetupDefaultSeed` | Central setup default database seed for input-aware settings backed by the shared config default provider, optional ACL group seeds, the initial static-page schema/version, and setup home content defaults. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDefaultSeedTest.php`, `tests/Setup/SetupRunnerTest.php`, `tests/Operations/TestDatabaseSeedTest.php` | -| Services | `App\Setup\SetupDatabaseSeeder`, `App\Setup\SetupConfigSeeder`, `App\Setup\SetupAdminAccountSeeder`, `App\Setup\SetupInitialContentSeeder`, `App\Setup\SetupStateMarkerWriter` | Coordinates DBAL-backed setup seeding through focused config, owner/admin-account, initial-content, and state-marker writers while keeping seed data centralized in `SetupDefaultSeed`, including runtime-backed default settings such as API availability/CORS defaults, and failing setup when required config writes cannot be persisted. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupDefaultSeedTest.php` | +| Service | `App\Setup\SetupDefaultSeed` | Central setup default database seed for input-aware settings backed by the shared config default provider, sensitive GeoIP2 credential defaults, optional ACL group seeds, the initial static-page schema/version, and setup home content defaults. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDefaultSeedTest.php`, `tests/Setup/SetupRunnerTest.php`, `tests/Operations/TestDatabaseSeedTest.php` | +| Services | `App\Setup\SetupDatabaseSeeder`, `App\Setup\SetupConfigSeeder`, `App\Setup\SetupAdminAccountSeeder`, `App\Setup\SetupInitialContentSeeder`, `App\Setup\SetupStateMarkerWriter` | Coordinates DBAL-backed setup seeding through focused config, owner/admin-account, initial-content, and state-marker writers while keeping seed data centralized in `SetupDefaultSeed`, including runtime-backed default settings such as API availability/CORS and GeoIP2 defaults, and failing setup when required config writes cannot be persisted. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupDefaultSeedTest.php` | | Service | `App\Setup\SetupDryRunPlanner` | Builds setup dry-run ActionLog steps from the shared default seed, describing planned writes, commands, settings, admin seed data, cache clearing, package discovery/rebuild subprocesses, and completion marking without mutating state, while keeping PHP CLI command planning non-throwing when no usable CLI binary is available. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service/value object | `App\Setup\SetupEnvironmentWriter`, `App\Setup\SetupEnvironmentSnapshot` | Writes setup environment overrides into `.env.{APP_ENV}.local` while preserving unrelated keys, and snapshots pre-existing env files so setup rollback can restore unrelated configuration instead of deleting it; snapshot restore failures are reported in rollback context without hiding the original setup error. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupEnvironmentSnapshotTest.php` | | Value object/service | `App\Setup\SetupInput`, `App\Setup\SetupInputNormalizer`, `App\Setup\SetupInputValidator` | Callable setup input DTO for installer language, site metadata, database connection data, admin credentials, APP_SECRET handling, and dry-run mode, plus shared CLI/web normalization and validation for database drivers, database URLs, table prefixes, boolean flags, default admin email values, supported languages, default URI, admin credentials, and APP_SECRET length. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupCliInputFactoryTest.php`, `tests/Setup/SetupWebInputFactoryTest.php`, `tests/Setup/SetupInputValidatorTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index e78c7325..e269f1e2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -83,6 +83,7 @@ - Added the MaxMind GeoIP2 provider slice on top of the foundation: local `.mmdb` lookups via the installed `geoip2/geoip2` dependency, safe provider status, project-relative database path config, sensitive credential preservation/redaction, password-form support for secret fields, and hermetic fake-reader tests without real MaxMind credentials or network access. - Added the narrow GeoIP2 update foundation: moved the intentionally small GeoIP settings surface to Statistics, changed the default database path to `var/geoip2/GeoLite2-City.mmdb`, derived MaxMind lookup locales from the site default language with `en` fallback, exposed a MaxMind signup help link, added an Admin Operations-backed database download action with non-JS POST fallback, added a daily scheduler callable, and added hermetic updater/scheduler tests that do not use real MaxMind credentials or network access. - Hardened GeoIP2 download logging: the MaxMind download client now bypasses the autowired Symfony HTTP client service so the license-key query string cannot be captured by HttpClient logging/profiling, and shared log redaction treats `license_key` as sensitive context. +- Aligned first-run setup seeding with the GeoIP2 defaults by explicitly persisting GeoIP disabled, the default `var/geoip2/GeoLite2-City.mmdb` path, and an intentionally empty sensitive MaxMind license-key setting. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Setup/SetupConfigSeeder.php b/src/Setup/SetupConfigSeeder.php index 6d92ad07..6023d9a0 100644 --- a/src/Setup/SetupConfigSeeder.php +++ b/src/Setup/SetupConfigSeeder.php @@ -29,7 +29,7 @@ public function seed(string $projectDir, SetupInput $input, string $databaseUrl) foreach ($settings as $setting) { $key = $setting['key']; - if (!$config->set($key, $setting['value'], $setting['type'], modifiedBy: 'setup')) { + if (!$config->set($key, $setting['value'], $setting['type'], sensitive: true === ($setting['sensitive'] ?? false), modifiedBy: 'setup')) { throw SetupStepFailedException::fromMessage(Message::error( ConfigMessageCode::CONFIG_WRITE_FAILED, ConfigMessageKey::CONFIG_WRITE_FAILED, diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index 513859a3..07d3a8b2 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -8,6 +8,7 @@ use App\Core\Access\AccessLevel; use App\Core\Config\ConfigDefaultProviderInterface; use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Content\Routing\ContentRouteLocalization; use App\Core\Statistics\AccessStatisticsPolicy; @@ -22,7 +23,7 @@ public function __construct(private ?ConfigDefaultProviderInterface $configDefau } /** - * @return list + * @return list */ public function configEntries(SetupInput $input): array { @@ -46,6 +47,9 @@ public function configEntries(SetupInput $input): array ['key' => ConfigAuditLogPolicy::EVENTS_KEY, 'value' => $this->setting($input, ConfigAuditLogPolicy::EVENTS_KEY, ConfigAuditLogPolicy::DEFAULT_CATEGORIES), 'type' => ConfigValueType::Json], ['key' => AccessStatisticsPolicy::ENABLED_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, true), 'type' => ConfigValueType::Boolean], + ['key' => MaxMindGeoIpConfig::ENABLED_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], + ['key' => MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::DATABASE_PATH_KEY, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH), 'type' => ConfigValueType::String], + ['key' => MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::LICENSE_KEY_KEY, ''), 'type' => ConfigValueType::String, 'sensitive' => true], ['key' => ApiFeaturePolicy::ENABLED_KEY, 'value' => $this->setting($input, ApiFeaturePolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => ApiFeaturePolicy::CORS_ENABLED_KEY, 'value' => $this->setting($input, ApiFeaturePolicy::CORS_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], ['key' => ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, 'value' => $this->setting($input, ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, []), 'type' => ConfigValueType::Json], diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index 13566de4..24530761 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -6,6 +6,7 @@ use App\Api\ApiFeaturePolicy; use App\Core\Config\ConfigDefaultProviderInterface; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Setup\DatabaseDriver; use App\Setup\SetupDefaultSeed; use App\Setup\SetupInput; @@ -29,6 +30,9 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertTrue($settings[ApiFeaturePolicy::ENABLED_KEY]); self::assertFalse($settings[ApiFeaturePolicy::CORS_ENABLED_KEY]); self::assertSame([], $settings[ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY]); + self::assertFalse($settings[MaxMindGeoIpConfig::ENABLED_KEY]); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]); + self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); } public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void @@ -66,6 +70,9 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( \App\Core\Log\ConfigAuditLogPolicy::EVENTS_KEY, \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY, \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, + MaxMindGeoIpConfig::ENABLED_KEY, + MaxMindGeoIpConfig::DATABASE_PATH_KEY, + MaxMindGeoIpConfig::LICENSE_KEY_KEY, ApiFeaturePolicy::ENABLED_KEY, ApiFeaturePolicy::CORS_ENABLED_KEY, ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, diff --git a/tests/Setup/SetupRunnerTest.php b/tests/Setup/SetupRunnerTest.php index dc28d08f..e1550771 100644 --- a/tests/Setup/SetupRunnerTest.php +++ b/tests/Setup/SetupRunnerTest.php @@ -7,6 +7,7 @@ use App\Core\ActionLog\ActionLog; use App\Core\Asset\AssetMessageCode; use App\Core\Asset\AssetMessageKey; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Process\PhpCliBinaryPreferenceStore; use App\Core\Process\PhpCliBinaryValidator; use App\Database\DatabaseReadyState; @@ -121,6 +122,10 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void $pdo = new PDO('sqlite:'.$databasePath); $configRows = $pdo->query('SELECT config_key, value FROM config_entry')->fetchAll(PDO::FETCH_KEY_PAIR); + $geoIpLicenseSensitive = $pdo->query(sprintf( + "SELECT sensitive FROM config_entry WHERE config_key = '%s'", + MaxMindGeoIpConfig::LICENSE_KEY_KEY, + ))->fetchColumn(); $aclGroups = $pdo->query('SELECT identifier, min_role FROM acl_group WHERE json_extract(metadata, "$.seeded_by") = "setup" ORDER BY min_role')->fetchAll(PDO::FETCH_ASSOC); $adminUser = $pdo->query("SELECT password_hash, role FROM user_account WHERE username = 'admin'")->fetch(PDO::FETCH_ASSOC); $stateMarkers = $pdo->query("SELECT marker_key, marker_value FROM state_marker WHERE subject_type = 'user_account' ORDER BY marker_key")->fetchAll(PDO::FETCH_KEY_PAIR); @@ -129,6 +134,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void $homeTitle = $pdo->query(sprintf("SELECT field_content FROM content_field_value WHERE revision_uid = '%s' AND field_identifier = 'title' AND language = '%s'", $seed->homeContentRevision()['uid'], $input->language()))->fetchColumn(); self::assertSame($seed->configMap($input), $this->decodedConfigRows($configRows, array_keys($seed->configMap($input)))); + self::assertSame(1, (int) $geoIpLicenseSensitive); self::assertSame(array_map(static fn (array $group): array => [ 'identifier' => $group['identifier'], 'min_role' => $group['min_role'], From c505dbf4d51962a652e458d6195a64f26b236a2b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:54:01 +0200 Subject: [PATCH 026/119] Surface GeoIP status in statistics settings --- dev/CLASSMAP.md | 4 +-- dev/WORKLOG.md | 1 + .../security-hardening/geoip-observability.md | 12 ++++---- src/Backend/AdminViewContextProvider.php | 3 ++ .../backend/admin/settings/section.html.twig | 28 +++++++++++++++++++ tests/Controller/BackendControllerTest.php | 2 ++ translations/languages/de/admin.yaml | 14 ++++++++++ translations/languages/en/admin.yaml | 14 ++++++++++ 8 files changed, 71 insertions(+), 7 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 7ab034ab..a1128988 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -102,7 +102,7 @@ | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the first Admin Settings tree plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | | Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Own focused Admin package install/detail/lifecycle and Admin Operations maintenance/detail/continuation routes that previously lived in the dynamic backend dispatcher. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.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` | @@ -194,7 +194,7 @@ | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | | Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging and access statistics; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, write atomically to `var/geoip2`, preserve the previous database on failure where possible, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, write atomically to `var/geoip2`, preserve the previous database on failure where possible, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; 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/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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index e269f1e2..f4aa7c26 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,6 +84,7 @@ - Added the narrow GeoIP2 update foundation: moved the intentionally small GeoIP settings surface to Statistics, changed the default database path to `var/geoip2/GeoLite2-City.mmdb`, derived MaxMind lookup locales from the site default language with `en` fallback, exposed a MaxMind signup help link, added an Admin Operations-backed database download action with non-JS POST fallback, added a daily scheduler callable, and added hermetic updater/scheduler tests that do not use real MaxMind credentials or network access. - Hardened GeoIP2 download logging: the MaxMind download client now bypasses the autowired Symfony HTTP client service so the license-key query string cannot be captured by HttpClient logging/profiling, and shared log redaction treats `license_key` as sensitive context. - Aligned first-run setup seeding with the GeoIP2 defaults by explicitly persisting GeoIP disabled, the default `var/geoip2/GeoLite2-City.mmdb` path, and an intentionally empty sensitive MaxMind license-key setting. +- Re-audited the GeoIP observability plan against the implementation, added safe Statistics settings status rendering, and clarified that persistent update-state history and coordinate fields are outside the first implementation. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index bbc520b3..a1857261 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -1,7 +1,7 @@ # GeoIP observability branch plan > **Status**: Draft -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-16 > **Owner**: Core > **Purpose:** Define the `feat-security-geoip-observability` implementation plan. @@ -32,22 +32,23 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 2. Add protected administrator-only Statistics settings for GeoIP enablement, the local database path, and the MaxMind license key. 3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. 4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until an administrator enables the task and stores a MaxMind license key. -5. Add safe Admin diagnostics for provider status, last update attempt, database freshness, and disabled/unconfigured state. +5. Add safe Admin diagnostics for provider status, database edition/build date when available, and disabled/unconfigured/unavailable state. Persistent last-update attempt/success state can be added later when a dedicated update-state store exists. 6. Wire access logs and statistics to consume normalized provider output only through the resolver interface and the shared client-identity resolver. ## Public interfaces and data decisions -- GeoIP output uses normalized nullable or `n/a` fields for country, region, city, latitude/longitude where available, provider status, and lookup status. +- GeoIP output uses normalized `n/a` fields for city, state/region, country, and continent. Latitude/longitude are intentionally out of scope for the first implementation because current logs/statistics do not persist or display coordinates. - The foundation keeps `n/a` placeholders as the stable default for access logs and statistics whenever lookup input is missing, providers are disabled/unconfigured/unavailable, or a provider throws. - Providers expose only safe status fields: provider key, coarse status, database edition/build date, update timestamps, next suggested update, and redacted failure code. No raw paths, IP inputs, license/account data, or full exception messages belong in provider status. - Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. - Provider secrets are protected config values and never rendered outside authorized Admin settings. - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. The MaxMind database update task is a trusted callable scheduled daily by default and remains inactive until an operator activates it in Scheduler. -- Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code when persistent update-state storage is added. The first foundation reports equivalent context through Operations, Scheduler runs, and the Message layer. +- Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code when persistent update-state storage is added. The first foundation reports update context through Operations, Scheduler runs, and the Message layer, while the Admin settings surface shows the safe resolver/provider status available at request time. - No public API response adds GeoIP data in this branch. - GeoIP enablement, database path/status, and the MaxMind license key are protected/audited Statistics configuration surfaces; license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. - The first MaxMind implementation uses the installed `geoip2/geoip2` package against a configured local `.mmdb` database. Request-time lookups must not download databases or require outbound network access. - The first production settings surface intentionally avoids a provider dropdown, Account ID field, and explicit GeoIP locale field until the product has a concrete need for them. MaxMind Reader locales are derived from `localization.default_language` with `en` as stable fallback. +- New setups explicitly seed GeoIP as disabled, keep the MaxMind license key empty and sensitive, and use `var/geoip2/GeoLite2-City.mmdb` as the default project-relative database path. - License key configuration is sensitive. Empty sensitive form submissions preserve existing stored values, API/settings read models return redacted display values, and PHPUnit coverage must use fakes or dummy strings rather than real MaxMind credentials. - The default local database path is `var/geoip2/GeoLite2-City.mmdb`. Admin-triggered downloads and scheduler downloads must write through a temporary workspace and atomically replace the configured target where the platform supports atomic rename. - The Statistics settings page may link operators to the official MaxMind GeoLite signup page for a free license key. A saved key reveals the database download action; missing keys make the scheduler callable fail with a translated Message-layer diagnostic so normal scheduler failure policy can disable repeatedly failing tasks. @@ -69,6 +70,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test scheduler task definition and missing-key failure message behavior. - Test that the task remains inactive until explicitly activated by an operator and that missing credentials produce clear failure context. - Test protected configuration redaction and null fallback for disabled, missing, invalid, and expired provider states. +- Test the Statistics settings status rendering so operators can see disabled/unconfigured/ready state without exposing secrets. - Run focused container lint when services/config are added. ## Documentation and tracking @@ -87,6 +89,6 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Acceptance criteria -- Operators can see whether GeoIP is configured and fresh. +- Operators can see whether GeoIP is disabled, unconfigured, unavailable, or ready, and can see safe database edition/build-date data when the local database can be opened. - Logs/statistics gain GeoIP fields when available and continue cleanly when unavailable. - No secret or sensitive provider detail leaks through logs, diagnostics, tests, or exports. diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index a361dc44..2d675320 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -5,6 +5,7 @@ namespace App\Backend; use App\Core\Diagnostics\SystemInfoProvider; +use App\Core\Geo\GeoIpResolverInterface; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\LogFileBrowser; use App\Core\Operation\Live\LiveOperationRunStore; @@ -19,6 +20,7 @@ public function __construct( private AccessStatisticsSnapshotProvider $accessStatisticsSnapshotProvider, private SystemInfoProvider $systemInfoProvider, private MaxMindGeoIpConfig $maxMindGeoIpConfig, + private GeoIpResolverInterface $geoIpResolver, ) { } @@ -46,6 +48,7 @@ public function variables(Request $request, ?BackendViewDefinition $view): array 'backend-admin-settings-statistics' => [ 'geoip_settings' => [ 'has_license_key' => $this->maxMindGeoIpConfig->hasLicenseKey(), + 'status' => $this->geoIpResolver->status()->toSafeArray(), ], ], default => [], diff --git a/templates/backend/admin/settings/section.html.twig b/templates/backend/admin/settings/section.html.twig index 019b5651..03a7e6cc 100644 --- a/templates/backend/admin/settings/section.html.twig +++ b/templates/backend/admin/settings/section.html.twig @@ -26,6 +26,34 @@ {% if settings_form %} {% include '@backend/partials/forms/_dynamic.html.twig' with {form: settings_form} only %} {% endif %} + {% if settings_section == 'statistics' and geoip_settings.status|default(null) %} + {% set geoip_status = geoip_settings.status %} +

{{ 'admin.settings.geoip.status.title'|trans }}

+
+
+
{{ 'admin.settings.geoip.status.provider'|trans }}
+
{{ geoip_status.provider_key|default('n/a') }}
+
+
+
{{ 'admin.settings.geoip.status.state'|trans }}
+
{{ ('admin.settings.geoip.status.values.' ~ (geoip_status.status|default('unknown')))|trans }}
+
+
+
{{ 'admin.settings.geoip.status.database_edition'|trans }}
+
{{ geoip_status.database_edition|default('n/a') ?: 'n/a' }}
+
+
+
{{ 'admin.settings.geoip.status.database_build_date'|trans }}
+
{{ geoip_status.database_build_date|default('n/a') ?: 'n/a' }}
+
+ {% if geoip_status.failure_code|default(null) %} +
+
{{ 'admin.settings.geoip.status.failure_code'|trans }}
+
{{ geoip_status.failure_code }}
+
+ {% endif %} +
+ {% endif %} {% if settings_section == 'statistics' and geoip_settings.has_license_key|default(false) %} {% include '@backend/admin/partials/_backend-actions.html.twig' with { actions: backend_actions(['geoip_database_update']), diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index f5dbf888..b55f3e92 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -943,6 +943,8 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists('input[name="statistics.geoip.enabled"]'); self::assertSelectorExists('input[name="statistics.geoip.maxmind.license_key"][type="password"]'); self::assertSelectorExists('a[href="https://www.maxmind.com/en/geolite2/signup"]'); + self::assertSelectorTextContains('h3', 'GeoIP2 status'); + self::assertSelectorTextContains('.system-definition-list', 'Disabled'); self::assertSelectorNotExists('input[name="_backend_action"][value="geoip_database_update"]'); $config = self::getContainer()->get(Config::class); diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 56f67b9f..7f051684 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -706,6 +706,20 @@ admin: title: 'Statistik-Einstellungen' foundation_title: 'Statistik-Konfiguration' foundation_text: 'Aufzeichnung, Anzeige und GeoIP-Anreicherung von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' + geoip: + status: + title: 'GeoIP2-Status' + provider: 'Provider' + state: 'Status' + database_edition: 'Datenbank-Edition' + database_build_date: 'Datenbank-Build-Datum' + failure_code: 'Fehlercode' + values: + ready: 'Bereit' + disabled: 'Deaktiviert' + unconfigured: 'Nicht konfiguriert' + unavailable: 'Nicht verfügbar' + unknown: 'Unbekannt' api: title: 'API-Einstellungen' foundation_title: 'API-Verfügbarkeit' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 75de6c19..c648d117 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -706,6 +706,20 @@ admin: title: 'Statistics settings' foundation_title: 'Statistics configuration' foundation_text: 'Access statistics recording, display, and GeoIP enrichment can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' + geoip: + status: + title: 'GeoIP2 status' + provider: 'Provider' + state: 'State' + database_edition: 'Database edition' + database_build_date: 'Database build date' + failure_code: 'Failure code' + values: + ready: 'Ready' + disabled: 'Disabled' + unconfigured: 'Unconfigured' + unavailable: 'Unavailable' + unknown: 'Unknown' api: title: 'API settings' foundation_title: 'API availability' From b72cee2be0f934a68effe7f9ce56039a397d3e31 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 00:56:11 +0200 Subject: [PATCH 027/119] Simplify GeoIP status scope --- dev/WORKLOG.md | 3 ++- .../security-hardening/geoip-observability.md | 10 ++++++---- src/Core/Geo/GeoIpProviderStatus.php | 15 --------------- tests/Core/Geo/GeoIpResolverTest.php | 6 ------ 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f4aa7c26..9fe2eec4 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,7 +84,8 @@ - Added the narrow GeoIP2 update foundation: moved the intentionally small GeoIP settings surface to Statistics, changed the default database path to `var/geoip2/GeoLite2-City.mmdb`, derived MaxMind lookup locales from the site default language with `en` fallback, exposed a MaxMind signup help link, added an Admin Operations-backed database download action with non-JS POST fallback, added a daily scheduler callable, and added hermetic updater/scheduler tests that do not use real MaxMind credentials or network access. - Hardened GeoIP2 download logging: the MaxMind download client now bypasses the autowired Symfony HTTP client service so the license-key query string cannot be captured by HttpClient logging/profiling, and shared log redaction treats `license_key` as sensitive context. - Aligned first-run setup seeding with the GeoIP2 defaults by explicitly persisting GeoIP disabled, the default `var/geoip2/GeoLite2-City.mmdb` path, and an intentionally empty sensitive MaxMind license-key setting. -- Re-audited the GeoIP observability plan against the implementation, added safe Statistics settings status rendering, and clarified that persistent update-state history and coordinate fields are outside the first implementation. +- Re-audited the GeoIP observability plan against the implementation, added safe Statistics settings status rendering, and clarified that persistent update-state history and coordinate fields are not planned for this branch. +- Simplified GeoIP status and the branch plan after product review: latitude/longitude and separate persistent GeoIP update-history storage are intentionally not planned because Scheduler run history and live Operation feedback cover update success/failure. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index a1857261..baf04a73 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -32,18 +32,18 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 2. Add protected administrator-only Statistics settings for GeoIP enablement, the local database path, and the MaxMind license key. 3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. 4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until an administrator enables the task and stores a MaxMind license key. -5. Add safe Admin diagnostics for provider status, database edition/build date when available, and disabled/unconfigured/unavailable state. Persistent last-update attempt/success state can be added later when a dedicated update-state store exists. +5. Add safe Admin diagnostics for provider status, database edition/build date when available, and disabled/unconfigured/unavailable state. Do not add a persistent GeoIP update-history store; Scheduler task runs already record scheduled update success/failure, and live Operations already provide immediate feedback for manual downloads. 6. Wire access logs and statistics to consume normalized provider output only through the resolver interface and the shared client-identity resolver. ## Public interfaces and data decisions -- GeoIP output uses normalized `n/a` fields for city, state/region, country, and continent. Latitude/longitude are intentionally out of scope for the first implementation because current logs/statistics do not persist or display coordinates. +- GeoIP output uses normalized `n/a` fields for city, state/region, country, and continent. Latitude/longitude storage is intentionally not planned because current logs/statistics and security review flows do not need coordinates. - The foundation keeps `n/a` placeholders as the stable default for access logs and statistics whenever lookup input is missing, providers are disabled/unconfigured/unavailable, or a provider throws. -- Providers expose only safe status fields: provider key, coarse status, database edition/build date, update timestamps, next suggested update, and redacted failure code. No raw paths, IP inputs, license/account data, or full exception messages belong in provider status. +- Providers expose only safe status fields: provider key, coarse status, database edition/build date, and redacted failure code. No raw paths, IP inputs, license/account data, update-history state, or full exception messages belong in provider status. - Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. - Provider secrets are protected config values and never rendered outside authorized Admin settings. - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. The MaxMind database update task is a trusted callable scheduled daily by default and remains inactive until an operator activates it in Scheduler. -- Update state records last attempt, last success, database edition, database build date, next suggested update, and redacted failure code when persistent update-state storage is added. The first foundation reports update context through Operations, Scheduler runs, and the Message layer, while the Admin settings surface shows the safe resolver/provider status available at request time. +- Update success/failure reporting stays with the existing Operation, Scheduler, and Message layers. A separate persistent GeoIP update-history table or settings blob is intentionally not planned for this branch because it would duplicate Scheduler run history and live Operation feedback. - No public API response adds GeoIP data in this branch. - GeoIP enablement, database path/status, and the MaxMind license key are protected/audited Statistics configuration surfaces; license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. - The first MaxMind implementation uses the installed `geoip2/geoip2` package against a configured local `.mmdb` database. Request-time lookups must not download databases or require outbound network access. @@ -86,6 +86,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - No geo-blocking. - No country allow/deny lists. - No abuse scoring based on GeoIP. +- No latitude/longitude storage. +- No separate persistent GeoIP update-history storage. ## Acceptance criteria diff --git a/src/Core/Geo/GeoIpProviderStatus.php b/src/Core/Geo/GeoIpProviderStatus.php index 05d5c64f..d11b4221 100644 --- a/src/Core/Geo/GeoIpProviderStatus.php +++ b/src/Core/Geo/GeoIpProviderStatus.php @@ -16,9 +16,6 @@ public function __construct( public string $status, public ?string $databaseEdition = null, public ?string $databaseBuildDate = null, - public ?string $lastUpdateAttemptAt = null, - public ?string $lastUpdateSuccessAt = null, - public ?string $nextSuggestedUpdateAt = null, public ?string $failureCode = null, ) { } @@ -27,18 +24,12 @@ public static function ready( string $providerKey, ?string $databaseEdition = null, ?string $databaseBuildDate = null, - ?string $lastUpdateAttemptAt = null, - ?string $lastUpdateSuccessAt = null, - ?string $nextSuggestedUpdateAt = null, ): self { return new self( $providerKey, self::READY, $databaseEdition, $databaseBuildDate, - $lastUpdateAttemptAt, - $lastUpdateSuccessAt, - $nextSuggestedUpdateAt, ); } @@ -68,9 +59,6 @@ public function isReady(): bool * status: string, * database_edition: ?string, * database_build_date: ?string, - * last_update_attempt_at: ?string, - * last_update_success_at: ?string, - * next_suggested_update_at: ?string, * failure_code: ?string * } */ @@ -81,9 +69,6 @@ public function toSafeArray(): array 'status' => $this->status, 'database_edition' => $this->databaseEdition, 'database_build_date' => $this->databaseBuildDate, - 'last_update_attempt_at' => $this->lastUpdateAttemptAt, - 'last_update_success_at' => $this->lastUpdateSuccessAt, - 'next_suggested_update_at' => $this->nextSuggestedUpdateAt, 'failure_code' => $this->failureCode, ]; } diff --git a/tests/Core/Geo/GeoIpResolverTest.php b/tests/Core/Geo/GeoIpResolverTest.php index 89af92ac..b0644718 100644 --- a/tests/Core/Geo/GeoIpResolverTest.php +++ b/tests/Core/Geo/GeoIpResolverTest.php @@ -88,9 +88,6 @@ public function testProviderStatusContainsOnlySafeDiagnosticFields(): void 'maxmind', databaseEdition: 'GeoLite2-City', databaseBuildDate: '2026-06-15', - lastUpdateAttemptAt: '2026-06-15T10:00:00+00:00', - lastUpdateSuccessAt: '2026-06-15T10:00:01+00:00', - nextSuggestedUpdateAt: '2026-06-22T10:00:00+00:00', ); self::assertSame([ @@ -98,9 +95,6 @@ public function testProviderStatusContainsOnlySafeDiagnosticFields(): void 'status' => 'ready', 'database_edition' => 'GeoLite2-City', 'database_build_date' => '2026-06-15', - 'last_update_attempt_at' => '2026-06-15T10:00:00+00:00', - 'last_update_success_at' => '2026-06-15T10:00:01+00:00', - 'next_suggested_update_at' => '2026-06-22T10:00:00+00:00', 'failure_code' => null, ], $status->toSafeArray()); } From 83c4532c2e5d3678281cf9b959ddfec140065c1d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 01:01:48 +0200 Subject: [PATCH 028/119] Align settings API GeoIP redaction test --- tests/Core/Config/SettingsApiReadModelTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Core/Config/SettingsApiReadModelTest.php b/tests/Core/Config/SettingsApiReadModelTest.php index 61dd5af9..d1cf2d46 100644 --- a/tests/Core/Config/SettingsApiReadModelTest.php +++ b/tests/Core/Config/SettingsApiReadModelTest.php @@ -25,7 +25,7 @@ public function testItRedactsSensitiveSettingsForApiDisplayButKeepsFormValuesEmp $readModel = new SettingsApiReadModel($this->registry(), $config); $licenseSetting = null; - foreach ($readModel->settings('security') as $setting) { + foreach ($readModel->settings('statistics') as $setting) { if (MaxMindGeoIpConfig::LICENSE_KEY_KEY === $setting['id']) { $licenseSetting = $setting; } @@ -33,7 +33,7 @@ public function testItRedactsSensitiveSettingsForApiDisplayButKeepsFormValuesEmp self::assertIsArray($licenseSetting); self::assertSame('[protected]', $licenseSetting['attributes']['value']); - self::assertSame('', $readModel->values('security')[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); + self::assertSame('', $readModel->values('statistics')[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); } private function registry(): CoreSettingsRegistry From 56cbca09e2ac3a34ff8f53a77cf0a9b9e715cfed Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 01:19:35 +0200 Subject: [PATCH 029/119] Harden GeoIP archive extraction --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + src/Core/Geo/MaxMindGeoIpArchiveExtractor.php | 64 +++++++++++ .../Geo/MaxMindGeoIpArchiveExtractorTest.php | 104 ++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index a1128988..02b7b406 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -194,7 +194,7 @@ | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | | Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, write atomically to `var/geoip2`, preserve the previous database on failure where possible, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; 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/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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 9fe2eec4..cdcf8629 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -86,6 +86,7 @@ - Aligned first-run setup seeding with the GeoIP2 defaults by explicitly persisting GeoIP disabled, the default `var/geoip2/GeoLite2-City.mmdb` path, and an intentionally empty sensitive MaxMind license-key setting. - Re-audited the GeoIP observability plan against the implementation, added safe Statistics settings status rendering, and clarified that persistent update-state history and coordinate fields are not planned for this branch. - Simplified GeoIP status and the branch plan after product review: latitude/longitude and separate persistent GeoIP update-history storage are intentionally not planned because Scheduler run history and live Operation feedback cover update success/failure. +- During PR-readiness review, hardened GeoIP archive extraction by rejecting unsafe TAR member paths before extraction and added direct extractor coverage for safe and unsafe archives. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php index f5f6db94..b1ba3072 100644 --- a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php @@ -33,6 +33,14 @@ public function extractDatabase(string $archivePath, string $workspaceDir): Work $archive = new PharData($archivePath); $archive->decompress(); + if (!$this->tarPathsAreSafe($tarPath)) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + ['stage' => 'extract', 'reason' => 'unsafe_archive_path'], + ); + } + $tar = new PharData($tarPath); $tar->extractTo($extractDir, null, true); } catch (Throwable $error) { @@ -75,6 +83,62 @@ private function findDatabase(string $extractDir): ?string return null; } + private function tarPathsAreSafe(string $tarPath): bool + { + $handle = @fopen($tarPath, 'rb'); + if (!is_resource($handle)) { + return false; + } + + try { + while (!feof($handle)) { + $header = fread($handle, 512); + if (!is_string($header) || '' === $header) { + break; + } + + if (512 !== strlen($header)) { + return false; + } + + if (str_repeat("\0", 512) === $header) { + return true; + } + + $name = rtrim(substr($header, 0, 100), "\0"); + $prefix = rtrim(substr($header, 345, 155), "\0"); + $path = '' === $prefix ? $name : $prefix.'/'.$name; + if (!$this->pathIsSafe($path)) { + return false; + } + + $size = octdec(trim(rtrim(substr($header, 124, 12), "\0 ")) ?: '0'); + if ($size > 0) { + $skip = (int) (ceil($size / 512) * 512); + if (0 !== fseek($handle, $skip, SEEK_CUR)) { + return false; + } + } + } + + return true; + } finally { + fclose($handle); + } + } + + private function pathIsSafe(string $path): bool + { + $path = str_replace('\\', '/', $path); + + return '' !== trim($path) + && !str_starts_with($path, '/') + && !str_starts_with($path, '//') + && 1 !== preg_match('/^[A-Za-z]:\//', $path) + && !str_contains('/'.$path.'/', '/../') + && !str_contains($path, "\0"); + } + /** * @param array $context * diff --git a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php new file mode 100644 index 00000000..c144ae86 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php @@ -0,0 +1,104 @@ +workspaceDir = $this->createTemporaryDirectory('maxmind-geoip-archive'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->workspaceDir); + } + + public function testItExtractsReadableDatabaseFromSafeArchive(): void + { + $archivePath = $this->archivePath('safe', [ + 'GeoLite2-City/GeoLite2-City.mmdb' => 'database', + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertTrue($result->isSuccess()); + self::assertSame('database', file_get_contents($result->value()['database_path'])); + } + + public function testItRejectsUnsafeArchivePaths(): void + { + $archivePath = $this->archivePath('unsafe', [ + '../escape.mmdb' => 'database', + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); + self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); + self::assertFileDoesNotExist(dirname($this->workspaceDir).'/escape.mmdb'); + } + + /** + * @param array $files + */ + private function archivePath(string $name, array $files): string + { + $archivePath = $this->workspaceDir.'/'.$name.'.tar.gz'; + file_put_contents($archivePath, gzencode($this->tarContents($files))); + + return $archivePath; + } + + /** + * @param array $files + */ + private function tarContents(array $files): string + { + $tar = ''; + + foreach ($files as $path => $contents) { + $tar .= $this->tarHeader($path, strlen($contents)); + $tar .= $contents; + $tar .= str_repeat("\0", (512 - (strlen($contents) % 512)) % 512); + } + + return $tar.str_repeat("\0", 1024); + } + + private function tarHeader(string $path, int $size): string + { + $header = str_pad(substr($path, 0, 100), 100, "\0"); + $header .= str_pad('0000644', 8, "\0"); + $header .= str_pad('0000000', 8, "\0"); + $header .= str_pad('0000000', 8, "\0"); + $header .= str_pad(decoct($size), 11, '0', STR_PAD_LEFT)."\0"; + $header .= str_pad(decoct(0), 11, '0', STR_PAD_LEFT)."\0"; + $header .= str_repeat(' ', 8); + $header .= '0'; + $header .= str_repeat("\0", 100); + $header .= "ustar\0"; + $header .= "00"; + $header .= str_repeat("\0", 247); + $header = str_pad($header, 512, "\0"); + $checksum = 0; + + for ($index = 0; $index < 512; ++$index) { + $checksum += ord($header[$index]); + } + + return substr_replace($header, str_pad(decoct($checksum), 6, '0', STR_PAD_LEFT)."\0 ", 148, 8); + } +} From de19383b900c4216ef9abed7c7068ddbeceb1a5c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 01:28:14 +0200 Subject: [PATCH 030/119] Harden GeoIP path portability --- dev/WORKLOG.md | 3 +++ src/Core/Geo/MaxMindGeoIpArchiveExtractor.php | 2 +- src/Core/Geo/MaxMindGeoIpConfig.php | 2 +- .../Geo/MaxMindGeoIpArchiveExtractorTest.php | 4 +-- tests/Core/Geo/MaxMindGeoIpConfigTest.php | 27 +++++++++++++++++-- .../Geo/MaxMindGeoIpDatabaseUpdaterTest.php | 9 +++++-- tests/Core/Geo/MaxMindGeoIpProviderTest.php | 7 ++++- .../Geo/MaxMindGeoIpSchedulerProviderTest.php | 2 +- 8 files changed, 46 insertions(+), 10 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index cdcf8629..9637bb58 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -88,5 +88,8 @@ - Simplified GeoIP status and the branch plan after product review: latitude/longitude and separate persistent GeoIP update-history storage are intentionally not planned because Scheduler run history and live Operation feedback cover update success/failure. - During PR-readiness review, hardened GeoIP archive extraction by rejecting unsafe TAR member paths before extraction and added direct extractor coverage for safe and unsafe archives. +### 2026-06-16 feat-security-geoip-observability +- Rechecked GeoIP portability and project-rule compliance, tightened Windows drive-letter rejection for configured database paths and TAR member paths, made GeoIP path tests separator-neutral, and reran full PHPUnit, JavaScript, lint, and Git whitespace verification. + ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php index b1ba3072..4936a15c 100644 --- a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php @@ -134,7 +134,7 @@ private function pathIsSafe(string $path): bool return '' !== trim($path) && !str_starts_with($path, '/') && !str_starts_with($path, '//') - && 1 !== preg_match('/^[A-Za-z]:\//', $path) + && 1 !== preg_match('/^[A-Za-z]:/', $path) && !str_contains('/'.$path.'/', '/../') && !str_contains($path, "\0"); } diff --git a/src/Core/Geo/MaxMindGeoIpConfig.php b/src/Core/Geo/MaxMindGeoIpConfig.php index 04e90bae..735139b5 100644 --- a/src/Core/Geo/MaxMindGeoIpConfig.php +++ b/src/Core/Geo/MaxMindGeoIpConfig.php @@ -57,7 +57,7 @@ public function databaseAbsolutePath(string $projectDir): ?string || str_starts_with($relativePath, '/') || str_starts_with($relativePath, '//') || str_starts_with($relativePath, '\\\\') - || 1 === preg_match('/^[A-Za-z]:\//', $relativePath) + || 1 === preg_match('/^[A-Za-z]:/', $relativePath) || str_contains('/'.$relativePath.'/', '/../') ) { return null; diff --git a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php index c144ae86..63e483f5 100644 --- a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php +++ b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php @@ -48,7 +48,7 @@ public function testItRejectsUnsafeArchivePaths(): void self::assertFalse($result->isSuccess()); self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); - self::assertFileDoesNotExist(dirname($this->workspaceDir).'/escape.mmdb'); + self::assertFileDoesNotExist(dirname($this->workspaceDir).DIRECTORY_SEPARATOR.'escape.mmdb'); } /** @@ -56,7 +56,7 @@ public function testItRejectsUnsafeArchivePaths(): void */ private function archivePath(string $name, array $files): string { - $archivePath = $this->workspaceDir.'/'.$name.'.tar.gz'; + $archivePath = $this->workspaceDir.DIRECTORY_SEPARATOR.$name.'.tar.gz'; file_put_contents($archivePath, gzencode($this->tarContents($files))); return $archivePath; diff --git a/tests/Core/Geo/MaxMindGeoIpConfigTest.php b/tests/Core/Geo/MaxMindGeoIpConfigTest.php index 45733f91..a502def3 100644 --- a/tests/Core/Geo/MaxMindGeoIpConfigTest.php +++ b/tests/Core/Geo/MaxMindGeoIpConfigTest.php @@ -45,12 +45,35 @@ public function testItResolvesSafeProjectRelativeDatabasePath(): void { $store = new Config($this->connection()); $config = new MaxMindGeoIpConfig($store); + $projectDir = 'project-root'; - self::assertSame('/project/var/geoip2/GeoLite2-City.mmdb', $config->databaseAbsolutePath('/project')); + self::assertSame( + $projectDir.DIRECTORY_SEPARATOR.'var'.DIRECTORY_SEPARATOR.'geoip2'.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb', + $config->databaseAbsolutePath($projectDir), + ); $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, '../secret.mmdb', ConfigValueType::String); - self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath('/project')); + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath($projectDir)); + } + + public function testItRejectsAbsoluteOrEscapingDatabasePaths(): void + { + $store = new Config($this->connection()); + $projectDir = 'project-root'; + + foreach ([ + '/secret.mmdb', + '//server/share/secret.mmdb', + '\\\\server\\share\\secret.mmdb', + 'C:/secret.mmdb', + 'C:secret.mmdb', + 'var/../secret.mmdb', + ] as $unsafePath) { + $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, $unsafePath, ConfigValueType::String); + + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath($projectDir), $unsafePath); + } } private function connection(): Connection diff --git a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php index 7e4c7e80..7fb0e1d4 100644 --- a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php +++ b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php @@ -76,7 +76,7 @@ public function testItDownloadsValidatesAndReplacesDatabase(): void self::assertTrue($result->isSuccess()); self::assertSame(['database_path' => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH], $result->value()); - self::assertSame('new database', file_get_contents($this->projectDir.'/'.MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH)); + self::assertSame('new database', file_get_contents($this->defaultDatabasePath())); self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, $result->messages()[0]->translationKey()); } @@ -142,6 +142,11 @@ private function connection(): Connection return $connection; } + + private function defaultDatabasePath(): string + { + return $this->projectDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH); + } } final readonly class SilentWorkflowResultMessageReporter implements WorkflowResultMessageReporterInterface @@ -184,7 +189,7 @@ public function __construct(private string $databaseContents = 'database') public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult { - $databasePath = $workspaceDir.'/GeoLite2-City.mmdb'; + $databasePath = $workspaceDir.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb'; file_put_contents($databasePath, $this->databaseContents); return WorkflowResult::success(['database_path' => $databasePath]); diff --git a/tests/Core/Geo/MaxMindGeoIpProviderTest.php b/tests/Core/Geo/MaxMindGeoIpProviderTest.php index 8e2069a0..dc1eedfb 100644 --- a/tests/Core/Geo/MaxMindGeoIpProviderTest.php +++ b/tests/Core/Geo/MaxMindGeoIpProviderTest.php @@ -75,7 +75,7 @@ public function testItReadsLocalDatabaseThroughGeoIp2Boundary(): void 'continent' => 'Europe', ], $provider->resolve('8.8.8.8')->toArray()); self::assertSame(1, $factory->openCount); - self::assertSame($this->projectDir.'/'.MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $factory->lastDatabasePath); + self::assertSame($this->defaultDatabasePath(), $factory->lastDatabasePath); self::assertSame(['en'], $factory->lastLocales); self::assertSame(1, $reader->cityLookupCount); } @@ -141,6 +141,11 @@ private function reader(): RecordingMaxMindReader 'continent' => ['names' => ['en' => 'Europe'], 'code' => 'EU'], ])); } + + private function defaultDatabasePath(): string + { + return $this->projectDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH); + } } final class RecordingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface diff --git a/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php index 72697b87..ad47848d 100644 --- a/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php +++ b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php @@ -76,7 +76,7 @@ public function download(string $url, string $targetPath): WorkflowResult { public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult { - $databasePath = $workspaceDir.'/GeoLite2-City.mmdb'; + $databasePath = $workspaceDir.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb'; file_put_contents($databasePath, 'database'); return WorkflowResult::success(['database_path' => $databasePath]); From 36155480a84977d9780d4f032a5bcf21e867a0ed Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 01:34:58 +0200 Subject: [PATCH 031/119] Harden GeoIP TAR validation --- dev/WORKLOG.md | 1 + src/Core/Geo/MaxMindGeoIpArchiveExtractor.php | 32 ++++++++++++++----- .../Geo/MaxMindGeoIpArchiveExtractorTest.php | 30 +++++++++++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 9637bb58..e79281d2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -90,6 +90,7 @@ ### 2026-06-16 feat-security-geoip-observability - Rechecked GeoIP portability and project-rule compliance, tightened Windows drive-letter rejection for configured database paths and TAR member paths, made GeoIP path tests separator-neutral, and reran full PHPUnit, JavaScript, lint, and Git whitespace verification. +- Completed an explicit #57-style PR-readiness pass for the GeoIP slice and hardened downloaded TAR validation by inspecting the compressed archive stream before `PharData` normalization and rejecting symlink, hardlink, and other non-file/non-directory entry types. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php index 4936a15c..989738b1 100644 --- a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php @@ -31,6 +31,14 @@ public function extractDatabase(string $archivePath, string $workspaceDir): Work @unlink($tarPath); } + if (!$this->tarPathsAreSafe($archivePath, compressed: true)) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + ['stage' => 'extract', 'reason' => 'unsafe_archive_path'], + ); + } + $archive = new PharData($archivePath); $archive->decompress(); if (!$this->tarPathsAreSafe($tarPath)) { @@ -83,16 +91,16 @@ private function findDatabase(string $extractDir): ?string return null; } - private function tarPathsAreSafe(string $tarPath): bool + private function tarPathsAreSafe(string $tarPath, bool $compressed = false): bool { - $handle = @fopen($tarPath, 'rb'); + $handle = $compressed ? @gzopen($tarPath, 'rb') : @fopen($tarPath, 'rb'); if (!is_resource($handle)) { return false; } try { - while (!feof($handle)) { - $header = fread($handle, 512); + while (true) { + $header = $compressed ? gzread($handle, 512) : fread($handle, 512); if (!is_string($header) || '' === $header) { break; } @@ -112,18 +120,26 @@ private function tarPathsAreSafe(string $tarPath): bool return false; } + $type = substr($header, 156, 1); + if (!in_array($type, ["\0", '0', '5'], true)) { + return false; + } + $size = octdec(trim(rtrim(substr($header, 124, 12), "\0 ")) ?: '0'); - if ($size > 0) { - $skip = (int) (ceil($size / 512) * 512); - if (0 !== fseek($handle, $skip, SEEK_CUR)) { + $skip = $size > 0 ? (int) (ceil($size / 512) * 512) : 0; + while ($skip > 0) { + $chunk = $compressed ? gzread($handle, min(8192, $skip)) : fread($handle, min(8192, $skip)); + if (!is_string($chunk) || '' === $chunk) { return false; } + + $skip -= strlen($chunk); } } return true; } finally { - fclose($handle); + $compressed ? gzclose($handle) : fclose($handle); } } diff --git a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php index 63e483f5..046c784d 100644 --- a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php +++ b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php @@ -51,8 +51,21 @@ public function testItRejectsUnsafeArchivePaths(): void self::assertFileDoesNotExist(dirname($this->workspaceDir).DIRECTORY_SEPARATOR.'escape.mmdb'); } + public function testItRejectsUnsupportedArchiveEntryTypes(): void + { + $archivePath = $this->archivePath('unsupported-type', [ + 'GeoLite2-City/link' => ['type' => '2', 'link' => '/tmp'], + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); + self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); + } + /** - * @param array $files + * @param array $files */ private function archivePath(string $name, array $files): string { @@ -63,14 +76,17 @@ private function archivePath(string $name, array $files): string } /** - * @param array $files + * @param array $files */ private function tarContents(array $files): string { $tar = ''; - foreach ($files as $path => $contents) { - $tar .= $this->tarHeader($path, strlen($contents)); + foreach ($files as $path => $entry) { + $contents = is_array($entry) ? (string) ($entry['contents'] ?? '') : $entry; + $type = is_array($entry) ? (string) ($entry['type'] ?? '0') : '0'; + $link = is_array($entry) ? (string) ($entry['link'] ?? '') : ''; + $tar .= $this->tarHeader($path, strlen($contents), $type, $link); $tar .= $contents; $tar .= str_repeat("\0", (512 - (strlen($contents) % 512)) % 512); } @@ -78,7 +94,7 @@ private function tarContents(array $files): string return $tar.str_repeat("\0", 1024); } - private function tarHeader(string $path, int $size): string + private function tarHeader(string $path, int $size, string $type = '0', string $link = ''): string { $header = str_pad(substr($path, 0, 100), 100, "\0"); $header .= str_pad('0000644', 8, "\0"); @@ -87,8 +103,8 @@ private function tarHeader(string $path, int $size): string $header .= str_pad(decoct($size), 11, '0', STR_PAD_LEFT)."\0"; $header .= str_pad(decoct(0), 11, '0', STR_PAD_LEFT)."\0"; $header .= str_repeat(' ', 8); - $header .= '0'; - $header .= str_repeat("\0", 100); + $header .= $type[0] ?? '0'; + $header .= str_pad(substr($link, 0, 100), 100, "\0"); $header .= "ustar\0"; $header .= "00"; $header .= str_repeat("\0", 247); From bbe125ee84be2c76df842f8e12e46c90935bc7e0 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 02:10:49 +0200 Subject: [PATCH 032/119] Address GeoIP review findings --- config/services.yaml | 1 + dev/WORKLOG.md | 1 + .../Settings/CoreSettingsFormHandler.php | 9 ++- src/Core/Geo/GeoIpResolver.php | 17 ++++- src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php | 5 ++ .../Settings/PackageSettingsFormHandler.php | 15 +++++ src/View/Twig/AdminViewTwigExtension.php | 64 +++++++++++++++++-- .../Controller/ApiSettingsControllerTest.php | 25 ++++++++ tests/Controller/BackendControllerTest.php | 31 ++++++++- .../Config/CoreSettingsFormHandlerTest.php | 26 +++++++- tests/Core/Geo/GeoIpResolverTest.php | 8 +++ .../Geo/MaxMindGeoIpDatabaseUpdaterTest.php | 3 + .../PackageSettingsFormHandlerTest.php | 40 ++++++++++++ 13 files changed, 232 insertions(+), 13 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 1f6fe6e1..b55f9575 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -109,6 +109,7 @@ services: - '../src/**/*Scope.php' - '../src/**/*Source.php' - '../src/Debug/RouteRenderOptions.php' + - '../src/Core/Geo/GeoIp2MaxMindDatabaseReader.php' - '../src/Core/Package/PackageSpec.php' - '../src/**/*Spec.php' - '../src/**/*Status.php' diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index e79281d2..3def9583 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -91,6 +91,7 @@ ### 2026-06-16 feat-security-geoip-observability - Rechecked GeoIP portability and project-rule compliance, tightened Windows drive-letter rejection for configured database paths and TAR member paths, made GeoIP path tests separator-neutral, and reran full PHPUnit, JavaScript, lint, and Git whitespace verification. - Completed an explicit #57-style PR-readiness pass for the GeoIP slice and hardened downloaded TAR validation by inspecting the compressed archive stream before `PharData` normalization and rejecting symlink, hardlink, and other non-file/non-directory entry types. +- Addressed Cloud Review findings and adjacent paths: excluded the manually constructed GeoIP2 reader wrapper from service autowiring, kept sensitive Core and package setting values out of invalid form re-renders and `[protected]` round-trips, preserved configured provider diagnostics when GeoIP is not ready, and set readable permissions on replaced GeoIP databases. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Config/Settings/CoreSettingsFormHandler.php b/src/Core/Config/Settings/CoreSettingsFormHandler.php index 7175bde8..9d56b936 100644 --- a/src/Core/Config/Settings/CoreSettingsFormHandler.php +++ b/src/Core/Config/Settings/CoreSettingsFormHandler.php @@ -18,6 +18,8 @@ final readonly class CoreSettingsFormHandler { + private const PROTECTED_VALUE = '[protected]'; + public function __construct( private CoreSettingsRegistry $registry, private Config $config, @@ -53,7 +55,7 @@ public function submit(string $section, array $submitted, ?string $modifiedBy = $metadata = $definition->metadata(); if ( true === ($metadata['sensitive'] ?? false) - && $this->isEmptySensitiveValue($result->value($definition->key())) + && $this->isUnchangedSensitiveValue($result->value($definition->key())) ) { continue; } @@ -171,8 +173,9 @@ private function isValidOptionalEmail(mixed $email): bool return is_string($email) && ('' === trim($email) || EmailAddress::isValid($email)); } - private function isEmptySensitiveValue(mixed $value): bool + private function isUnchangedSensitiveValue(mixed $value): bool { - return null === $value || (is_string($value) && '' === trim($value)); + return null === $value + || (is_string($value) && in_array(trim($value), ['', self::PROTECTED_VALUE], true)); } } diff --git a/src/Core/Geo/GeoIpResolver.php b/src/Core/Geo/GeoIpResolver.php index 35dc05fa..aa953a6d 100644 --- a/src/Core/Geo/GeoIpResolver.php +++ b/src/Core/Geo/GeoIpResolver.php @@ -41,7 +41,9 @@ public function resolve(?string $ipAddress): GeoIpResult public function status(): GeoIpProviderStatus { - return $this->activeProvider()?->status() ?? $this->fallbackProvider->status(); + return $this->activeProvider()?->status() + ?? $this->diagnosticProvider()?->status() + ?? $this->fallbackProvider->status(); } private function activeProvider(): ?GeoIpProviderInterface @@ -59,6 +61,19 @@ private function activeProvider(): ?GeoIpProviderInterface return null; } + private function diagnosticProvider(): ?GeoIpProviderInterface + { + foreach ($this->providers as $provider) { + if ($provider === $this->fallbackProvider) { + continue; + } + + return $provider; + } + + return null; + } + /** * @param iterable $providers * diff --git a/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php b/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php index 28401286..5f364df7 100644 --- a/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php +++ b/src/Core/Geo/MaxMindGeoIpDatabaseUpdater.php @@ -128,7 +128,11 @@ private function replaceDatabase(string $candidate, string $targetPath): bool return false; } + @chmod($temporaryTarget, 0644); + if (@rename($temporaryTarget, $targetPath)) { + @chmod($targetPath, 0644); + return true; } @@ -146,6 +150,7 @@ private function replaceDatabase(string $candidate, string $targetPath): bool } if (@rename($temporaryTarget, $targetPath)) { + @chmod($targetPath, 0644); @unlink($backupPath); return true; diff --git a/src/Core/Package/Settings/PackageSettingsFormHandler.php b/src/Core/Package/Settings/PackageSettingsFormHandler.php index 1416d30e..2f7ea05c 100644 --- a/src/Core/Package/Settings/PackageSettingsFormHandler.php +++ b/src/Core/Package/Settings/PackageSettingsFormHandler.php @@ -10,6 +10,8 @@ final readonly class PackageSettingsFormHandler { + private const PROTECTED_VALUE = '[protected]'; + public function __construct( private PackageSettingRegistry $registry, private PackageSettings $settings, @@ -33,6 +35,13 @@ public function submit(string $packageName, array $submitted, ?string $modifiedB } foreach ($definitions as $definition) { + if ( + true === ($definition->metadata()['sensitive'] ?? false) + && $this->isUnchangedSensitiveValue($result->value($definition->key())) + ) { + continue; + } + if (!$this->settings->set($packageName, $definition->key(), $result->value($definition->key()), $definition->valueType(), $modifiedBy)) { return new FormSubmissionResult($result->values(), [ '__form' => [FormErrorKey::SAVE_FAILED], @@ -42,4 +51,10 @@ public function submit(string $packageName, array $submitted, ?string $modifiedB return $result; } + + private function isUnchangedSensitiveValue(mixed $value): bool + { + return null === $value + || (is_string($value) && in_array(trim($value), ['', self::PROTECTED_VALUE], true)); + } } diff --git a/src/View/Twig/AdminViewTwigExtension.php b/src/View/Twig/AdminViewTwigExtension.php index 6d41cd16..b1af2a84 100644 --- a/src/View/Twig/AdminViewTwigExtension.php +++ b/src/View/Twig/AdminViewTwigExtension.php @@ -6,12 +6,14 @@ use App\Backend\BackendActions; use App\Core\Config\Config; +use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreSettingsRegistry; use App\Core\Package\PackageAdminOverview; use App\Core\Package\Settings\PackageSettingRegistry; use App\Core\Package\Settings\PackageSettings; use App\Core\Package\ThemeAdminOverview; use App\Form\FormBuilder; +use App\Form\FormFieldDefinition; use App\View\SystemPackageMetadataProvider; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -127,7 +129,7 @@ public function coreSettingsForm(string $section): array $values[$definition->key()] = $value; } - $values = array_replace($values, $this->requestFormValues($request)); + $values = array_replace($values, $this->requestFormValues($request, $this->sensitiveDefinitionKeys($definitions))); $errors = $this->requestFormErrors($request); return $this->formBuilder->build( @@ -147,12 +149,18 @@ public function packageSettingsForm(string $packageName): array { $request = $this->requestStack->getCurrentRequest(); $errors = $this->requestFormErrors($request); + $fields = $this->packageSettings->formFields($packageName, $this->packageSettingRegistry); + $sensitiveKeys = $this->sensitiveFieldKeys($fields); + $values = array_replace( + array_fill_keys($sensitiveKeys, ''), + $this->requestFormValues($request, $sensitiveKeys), + ); return $this->formBuilder->build( 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($packageName)), $packageName, - $this->packageSettings->formFields($packageName, $this->packageSettingRegistry), - $this->requestFormValues($request), + $fields, + $values, $errors, $errors['__form'] ?? [], )->toArray(); @@ -190,13 +198,23 @@ private function defaultFooterCopyright(): string } /** + * @param list $excludedKeys + * * @return array */ - private function requestFormValues(?Request $request): array + private function requestFormValues(?Request $request, array $excludedKeys = []): array { $values = $request?->attributes->get('_system_form_values'); - return is_array($values) ? $values : []; + if (!is_array($values)) { + return []; + } + + foreach ($excludedKeys as $key) { + unset($values[$key]); + } + + return $values; } /** @@ -208,4 +226,40 @@ private function requestFormErrors(?Request $request): array return is_array($errors) ? $errors : []; } + + /** + * @param iterable $definitions + * + * @return list + */ + private function sensitiveDefinitionKeys(iterable $definitions): array + { + $keys = []; + + foreach ($definitions as $definition) { + if (true === ($definition->metadata()['sensitive'] ?? false)) { + $keys[] = $definition->key(); + } + } + + return $keys; + } + + /** + * @param iterable $fields + * + * @return list + */ + private function sensitiveFieldKeys(iterable $fields): array + { + $keys = []; + + foreach ($fields as $field) { + if (true === ($field->metadata()['sensitive'] ?? false)) { + $keys[] = $field->name(); + } + } + + return $keys; + } } diff --git a/tests/Controller/ApiSettingsControllerTest.php b/tests/Controller/ApiSettingsControllerTest.php index b4fd960a..95fa3cbb 100644 --- a/tests/Controller/ApiSettingsControllerTest.php +++ b/tests/Controller/ApiSettingsControllerTest.php @@ -5,6 +5,9 @@ namespace App\Tests\Controller; use App\Core\Access\AccessLevel; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Entity\ApiKey; use App\Security\ApiKeyStatus; use App\Security\ApiKeyVault; @@ -102,6 +105,28 @@ public function testSettingsSectionCanBePatchedWithReadWriteAdminApiKeys(): void self::assertSame('API test footer', $footer['attributes']['value']); } + public function testSettingsPatchPreservesSensitiveValuesWhenClientEchoesProtectedPlaceholder(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecret', AccessLevel::ADMIN); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'stored-api-secret', ConfigValueType::String, sensitive: true); + + $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + MaxMindGeoIpConfig::ENABLED_KEY => true, + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + public function testSettingsPatchReturnsValidationErrors(): void { $client = self::createClient(); diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index b55f3e92..11351479 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -9,6 +9,7 @@ use App\Core\ActionLog\ActionLogStatus; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; @@ -935,6 +936,10 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists(sprintf('input[name="%s"]', ConfigAuditLogPolicy::ENABLED_KEY)); self::assertSelectorExists(sprintf('input[name="%s[]"]', ConfigAuditLogPolicy::EVENTS_KEY)); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set('statistics.geoip.maxmind.license_key', '', ConfigValueType::String, sensitive: true); + $client->request('GET', '/admin/settings/statistics'); self::assertResponseIsSuccessful(); @@ -944,11 +949,9 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists('input[name="statistics.geoip.maxmind.license_key"][type="password"]'); self::assertSelectorExists('a[href="https://www.maxmind.com/en/geolite2/signup"]'); self::assertSelectorTextContains('h3', 'GeoIP2 status'); - self::assertSelectorTextContains('.system-definition-list', 'Disabled'); + self::assertSelectorTextContains('.system-definition-list', 'Provider'); self::assertSelectorNotExists('input[name="_backend_action"][value="geoip_database_update"]'); - $config = self::getContainer()->get(Config::class); - self::assertInstanceOf(Config::class, $config); $config->set('statistics.geoip.maxmind.license_key', 'saved-test-key', ConfigValueType::String, sensitive: true); $client->request('GET', '/admin/settings/statistics'); @@ -1041,6 +1044,28 @@ public function testAdminSettingsFormsRenderValidationErrors(): void self::assertStringContainsString('The submitted value does not match the expected format.', $html); } + public function testAdminSettingsFormsDoNotReRenderSubmittedSensitiveValuesAfterValidationErrors(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, 8); + $crawler = $client->request('GET', '/admin/settings/statistics'); + $form = $crawler->selectButton('Save settings')->form([ + 'statistics.enabled' => '1', + 'statistics.respect_do_not_track' => '1', + MaxMindGeoIpConfig::ENABLED_KEY => '1', + MaxMindGeoIpConfig::DATABASE_PATH_KEY => '', + MaxMindGeoIpConfig::LICENSE_KEY_KEY => 'submitted-geoip-secret', + ]); + + $client->submit($form); + + self::assertResponseIsSuccessful(); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('This field is required.', $html); + self::assertStringNotContainsString('submitted-geoip-secret', $html); + self::assertSelectorExists(sprintf('input[name="%s"][type="password"]', MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + public function testAdminTestUserHelperRestoresUsableAccountStatus(): void { $client = self::createClient(); diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index c9cc5291..9bdb695c 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -33,7 +33,7 @@ public function testItPreservesExistingSensitiveSettingsWhenSubmittedEmpty(): vo $result = $handler->submit('statistics', [ 'statistics.enabled' => '1', - 'statistics.respect_dnt' => '1', + 'statistics.respect_do_not_track' => '1', MaxMindGeoIpConfig::ENABLED_KEY => '0', MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, MaxMindGeoIpConfig::LICENSE_KEY_KEY => '', @@ -43,6 +43,30 @@ public function testItPreservesExistingSensitiveSettingsWhenSubmittedEmpty(): vo self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); } + public function testItPreservesExistingSensitiveSettingsWhenSubmittedProtectedPlaceholder(): void + { + $config = new Config($this->connection()); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); + + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + ); + + $result = $handler->submit('statistics', [ + 'statistics.enabled' => '1', + 'statistics.respect_do_not_track' => '1', + MaxMindGeoIpConfig::ENABLED_KEY => '1', + MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', + ], 'test'); + + self::assertTrue($result->isValid()); + self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + private function registry(): CoreSettingsRegistry { $projectDir = dirname(__DIR__, 3); diff --git a/tests/Core/Geo/GeoIpResolverTest.php b/tests/Core/Geo/GeoIpResolverTest.php index b0644718..3b5026b9 100644 --- a/tests/Core/Geo/GeoIpResolverTest.php +++ b/tests/Core/Geo/GeoIpResolverTest.php @@ -27,6 +27,14 @@ public function testItReturnsPlaceholdersWhenNoProviderIsReady(): void 'continent' => 'n/a', ], $resolver->resolve('203.0.113.10')->toArray()); + self::assertSame('maxmind', $resolver->status()->providerKey); + self::assertSame('unconfigured', $resolver->status()->status); + } + + public function testItReportsFallbackStatusWhenNoProviderIsConfigured(): void + { + $resolver = new GeoIpResolver([], new NullGeoIpProvider()); + self::assertSame('none', $resolver->status()->providerKey); self::assertSame('disabled', $resolver->status()->status); } diff --git a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php index 7fb0e1d4..835bc4cd 100644 --- a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php +++ b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php @@ -77,6 +77,9 @@ public function testItDownloadsValidatesAndReplacesDatabase(): void self::assertTrue($result->isSuccess()); self::assertSame(['database_path' => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH], $result->value()); self::assertSame('new database', file_get_contents($this->defaultDatabasePath())); + if ('\\' !== DIRECTORY_SEPARATOR) { + self::assertSame('0644', substr(sprintf('%o', fileperms($this->defaultDatabasePath()) ?: 0), -4)); + } self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, $result->messages()[0]->translationKey()); } diff --git a/tests/Core/Package/PackageSettingsFormHandlerTest.php b/tests/Core/Package/PackageSettingsFormHandlerTest.php index 356b0e55..32146adc 100644 --- a/tests/Core/Package/PackageSettingsFormHandlerTest.php +++ b/tests/Core/Package/PackageSettingsFormHandlerTest.php @@ -63,6 +63,46 @@ public function testItPersistsTypedPackageSettingsFromSubmittedFormValues(): voi self::assertSame('comfortable', $settings->get('form-module', 'display.mode')); self::assertTrue($settings->get('form-module', 'feature.enabled')); } + + public function testItPreservesSensitivePackageSettingsWhenSubmittedProtectedPlaceholder(): void + { + self::bootKernel(); + $settings = self::getContainer()->get(PackageSettings::class); + $settings->set('form-module', 'api.secret', 'stored-secret', ConfigValueType::String, 'test'); + $handler = new PackageSettingsFormHandler( + new PackageSettingRegistry( + [new FormHandlerPackageSettingProvider([ + new PackageSettingDefinition( + 'form-module', + 'api.secret', + 'API secret', + '', + ConfigValueType::String, + inputType: FormInputType::Password, + metadata: ['sensitive' => true], + ), + ])], + new FormHandlerActivePackageProvider([ + new ExtensionPackage( + '10000000-0000-7000-8000-000000000778', + [PackageScope::Module], + 'form-module', + 'packages/form-module', + ExtensionPackageStatus::Active, + ), + ]), + ), + $settings, + new FormSubmissionHandler(), + ); + + $result = $handler->submit('form-module', [ + 'api.secret' => '[protected]', + ], 'test'); + + self::assertTrue($result->isValid()); + self::assertSame('stored-secret', $settings->get('form-module', 'api.secret')); + } } final readonly class FormHandlerPackageSettingProvider implements PackageSettingProviderInterface From 4afcaaa2529d25fbb34fc1102d89607c3f16deb6 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 02:58:42 +0200 Subject: [PATCH 033/119] Address GeoIP review follow-ups --- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 1 + .../admin-acl-enforcement.md | 24 ++- .../security-hardening/geoip-observability.md | 1 + src/Backend/AdminViewContextProvider.php | 13 ++ src/Backend/BackendActionResponder.php | 11 +- src/Backend/BackendActions.php | 117 ++++++++++- src/Controller/BackendController.php | 4 +- src/Core/Config/Api/SettingsApiHandler.php | 21 +- src/Core/Config/Api/SettingsApiReadModel.php | 23 ++- .../Config/Settings/CoreSettingDefinition.php | 30 +++ .../Settings/CoreSettingsFormHandler.php | 18 +- .../Config/Settings/CoreSettingsRegistry.php | 13 +- src/Core/Geo/GeoIpResult.php | 33 +++- .../Geo/HttpMaxMindGeoIpDownloadClient.php | 58 +++++- src/View/Twig/AdminViewTwigExtension.php | 28 ++- .../backend/admin/settings/section.html.twig | 4 +- .../Controller/ApiSettingsControllerTest.php | 47 ++++- tests/Controller/BackendControllerTest.php | 17 +- .../Config/CoreSettingsFormHandlerTest.php | 6 +- .../Core/Config/SettingsApiReadModelTest.php | 7 +- .../HttpMaxMindGeoIpDownloadClientTest.php | 184 ++++++++++++++++++ .../DatabaseAccessStatisticsRecorderTest.php | 41 ++++ 23 files changed, 653 insertions(+), 54 deletions(-) create mode 100644 tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 02b7b406..2e40e230 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -97,7 +97,7 @@ | Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Service | `App\Backend\BackendAccessGuard` | Converts the current Symfony user into an access actor and checks backend area access through the shared ACL resolver. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | -| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, and cache clearing, with shared CSRF validation, translated flashes, JSON operation-start responses, and audit logging for controller adapters. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | +| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and action-level access metadata for Owner-only operations. | `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` | | Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | @@ -128,7 +128,7 @@ |------|--------|---------|------|-------| | Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | -| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit policy controls, and GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit policy controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | | Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, password inputs for sensitive settings, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | | Entity | `App\Entity\PackageSettingEntry` | Doctrine entity for package-scoped settings stored separately from global configuration so purge can remove package-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | @@ -194,7 +194,7 @@ | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | | Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; 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/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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 3def9583..ed067672 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -92,6 +92,7 @@ - Rechecked GeoIP portability and project-rule compliance, tightened Windows drive-letter rejection for configured database paths and TAR member paths, made GeoIP path tests separator-neutral, and reran full PHPUnit, JavaScript, lint, and Git whitespace verification. - Completed an explicit #57-style PR-readiness pass for the GeoIP slice and hardened downloaded TAR validation by inspecting the compressed archive stream before `PharData` normalization and rejecting symlink, hardlink, and other non-file/non-directory entry types. - Addressed Cloud Review findings and adjacent paths: excluded the manually constructed GeoIP2 reader wrapper from service autowiring, kept sensitive Core and package setting values out of invalid form re-renders and `[protected]` round-trips, preserved configured provider diagnostics when GeoIP is not ready, and set readable permissions on replaced GeoIP databases. +- Addressed the next GeoIP review round and adjacent paths: streamed MaxMind archives instead of materializing response bodies, bounded GeoIP labels before statistics inserts for strict SQL platforms, gated GeoIP settings/download controls through shared `AccessRule`/`AccessActor` metadata, added future ACL-matrix metadata and draft notes, and covered Owner/Admin API/UI behavior with focused tests. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index 503ebec4..def28bac 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -1,9 +1,9 @@ # Admin ACL enforcement branch plan -> **Status**: Draft -> **Updated**: 2026-06-15 -> **Owner**: Core -> **Purpose:** Define the `feat-security-admin-acl-enforcement` implementation plan. +> **Status**: Draft +> **Updated**: 2026-06-16 +> **Owner**: Core +> **Purpose:** Define the `feat-security-admin-acl-enforcement` implementation plan. ## Goal @@ -13,6 +13,16 @@ Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan. This branch should make it obvious which Admin features are operational delegation and which are site-control powers. The first implementation should ship safe code-owned defaults, then leave room for a later Owner-only configuration UI where that is useful and safe. +Longer term, Owner-facing ACL settings should expose a bounded configuration matrix with one row per protected feature/action: + +| Column | Purpose | +| --- | --- | +| Feature | Stable machine-readable action or feature identifier, for example `settings.statistics.geoip` or `backend.action.geoip_database_update`. | +| Required ACL | Default or configured `AccessRule`, expressed as a minimum access level and/or allowed ACL group. | +| Configurable | Whether Owners may change the required ACL in the ACL settings UI. | + +The same matrix must feed Admin UI visibility, Editor UI visibility where applicable, API handlers, live operations, scheduler/admin triggers, and service-layer mutation checks. Navigation remains only a projection of the policy; backend enforcement stays authoritative. + ## Git handling Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. @@ -28,7 +38,7 @@ Codex may create local commits for this branch when each commit has a clear them 1. Inventory current Admin surfaces, including settings, users/groups, package/theme management, scheduler, operations, logs/audit, backups, diagnostics, API management, and future security settings. 2. Define stable Admin action identifiers grouped by domain, for example `system.admin.settings.security.update`, `system.admin.packages.activate`, or `system.admin.scheduler.web_trigger.update`. -3. Add an Admin action catalogue with metadata: identifier, domain, title/description translation keys, default minimum role, sensitivity, mutation/read flag, configurable flag, audit category, and optional confirmation requirement. +3. Add an Admin action catalogue with metadata: identifier, domain, title/description translation keys, default minimum role or ACL group rule, sensitivity, mutation/read flag, configurable flag, audit category, and optional confirmation requirement. 4. Add a shared Admin action authority policy service that evaluates actor access level, action identifier, target context, target subject, account status, and optional workflow metadata. 5. Encode the first static default matrix in code: delegated Admin read/mutate actions, Owner-only actions, and denied/unknown actions. 6. Add a narrow Owner-only configuration descriptor shape for later tuning, but do not build a broad permission UI unless this branch can keep validation, audit, docs, and rollback small enough for review. @@ -67,7 +77,8 @@ The first matrix should use conservative defaults. "View" means the actor may op ## Configurability policy - The first implementation should be code-owned and test-backed. This avoids shipping a confusing half-permission UI while the Admin surface is still changing. -- A later Owner-only settings UI may relax or tighten selected Admin capabilities only through bounded descriptors. Each configurable action must define default minimum role, allowed role range, whether it may be disabled, audit behavior, affected routes/API/live operations, and safe rollback. +- A later Owner-only settings UI may relax or tighten selected Admin and Editor capabilities only through bounded descriptors. Each configurable feature/action must define default `AccessRule`, allowed role/group range, whether it may be disabled, audit behavior, affected routes/API/live operations, and safe rollback. +- The Owner UI should display the matrix as `Feature`, `Required ACL`, and `Configurable`. Non-configurable rows may be visible for transparency, but their controls remain disabled with explanatory copy. - Some actions are not ordinary configurable settings: last-Owner protection, Owner recovery, protected secret redaction, privacy ceilings, raw-token exposure, `APP_SECRET` emergency handling, and unknown-action deny-by-default. - Owner configuration may delegate additional read or mutation actions to Admins, but it must not allow Admins to grant themselves Owner role, change Owner-only recovery/security boundaries, reveal secrets without a dedicated reveal flow, or bypass domain confirmations/audit. - Configured changes to Admin action authority must be audited with actor, old/new policy summary, affected action identifiers, and redacted context. @@ -76,6 +87,7 @@ The first matrix should use conservative defaults. "View" means the actor may op ## Public interfaces and data decisions - Admin action identifiers are stable, English, machine-readable strings and are not localized. +- Feature/action descriptors should expose enough metadata for a future matrix UI without making the first implementation database-configurable by default: stable feature key, default access rule, configurability flag, domain, sensitivity, and affected public entry points. - The first matrix is code-owned and test-backed. Database-configurable Admin ACLs are a later Owner-only feature only if product need appears and the bounded descriptor model is implemented. - `Admin` is a delegated operations role. `Owner` remains the site-control role. - Owner-only defaults include protected secrets, Security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, full-data exports/downloads, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency global operational controls. diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index baf04a73..24cdf4d9 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -46,6 +46,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update success/failure reporting stays with the existing Operation, Scheduler, and Message layers. A separate persistent GeoIP update-history table or settings blob is intentionally not planned for this branch because it would duplicate Scheduler run history and live Operation feedback. - No public API response adds GeoIP data in this branch. - GeoIP enablement, database path/status, and the MaxMind license key are protected/audited Statistics configuration surfaces; license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. +- GeoIP settings and the manual database-update action should declare stable access metadata for the later ACL matrix. Initial defaults are Owner-only and non-configurable in this slice; a later Admin ACL enforcement branch may surface them in an Owner-only matrix with `Feature`, `Required ACL`, and `Configurable` columns. - The first MaxMind implementation uses the installed `geoip2/geoip2` package against a configured local `.mmdb` database. Request-time lookups must not download databases or require outbound network access. - The first production settings surface intentionally avoids a provider dropdown, Account ID field, and explicit GeoIP locale field until the product has a concrete need for them. MaxMind Reader locales are derived from `localization.default_language` with `en` as stable fallback. - New setups explicitly seed GeoIP as disabled, keep the MaxMind license key empty and sensitive, and use `var/geoip2/GeoLite2-City.mmdb` as the default project-relative database path. diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index 2d675320..9b5b2127 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -4,12 +4,15 @@ namespace App\Backend; +use App\Core\Access\AccessActor; use App\Core\Diagnostics\SystemInfoProvider; use App\Core\Geo\GeoIpResolverInterface; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\LogFileBrowser; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Statistics\AccessStatisticsSnapshotProvider; +use App\Entity\UserAccount; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; final readonly class AdminViewContextProvider @@ -21,6 +24,8 @@ public function __construct( private SystemInfoProvider $systemInfoProvider, private MaxMindGeoIpConfig $maxMindGeoIpConfig, private GeoIpResolverInterface $geoIpResolver, + private Security $security, + private BackendActions $backendActions, ) { } @@ -48,6 +53,7 @@ public function variables(Request $request, ?BackendViewDefinition $view): array 'backend-admin-settings-statistics' => [ 'geoip_settings' => [ 'has_license_key' => $this->maxMindGeoIpConfig->hasLicenseKey(), + 'can_update' => [] !== $this->backendActions->definitions([BackendActions::GEOIP_DATABASE_UPDATE], $this->actor()), 'status' => $this->geoIpResolver->status()->toSafeArray(), ], ], @@ -65,4 +71,11 @@ private function operationVariables(): array 'operation_lock' => $this->liveOperationRunStore->runnerLockStatus(3600), ]; } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } } diff --git a/src/Backend/BackendActionResponder.php b/src/Backend/BackendActionResponder.php index a989215c..1237ec4d 100644 --- a/src/Backend/BackendActionResponder.php +++ b/src/Backend/BackendActionResponder.php @@ -5,10 +5,12 @@ namespace App\Backend; use App\Backend\BackendMessageKey; +use App\Core\Access\AccessActor; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationHttpResponder; use App\Core\Workflow\WorkflowResult; +use App\Entity\UserAccount; use App\Form\FormTokenValidator; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; @@ -44,13 +46,13 @@ public function respond(Request $request, mixed $user): Response ); if ('1' === $this->stringField($request, '_operation_live')) { - $result = $validToken ? $this->backendActions->startLive($action) : $this->invalidCsrfResult($action); + $result = $validToken ? $this->backendActions->startLive($action, $this->actor($user)) : $this->invalidCsrfResult($action); $this->audit($user, $action, $result, 'live'); return $this->liveOperationResponder->render($result); } - $result = $validToken ? $this->backendActions->run($action) : $this->invalidCsrfResult($action); + $result = $validToken ? $this->backendActions->run($action, $this->actor($user)) : $this->invalidCsrfResult($action); $this->flashResult($result); $this->audit($user, $action, $result, 'sync'); @@ -71,6 +73,11 @@ private function invalidCsrfResult(string $action): WorkflowResult ], ['action' => $action]); } + private function actor(mixed $user): AccessActor + { + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + /** * @param WorkflowResult $result */ diff --git a/src/Backend/BackendActions.php b/src/Backend/BackendActions.php index 297b7507..d2a4058e 100644 --- a/src/Backend/BackendActions.php +++ b/src/Backend/BackendActions.php @@ -6,6 +6,11 @@ use App\Backend\BackendMessageCode; use App\Backend\BackendMessageKey; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\Access\AccessMessageCode; +use App\Core\Access\AccessMessageKey; +use App\Core\Access\AccessRule; use App\Core\Message\Message; use App\Core\Operation\ActionQueue; use App\Core\Operation\Live\LiveOperationQueueFactory; @@ -41,8 +46,9 @@ public function __construct( * * @return list */ - public function definitions(array $ids = []): array + public function definitions(array $ids = [], ?AccessActor $actor = null): array { + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); $definitions = [ self::PACKAGE_DISCOVERY => [ 'id' => self::PACKAGE_DISCOVERY, @@ -67,28 +73,50 @@ public function definitions(array $ids = []): array 'label_key' => 'admin.actions.geoip_database_update.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'backend.action.geoip_database_update', + 'access_configurable' => false, + 'access_rule' => AccessRule::from(AccessLevel::OWNER), ], ]; if ([] === $ids) { - return array_values($definitions); + return array_values(array_map( + $this->publicDefinition(...), + array_filter( + $definitions, + fn (array $definition): bool => $this->definitionAllows($definition, $actor), + ), + )); } - return array_values(array_filter( - array_map(static fn (string $id): ?array => $definitions[$id] ?? null, $ids), - )); + $visible = []; + foreach ($ids as $id) { + $definition = $definitions[$id] ?? null; + + if ($this->definitionAllows($definition, $actor)) { + $visible[] = $this->publicDefinition($definition); + } + } + + return $visible; } /** * @return WorkflowResult */ - public function run(string $action): WorkflowResult + public function run(string $action, ?AccessActor $actor = null): WorkflowResult { + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); + + if (!$this->actionAllows($action, $actor)) { + return $this->accessDenied($action, $actor); + } + return match ($action) { self::PACKAGE_DISCOVERY => ($this->packageDiscoveryRunner)('admin_ui'), self::ASSET_REBUILD => $this->assetRebuildDispatcher->dispatch($this->kernel->getEnvironment(), 'admin_ui'), self::CACHE_CLEAR => $this->clearCache(), - self::GEOIP_DATABASE_UPDATE => $this->startLive($action), + self::GEOIP_DATABASE_UPDATE => $this->startLive($action, $actor), default => WorkflowResult::invalid([ Message::warning( BackendMessageCode::BACKEND_ACTION_UNKNOWN, @@ -103,8 +131,14 @@ public function run(string $action): WorkflowResult /** * @return WorkflowResult> */ - public function startLive(string $action): WorkflowResult + public function startLive(string $action, ?AccessActor $actor = null): WorkflowResult { + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); + + if (!$this->actionAllows($action, $actor)) { + return $this->accessDenied($action, $actor); + } + return match ($action) { self::PACKAGE_DISCOVERY => $this->liveOperationStarter->start( LiveOperationQueueFactory::PACKAGE_DISCOVERY, @@ -137,6 +171,73 @@ public function startLive(string $action): WorkflowResult }; } + /** + * @param array|null $definition + */ + private function definitionAllows(?array $definition, AccessActor $actor): bool + { + if (null === $definition) { + return false; + } + + $rule = $definition['access_rule'] ?? null; + + return !$rule instanceof AccessRule || $rule->allows($actor); + } + + /** + * @param array $definition + * + * @return array + */ + private function publicDefinition(array $definition): array + { + unset($definition['access_rule']); + + return $definition; + } + + private function actionAllows(string $action, AccessActor $actor): bool + { + if (!in_array($action, [ + self::PACKAGE_DISCOVERY, + self::ASSET_REBUILD, + self::CACHE_CLEAR, + self::GEOIP_DATABASE_UPDATE, + ], true)) { + return true; + } + + return [] !== $this->definitions([$action], $actor); + } + + /** + * @return WorkflowResult + */ + private function accessDenied(string $action, AccessActor $actor): WorkflowResult + { + return WorkflowResult::invalid([ + Message::warning( + AccessMessageCode::ACCESS_DENIED, + AccessMessageKey::ACCESS_DENIED, + [ + '%capability%' => 'backend.action.'.$action, + '%required_level%' => AccessLevel::OWNER, + '%actor_level%' => $actor->accessLevel(), + ], + [ + ...$actor->toContext(), + 'capability' => 'backend.action.'.$action, + 'required_access_level' => AccessLevel::OWNER, + ], + ), + ], [ + 'action' => $action, + 'required_access_level' => AccessLevel::OWNER, + 'actor_access_level' => $actor->accessLevel(), + ]); + } + /** * @return WorkflowResult */ diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 9607b37a..24469330 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -219,14 +219,14 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): $auditAction = 'settings.core.save'; $auditContext = ['section' => $context['settings_section']]; $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) - ? $this->coreSettingsFormHandler->submit($context['settings_section'], $request->request->all(), $this->actor()->userUid()) + ? $this->coreSettingsFormHandler->submit($context['settings_section'], $request->request->all(), $this->actor()->userUid(), $this->actor()) : $this->invalidCsrfResult($request); } elseif ('backend-admin-settings-packages' === $view->uid()) { $expectedFormId = 'admin-settings-packages'; $auditAction = 'settings.core.save'; $auditContext = ['section' => 'packages']; $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) - ? $this->coreSettingsFormHandler->submit('packages', $request->request->all(), $this->actor()->userUid()) + ? $this->coreSettingsFormHandler->submit('packages', $request->request->all(), $this->actor()->userUid(), $this->actor()) : $this->invalidCsrfResult($request); } elseif (isset($context['package_name']) && is_string($context['package_name'])) { $expectedFormId = 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($context['package_name'])); diff --git a/src/Core/Config/Api/SettingsApiHandler.php b/src/Core/Config/Api/SettingsApiHandler.php index b9ef356f..beeb9ac9 100644 --- a/src/Core/Config/Api/SettingsApiHandler.php +++ b/src/Core/Config/Api/SettingsApiHandler.php @@ -12,6 +12,7 @@ use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; +use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; use App\Core\Config\Settings\CoreSettingsFormHandler; use App\Core\Message\CommonMessageCode; @@ -43,16 +44,17 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + $actor = $this->actor($request); $section = $this->sectionFromPath($request->getPathInfo()); if ($request->isMethod(Request::METHOD_PATCH)) { return null === $section ? $this->notFound($request, null) - : $this->updateSection($request, $section); + : $this->updateSection($request, $section, $actor); } $settings = null === $section - ? $this->readModel->sections() - : $this->readModel->settings($section); + ? $this->readModel->sections($actor) + : $this->readModel->settings($section, $actor); if (null !== $section && [] === $settings) { return $this->notFound($request, $section); } @@ -75,9 +77,9 @@ private function sectionFromPath(string $path): ?string return '' === $section ? null : $section; } - private function updateSection(Request $request, string $section): Response + private function updateSection(Request $request, string $section, AccessActor $actor): Response { - $currentValues = $this->readModel->values($section); + $currentValues = $this->readModel->values($section, $actor); if ([] === $currentValues) { return $this->notFound($request, $section); } @@ -102,13 +104,13 @@ private function updateSection(Request $request, string $section): Response $result = $this->formHandler->submit($section, [ ...$currentValues, ...$values, - ], $context?->actor()->username()); + ], $context?->actor()->username(), $actor); if (!$result->isValid()) { return $this->validationFailed($request, $result->errors(), ['section' => $section]); } - $settings = $this->readModel->settings($section); + $settings = $this->readModel->settings($section, $actor); return $this->responder->data($settings, meta: [ 'count' => count($settings), @@ -141,6 +143,11 @@ private function invalidRequest(Request $request, string $reason): Response ); } + private function actor(Request $request): AccessActor + { + return ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + } + /** * @param array $errors * @param array $context diff --git a/src/Core/Config/Api/SettingsApiReadModel.php b/src/Core/Config/Api/SettingsApiReadModel.php index 5493459e..cbb1a520 100644 --- a/src/Core/Config/Api/SettingsApiReadModel.php +++ b/src/Core/Config/Api/SettingsApiReadModel.php @@ -4,6 +4,8 @@ namespace App\Core\Config\Api; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; use App\Core\Config\Config; use App\Core\Config\Settings\CoreSettingsRegistry; @@ -18,11 +20,16 @@ public function __construct( /** * @return list> */ - public function sections(): array + public function sections(?AccessActor $actor = null): array { $sections = []; + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); foreach ($this->settings->allDefinitions() as $definition) { + if (!$definition->allows($actor)) { + continue; + } + $field = $definition->formField(); if (false === ($field->metadata()['persist'] ?? true)) { continue; @@ -48,15 +55,20 @@ public function sections(): array /** * @return list> */ - public function settings(?string $section = null): array + public function settings(?string $section = null, ?AccessActor $actor = null): array { $resources = []; + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); foreach ($this->settings->allDefinitions() as $definition) { if (null !== $section && $definition->section() !== $section) { continue; } + if (!$definition->allows($actor)) { + continue; + } + $field = $definition->formField(); if (false === ($field->metadata()['persist'] ?? true)) { @@ -89,15 +101,20 @@ public function settings(?string $section = null): array /** * @return array */ - public function values(string $section): array + public function values(string $section, ?AccessActor $actor = null): array { $values = []; + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); foreach ($this->settings->allDefinitions() as $definition) { if ($definition->section() !== $section) { continue; } + if (!$definition->allows($actor)) { + continue; + } + $field = $definition->formField(); if (false === ($field->metadata()['persist'] ?? true)) { continue; diff --git a/src/Core/Config/Settings/CoreSettingDefinition.php b/src/Core/Config/Settings/CoreSettingDefinition.php index 600721a2..ff979a42 100644 --- a/src/Core/Config/Settings/CoreSettingDefinition.php +++ b/src/Core/Config/Settings/CoreSettingDefinition.php @@ -4,6 +4,9 @@ namespace App\Core\Config\Settings; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\Access\AccessRule; use App\Core\Config\ConfigValueType; use App\Form\FormFieldDefinition; use App\Form\FormInputType; @@ -58,6 +61,33 @@ public function metadata(): array return $this->metadata; } + public function minimumAccessLevel(): int + { + $level = $this->metadata['minimum_access_level'] ?? AccessLevel::ADMIN; + + return is_int($level) ? AccessLevel::assert($level) : AccessLevel::ADMIN; + } + + public function allowsAccessLevel(int $accessLevel): bool + { + return $this->allows(AccessActor::fromAccess($accessLevel)); + } + + public function accessRule(): AccessRule + { + $groups = $this->metadata['access_groups'] ?? []; + + return AccessRule::from( + $this->minimumAccessLevel(), + is_array($groups) ? $groups : [], + ); + } + + public function allows(AccessActor $actor): bool + { + return $this->accessRule()->allows($actor); + } + public function formField(): FormFieldDefinition { return new FormFieldDefinition( diff --git a/src/Core/Config/Settings/CoreSettingsFormHandler.php b/src/Core/Config/Settings/CoreSettingsFormHandler.php index 9d56b936..e860f733 100644 --- a/src/Core/Config/Settings/CoreSettingsFormHandler.php +++ b/src/Core/Config/Settings/CoreSettingsFormHandler.php @@ -5,8 +5,9 @@ namespace App\Core\Config\Settings; use App\Api\ApiFeaturePolicy; -use App\Core\Config\Config; +use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; +use App\Core\Config\Config; use App\Core\Validation\EmailAddress; use App\Entity\AclGroup; use App\Form\FormErrorKey; @@ -31,9 +32,9 @@ public function __construct( /** * @param array $submitted */ - public function submit(string $section, array $submitted, ?string $modifiedBy = null): FormSubmissionResult + public function submit(string $section, array $submitted, ?string $modifiedBy = null, ?AccessActor $actor = null): FormSubmissionResult { - $definitions = $this->registry->definitions($section); + $definitions = $this->definitionsForActor($section, $actor ?? AccessActor::fromAccess(AccessLevel::ADMIN)); $result = $this->submissionHandler->submit( array_map(static fn (CoreSettingDefinition $definition): FormFieldDefinition => $definition->formField(), $definitions), $submitted, @@ -76,6 +77,17 @@ public function submit(string $section, array $submitted, ?string $modifiedBy = return $result; } + /** + * @return list + */ + private function definitionsForActor(string $section, AccessActor $actor): array + { + return array_values(array_filter( + $this->registry->definitions($section), + static fn (CoreSettingDefinition $definition): bool => $definition->allows($actor), + )); + } + private function validateDomainSettings(string $section, FormSubmissionResult $result): ?FormSubmissionResult { if ('api' === $section) { diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index b0e004d2..ee770ddb 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -5,6 +5,7 @@ namespace App\Core\Config\Settings; use App\Api\ApiFeaturePolicy; +use App\Core\Access\AccessLevel; use App\Core\Config\ConfigValueType; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; @@ -86,11 +87,21 @@ public function allDefinitions(): array new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ + 'access_feature' => 'settings.statistics.geoip', + 'access_configurable' => false, + 'minimum_access_level' => AccessLevel::OWNER, 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', ], sortOrder: 30), - new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], sortOrder: 40), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], metadata: [ + 'access_feature' => 'settings.statistics.geoip', + 'access_configurable' => false, + 'minimum_access_level' => AccessLevel::OWNER, + ], sortOrder: 40), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'admin.settings.fields.geoip_license_key.label', '', ConfigValueType::String, FormInputType::Password, help: 'admin.settings.fields.geoip_license_key.help', validation: ['max_length' => 180], metadata: [ + 'access_feature' => 'settings.statistics.geoip', + 'access_configurable' => false, + 'minimum_access_level' => AccessLevel::OWNER, 'sensitive' => true, 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', diff --git a/src/Core/Geo/GeoIpResult.php b/src/Core/Geo/GeoIpResult.php index 46d4dae8..05ddd2ad 100644 --- a/src/Core/Geo/GeoIpResult.php +++ b/src/Core/Geo/GeoIpResult.php @@ -7,13 +7,23 @@ final readonly class GeoIpResult { public const PLACEHOLDER = 'n/a'; + public const MAX_LABEL_LENGTH = 80; + + public string $city; + public string $state; + public string $country; + public string $continent; public function __construct( - public string $city = self::PLACEHOLDER, - public string $state = self::PLACEHOLDER, - public string $country = self::PLACEHOLDER, - public string $continent = self::PLACEHOLDER, + string $city = self::PLACEHOLDER, + string $state = self::PLACEHOLDER, + string $country = self::PLACEHOLDER, + string $continent = self::PLACEHOLDER, ) { + $this->city = self::label($city); + $this->state = self::label($state); + $this->country = self::label($country); + $this->continent = self::label($continent); } /** @@ -28,4 +38,19 @@ public function toArray(): array 'continent' => $this->continent, ]; } + + private static function label(string $value): string + { + $value = trim($value); + + if ('' === $value) { + return self::PLACEHOLDER; + } + + if (function_exists('mb_substr')) { + return mb_substr($value, 0, self::MAX_LABEL_LENGTH); + } + + return substr($value, 0, self::MAX_LABEL_LENGTH); + } } diff --git a/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php b/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php index 73f3848a..01e0c7dc 100644 --- a/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php +++ b/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php @@ -9,6 +9,7 @@ use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; final readonly class HttpMaxMindGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface { @@ -28,13 +29,23 @@ public function download(string $url, string $targetPath): WorkflowResult } try { - $response = $this->httpClient()->request('GET', $url, [ + $client = $this->httpClient(); + $response = $client->request('GET', $url, [ 'timeout' => 60.0, 'max_duration' => 180.0, - 'buffer' => $target, ]); $status = $response->getStatusCode(); - $response->getContent(false); + + if ($status >= 200 && $status < 300 && !$this->writeResponse($client, $response, $target)) { + fclose($target); + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + ['stage' => 'download'], + ); + } } catch (TransportExceptionInterface) { fclose($target); @unlink($targetPath); @@ -79,6 +90,47 @@ public function download(string $url, string $targetPath): WorkflowResult return WorkflowResult::success(null, ['stage' => 'download']); } + /** + * @param resource $target + */ + private function writeResponse(HttpClientInterface $client, ResponseInterface $response, mixed $target): bool + { + foreach ($client->stream($response) as $chunk) { + $content = $chunk->getContent(); + + if ('' === $content) { + continue; + } + + if (!$this->writeAll($target, $content)) { + return false; + } + } + + return fflush($target); + } + + /** + * @param resource $target + */ + private function writeAll(mixed $target, string $content): bool + { + $offset = 0; + $length = strlen($content); + + while ($offset < $length) { + $written = @fwrite($target, substr($content, $offset)); + + if (!is_int($written) || $written <= 0) { + return false; + } + + $offset += $written; + } + + return true; + } + private function httpClient(): HttpClientInterface { return $this->httpClient ?? HttpClient::create(); diff --git a/src/View/Twig/AdminViewTwigExtension.php b/src/View/Twig/AdminViewTwigExtension.php index b1af2a84..51b0e1f6 100644 --- a/src/View/Twig/AdminViewTwigExtension.php +++ b/src/View/Twig/AdminViewTwigExtension.php @@ -5,6 +5,7 @@ namespace App\View\Twig; use App\Backend\BackendActions; +use App\Core\Access\AccessActor; use App\Core\Config\Config; use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreSettingsRegistry; @@ -12,9 +13,11 @@ use App\Core\Package\Settings\PackageSettingRegistry; use App\Core\Package\Settings\PackageSettings; use App\Core\Package\ThemeAdminOverview; +use App\Entity\UserAccount; use App\Form\FormBuilder; use App\Form\FormFieldDefinition; use App\View\SystemPackageMetadataProvider; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Throwable; @@ -33,6 +36,7 @@ public function __construct( private readonly PackageSettings $packageSettings, private readonly PackageSettingRegistry $packageSettingRegistry, private readonly SystemPackageMetadataProvider $systemPackageMetadata, + private readonly Security $security, private readonly RequestStack $requestStack, ) { } @@ -70,7 +74,7 @@ public function extensionPackages(): array */ public function backendActions(array $ids = []): array { - return $this->backendActions->definitions($ids); + return $this->backendActions->definitions($ids, $this->actor()); } /** @@ -116,7 +120,7 @@ public function footerCopyright(string $area = 'frontend'): string */ public function coreSettingsForm(string $section): array { - $definitions = $this->coreSettingsRegistry->definitions($section); + $definitions = $this->coreSettingDefinitions($section); $values = []; $request = $this->requestStack->getCurrentRequest(); @@ -142,6 +146,19 @@ public function coreSettingsForm(string $section): array )->toArray(); } + /** + * @return list + */ + private function coreSettingDefinitions(string $section): array + { + $actor = $this->actor(); + + return array_values(array_filter( + $this->coreSettingsRegistry->definitions($section), + static fn (CoreSettingDefinition $definition): bool => $definition->allows($actor), + )); + } + /** * @return array */ @@ -197,6 +214,13 @@ private function defaultFooterCopyright(): string return trim(sprintf('Powered by %s %s', $linkedName, $version)); } + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + /** * @param list $excludedKeys * diff --git a/templates/backend/admin/settings/section.html.twig b/templates/backend/admin/settings/section.html.twig index 03a7e6cc..c8335364 100644 --- a/templates/backend/admin/settings/section.html.twig +++ b/templates/backend/admin/settings/section.html.twig @@ -26,7 +26,7 @@ {% if settings_form %} {% include '@backend/partials/forms/_dynamic.html.twig' with {form: settings_form} only %} {% endif %} - {% if settings_section == 'statistics' and geoip_settings.status|default(null) %} + {% if settings_section == 'statistics' and geoip_settings.can_update|default(false) and geoip_settings.status|default(null) %} {% set geoip_status = geoip_settings.status %}

{{ 'admin.settings.geoip.status.title'|trans }}

@@ -54,7 +54,7 @@ {% endif %}
{% endif %} - {% if settings_section == 'statistics' and geoip_settings.has_license_key|default(false) %} + {% if settings_section == 'statistics' and geoip_settings.can_update|default(false) and geoip_settings.has_license_key|default(false) %} {% include '@backend/admin/partials/_backend-actions.html.twig' with { actions: backend_actions(['geoip_database_update']), class: 'system-settings-actions', diff --git a/tests/Controller/ApiSettingsControllerTest.php b/tests/Controller/ApiSettingsControllerTest.php index 95fa3cbb..493b9d23 100644 --- a/tests/Controller/ApiSettingsControllerTest.php +++ b/tests/Controller/ApiSettingsControllerTest.php @@ -108,7 +108,7 @@ public function testSettingsSectionCanBePatchedWithReadWriteAdminApiKeys(): void public function testSettingsPatchPreservesSensitiveValuesWhenClientEchoesProtectedPlaceholder(): void { $client = self::createClient(); - $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecret', AccessLevel::ADMIN); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecret', AccessLevel::OWNER); $config = self::getContainer()->get(Config::class); self::assertInstanceOf(Config::class, $config); $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'stored-api-secret', ConfigValueType::String, sensitive: true); @@ -127,6 +127,35 @@ public function testSettingsPatchPreservesSensitiveValuesWhenClientEchoesProtect self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); } + public function testGeoIpSettingsAreHiddenAndRejectedForDelegatedAdminApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetgeoipadmin', AccessLevel::ADMIN); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'stored-api-secret', ConfigValueType::String, sensitive: true); + + $client->request('GET', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertNull($this->optionalResourceById($payload['data'], MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + + $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + MaxMindGeoIpConfig::LICENSE_KEY_KEY => 'delegated-admin-secret', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(422); + self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + public function testSettingsPatchReturnsValidationErrors(): void { $client = self::createClient(); @@ -225,6 +254,22 @@ private function resourceById(array $resources, string $id): array self::fail(sprintf('Resource "%s" was not returned.', $id)); } + /** + * @param list> $resources + * + * @return array|null + */ + private function optionalResourceById(array $resources, string $id): ?array + { + foreach ($resources as $resource) { + if (($resource['id'] ?? null) === $id) { + return $resource; + } + } + + return null; + } + /** * @return array */ diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 11351479..bde6faa3 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -383,7 +383,7 @@ public function testAdminRouteRequiresAdministrativeAccess(): void public function testAdminRouteAllowsAccessLevelEight(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); $client->request('GET', '/admin'); self::assertResponseIsSuccessful(); @@ -940,6 +940,7 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertInstanceOf(Config::class, $config); $config->set('statistics.geoip.maxmind.license_key', '', ConfigValueType::String, sensitive: true); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/settings/statistics'); self::assertResponseIsSuccessful(); @@ -958,6 +959,16 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertResponseIsSuccessful(); self::assertSelectorExists('input[name="_backend_action"][value="geoip_database_update"]'); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $client->request('GET', '/admin/settings/statistics'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form#admin-settings-statistics'); + self::assertSelectorExists('input[name="statistics.enabled"]'); + self::assertSelectorNotExists('input[name="statistics.geoip.enabled"]'); + self::assertSelectorNotExists('input[name="statistics.geoip.maxmind.license_key"]'); + self::assertSelectorNotExists('input[name="_backend_action"][value="geoip_database_update"]'); + $client->request('GET', '/admin/settings/scheduler'); self::assertResponseIsSuccessful(); @@ -980,7 +991,7 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void public function testAdminSettingsFormsPersistCoreSettings(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); $config = self::getContainer()->get(Config::class); $logDir = self::getContainer()->getParameter('kernel.logs_dir'); @@ -1047,7 +1058,7 @@ public function testAdminSettingsFormsRenderValidationErrors(): void public function testAdminSettingsFormsDoNotReRenderSubmittedSensitiveValuesAfterValidationErrors(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $crawler = $client->request('GET', '/admin/settings/statistics'); $form = $crawler->selectButton('Save settings')->form([ 'statistics.enabled' => '1', diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index 9bdb695c..10499f09 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -4,6 +4,8 @@ namespace App\Tests\Core\Config; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\Config\Settings\CoreSettingsFormHandler; @@ -37,7 +39,7 @@ public function testItPreservesExistingSensitiveSettingsWhenSubmittedEmpty(): vo MaxMindGeoIpConfig::ENABLED_KEY => '0', MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, MaxMindGeoIpConfig::LICENSE_KEY_KEY => '', - ], 'test'); + ], 'test', AccessActor::fromAccess(AccessLevel::OWNER)); self::assertTrue($result->isValid()); self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); @@ -61,7 +63,7 @@ public function testItPreservesExistingSensitiveSettingsWhenSubmittedProtectedPl MaxMindGeoIpConfig::ENABLED_KEY => '1', MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', - ], 'test'); + ], 'test', AccessActor::fromAccess(AccessLevel::OWNER)); self::assertTrue($result->isValid()); self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); diff --git a/tests/Core/Config/SettingsApiReadModelTest.php b/tests/Core/Config/SettingsApiReadModelTest.php index d1cf2d46..3c33749b 100644 --- a/tests/Core/Config/SettingsApiReadModelTest.php +++ b/tests/Core/Config/SettingsApiReadModelTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Core\Config; use App\Core\Config\Api\SettingsApiReadModel; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\Config\Settings\CoreSettingsRegistry; @@ -25,7 +27,7 @@ public function testItRedactsSensitiveSettingsForApiDisplayButKeepsFormValuesEmp $readModel = new SettingsApiReadModel($this->registry(), $config); $licenseSetting = null; - foreach ($readModel->settings('statistics') as $setting) { + foreach ($readModel->settings('statistics', AccessActor::fromAccess(AccessLevel::OWNER)) as $setting) { if (MaxMindGeoIpConfig::LICENSE_KEY_KEY === $setting['id']) { $licenseSetting = $setting; } @@ -33,7 +35,8 @@ public function testItRedactsSensitiveSettingsForApiDisplayButKeepsFormValuesEmp self::assertIsArray($licenseSetting); self::assertSame('[protected]', $licenseSetting['attributes']['value']); - self::assertSame('', $readModel->values('statistics')[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); + self::assertSame('', $readModel->values('statistics', AccessActor::fromAccess(AccessLevel::OWNER))[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); + self::assertArrayNotHasKey(MaxMindGeoIpConfig::LICENSE_KEY_KEY, $readModel->values('statistics', AccessActor::fromAccess(AccessLevel::ADMIN))); } private function registry(): CoreSettingsRegistry diff --git a/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php b/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php new file mode 100644 index 00000000..23172bec --- /dev/null +++ b/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php @@ -0,0 +1,184 @@ +download('https://example.test/geoip.tar.gz', $target); + + self::assertTrue($result->isSuccess()); + self::assertSame('archive-chunk', file_get_contents($target)); + self::assertArrayNotHasKey('buffer', $client->lastOptions); + } finally { + @unlink($target); + @rmdir($workspace); + } + } +} + +final class ChunkedGeoIpHttpClient implements HttpClientInterface +{ + /** @var array */ + public array $lastOptions = []; + + /** + * @param list $chunks + */ + public function __construct(private readonly int $statusCode, private readonly array $chunks) + { + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $this->lastOptions = $options; + + return new ThrowingContentGeoIpResponse($this->statusCode); + } + + public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface + { + $response = $responses instanceof ResponseInterface ? $responses : iterator_to_array($responses)[0]; + + return new ChunkedGeoIpResponseStream($response, array_map( + static fn (string $content): ChunkInterface => new GeoIpResponseChunk($content), + $this->chunks, + )); + } + + public function withOptions(array $options): static + { + return $this; + } +} + +final class ThrowingContentGeoIpResponse implements ResponseInterface +{ + public function __construct(private readonly int $statusCode) + { + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getHeaders(bool $throw = true): array + { + return []; + } + + public function getContent(bool $throw = true): string + { + throw new \LogicException('The download client must stream chunks instead of materializing the response body.'); + } + + public function toArray(bool $throw = true): array + { + return []; + } + + public function cancel(): void + { + } + + public function getInfo(?string $type = null): mixed + { + return null === $type ? ['http_code' => $this->statusCode] : null; + } +} + +final class ChunkedGeoIpResponseStream implements ResponseStreamInterface +{ + private int $position = 0; + + /** + * @param list $chunks + */ + public function __construct(private readonly ResponseInterface $response, private readonly array $chunks) + { + } + + public function current(): ChunkInterface + { + return $this->chunks[$this->position]; + } + + public function next(): void + { + ++$this->position; + } + + public function key(): ResponseInterface + { + return $this->response; + } + + public function valid(): bool + { + return isset($this->chunks[$this->position]); + } + + public function rewind(): void + { + $this->position = 0; + } +} + +final class GeoIpResponseChunk implements ChunkInterface +{ + public function __construct(private readonly string $content) + { + } + + public function isTimeout(): bool + { + return false; + } + + public function isFirst(): bool + { + return false; + } + + public function isLast(): bool + { + return false; + } + + public function getInformationalStatus(): ?array + { + return null; + } + + public function getContent(): string + { + return $this->content; + } + + public function getOffset(): int + { + return 0; + } + + public function getError(): ?string + { + return null; + } +} diff --git a/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php b/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php index cac46004..f76cb644 100644 --- a/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php +++ b/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php @@ -5,6 +5,9 @@ namespace App\Tests\Core\Statistics; use App\Core\Log\AccessRequestMetadata; +use App\Core\Geo\GeoIpResolverInterface; +use App\Core\Geo\GeoIpProviderStatus; +use App\Core\Geo\GeoIpResult; use App\Core\Geo\NullGeoIpResolver; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; @@ -130,6 +133,27 @@ public function testItRedactsTokenizedPathSegments(): void self::assertStringNotContainsString('test-token', implode(' ', array_map('strval', $row))); } + public function testItBoundsGeoIpLabelsBeforeRecording(): void + { + $label = str_repeat('x', 120); + + (new DatabaseAccessStatisticsRecorder( + $this->connection, + new VisitorIdGenerator('test-secret'), + new UserAgentClassifier(), + new AccessRequestMetadata(), + new StaticGeoIpResolver(new GeoIpResult($label, $label, $label, $label)), + ))->record(Request::create('/docs', 'GET'), new Response('', 200)); + + $row = $this->connection->fetchAssociative('SELECT city, state, country, continent FROM access_statistic_event'); + + self::assertIsArray($row); + self::assertSame(80, strlen((string) $row['city'])); + self::assertSame(80, strlen((string) $row['state'])); + self::assertSame(80, strlen((string) $row['country'])); + self::assertSame(80, strlen((string) $row['continent'])); + } + public function testItDoesNotThrowWhenStatisticsTableIsUnavailable(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); @@ -234,3 +258,20 @@ public function testItDeletesStatisticEventsOlderThanThreeMonths(): void self::assertSame(1, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM access_statistic_event WHERE path = ?', ['/docs'])); } } + +final readonly class StaticGeoIpResolver implements GeoIpResolverInterface +{ + public function __construct(private GeoIpResult $result) + { + } + + public function resolve(?string $ipAddress): GeoIpResult + { + return $this->result; + } + + public function status(): GeoIpProviderStatus + { + return GeoIpProviderStatus::ready('test'); + } +} From bbeec04a01a0755ec2a9e88155c7be577e7b73bb Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 08:01:19 +0200 Subject: [PATCH 034/119] Reject unsupported GeoIP databases --- dev/WORKLOG.md | 1 + .../security-hardening/geoip-observability.md | 1 + src/Core/Geo/MaxMindGeoIpProvider.php | 9 +++++++ tests/Core/Geo/MaxMindGeoIpProviderTest.php | 27 ++++++++++++++++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ed067672..e65465df 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -93,6 +93,7 @@ - Completed an explicit #57-style PR-readiness pass for the GeoIP slice and hardened downloaded TAR validation by inspecting the compressed archive stream before `PharData` normalization and rejecting symlink, hardlink, and other non-file/non-directory entry types. - Addressed Cloud Review findings and adjacent paths: excluded the manually constructed GeoIP2 reader wrapper from service autowiring, kept sensitive Core and package setting values out of invalid form re-renders and `[protected]` round-trips, preserved configured provider diagnostics when GeoIP is not ready, and set readable permissions on replaced GeoIP databases. - Addressed the next GeoIP review round and adjacent paths: streamed MaxMind archives instead of materializing response bodies, bounded GeoIP labels before statistics inserts for strict SQL platforms, gated GeoIP settings/download controls through shared `AccessRule`/`AccessActor` metadata, added future ACL-matrix metadata and draft notes, and covered Owner/Admin API/UI behavior with focused tests. +- Addressed the non-City MaxMind database readiness review by rejecting readable non-City databases before reporting provider readiness, with coverage that prevents `city()` lookups for unsupported databases. Deferred one-off Scheduler task ACL gates to the planned Admin ACL enforcement matrix instead of adding GeoIP-specific Scheduler policy in this branch. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md index 24cdf4d9..faa80ec3 100644 --- a/dev/draft/security-hardening/geoip-observability.md +++ b/dev/draft/security-hardening/geoip-observability.md @@ -43,6 +43,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. - Provider secrets are protected config values and never rendered outside authorized Admin settings. - Scheduler task identifiers use stable system-owned names and do not expose provider credentials. The MaxMind database update task is a trusted callable scheduled daily by default and remains inactive until an operator activates it in Scheduler. +- Task-level Scheduler ACL enforcement is intentionally deferred to the Admin ACL enforcement branch and its shared feature/action matrix. This GeoIP slice keeps the direct Statistics download action Owner-only but does not introduce one-off Scheduler task gates. - Update success/failure reporting stays with the existing Operation, Scheduler, and Message layers. A separate persistent GeoIP update-history table or settings blob is intentionally not planned for this branch because it would duplicate Scheduler run history and live Operation feedback. - No public API response adds GeoIP data in this branch. - GeoIP enablement, database path/status, and the MaxMind license key are protected/audited Statistics configuration surfaces; license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. diff --git a/src/Core/Geo/MaxMindGeoIpProvider.php b/src/Core/Geo/MaxMindGeoIpProvider.php index 8282fd76..7f1d1ca6 100644 --- a/src/Core/Geo/MaxMindGeoIpProvider.php +++ b/src/Core/Geo/MaxMindGeoIpProvider.php @@ -51,6 +51,10 @@ public function status(): GeoIpProviderStatus return $this->status = GeoIpProviderStatus::unavailable($this->key(), 'database_unreadable'); } + if (!$this->databaseSupportsCityLookups($metadata->databaseType ?? null)) { + return $this->status = GeoIpProviderStatus::unavailable($this->key(), 'database_unsupported'); + } + return $this->status = GeoIpProviderStatus::ready( $this->key(), databaseEdition: is_string($metadata->databaseType) ? $metadata->databaseType : null, @@ -111,6 +115,11 @@ private function locationValue(?string $value): string return is_string($value) && '' !== trim($value) ? trim($value) : GeoIpResult::PLACEHOLDER; } + private function databaseSupportsCityLookups(mixed $databaseType): bool + { + return is_string($databaseType) && str_contains($databaseType, 'City'); + } + private function formatBuildDate(int $buildEpoch): string { return (new DateTimeImmutable('@'.$buildEpoch)) diff --git a/tests/Core/Geo/MaxMindGeoIpProviderTest.php b/tests/Core/Geo/MaxMindGeoIpProviderTest.php index dc1eedfb..e1a787de 100644 --- a/tests/Core/Geo/MaxMindGeoIpProviderTest.php +++ b/tests/Core/Geo/MaxMindGeoIpProviderTest.php @@ -114,6 +114,25 @@ public function testItReportsUnreadableDatabaseWithoutLeakingPath(): void self::assertStringNotContainsString($this->projectDir, json_encode($status->toSafeArray(), JSON_THROW_ON_ERROR)); } + public function testItRejectsReadableNonCityDatabasesBeforeReportingReady(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader('GeoLite2-Country'); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($reader), $this->projectDir); + + $status = $provider->status(); + + self::assertSame('unavailable', $status->status); + self::assertSame('database_unsupported', $status->failureCode); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(0, $reader->cityLookupCount); + } + private function enabledConfig(): MaxMindGeoIpConfig { $store = new Config($this->connection()); @@ -130,7 +149,7 @@ private function connection(): Connection return $connection; } - private function reader(): RecordingMaxMindReader + private function reader(string $databaseType = 'GeoLite2-City'): RecordingMaxMindReader { return new RecordingMaxMindReader(new City([ 'city' => ['names' => ['en' => 'Berlin']], @@ -139,7 +158,7 @@ private function reader(): RecordingMaxMindReader ], 'country' => ['names' => ['en' => 'Germany'], 'iso_code' => 'DE'], 'continent' => ['names' => ['en' => 'Europe'], 'code' => 'EU'], - ])); + ]), $databaseType); } private function defaultDatabasePath(): string @@ -181,7 +200,7 @@ final class RecordingMaxMindReader implements MaxMindGeoIpDatabaseReaderInterfac { public int $cityLookupCount = 0; - public function __construct(private readonly City $city) + public function __construct(private readonly City $city, private readonly string $databaseType = 'GeoLite2-City') { } @@ -198,7 +217,7 @@ public function metadata(): Metadata 'binary_format_major_version' => 2, 'binary_format_minor_version' => 0, 'build_epoch' => 1781481600, - 'database_type' => 'GeoLite2-City', + 'database_type' => $this->databaseType, 'languages' => ['en'], 'description' => ['en' => 'Test database'], 'ip_version' => 6, From 0fdc5e4eefb2de87a4a111668ea41ba232ed1da4 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 08:42:55 +0200 Subject: [PATCH 035/119] Prepare abuse foundation worklog --- dev/WORKLOG.md | 22 +++------------------- dev/WORKLOG_HISTORY.md | 6 ++++++ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index e65465df..aae6059d 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-16 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -76,24 +76,8 @@ ## 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-15 feat-security-geoip-observability -- Started the GeoIP observability branch by compacting the completed `feat-security-policy-docs` notes into `dev/WORKLOG_HISTORY.md`. -- Added a narrow provider-neutral GeoIP resolver foundation so access logs and access statistics keep using normalized `n/a` fallback fields until a real provider returns data. -- Verified the foundation with focused GeoIP/access-log/statistics PHPUnit coverage, PHP syntax checks, container linting, focused linting for changed files, and Git whitespace checks. -- Added the MaxMind GeoIP2 provider slice on top of the foundation: local `.mmdb` lookups via the installed `geoip2/geoip2` dependency, safe provider status, project-relative database path config, sensitive credential preservation/redaction, password-form support for secret fields, and hermetic fake-reader tests without real MaxMind credentials or network access. -- Added the narrow GeoIP2 update foundation: moved the intentionally small GeoIP settings surface to Statistics, changed the default database path to `var/geoip2/GeoLite2-City.mmdb`, derived MaxMind lookup locales from the site default language with `en` fallback, exposed a MaxMind signup help link, added an Admin Operations-backed database download action with non-JS POST fallback, added a daily scheduler callable, and added hermetic updater/scheduler tests that do not use real MaxMind credentials or network access. -- Hardened GeoIP2 download logging: the MaxMind download client now bypasses the autowired Symfony HTTP client service so the license-key query string cannot be captured by HttpClient logging/profiling, and shared log redaction treats `license_key` as sensitive context. -- Aligned first-run setup seeding with the GeoIP2 defaults by explicitly persisting GeoIP disabled, the default `var/geoip2/GeoLite2-City.mmdb` path, and an intentionally empty sensitive MaxMind license-key setting. -- Re-audited the GeoIP observability plan against the implementation, added safe Statistics settings status rendering, and clarified that persistent update-state history and coordinate fields are not planned for this branch. -- Simplified GeoIP status and the branch plan after product review: latitude/longitude and separate persistent GeoIP update-history storage are intentionally not planned because Scheduler run history and live Operation feedback cover update success/failure. -- During PR-readiness review, hardened GeoIP archive extraction by rejecting unsafe TAR member paths before extraction and added direct extractor coverage for safe and unsafe archives. - -### 2026-06-16 feat-security-geoip-observability -- Rechecked GeoIP portability and project-rule compliance, tightened Windows drive-letter rejection for configured database paths and TAR member paths, made GeoIP path tests separator-neutral, and reran full PHPUnit, JavaScript, lint, and Git whitespace verification. -- Completed an explicit #57-style PR-readiness pass for the GeoIP slice and hardened downloaded TAR validation by inspecting the compressed archive stream before `PharData` normalization and rejecting symlink, hardlink, and other non-file/non-directory entry types. -- Addressed Cloud Review findings and adjacent paths: excluded the manually constructed GeoIP2 reader wrapper from service autowiring, kept sensitive Core and package setting values out of invalid form re-renders and `[protected]` round-trips, preserved configured provider diagnostics when GeoIP is not ready, and set readable permissions on replaced GeoIP databases. -- Addressed the next GeoIP review round and adjacent paths: streamed MaxMind archives instead of materializing response bodies, bounded GeoIP labels before statistics inserts for strict SQL platforms, gated GeoIP settings/download controls through shared `AccessRule`/`AccessActor` metadata, added future ACL-matrix metadata and draft notes, and covered Owner/Admin API/UI behavior with focused tests. -- Addressed the non-City MaxMind database readiness review by rejecting readable non-City databases before reporting provider readiness, with coverage that prevents `city()` lookups for unsupported databases. Deferred one-off Scheduler task ACL gates to the planned Admin ACL enforcement matrix instead of adding GeoIP-specific Scheduler policy in this branch. +### 2026-06-16 feat-security-abuse-foundation +- Started the Abuse Foundation branch by compacting the completed GeoIP observability notes into `dev/WORKLOG_HISTORY.md` and refreshing the Security hardening drafts/project rules for the next implementation slice. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index 9efb59fa..7a911102 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -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-15 to 2026-06-16 feat-security-geoip-observability +- Implemented the GeoIP observability slice: provider-neutral resolver boundary, MaxMind/GeoIP2 local database provider, protected Statistics settings, safe provider diagnostics, Statistics/Admin status rendering, explicit setup defaults, manual Operations-backed database downloads, scheduler callable, and access log/statistics enrichment while preserving `n/a` fallbacks. +- Hardened GeoIP secrets, file handling, and portability through review rounds: no real MaxMind credentials in tests, no logged license-key URLs, project-relative `var/geoip2/GeoLite2-City.mmdb` path, Windows/path traversal rejection, compressed TAR validation, unsafe archive-member rejection, symlink/hardlink rejection, atomic replacement with readable permissions, streamed downloads, unsupported non-City database rejection, and bounded location labels for strict SQL platforms. +- Kept the slice narrow after product review: no latitude/longitude, no persistent GeoIP update-history store, no geo-blocking, no provider dropdown/account ID/locale settings, and no one-off Scheduler task ACL gates; task-level Scheduler policy is deferred to the Admin ACL enforcement matrix while direct GeoIP settings/download controls are Owner-only. +- Closed the branch with full verification and review context: full PHPUnit, JavaScript, lint, container, focused GeoIP/API/UI tests, class-map/worklog/draft updates, and Codex Cloud Review follow-ups. + ### 2026-06-15 feat-security-policy-docs - Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation policy source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. - Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch plans; then refined captcha TTLs, website burst/sustained budgets, scheduler trigger limits, high-signal probe limits, recovery login bypass behavior, captcha auto-success policy, and Admin/Owner protections. From 41da72080f49dbe88a1b44aa45172983083176fd Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 09:32:54 +0200 Subject: [PATCH 036/119] Document abuse foundation log projection scope --- dev/WORKLOG.md | 4 +++ dev/draft/0.2.x-SecurityHardeningPlan.md | 4 ++- dev/draft/0.4.x-ContactMailLogging.md | 11 +++---- .../security-hardening/abuse-foundation.md | 30 +++++++++++++++---- .../security-hardening/policy-defaults.md | 9 ++++-- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index aae6059d..de1c42b5 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -78,6 +78,10 @@ ### 2026-06-16 feat-security-abuse-foundation - Started the Abuse Foundation branch by compacting the completed GeoIP observability notes into `dev/WORKLOG_HISTORY.md` and refreshing the Security hardening drafts/project rules for the next implementation slice. +- Expanded the slice to include parallel database log projections for message, audit, and access logs while retaining the 30-day rotating file logs as the raw fallback. Added policy-bounded retention settings/defaults, `security_signal_event` passive signal storage, DB-backed Admin/API log browsing with UUID detail links, source tabs, broad hidden-field search, and source-specific filters where `DEBUG`/`INFO` are hidden by default only for level-aware sources. +- Updated the Security hardening master plan, Abuse Foundation detail plan, Logging draft, policy defaults, class map, translations, migration baseline, and setup/default settings coverage for the new logging projection and passive-signal scope. +- Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. +- Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 63f60d38..a32292fd 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -100,7 +100,9 @@ Scope: - Add request-intent classification for browser navigation, Turbo prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. - Include additional intents for setup apply, CORS preflight, package/admin operations, upload/archive validation, export/download, backup/restore, self-update, and diagnostics/support-bundle generation where those routes already exist or are introduced by later branches. - Add a central action cost catalogue with separate website and API families. +- Add database-backed log lookup projections for message, audit, and access logs while keeping 30-day rotating file logs as the durable raw operational fallback. Projections store minimized structured fields only; they do not duplicate full raw log lines. - Add database-backed passive suspicious-signal recording with TTL cleanup metadata. These records remain observational in this branch and become enforcement inputs only in later branches. +- Move Admin/API log browsing to the database projection, split the Admin Log Viewer into source tabs, keep broad free-text search over hidden identifiers/context, show only meaningful filters per source, and hide `DEBUG`/`INFO` by default through a multi-level filter where levels are meaningful. Message projections keep level data, security signals keep severity, and current access/audit projections omit level fields because they do not carry multiple meaningful levels. - Exempt `/api/live/**` from normal enforcement while allowing passive signal hooks for clearly abusive patterns. - Add audit/message events for suspicious signals with redacted context. @@ -331,7 +333,7 @@ Acceptance: - Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. - Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. -- Evaluate whether Security events should be projected into database-backed query tables alongside the 30-day rotating file logs. File logs remain the durable raw source; the open question is whether DB projection should become the preferred read model for Admin review, abuse correlation, and Security dashboards. +- Database-backed lookup projections for message, audit, access, and passive security signals are the preferred Admin/API read model for Security review and abuse correlation. File logs remain the durable raw source and operator fallback. - Define backup/export handling for short-retention security data before enabling any database-backed security projection, so restore and support workflows do not reintroduce expired IP-derived records. ## References diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index b0b6b4be..e52105a6 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -31,8 +31,9 @@ Audit logging should start with a simple proposed event set and remain easy to a - Use translated user-facing messages. - Do not expose mail transport errors directly to users. - Log delivery failures with safe context. -- Prefer filesystem-backed structured logs over database-backed operational logs for access, error, and security events. -- Keep rotating filesystem logs as the durable raw operational source with a 30-day default retention, but evaluate a parallel database-backed security event projection for query-heavy Security features. +- Keep filesystem-backed structured logs as the durable raw operational fallback for access, message, and audit events. +- Write database-backed lookup projections for message, audit, and access logs in parallel to the file channels so Admin UI, Admin API, and later Security features can query logs without scanning rotated files. +- Store passive abuse/security signals in `security_signal_event` as a separate domain event stream with policy-bounded retention. - Use stable structured log records that can be emitted as JSONL or converted to JSONL for UI filtering, scripted analysis, and retention jobs. - Start with separate channels for `access`, `error`, and `security`; keep anonymized statistics output separate from raw request-derived logs. - Define a mail template structure that themes may style but not silently break. @@ -92,8 +93,8 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Missing MaxMind API key disables GeoIP lookup, GeoIP database updates, and Geo-blocking gracefully. Logs should continue with normalized empty GeoIP values such as `N/A`. - **Decision recorded:** Keep statistics operational and privacy-conscious. - **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. -- **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context for UI filtering. -- **Implemented baseline:** Admin Logs browsing is split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser` so future export/API/support tooling can reuse smaller pieces. +- **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context as the raw fallback, and use database-backed lookup projections for Admin filtering and query-heavy Security review. +- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves the Admin/UI read path to `DatabaseLogBrowser` while retaining file parsing as an operator/fallback helper. - **Decision recorded:** Separate message, audit, and access file channels; live-operation terminal summaries use the message channel with operation-specific message keys, and raw request-derived logs stay separate from anonymized statistics output. Log files use `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` so filenames stay descriptive without product or system owner prefixes. - **Decision recorded:** Access statistics run as a separate database-backed model in parallel to raw `access` files. Each request may write a short-lived raw access-log entry plus one `access_statistic_event` row with an internally generated request id, first-party cookie-derived anonymized visitor id, method, requested path, resolved route, surface, status, duration, referrer host, preferred language, content metadata, coarse browser family, device type, bot classification, and GeoIP placeholder fields. Raw access logs keep the full user-agent, IP/proxy hints, and an optional safe inbound `correlation_id` for operational review, but IP addresses, user-agents, inbound correlation ids, and raw visitor-cookie tokens are not stored in the statistics table. `X-Request-ID` and `X-Correlation-ID` are never reused as the internal request id. Request id, visitor id, requested path, and resolved route are exposed to Twig error pages as a visitor-shareable debug reference. The latest Admin Logs snapshot is still written below `var/statistics/{environment}/access/latest.json` as a cache/output boundary so a later scheduler or richer statistics module can replace the producer without changing the Admin Logs UI. - **Decision recorded:** The first Admin Logs statistics view supports fixed windows (`1h`, `24h`, `7d`, `30d`, `all`) over stored anonymized statistic events. Long-term compaction into daily/monthly aggregates remains deferred until the statistics feature grows beyond this foundation slice. @@ -102,5 +103,5 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** First audit-worthy events should include configuration, protected setting, provider, package lifecycle, schema, import, backup restore, user/ACL, API token, and high-impact maintenance changes. The first implemented events are login success, login failure, logout, backend maintenance action starts/completions, package ZIP verification starts, and package lifecycle action starts/completions. - **Decision recorded:** Defer a generic operations-message event until after the logger is implemented; start with explicit recorder/service boundaries and keep audit/access/security logs separate from the live ActionLog overlay. - **Decision recorded:** Built-in raw log channels use 30-day rotating files by default. Long-term statistics stay separate and may move to database-backed aggregates. -- **Open decision:** Evaluate whether Security features should write a parallel database-backed security event projection in addition to the 30-day rotating file logs. File logs should remain the durable raw operational fallback because they are simple, inspectable, and safer during database degradation. A DB projection may be better for Security review, rate/abuse correlation, Admin filtering, cleanup jobs, and avoiding file scans, but must duplicate only redacted/minimized fields, define retention explicitly, and degrade without weakening enforcement or hiding diagnostics. +- **Decision recorded:** Security features write parallel database-backed lookup projections in addition to the 30-day rotating file logs. File logs remain the durable raw operational fallback because they are simple, inspectable, and safer during database degradation. The DB projection is the primary Admin/API read path, duplicates only redacted/minimized fields instead of full raw log lines, keeps level/severity only where it supports meaningful filtering, purges by bounded retention after successful writes, uses UUID detail identifiers, and degrades without weakening enforcement or hiding file-log diagnostics. - **Decision recorded:** IP addresses, IP buckets, and stable IP-derived hashes are short-retention security data and must remain queryable for at most 30 days. Longer-term statistics and security correlation should use the internal first-party visitor ID, authenticated user IDs where applicable, API key fingerprints where applicable, or aggregated anonymized dimensions. Backups, exports, diagnostics bundles, and database projections must not silently extend IP retention beyond the raw-log window. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index c7aedf50..ea4b8c71 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -31,19 +31,32 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 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, captcha refresh, captcha failure, login, registration, password reset, contact, setup apply, package/admin operation, upload/archive validation, export/download, import, 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 passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. -6. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. +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. +7. Add database-backed passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. +8. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. ## Public interfaces and data decisions - Controllers and future packages call a Studio-owned abuse facade instead of Symfony RateLimiter directly. +- Existing Monolog file channels remain the raw operational fallback with 30-day retention; database log projection tables are lookup/read-model copies for Admin UI, Admin API, and later abuse correlation. +- The first projection uses one table per log family: `message_log_entry`, `audit_log_entry`, and `access_log_entry`; passive security signals use the domain event table `security_signal_event`. +- Projection writes must happen after the normal file-log payload has been normalized/redacted and must never store raw secrets, credentials, API keys, visitor-cookie material, captcha answers, unredacted tokenized URLs, or duplicated full raw log lines. +- Message projections keep a level because message logs carry meaningful severities. Security signals keep severity. Current access and audit projections omit level fields because their existing writers only emit one operational level and the value would not support useful filtering. +- Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. +- Setup requests may already write file logs before the database exists. Database log projections and passive-signal storage must check the existing database-ready boundary before any DBAL read/write, including retention-setting lookups; when `APP_SETUP_COMPLETED` is not truthy and no explicit unready override is active, they must no-op instead of touching Doctrine/DBAL. +- Admin Logs and `/api/v1/admin/logs/**` read from database projections, not from rotated files. File logs stay inspectable for operators and useful during database degradation, but they are not the primary UI read path. +- This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. +- Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. +- The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. +- The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. - Client identity must respect Symfony trusted-proxy configuration and must not trust raw forwarding headers outside that configuration. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - Probe-path detection is configurable and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. -- First implementation uses a portable database table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, first-seen timestamp, last-seen timestamp, expiry timestamp, safe context hash, and optional audit reference. +- First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. -- Keep passive signals separate from raw file logs. If a broader database-backed security event projection is introduced later, this branch's signal store should either feed it through a documented boundary or remain the focused enforcement-oriented read model. +- Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. - IP subjects and stable IP-derived hashes must expire within 30 days. Longer-lived passive signals must use visitor ID, authenticated user ID, API key fingerprint, or aggregate keys without retaining the IP-derived subject. - TTL and expiry use an injectable clock/time boundary for deterministic tests. - Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. @@ -60,9 +73,11 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - High-signal probe paths are suspicious even when the route does not exist or is only a honeypot; later enforcement should return a generic `400` without revealing route existence. - Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. - Setup/install requests happen before an Owner session exists, so classification must not depend on authenticated recovery context for pre-setup protection. +- During setup and pre-database states, file logging remains available but database projections and signal persistence must be skipped before any DBAL call. - Upload, package, import, backup, and restore paths must not be classified as high-signal probes solely because their filenames resemble archive/database defaults; failed validation results should emit separate upload/archive signals. - Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. - Passive-signal storage failure records a safe diagnostic and must not change request outcome in this foundation branch. +- Database log projection storage failure must not break the request or the raw file log write. The UI may show fewer projected rows while file logs remain the operational fallback. - Cleanup must remove or anonymize expired IP-derived signal keys before any Admin export, support bundle, or statistics projection can expose them. ## Tests and validation @@ -73,6 +88,10 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - Test redaction in passive signal messages. +- Test database log projection writes for message, audit, and access logs without bypassing the existing file-log path. +- Test database projection and signal recorder no-op before touching DBAL while setup/database readiness is false. +- Test projection retention purge-after-write behavior and the 30-day maximum for configurable lookup retention. +- Test Admin/API log browsing reads database projections, uses UUID detail links, exposes source tabs, keeps broad free-text matching for hidden identifiers/context, shows only meaningful filters per tab, omits raw-line storage, omits access/audit level filters, and hides `DEBUG`/`INFO` by default for level-aware sources unless selected. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. - Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. - Test trusted-proxy/client-identity behavior and storage-failure degradation. @@ -82,10 +101,11 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update Security draft with facade and classification names if they become stable public extension points. - Update class map for the facade and value objects only if they are contributor-facing services. -- Update class map for the passive-signal entity/repository/cleanup command if they are added. +- Update class map for the database log browser/projector, retention policy, and passive-signal recorder if they are added. - Record default cost catalogue decisions in the worklog. - Update Security policy defaults if implementation evidence changes signal retention, subject composition, or suspicious-intent weighting. - Record whether the branch keeps only the passive-signal store or also introduces/reuses a broader security event projection. +- Carry a follow-up into `feat-security-admin-acl-enforcement` for Owner/ACL-controlled visibility and mutation of security signals, IP-bearing access projections, exports, cleanup operations, and future signal review actions. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 0ad6d55a..c68a794e 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -133,6 +133,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. - 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. - Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. +- Security-signal visibility, IP-bearing access-log visibility, signal cleanup/mutation, and future review actions need explicit Owner/ACL policy in `feat-security-admin-acl-enforcement` instead of relying indefinitely on broad Admin-area access. - Trusted-proxy configuration is part of the security boundary. Client identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use one trusted client-identity resolver and must not parse raw forwarding headers directly. - HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. @@ -177,8 +178,10 @@ The codebase and other feature drafts expose several security-relevant surfaces ## Logging And Projection Policy - Rotating file logs remain the durable raw operational source. -- A database-backed security event projection is an open read-model decision for query-heavy review and abuse correlation. -- If introduced, the projection must duplicate only minimized/redacted fields, keep IP-derived data within the 30-day limit, and degrade without weakening enforcement or hiding diagnostics. +- Database-backed message, audit, and access lookup projections are the Admin/API read model for query-heavy review and abuse correlation. +- Passive security signals are stored separately as `security_signal_event` rows with explicit expiry and remain observational until later enforcement branches consume them. +- The projection must duplicate only minimized/redacted structured fields, never full raw log lines, keep IP-derived data within the 30-day limit, purge expired rows after successful writes, and degrade without weakening enforcement or hiding file-log diagnostics. +- Level/severity fields are stored only where they support meaningful filtering: message projections keep level, security signals keep severity, and current access/audit projections omit level fields. - Backups, exports, diagnostics, and support bundles must not silently extend IP retention. ## Configuration Posture @@ -199,7 +202,7 @@ These are first soft decisions for which values should stay fixed, become protec | Enforcement order, Owner recovery, Admin/Owner lockout protection | Code-level policy and tests | No ordinary Admin setting | Requires a policy update and explicit recovery tests to change | | IP privacy ceiling and raw-secret redaction | Code-level policy and tests | No increase allowed | IP-derived data max 30 days; raw credentials, API keys, visitor tokens, session IDs, captcha answers, and full user agents are never policy records | | Raw file-log retention | Existing log configuration or code default | Yes, bounded | Default 30 days; IP-bearing logs must not become queryable beyond 30 days through archives, projections, exports, or support bundles | -| Database security event projection | Feature branch decision | Yes, bounded | Stores minimized/redacted read-model data only; must degrade without hiding diagnostics or weakening enforcement | +| Database log projections and security signals | Config-backed defaults in Abuse Foundation | Yes, bounded | Message/audit/access projections default to 30 days; visitor/user/API security signals default to 7 days; IP-derived signals default to 1 day; all IP-derived/queryable projection data remains capped at 30 days | | GeoIP enablement, database path, license key, and update task | Protected config/Admin setting with null fallback | Yes, protected and audited | License key never public; disabled/unconfigured state uses `NullGeoIpResolver`; no geo-blocking | | GeoIP license key | Secret/protected setting | Yes, protected only | Never rendered, exported, logged, or included in diagnostics | | Probe-path defaults | Code defaults plus config descriptor | Yes, audited | Defaults remain broad; patterns are anchored/normalized and tested against false positives | From 64ad1db0cbeadb2cc3e0e14d8b566510759ca420 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 09:33:07 +0200 Subject: [PATCH 037/119] Add database log projection foundation --- dev/CLASSMAP.md | 9 +- migrations/Version20260531000000.php | 101 +++++++++++ .../Config/Settings/CoreSettingsRegistry.php | 6 + src/Core/Log/AccessLogger.php | 8 +- src/Core/Log/AuditLogger.php | 8 +- src/Core/Log/DatabaseLogProjector.php | 158 ++++++++++++++++++ src/Core/Log/DatabaseLogRetentionPolicy.php | 65 +++++++ src/Core/Log/MonologMessageLogger.php | 9 +- src/Database/TablePrefix.php | 4 + src/Security/Abuse/SecuritySignalRecorder.php | 125 ++++++++++++++ src/Setup/SetupDefaultSeed.php | 6 + .../Core/Config/CoreSettingsRegistryTest.php | 8 + tests/Core/Log/DatabaseLogProjectorTest.php | 153 +++++++++++++++++ tests/Operations/SqliteMigrationTest.php | 14 ++ .../Abuse/SecuritySignalRecorderTest.php | 135 +++++++++++++++ tests/Setup/SetupDefaultSeedTest.php | 9 + translations/languages/de/admin.yaml | 23 ++- translations/languages/en/admin.yaml | 23 ++- 18 files changed, 848 insertions(+), 16 deletions(-) create mode 100644 src/Core/Log/DatabaseLogProjector.php create mode 100644 src/Core/Log/DatabaseLogRetentionPolicy.php create mode 100644 src/Security/Abuse/SecuritySignalRecorder.php create mode 100644 tests/Core/Log/DatabaseLogProjectorTest.php create mode 100644 tests/Security/Abuse/SecuritySignalRecorderTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 2e40e230..5c6994e7 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -192,13 +192,14 @@ | Event payload | `App\Core\Package\Event\PackageAssetSyncCompletedEvent` | Public observe hook dispatched with asset sync metrics after generated registries are written. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | | Value object | `App\Core\Workflow\WorkflowResult` | Value object for recoverable workflow results with success, invalid, review, blocked, failed states, message-backed issues, messages, and context. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | -| Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | -| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel and the database lookup projection without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | -| Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; 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/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.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 and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogEntryPresenter`, `App\Core\Log\LogPagination` | Parses Monolog line output and provides a small Admin Logs facade over known source discovery, reverse line reading, filtering, entry IDs/summaries, pagination, and filter option models. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | +| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/migrations/Version20260531000000.php b/migrations/Version20260531000000.php index 45e309be..14b46855 100644 --- a/migrations/Version20260531000000.php +++ b/migrations/Version20260531000000.php @@ -155,6 +155,103 @@ public function up(Schema $schema): void $this->addIndex($accessStatistic, ['country', 'occurred_at'], 'idx_access_statistic_country_at'); $this->addIndex($accessStatistic, ['continent', 'occurred_at'], 'idx_access_statistic_continent_at'); + $messageLog = $schema->createTable('message_log_entry'); + $messageLog->addColumn('uid', 'string', ['length' => 36]); + $messageLog->addColumn('occurred_at', 'datetime_immutable'); + $messageLog->addColumn('level', 'string', ['length' => 16]); + $messageLog->addColumn('message', 'string', ['length' => 255]); + $messageLog->addColumn('code', 'string', ['length' => 160, 'notnull' => false]); + $messageLog->addColumn('context', 'json'); + $this->addPrimaryKey($messageLog, 'uid'); + $this->addIndex($messageLog, ['occurred_at'], 'idx_message_log_occurred_at'); + $this->addIndex($messageLog, ['level', 'occurred_at'], 'idx_message_log_level_at'); + $this->addIndex($messageLog, ['code', 'occurred_at'], 'idx_message_log_code_at'); + + $auditLog = $schema->createTable('audit_log_entry'); + $auditLog->addColumn('uid', 'string', ['length' => 36]); + $auditLog->addColumn('occurred_at', 'datetime_immutable'); + $auditLog->addColumn('user_name', 'string', ['length' => 180]); + $auditLog->addColumn('user_uid', 'string', ['length' => 36, 'notnull' => false]); + $auditLog->addColumn('user_access_level', 'integer'); + $auditLog->addColumn('action', 'string', ['length' => 160]); + $auditLog->addColumn('request_id', 'string', ['length' => 64]); + $auditLog->addColumn('visitor_id', 'string', ['length' => 64]); + $auditLog->addColumn('requested_path', 'string', ['length' => 1024]); + $auditLog->addColumn('resolved_route', 'string', ['length' => 190]); + $auditLog->addColumn('context', 'json'); + $this->addPrimaryKey($auditLog, 'uid'); + $this->addIndex($auditLog, ['occurred_at'], 'idx_audit_log_occurred_at'); + $this->addIndex($auditLog, ['action', 'occurred_at'], 'idx_audit_log_action_at'); + $this->addIndex($auditLog, ['user_uid', 'occurred_at'], 'idx_audit_log_user_at'); + $this->addIndex($auditLog, ['request_id'], 'idx_audit_log_request_id'); + + $accessLog = $schema->createTable('access_log_entry'); + $accessLog->addColumn('uid', 'string', ['length' => 36]); + $accessLog->addColumn('occurred_at', 'datetime_immutable'); + $accessLog->addColumn('request_id', 'string', ['length' => 64]); + $accessLog->addColumn('correlation_id', 'string', ['length' => 64]); + $accessLog->addColumn('method', 'string', ['length' => 16]); + $accessLog->addColumn('path', 'string', ['length' => 1024]); + $accessLog->addColumn('requested_path', 'string', ['length' => 1024]); + $accessLog->addColumn('route', 'string', ['length' => 190]); + $accessLog->addColumn('resolved_route', 'string', ['length' => 190]); + $accessLog->addColumn('surface', 'string', ['length' => 40]); + $accessLog->addColumn('query_string', 'string', ['length' => 1024]); + $accessLog->addColumn('http_status', 'integer'); + $accessLog->addColumn('duration_ms', 'integer', ['notnull' => false]); + $accessLog->addColumn('visitor_id', 'string', ['length' => 64]); + $accessLog->addColumn('scheme', 'string', ['length' => 10]); + $accessLog->addColumn('host', 'string', ['length' => 255]); + $accessLog->addColumn('client_ip', 'string', ['length' => 45]); + $accessLog->addColumn('proxy_client_ip', 'string', ['length' => 45]); + $accessLog->addColumn('user_agent', 'string', ['length' => 500]); + $accessLog->addColumn('referrer', 'string', ['length' => 1024]); + $accessLog->addColumn('referrer_host', 'string', ['length' => 255]); + $accessLog->addColumn('accept_language', 'string', ['length' => 255]); + $accessLog->addColumn('preferred_language', 'string', ['length' => 20]); + $accessLog->addColumn('request_content_type', 'string', ['length' => 120]); + $accessLog->addColumn('response_content_type', 'string', ['length' => 120]); + $accessLog->addColumn('response_size', 'integer', ['notnull' => false]); + $accessLog->addColumn('city', 'string', ['length' => 80]); + $accessLog->addColumn('state', 'string', ['length' => 80]); + $accessLog->addColumn('country', 'string', ['length' => 80]); + $accessLog->addColumn('continent', 'string', ['length' => 80]); + $accessLog->addColumn('context', 'json'); + $this->addPrimaryKey($accessLog, 'uid'); + $this->addIndex($accessLog, ['occurred_at'], 'idx_access_log_occurred_at'); + $this->addIndex($accessLog, ['request_id'], 'idx_access_log_request_id'); + $this->addIndex($accessLog, ['visitor_id', 'occurred_at'], 'idx_access_log_visitor_at'); + $this->addIndex($accessLog, ['surface', 'occurred_at'], 'idx_access_log_surface_at'); + $this->addIndex($accessLog, ['resolved_route', 'occurred_at'], 'idx_access_log_route_at'); + $this->addIndex($accessLog, ['http_status', 'occurred_at'], 'idx_access_log_status_at'); + $this->addIndex($accessLog, ['client_ip', 'occurred_at'], 'idx_access_log_client_ip_at'); + + $securitySignalLog = $schema->createTable('security_signal_event'); + $securitySignalLog->addColumn('uid', 'string', ['length' => 36]); + $securitySignalLog->addColumn('occurred_at', 'datetime_immutable'); + $securitySignalLog->addColumn('expires_at', 'datetime_immutable'); + $securitySignalLog->addColumn('signal_type', 'string', ['length' => 80]); + $securitySignalLog->addColumn('reason_code', 'string', ['length' => 120]); + $securitySignalLog->addColumn('severity', 'string', ['length' => 16]); + $securitySignalLog->addColumn('confidence', 'integer'); + $securitySignalLog->addColumn('subject_type', 'string', ['length' => 40]); + $securitySignalLog->addColumn('subject_identifier', 'string', ['length' => 190]); + $securitySignalLog->addColumn('ip_derived', 'boolean'); + $securitySignalLog->addColumn('request_family', 'string', ['length' => 40]); + $securitySignalLog->addColumn('request_intent', 'string', ['length' => 80]); + $securitySignalLog->addColumn('request_id', 'string', ['length' => 64]); + $securitySignalLog->addColumn('visitor_id', 'string', ['length' => 64]); + $securitySignalLog->addColumn('path', 'string', ['length' => 1024]); + $securitySignalLog->addColumn('route', 'string', ['length' => 190]); + $securitySignalLog->addColumn('http_status', 'integer', ['notnull' => false]); + $securitySignalLog->addColumn('context', 'json'); + $this->addPrimaryKey($securitySignalLog, 'uid'); + $this->addIndex($securitySignalLog, ['occurred_at'], 'idx_security_signal_occurred_at'); + $this->addIndex($securitySignalLog, ['expires_at'], 'idx_security_signal_expires_at'); + $this->addIndex($securitySignalLog, ['subject_type', 'subject_identifier', 'occurred_at'], 'idx_security_signal_subject_at'); + $this->addIndex($securitySignalLog, ['signal_type', 'occurred_at'], 'idx_security_signal_type_at'); + $this->addIndex($securitySignalLog, ['reason_code', 'occurred_at'], 'idx_security_signal_reason_at'); + $aclGroup = $schema->createTable('acl_group'); $aclGroup->addColumn('uid', 'string', ['length' => 36]); $aclGroup->addColumn('identifier', 'string', ['length' => 80]); @@ -390,6 +487,10 @@ public function down(Schema $schema): void 'user_acl_group', 'user_account', 'acl_group', + 'security_signal_event', + 'access_log_entry', + 'audit_log_entry', + 'message_log_entry', 'scheduler_task_run', 'scheduler_task', 'package_setting_entry', diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index ee770ddb..8cee9b60 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -9,6 +9,7 @@ use App\Core\Config\ConfigValueType; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; @@ -84,6 +85,11 @@ public function allDefinitions(): array ConfigAuditLogPolicy::CATEGORY_SETTINGS => 'admin.settings.options.audit.settings', ConfigAuditLogPolicy::CATEGORY_OTHER => 'admin.settings.options.audit.other', ], sortOrder: 50), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.message_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.message_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 60), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.audit_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.audit_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 70), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 80), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 90), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_ip_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_ip_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 100), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ diff --git a/src/Core/Log/AccessLogger.php b/src/Core/Log/AccessLogger.php index 11929c08..aadae9d3 100644 --- a/src/Core/Log/AccessLogger.php +++ b/src/Core/Log/AccessLogger.php @@ -20,6 +20,7 @@ public function __construct( private VisitorIdGenerator $visitorIdGenerator, private AccessRequestMetadata $accessRequestMetadata, private GeoIpResolverInterface $geoIpResolver, + private ?DatabaseLogProjector $databaseLogProjector = null, ) { } @@ -29,7 +30,7 @@ public function log(Request $request, Response $response): void $geoIp = $this->geoIpResolver->resolve($this->visitorIdGenerator->sourceIp($request)); $path = $this->accessRequestMetadata->sanitizedPath($request); - $this->logger->info('access.request', [ + $context = [ 'request_id' => $this->accessRequestMetadata->requestId($request), 'correlation_id' => $this->accessRequestMetadata->correlationId($request), 'method' => $request->getMethod(), @@ -60,7 +61,10 @@ public function log(Request $request, Response $response): void 'state' => $geoIp->state, 'country' => $geoIp->country, 'continent' => $geoIp->continent, - ]); + ]; + + $this->logger->info('access.request', $context); + $this->databaseLogProjector?->recordAccess($context); } private function userAgent(Request $request): string diff --git a/src/Core/Log/AuditLogger.php b/src/Core/Log/AuditLogger.php index b592955e..be0428af 100644 --- a/src/Core/Log/AuditLogger.php +++ b/src/Core/Log/AuditLogger.php @@ -19,6 +19,7 @@ public function __construct( private ?RequestStack $requestStack = null, private ?AccessRequestMetadata $accessRequestMetadata = null, private ?VisitorIdGenerator $visitorIdGenerator = null, + private ?DatabaseLogProjector $databaseLogProjector = null, ) { } @@ -31,13 +32,16 @@ public function log(AccessActor $actor, string $action, array $context = []): vo return; } - $this->logger->info($action, [ + $payload = [ 'user' => $actor->username() ?? 'anonymous', 'user_uid' => $actor->userUid(), 'user_access_level' => $actor->accessLevel(), 'action' => $action, 'context' => $this->normalize($this->withRequestTrace($context)), - ]); + ]; + + $this->logger->info($action, $payload); + $this->databaseLogProjector?->recordAudit($payload); } /** diff --git a/src/Core/Log/DatabaseLogProjector.php b/src/Core/Log/DatabaseLogProjector.php new file mode 100644 index 00000000..75cb24d2 --- /dev/null +++ b/src/Core/Log/DatabaseLogProjector.php @@ -0,0 +1,158 @@ + $context + */ + public function recordMessage(string $level, string $message, array $context): void + { + $this->write('message', 'message_log_entry', [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $this->now(), + 'level' => $this->short($level, 16), + 'message' => $this->short($message, 255), + 'code' => $this->optionalShort($context['code'] ?? null, 160), + 'context' => $this->json($context), + ]); + } + + /** + * @param array $context + */ + public function recordAudit(array $context): void + { + $nested = is_array($context['context'] ?? null) ? $context['context'] : []; + + $this->write('audit', 'audit_log_entry', [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $this->now(), + 'user_name' => $this->short($context['user'] ?? 'anonymous', 180), + 'user_uid' => $this->optionalShort($context['user_uid'] ?? null, 36), + 'user_access_level' => is_numeric($context['user_access_level'] ?? null) ? (int) $context['user_access_level'] : 0, + 'action' => $this->short($context['action'] ?? self::PLACEHOLDER, 160), + 'request_id' => $this->short($nested['request_id'] ?? self::PLACEHOLDER, 64), + 'visitor_id' => $this->short($nested['visitor_id'] ?? self::PLACEHOLDER, 64), + 'requested_path' => $this->short($nested['requested_path'] ?? self::PLACEHOLDER, 1024), + 'resolved_route' => $this->short($nested['resolved_route'] ?? self::PLACEHOLDER, 190), + 'context' => $this->json($nested), + ]); + } + + /** + * @param array $context + */ + public function recordAccess(array $context): void + { + $this->write('access', 'access_log_entry', [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $this->now(), + 'request_id' => $this->short($context['request_id'] ?? self::PLACEHOLDER, 64), + 'correlation_id' => $this->short($context['correlation_id'] ?? self::PLACEHOLDER, 64), + 'method' => $this->short($context['method'] ?? self::PLACEHOLDER, 16), + 'path' => $this->short($context['path'] ?? self::PLACEHOLDER, 1024), + 'requested_path' => $this->short($context['requested_path'] ?? $context['path'] ?? self::PLACEHOLDER, 1024), + 'route' => $this->short($context['route'] ?? self::PLACEHOLDER, 190), + 'resolved_route' => $this->short($context['resolved_route'] ?? $context['route'] ?? self::PLACEHOLDER, 190), + 'surface' => $this->short($context['surface'] ?? self::PLACEHOLDER, 40), + 'query_string' => $this->short($context['query_string'] ?? '', 1024), + 'http_status' => is_numeric($context['http_status'] ?? null) ? (int) $context['http_status'] : 0, + 'duration_ms' => is_numeric($context['duration_ms'] ?? null) ? (int) $context['duration_ms'] : null, + 'visitor_id' => $this->short($context['visitor_id'] ?? self::PLACEHOLDER, 64), + 'scheme' => $this->short($context['scheme'] ?? self::PLACEHOLDER, 10), + 'host' => $this->short($context['host'] ?? self::PLACEHOLDER, 255), + 'client_ip' => $this->short($context['client_ip'] ?? $context['ip'] ?? self::PLACEHOLDER, 45), + 'proxy_client_ip' => $this->short($context['proxy_client_ip'] ?? self::PLACEHOLDER, 45), + 'user_agent' => $this->short($context['user_agent'] ?? self::PLACEHOLDER, 500), + 'referrer' => $this->short($context['referrer'] ?? self::PLACEHOLDER, 1024), + 'referrer_host' => $this->short($context['referrer_host'] ?? self::PLACEHOLDER, 255), + 'accept_language' => $this->short($context['accept_language'] ?? self::PLACEHOLDER, 255), + 'preferred_language' => $this->short($context['preferred_language'] ?? self::PLACEHOLDER, 20), + 'request_content_type' => $this->short($context['request_content_type'] ?? self::PLACEHOLDER, 120), + 'response_content_type' => $this->short($context['response_content_type'] ?? self::PLACEHOLDER, 120), + 'response_size' => is_numeric($context['response_size'] ?? null) ? (int) $context['response_size'] : null, + 'city' => $this->short($context['city'] ?? self::PLACEHOLDER, 80), + 'state' => $this->short($context['state'] ?? self::PLACEHOLDER, 80), + 'country' => $this->short($context['country'] ?? self::PLACEHOLDER, 80), + 'continent' => $this->short($context['continent'] ?? self::PLACEHOLDER, 80), + 'context' => $this->json($context), + ]); + } + + /** + * @param array $values + */ + private function write(string $source, string $table, array $values): void + { + if (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady()) { + return; + } + + try { + $this->connection->insert($table, $values); + $this->purge($source, $table); + } catch (Throwable) { + return; + } + } + + private function purge(string $source, string $table): void + { + $cutoff = (new DateTimeImmutable())->sub(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSource($source).'D')); + $this->connection->executeStatement('DELETE FROM '.$table.' WHERE occurred_at < ?', [ + $cutoff->format('Y-m-d H:i:s'), + ]); + } + + private function now(): string + { + return (new DateTimeImmutable())->format('Y-m-d H:i:s'); + } + + private function short(mixed $value, int $length): string + { + $value = is_scalar($value) ? (string) $value : self::PLACEHOLDER; + $value = trim($value); + + return mb_substr('' === $value ? self::PLACEHOLDER : $value, 0, $length); + } + + private function optionalShort(mixed $value, int $length): ?string + { + if (null === $value || '' === trim((string) $value)) { + return null; + } + + return $this->short($value, $length); + } + + private function json(mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (Throwable) { + return '{}'; + } + } +} diff --git a/src/Core/Log/DatabaseLogRetentionPolicy.php b/src/Core/Log/DatabaseLogRetentionPolicy.php new file mode 100644 index 00000000..8c018c53 --- /dev/null +++ b/src/Core/Log/DatabaseLogRetentionPolicy.php @@ -0,0 +1,65 @@ + $this->days(self::AUDIT_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS), + 'access' => $this->days(self::ACCESS_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS), + 'message' => $this->days(self::MESSAGE_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS), + default => self::DEFAULT_LOG_RETENTION_DAYS, + }; + } + + public function retentionDaysForSignal(bool $ipDerived): int + { + return $ipDerived + ? $this->days(self::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, self::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS) + : $this->days(self::SECURITY_SIGNAL_RETENTION_DAYS_KEY, self::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS); + } + + private function days(string $key, int $default): int + { + try { + $encoded = $this->connection->fetchOne('SELECT value FROM config_entry WHERE config_key = ?', [$key]); + } catch (Throwable) { + return $default; + } + + if (!is_string($encoded)) { + return $default; + } + + try { + $value = json_decode($encoded, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return $default; + } + + $days = is_int($value) ? $value : (is_numeric($value) ? (int) $value : $default); + + return max(1, min(self::MAX_IP_DERIVED_RETENTION_DAYS, $days)); + } +} diff --git a/src/Core/Log/MonologMessageLogger.php b/src/Core/Log/MonologMessageLogger.php index d87fc727..0fd64b07 100644 --- a/src/Core/Log/MonologMessageLogger.php +++ b/src/Core/Log/MonologMessageLogger.php @@ -21,7 +21,10 @@ final class MonologMessageLogger implements MessageLoggerInterface */ private array $seenSignatures = []; - public function __construct(private readonly LoggerInterface $logger) + public function __construct( + private readonly LoggerInterface $logger, + private readonly ?DatabaseLogProjector $databaseLogProjector = null, + ) { } @@ -56,7 +59,9 @@ public function logBatch(iterable $records): void } try { - $this->logger->log($this->psrLevel($message->level()), $message->translationKey(), $context); + $level = $this->psrLevel($message->level()); + $this->logger->log($level, $message->translationKey(), $context); + $this->databaseLogProjector?->recordMessage(strtoupper($level), $message->translationKey(), $context); $this->seenSignatures[$signature] = true; } catch (Throwable) { continue; diff --git a/src/Database/TablePrefix.php b/src/Database/TablePrefix.php index 17426340..b99c7488 100644 --- a/src/Database/TablePrefix.php +++ b/src/Database/TablePrefix.php @@ -12,10 +12,12 @@ * @var list */ public const TABLES = [ + 'access_log_entry', 'access_statistic_event', 'account_token', 'acl_group', 'api_key', + 'audit_log_entry', 'config_entry', 'content_field_value', 'content_item', @@ -24,9 +26,11 @@ 'content_schema_version', 'extension_package', 'messenger_messages', + 'message_log_entry', 'package_setting_entry', 'scheduler_task', 'scheduler_task_run', + 'security_signal_event', 'site_menu', 'site_menu_item', 'state_marker', diff --git a/src/Security/Abuse/SecuritySignalRecorder.php b/src/Security/Abuse/SecuritySignalRecorder.php new file mode 100644 index 00000000..893addab --- /dev/null +++ b/src/Security/Abuse/SecuritySignalRecorder.php @@ -0,0 +1,125 @@ + $context + */ + public function record( + string $signalType, + string $reasonCode, + string $subjectType, + string $subjectIdentifier, + bool $ipDerived = false, + string $severity = 'INFO', + int $confidence = 50, + string $requestFamily = 'unknown', + string $requestIntent = 'unknown', + string $requestId = self::PLACEHOLDER, + string $visitorId = self::PLACEHOLDER, + string $path = self::PLACEHOLDER, + string $route = self::PLACEHOLDER, + ?int $httpStatus = null, + array $context = [], + ): void { + if (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady()) { + return; + } + + $now = new DateTimeImmutable(); + $expiresAt = $now->add(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSignal($ipDerived).'D')); + $context = [ + ...$context, + 'signal_type' => $this->short($signalType, 80), + 'reason_code' => $this->short($reasonCode, 120), + 'subject_type' => $this->short($subjectType, 40), + 'subject_identifier' => $this->short($subjectIdentifier, 190), + 'ip_derived' => $ipDerived, + 'request_family' => $this->short($requestFamily, 40), + 'request_intent' => $this->short($requestIntent, 80), + ]; + + try { + $this->connection->insert(self::TABLE, [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $now->format('Y-m-d H:i:s'), + 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), + 'signal_type' => $context['signal_type'], + 'reason_code' => $context['reason_code'], + 'severity' => $this->severity($severity), + 'confidence' => max(0, min(100, $confidence)), + 'subject_type' => $context['subject_type'], + 'subject_identifier' => $context['subject_identifier'], + 'ip_derived' => $ipDerived ? 1 : 0, + 'request_family' => $context['request_family'], + 'request_intent' => $context['request_intent'], + 'request_id' => $this->short($requestId, 64), + 'visitor_id' => $this->short($visitorId, 64), + 'path' => $this->short($path, 1024), + 'route' => $this->short($route, 190), + 'http_status' => $httpStatus, + 'context' => $this->json($context), + ]); + $this->purgeExpired(); + } catch (Throwable) { + return; + } + } + + public function purgeExpired(): int + { + try { + return $this->connection->executeStatement('DELETE FROM '.self::TABLE.' WHERE expires_at <= ?', [ + (new DateTimeImmutable())->format('Y-m-d H:i:s'), + ]); + } catch (Throwable) { + return 0; + } + } + + private function short(mixed $value, int $length): string + { + $value = is_scalar($value) ? trim((string) $value) : self::PLACEHOLDER; + + return mb_substr('' === $value ? self::PLACEHOLDER : $value, 0, $length); + } + + private function severity(string $severity): string + { + $severity = strtoupper(trim($severity)); + + return in_array($severity, ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL'], true) ? $severity : 'INFO'; + } + + private function json(mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (Throwable) { + return '{}'; + } + } +} diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index 07d3a8b2..f2bb62b7 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -10,6 +10,7 @@ use App\Core\Config\ConfigValueType; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Content\Routing\ContentRouteLocalization; use App\Core\Statistics\AccessStatisticsPolicy; use App\Localization\LocaleToken; @@ -45,6 +46,11 @@ public function configEntries(SetupInput $input): array ['key' => UserFlowConfig::REGISTRATION_MODE_KEY, 'value' => $this->setting($input, UserFlowConfig::REGISTRATION_MODE_KEY, UserFlowConfig::REGISTRATION_DISABLED), 'type' => ConfigValueType::String], ['key' => ConfigAuditLogPolicy::ENABLED_KEY, 'value' => $this->setting($input, ConfigAuditLogPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => ConfigAuditLogPolicy::EVENTS_KEY, 'value' => $this->setting($input, ConfigAuditLogPolicy::EVENTS_KEY, ConfigAuditLogPolicy::DEFAULT_CATEGORIES), 'type' => ConfigValueType::Json], + ['key' => DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS), 'type' => ConfigValueType::Integer], ['key' => AccessStatisticsPolicy::ENABLED_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => MaxMindGeoIpConfig::ENABLED_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index bd16d4fe..f07cf06c 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -8,6 +8,7 @@ use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreConfigDefaultProvider; use App\Core\Config\Settings\CoreSettingsRegistry; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Statistics\AccessStatisticsPolicy; @@ -59,10 +60,17 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.preview', ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, + DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_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_LOG_RETENTION_DAYS, $security[5]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, $security[9]->defaultValue()); self::assertSame([ AccessStatisticsPolicy::ENABLED_KEY, diff --git a/tests/Core/Log/DatabaseLogProjectorTest.php b/tests/Core/Log/DatabaseLogProjectorTest.php new file mode 100644 index 00000000..a374add7 --- /dev/null +++ b/tests/Core/Log/DatabaseLogProjectorTest.php @@ -0,0 +1,153 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + $allowState = $this->setEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, '0'); + $connection = $this->createMock(Connection::class); + $connection->expects(self::never())->method('insert'); + $connection->expects(self::never())->method('fetchOne'); + $connection->expects(self::never())->method('executeStatement'); + + try { + $projector = new DatabaseLogProjector( + $connection, + new DatabaseLogRetentionPolicy($connection), + new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), + ); + + $projector->recordAccess([ + 'request_id' => 'setup-request', + 'method' => 'GET', + 'path' => '/setup', + ]); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); + } + } + + public function testItWritesAndPurgesDatabaseLogProjectionRows(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $this->createTables($connection); + $connection->insert('config_entry', [ + 'config_key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + 'value' => '1', + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-16 00:00:00', + 'modified_by' => 'test', + ]); + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2000-01-01 00:00:00', + 'request_id' => 'old', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/old', + 'requested_path' => '/old', + 'route' => 'old', + 'resolved_route' => 'old', + 'surface' => 'public', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => null, + 'visitor_id' => 'old-visitor', + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '127.0.0.1', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Old Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => null, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => '{}', + ]); + + $projector = new DatabaseLogProjector($connection, new DatabaseLogRetentionPolicy($connection)); + $projector->recordAccess([ + 'request_id' => 'current', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'http_status' => 200, + 'client_ip' => '203.0.113.1', + 'city' => str_repeat('x', 120), + ]); + + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM access_log_entry')); + self::assertSame('current', $connection->fetchOne('SELECT request_id FROM access_log_entry')); + self::assertSame(80, strlen((string) $connection->fetchOne('SELECT city FROM access_log_entry'))); + } + + private function createTables(\Doctrine\DBAL\Connection $connection): void + { + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + } + + /** + * @return array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server_value' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env_value' => $_ENV[$key] ?? null, + 'getenv_value' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server_value']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env_value']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv_value'] ? putenv($key) : putenv($key.'='.$state['getenv_value']); + } +} diff --git a/tests/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index 014cbd1e..a6c9a4f6 100644 --- a/tests/Operations/SqliteMigrationTest.php +++ b/tests/Operations/SqliteMigrationTest.php @@ -38,6 +38,10 @@ public function testMigrationsApplyToConfiguredSqliteDatabase(): void self::assertContains('ui_alert_inbox', $tables); self::assertContains('config_entry', $tables); self::assertContains('state_marker', $tables); + self::assertContains('message_log_entry', $tables); + self::assertContains('audit_log_entry', $tables); + self::assertContains('access_log_entry', $tables); + self::assertContains('security_signal_event', $tables); self::assertContains('access_statistic_event', $tables); self::assertContains('content_schema', $tables); self::assertContains('content_revision', $tables); @@ -99,6 +103,10 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void static fn ($index): string => $index->getName(), $schema->getTable('ui_alert_inbox')->getIndexes(), ); + $signalIndexes = array_map( + static fn ($index): string => $index->getName(), + $schema->getTable('security_signal_event')->getIndexes(), + ); $userGroupForeignKeys = array_map( static fn ($foreignKey): string => $foreignKey->getName(), $schema->getTable('user_acl_group')->getForeignKeys(), @@ -110,6 +118,8 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void self::assertContains('studio_pk_ui_alert_inbox', $alertIndexes); self::assertContains('studio_idx_ui_alert_inbox_topic_cursor', $alertIndexes); self::assertContains('studio_idx_ui_alert_inbox_expires_at', $alertIndexes); + self::assertContains('studio_pk_security_signal_event', $signalIndexes); + self::assertContains('studio_idx_security_signal_subject_at', $signalIndexes); self::assertContains('studio_fk_user_acl_group_user', $userGroupForeignKeys); self::assertContains('studio_fk_user_acl_group_group', $userGroupForeignKeys); } finally { @@ -198,6 +208,10 @@ private function initialMigrationTables(): array 'package_setting_entry', 'scheduler_task', 'scheduler_task_run', + 'message_log_entry', + 'audit_log_entry', + 'access_log_entry', + 'security_signal_event', 'state_marker', 'access_statistic_event', 'acl_group', diff --git a/tests/Security/Abuse/SecuritySignalRecorderTest.php b/tests/Security/Abuse/SecuritySignalRecorderTest.php new file mode 100644 index 00000000..9b0abb4d --- /dev/null +++ b/tests/Security/Abuse/SecuritySignalRecorderTest.php @@ -0,0 +1,135 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + $allowState = $this->setEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, '0'); + $connection = $this->createMock(Connection::class); + $connection->expects(self::never())->method('insert'); + $connection->expects(self::never())->method('fetchOne'); + $connection->expects(self::never())->method('executeStatement'); + + try { + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), + ); + + $recorder->record('probe', 'security.probe.setup', 'visitor', 'visitor-id'); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); + } + } + + public function testItRecordsSignalsWithShortIpDerivedRetentionAndPurgesExpiredRows(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, + 'value' => '1', + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-16 00:00:00', + 'modified_by' => 'test', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2000-01-01 00:00:00', + 'expires_at' => '2000-01-02 00:00:00', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.old', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'ip_hash', + 'subject_identifier' => 'old', + 'ip_derived' => 1, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'old', + 'visitor_id' => 'n/a', + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{}', + ]); + + $recorder = new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)); + $recorder->record( + 'probe', + 'security.probe.env', + 'ip_hash', + 'hash-value', + ipDerived: true, + severity: 'warning', + confidence: 140, + requestFamily: 'browser', + requestIntent: 'suspicious_probe', + path: '/.env', + httpStatus: 400, + ); + + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame('security.probe.env', $connection->fetchOne('SELECT reason_code FROM security_signal_event')); + self::assertSame(100, (int) $connection->fetchOne('SELECT confidence FROM security_signal_event')); + self::assertSame(1, (int) $connection->fetchOne('SELECT ip_derived FROM security_signal_event')); + self::assertGreaterThan(new \DateTimeImmutable(), new \DateTimeImmutable((string) $connection->fetchOne('SELECT expires_at FROM security_signal_event'))); + } + + /** + * @return array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server_value' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env_value' => $_ENV[$key] ?? null, + 'getenv_value' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server_value']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env_value']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv_value'] ? putenv($key) : putenv($key.'='.$state['getenv_value']); + } +} diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index 24530761..237fb789 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -7,6 +7,7 @@ use App\Api\ApiFeaturePolicy; use App\Core\Config\ConfigDefaultProviderInterface; use App\Core\Geo\MaxMindGeoIpConfig; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Setup\DatabaseDriver; use App\Setup\SetupDefaultSeed; use App\Setup\SetupInput; @@ -33,6 +34,9 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertFalse($settings[MaxMindGeoIpConfig::ENABLED_KEY]); self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]); self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY]); } public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void @@ -68,6 +72,11 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( UserFlowConfig::REGISTRATION_MODE_KEY, \App\Core\Log\ConfigAuditLogPolicy::ENABLED_KEY, \App\Core\Log\ConfigAuditLogPolicy::EVENTS_KEY, + DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY, \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, MaxMindGeoIpConfig::ENABLED_KEY, diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 7f051684..6b7ff2f1 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -598,19 +598,21 @@ admin: foundation_text: 'Operations-Logs, Audit-Ansichten, Paketdiagnostik und sicherheitsrelevante Ereignisse docken hier an.' empty_value: 'n/a' sources: + label: 'Logquellen' application: 'Anwendung' message: 'Meldungen' audit: 'Audit' access: 'Zugriffe' + security_signal: 'Security-Signale' filters: title: 'Filter' source: 'Log' level: 'Level' - any_level: 'Alle Level' search: 'Suche' window: 'Zeitraum' match: 'Treffer' audit_action: 'Audit-Aktion' + signal_reason: 'Signal-Grund' per_page: 'Zeilen' limit: 'Limit' contains: 'Enthält' @@ -625,8 +627,6 @@ admin: entries: title: 'Einträge' empty: 'Keine Log-Einträge entsprechen den aktuellen Filtern.' - no_files: 'Für diese Auswahl existiert noch keine Log-Datei.' - files: 'Gelesen wird: %files%' open: 'Details' page_summary: 'Seite %page% von %pages% · %total% Einträge' previous: 'Zurück' @@ -642,6 +642,8 @@ admin: location: 'Ort' user: 'Nutzer' action: 'Aktion' + security_signal: 'Signal' + subject: 'Subjekt' channel: 'Channel' context: 'Kontext' details: 'Details' @@ -809,6 +811,21 @@ admin: label: 'Audit-Logging aktivieren' audit_events: label: 'Audit-Ereigniskategorien' + message_log_retention_days: + label: 'Datenbank-Retention für Meldungslogs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. File-Logs behalten separat ihre feste 30-Tage-Rotation.' + audit_log_retention_days: + label: 'Datenbank-Retention für Audit-Logs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Audit-Daten Request-bezogene Identifier enthalten können.' + access_log_retention_days: + label: 'Datenbank-Retention für Access-Logs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Access-Logs IP-abgeleitete Daten enthalten können.' + security_signal_retention_days: + label: 'Retention für Security-Signale' + help: 'Standard-Retention für passive Security-Signale, die über Visitor-, User- oder API-Identität geschlüsselt sind.' + security_signal_ip_retention_days: + label: 'Retention für IP-abgeleitete Signale' + help: 'Kurze Retention für Signale, die nur über IP-abgeleitete Identifier geschlüsselt sind. Unterhalb der 30-Tage-Datenschutzgrenze halten.' geoip_enabled: label: 'GeoIP-Lookups aktivieren' help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index c648d117..ccdbb0d9 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -598,19 +598,21 @@ admin: foundation_text: 'Operational logs, audit views, package diagnostics, and security-relevant events will attach here.' empty_value: 'n/a' sources: + label: 'Log sources' application: 'Application' message: 'Messages' audit: 'Audit' access: 'Access' + security_signal: 'Security signals' filters: title: 'Filters' source: 'Log' level: 'Level' - any_level: 'Any level' search: 'Search' window: 'Time window' match: 'Match' audit_action: 'Audit action' + signal_reason: 'Signal reason' per_page: 'Rows' limit: 'Limit' contains: 'Contains' @@ -625,8 +627,6 @@ admin: entries: title: 'Entries' empty: 'No log entries match the current filters.' - no_files: 'No log file exists for this selection yet.' - files: 'Reading: %files%' open: 'Details' page_summary: 'Page %page% of %pages% · %total% entries' previous: 'Previous' @@ -642,6 +642,8 @@ admin: location: 'Location' user: 'User' action: 'Action' + security_signal: 'Signal' + subject: 'Subject' channel: 'Channel' context: 'Context' details: 'Details' @@ -809,6 +811,21 @@ admin: label: 'Enable audit logging' audit_events: label: 'Audit event categories' + message_log_retention_days: + label: 'Message log database retention' + help: 'Database lookup copy retention in days. File logs keep their fixed 30-day rotation separately.' + audit_log_retention_days: + label: 'Audit log database retention' + help: 'Database lookup copy retention in days. Values above 30 days are not allowed because audit data can contain request-derived identifiers.' + access_log_retention_days: + label: 'Access log database retention' + help: 'Database lookup copy retention in days. Values above 30 days are not allowed because access logs can contain IP-derived data.' + security_signal_retention_days: + label: 'Security signal retention' + help: 'Default retention for passive security signals that are keyed by visitor, user, or API identity.' + security_signal_ip_retention_days: + label: 'IP-derived signal retention' + help: 'Short retention for signals that are keyed only by IP-derived identifiers. Keep this lower than the 30-day privacy ceiling.' geoip_enabled: label: 'Enable GeoIP lookups' help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' From 784f1042a24fd3e349e85113c66d6e11dc1c74ab Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 09:33:15 +0200 Subject: [PATCH 038/119] Move admin log browsing to database projections --- src/Api/Admin/AdminLogApiHandler.php | 21 +- .../AdminOperationalApiEndpointProvider.php | 2 +- src/Backend/AdminViewContextProvider.php | 6 +- src/Controller/BackendController.php | 8 +- src/Core/Log/DatabaseLogBrowser.php | 310 ++++++++++++++++++ src/Core/Log/LogEntryFilter.php | 34 +- src/Core/Log/LogFileBrowser.php | 4 + src/Core/Log/LogPagination.php | 2 +- templates/backend/admin/log-detail.html.twig | 14 +- templates/backend/admin/logs.html.twig | 70 ++-- .../ApiAdminOperationalControllerTest.php | 8 + tests/Controller/BackendControllerTest.php | 58 +++- tests/Core/Log/DatabaseLogBrowserTest.php | 131 ++++++++ 13 files changed, 600 insertions(+), 68 deletions(-) create mode 100644 src/Core/Log/DatabaseLogBrowser.php create mode 100644 tests/Core/Log/DatabaseLogBrowserTest.php diff --git a/src/Api/Admin/AdminLogApiHandler.php b/src/Api/Admin/AdminLogApiHandler.php index 9bff4019..7d46698d 100644 --- a/src/Api/Admin/AdminLogApiHandler.php +++ b/src/Api/Admin/AdminLogApiHandler.php @@ -12,7 +12,7 @@ use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Core\Access\AccessLevel; -use App\Core\Log\LogFileBrowser; +use App\Core\Log\DatabaseLogBrowser; use App\Core\Message\Message; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -20,7 +20,7 @@ final readonly class AdminLogApiHandler implements ApiEndpointHandlerInterface { public function __construct( - private LogFileBrowser $logs, + private DatabaseLogBrowser $logs, private ApiListQueryNormalizer $listQueries, private ApiAccessGuard $accessGuard, private ApiResponder $responder, @@ -75,18 +75,31 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo */ private function sourceResources(array $sources): array { - return array_map(static fn (array $source): array => [ + return array_map(fn (array $source): array => [ 'type' => 'log_source', 'id' => $source['key'], 'attributes' => [ 'source' => $source['key'], 'label_key' => $source['label'], 'path' => '/api/v1/admin/logs/'.$source['key'], - 'filters' => ['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], + 'filters' => $this->filtersForSource((string) $source['key']), ], ], $sources); } + /** + * @return list + */ + private function filtersForSource(string $source): array + { + return match ($source) { + 'message' => ['level', 'q', 'match', 'time_window', 'limit', 'page'], + 'audit' => ['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], + 'security_signal' => ['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], + default => ['q', 'match', 'time_window', 'limit', 'page'], + }; + } + private function sourceFromPath(string $path): ?string { $prefix = '/api/v1/admin/logs/'; diff --git a/src/Api/Admin/AdminOperationalApiEndpointProvider.php b/src/Api/Admin/AdminOperationalApiEndpointProvider.php index 24133eab..8dc12c79 100644 --- a/src/Api/Admin/AdminOperationalApiEndpointProvider.php +++ b/src/Api/Admin/AdminOperationalApiEndpointProvider.php @@ -23,7 +23,7 @@ public function apiEndpoints(): array return [ $this->endpoint('/api/v1/admin/backups', 'listAdminBackups', 'List backup API capabilities prepared for future backup operations.', self::HANDLER_BACKUPS), $this->endpoint('/api/v1/admin/logs', 'listAdminLogSources', 'List administrative log sources visible to administrators.', self::HANDLER_LOGS), - $this->endpoint('/api/v1/admin/logs/{log}', 'listAdminLogEntries', 'List administrative log entries for one log source.', self::HANDLER_LOGS, parameters: $this->logParameters(), pathPattern: '#^/api/v1/admin/logs/[a-z]+$#'), + $this->endpoint('/api/v1/admin/logs/{log}', 'listAdminLogEntries', 'List administrative log entries for one log source.', self::HANDLER_LOGS, parameters: $this->logParameters(), pathPattern: '#^/api/v1/admin/logs/[a-z_]+$#'), $this->endpoint('/api/v1/admin/operations', 'listAdminOperations', 'List live operation runs visible to administrators.', self::HANDLER_OPERATIONS), $this->endpoint('/api/v1/admin/operations/{action}', 'runAdminOperationMaintenance', 'Review or run an administrative live-operation maintenance action.', self::HANDLER_OPERATIONS, Request::METHOD_POST, parameters: $this->maintenanceOperationParameters(), responseSchema: ['type' => 'object'], pathPattern: '#^/api/v1/admin/operations/(cleanup|clear-stale-lock|kill-stale-runner)$#'), $this->endpoint('/api/v1/admin/operations/{operation_id}', 'getAdminOperation', 'Return one live operation report visible to administrators.', self::HANDLER_OPERATIONS, parameters: $this->operationParameters(), pathPattern: '#^/api/v1/admin/operations/[a-f0-9]{32}$#'), diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index 9b5b2127..e3db372d 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -8,7 +8,7 @@ use App\Core\Diagnostics\SystemInfoProvider; use App\Core\Geo\GeoIpResolverInterface; use App\Core\Geo\MaxMindGeoIpConfig; -use App\Core\Log\LogFileBrowser; +use App\Core\Log\DatabaseLogBrowser; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Statistics\AccessStatisticsSnapshotProvider; use App\Entity\UserAccount; @@ -19,7 +19,7 @@ { public function __construct( private LiveOperationRunStore $liveOperationRunStore, - private LogFileBrowser $logFileBrowser, + private DatabaseLogBrowser $logBrowser, private AccessStatisticsSnapshotProvider $accessStatisticsSnapshotProvider, private SystemInfoProvider $systemInfoProvider, private MaxMindGeoIpConfig $maxMindGeoIpConfig, @@ -41,7 +41,7 @@ public function variables(Request $request, ?BackendViewDefinition $view): array return match ($view->uid()) { 'backend-admin-operations' => $this->operationVariables(), 'backend-admin-logs' => [ - 'log_view' => $this->logFileBrowser->browse($request->query->all()), + 'log_view' => $this->logBrowser->browse($request->query->all()), ], 'backend-admin-statistics' => [ 'access_statistics' => $this->accessStatisticsSnapshotProvider->snapshot($request->query->get('statistics_window')), diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 24469330..292742c7 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -13,8 +13,8 @@ use App\Core\Access\AccessActor; use App\Core\Message\Message; use App\Core\Config\Settings\CoreSettingsFormHandler; +use App\Core\Log\DatabaseLogBrowser; use App\Core\Log\AuditLoggerInterface; -use App\Core\Log\LogFileBrowser; use App\Core\Package\Settings\PackageSettingsFormHandler; use App\Entity\UserAccount; use App\Form\FormErrorKey; @@ -42,7 +42,7 @@ public function __construct( private readonly PackageSettingsFormHandler $packageSettingsFormHandler, private readonly AdminViewContextProvider $adminViewContextProvider, private readonly BackendActionResponder $backendActionResponder, - private readonly LogFileBrowser $logFileBrowser, + private readonly DatabaseLogBrowser $logBrowser, private readonly AuditLoggerInterface $auditLogger, private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, @@ -55,7 +55,7 @@ public function adminIndex(Request $request): Response return $this->handle($request, BackendArea::Admin); } - #[Route('/admin/logs/{entryId}', name: 'backend_admin_log_detail', requirements: ['entryId' => '[a-f0-9]{24}'], methods: ['GET'])] + #[Route('/admin/logs/{entryId}', name: 'backend_admin_log_detail', requirements: ['entryId' => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'], methods: ['GET'])] public function logDetail(Request $request, string $entryId): Response { $access = $this->adminAccessResponse($request); @@ -65,7 +65,7 @@ public function logDetail(Request $request, string $entryId): Response } $source = $request->query->get('source', 'message'); - $entry = $this->logFileBrowser->entry(is_string($source) ? $source : 'message', $entryId); + $entry = $this->logBrowser->entry(is_string($source) ? $source : 'message', $entryId); if (null === $entry) { return $this->httpError->notFound($request); diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php new file mode 100644 index 00000000..4856a71e --- /dev/null +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -0,0 +1,310 @@ + ['label' => 'admin.logs.sources.message', 'table' => 'message_log_entry'], + 'audit' => ['label' => 'admin.logs.sources.audit', 'table' => 'audit_log_entry'], + 'access' => ['label' => 'admin.logs.sources.access', 'table' => 'access_log_entry'], + 'security_signal' => ['label' => 'admin.logs.sources.security_signal', 'table' => 'security_signal_event'], + ]; + + public function __construct( + private Connection $connection, + private LogEntryFilter $entryFilter = new LogEntryFilter(), + private LogPagination $pagination = new LogPagination(), + ) { + } + + /** + * @param array $query + * + * @return array + */ + public function browse(array $query): array + { + $source = $this->source($query['source'] ?? null); + $filters = $this->entryFilter->filters($query); + if (!$this->supportsLevelFilter($source)) { + $filters['level'] = ''; + $filters['levels'] = []; + } + $criteria = $this->criteria($source, $filters); + $matched = $this->count($source, $criteria); + $entries = $this->entries($source, $criteria, $filters); + + return [ + 'sources' => $this->sourceOptions(), + 'selected_source' => $source, + 'capabilities' => $this->capabilities($source), + 'filters' => $filters, + 'entries' => $entries, + 'files' => [], + 'pagination' => $this->pagination->pagination($filters, $matched), + 'per_page_options' => $this->pagination->perPageOptions(), + 'time_window_options' => $this->pagination->timeWindowOptions(), + 'match_options' => $this->pagination->matchOptions(), + ]; + } + + /** + * @return array|null + */ + public function entry(string $source, string $id): ?array + { + $source = $this->source($source); + $row = $this->connection->fetchAssociative(sprintf( + 'SELECT * FROM %s WHERE uid = ?', + self::SOURCES[$source]['table'], + ), [$id]); + + return is_array($row) ? $this->present($source, $row) : null; + } + + /** + * @return list + */ + public function sourceOptions(): array + { + $options = []; + + foreach (self::SOURCES as $key => $source) { + $options[] = ['key' => $key, 'label' => $source['label']]; + } + + return $options; + } + + private function source(mixed $source): string + { + return is_string($source) && isset(self::SOURCES[$source]) ? $source : 'message'; + } + + /** + * @return array{level_filter: bool, audit_action_filter: bool, signal_reason_filter: bool} + */ + private function capabilities(string $source): array + { + return [ + 'level_filter' => $this->supportsLevelFilter($source), + 'audit_action_filter' => 'audit' === $source, + 'signal_reason_filter' => 'security_signal' === $source, + ]; + } + + private function supportsLevelFilter(string $source): bool + { + return in_array($source, ['message', 'security_signal'], true); + } + + /** + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * + * @return array{where: list, params: list} + */ + private function criteria(string $source, array $filters): array + { + $where = ['occurred_at >= ?']; + $params = [$this->cutoff($filters['time_window'])]; + + if ($this->supportsLevelFilter($source) && [] !== $filters['levels']) { + $levelColumn = 'security_signal' === $source ? 'severity' : 'level'; + $where[] = $levelColumn.' IN ('.implode(', ', array_fill(0, count($filters['levels']), '?')).')'; + array_push($params, ...$filters['levels']); + } + + if ('audit' === $source && '' !== $filters['audit_action']) { + $where[] = 'action = ?'; + $params[] = $filters['audit_action']; + } + + if ('security_signal' === $source && '' !== $filters['audit_action']) { + $where[] = 'reason_code = ?'; + $params[] = $filters['audit_action']; + } + + if ('' !== $filters['search']) { + $columns = match ($source) { + 'access' => ['context', 'request_id', 'correlation_id', 'path', 'requested_path', 'route', 'resolved_route', 'client_ip', 'proxy_client_ip', 'visitor_id', 'host', 'user_agent', 'referrer_host'], + 'audit' => ['context', 'action', 'user_name', 'user_uid', 'request_id', 'visitor_id', 'requested_path', 'resolved_route'], + 'security_signal' => ['context', 'signal_type', 'reason_code', 'subject_type', 'subject_identifier', 'request_id', 'visitor_id', 'path', 'route'], + default => ['context', 'message', 'code'], + }; + $operator = 'equals' === $filters['match'] ? '= ?' : 'LIKE ?'; + $needle = 'equals' === $filters['match'] ? $filters['search'] : '%'.$filters['search'].'%'; + $where[] = '('.implode(' OR ', array_map(static fn (string $column): string => $column.' '.$operator, $columns)).')'; + + foreach ($columns as $_) { + $params[] = $needle; + } + } + + return ['where' => $where, 'params' => $params]; + } + + /** + * @param array{where: list, params: list} $criteria + */ + private function count(string $source, array $criteria): int + { + $value = $this->connection->fetchOne(sprintf( + 'SELECT COUNT(*) FROM %s WHERE %s', + self::SOURCES[$source]['table'], + implode(' AND ', $criteria['where']), + ), $criteria['params']); + + return is_numeric($value) ? (int) $value : 0; + } + + /** + * @param array{where: list, params: list} $criteria + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * + * @return list> + */ + private function entries(string $source, array $criteria, array $filters): array + { + $limit = 'all' === $filters['per_page'] ? 500 : (int) $filters['per_page']; + $offset = 'all' === $filters['per_page'] ? 0 : ($filters['page'] - 1) * (int) $filters['per_page']; + $sql = sprintf( + 'SELECT * FROM %s WHERE %s ORDER BY occurred_at DESC, uid DESC LIMIT %d OFFSET %d', + self::SOURCES[$source]['table'], + implode(' AND ', $criteria['where']), + $limit, + $offset, + ); + + return array_map( + fn (array $row): array => $this->present($source, $row), + $this->connection->fetchAllAssociative($sql, $criteria['params']), + ); + } + + /** + * @param array $row + * + * @return array + */ + private function present(string $source, array $row): array + { + $context = $this->decode($row['context'] ?? '{}'); + $context = $this->displayContext($source, $row, $context); + $message = $this->message($source, $row); + $entry = [ + 'id' => (string) $row['uid'], + 'timestamp' => (string) ($row['occurred_at'] ?? ''), + 'channel' => $source, + 'level' => (string) ($row['level'] ?? ('security_signal' === $source ? $row['severity'] ?? 'INFO' : '')), + 'message' => $message, + 'context' => $context, + 'context_json' => json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '', + 'raw_context' => (string) ($row['context'] ?? ''), + 'file' => '', + 'raw' => '', + 'source' => $source, + 'summary' => $this->summary($source, $row), + ]; + + return $entry; + } + + /** + * @param array $row + * @param array $context + * + * @return array + */ + private function displayContext(string $source, array $row, array $context): array + { + return match ($source) { + 'audit' => [ + ...$context, + 'user' => $row['user_name'] ?? 'anonymous', + 'user_uid' => $row['user_uid'] ?? null, + 'user_access_level' => $row['user_access_level'] ?? 0, + 'action' => $row['action'] ?? 'n/a', + 'request_id' => $row['request_id'] ?? 'n/a', + 'visitor_id' => $row['visitor_id'] ?? 'n/a', + 'requested_path' => $row['requested_path'] ?? 'n/a', + 'resolved_route' => $row['resolved_route'] ?? 'n/a', + ], + 'security_signal' => [ + ...$context, + 'signal_type' => $row['signal_type'] ?? 'n/a', + 'reason_code' => $row['reason_code'] ?? 'n/a', + 'severity' => $row['severity'] ?? 'INFO', + 'confidence' => $row['confidence'] ?? 0, + 'subject_type' => $row['subject_type'] ?? 'n/a', + 'subject_identifier' => $row['subject_identifier'] ?? 'n/a', + 'ip_derived' => (bool) ($row['ip_derived'] ?? false), + 'request_family' => $row['request_family'] ?? 'n/a', + 'request_intent' => $row['request_intent'] ?? 'n/a', + 'request_id' => $row['request_id'] ?? 'n/a', + 'visitor_id' => $row['visitor_id'] ?? 'n/a', + 'path' => $row['path'] ?? 'n/a', + 'route' => $row['route'] ?? 'n/a', + 'http_status' => $row['http_status'] ?? null, + 'expires_at' => $row['expires_at'] ?? null, + ], + default => $context, + }; + } + + /** + * @param array $row + */ + private function message(string $source, array $row): string + { + return match ($source) { + 'access' => 'access.request', + 'audit' => (string) ($row['action'] ?? 'n/a'), + 'security_signal' => (string) ($row['reason_code'] ?? $row['signal_type'] ?? 'n/a'), + default => (string) ($row['message'] ?? 'n/a'), + }; + } + + /** + * @param array $row + */ + private function summary(string $source, array $row): string + { + return match ($source) { + 'access' => trim(($row['method'] ?? 'n/a').' '.($row['requested_path'] ?? $row['path'] ?? 'n/a')), + 'audit' => (string) ($row['action'] ?? 'n/a'), + 'security_signal' => trim(($row['signal_type'] ?? 'n/a').': '.($row['reason_code'] ?? 'n/a')), + default => (string) ($row['message'] ?? 'n/a'), + }; + } + + /** + * @return array + */ + private function decode(mixed $encoded): array + { + if (!is_string($encoded) || '' === $encoded) { + return []; + } + + $decoded = json_decode($encoded, true); + + return is_array($decoded) ? $decoded : []; + } + + private function cutoff(string $window): string + { + $modifier = match ($window) { + '1h' => '-1 hour', + '7d' => '-7 days', + '30d' => '-30 days', + default => '-24 hours', + }; + + return (new \DateTimeImmutable($modifier))->format('Y-m-d H:i:s'); + } +} diff --git a/src/Core/Log/LogEntryFilter.php b/src/Core/Log/LogEntryFilter.php index 9618ac4c..9f9c937c 100644 --- a/src/Core/Log/LogEntryFilter.php +++ b/src/Core/Log/LogEntryFilter.php @@ -7,16 +7,21 @@ final readonly class LogEntryFilter { private const DEFAULT_PER_PAGE = 50; + private const DEFAULT_LEVELS = ['NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']; + private const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']; /** * @param array $query * - * @return array{level: string, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} + * @return array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} */ public function filters(array $query): array { + $levels = $this->levels($query['level'] ?? $query['levels'] ?? null); + return [ - 'level' => $this->level($query['level'] ?? null), + 'level' => 1 === count($levels) ? $levels[0] : '', + 'levels' => $levels, 'search' => $this->search($query['q'] ?? null), 'match' => $this->match($query['match'] ?? null), 'time_window' => $this->timeWindow($query['time_window'] ?? null), @@ -28,11 +33,11 @@ public function filters(array $query): array /** * @param array $entry - * @param array{level: string, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters */ public function matches(array $entry, array $filters): bool { - if ('' !== $filters['level'] && $entry['level'] !== $filters['level']) { + if ([] !== $filters['levels'] && !in_array($entry['level'], $filters['levels'], true)) { return false; } @@ -75,17 +80,24 @@ public function matchesTimeWindow(array $entry, string $window): bool return $timestamp >= $cutoff; } - private function level(mixed $level): string + /** + * @return list + */ + private function levels(mixed $level): array { - if (!is_string($level)) { - return ''; + if (null === $level || '' === $level || [] === $level) { + return self::DEFAULT_LEVELS; } - $level = strtoupper(trim($level)); + $levels = is_array($level) ? $level : [$level]; + $levels = array_values(array_unique(array_filter(array_map( + static fn (mixed $candidate): string => is_string($candidate) ? strtoupper(trim($candidate)) : '', + $levels, + )))); + + $levels = array_values(array_filter($levels, static fn (string $candidate): bool => in_array($candidate, self::LEVELS, true))); - return in_array($level, ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY'], true) - ? $level - : ''; + return [] === $levels ? self::DEFAULT_LEVELS : $levels; } private function search(mixed $search): string diff --git a/src/Core/Log/LogFileBrowser.php b/src/Core/Log/LogFileBrowser.php index 43db955f..91defc5d 100644 --- a/src/Core/Log/LogFileBrowser.php +++ b/src/Core/Log/LogFileBrowser.php @@ -27,6 +27,10 @@ public function browse(array $query): array { $source = $this->sourceRegistry->source($query['source'] ?? null); $filters = $this->entryFilter->filters($query); + if (in_array($source, ['access', 'audit'], true)) { + $filters['level'] = ''; + $filters['levels'] = []; + } $files = $this->sourceRegistry->files($this->logDir, $this->environment, $source); $entries = []; $matched = 0; diff --git a/src/Core/Log/LogPagination.php b/src/Core/Log/LogPagination.php index 485d2bd0..195bcc4d 100644 --- a/src/Core/Log/LogPagination.php +++ b/src/Core/Log/LogPagination.php @@ -7,7 +7,7 @@ final readonly class LogPagination { /** - * @param array{level: string, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters * * @return array{page: int, per_page: int|string, total: int, total_pages: int, has_previous: bool, has_next: bool, previous_page: int, next_page: int} */ diff --git a/templates/backend/admin/log-detail.html.twig b/templates/backend/admin/log-detail.html.twig index 8c9063c2..0b722940 100644 --- a/templates/backend/admin/log-detail.html.twig +++ b/templates/backend/admin/log-detail.html.twig @@ -19,7 +19,9 @@ - + {% if log_entry.level|default('') is not empty %} + + {% endif %} @@ -45,8 +47,10 @@ {% endif %} -
-

{{ 'admin.logs.detail.raw'|trans }}

-
{{ log_entry.raw|default('') }}
-
+ {% if log_entry.raw|default('') is not empty %} +
+

{{ 'admin.logs.detail.raw'|trans }}

+
{{ log_entry.raw }}
+
+ {% endif %} {% endblock %} diff --git a/templates/backend/admin/logs.html.twig b/templates/backend/admin/logs.html.twig index e94d3287..8bb8414c 100644 --- a/templates/backend/admin/logs.html.twig +++ b/templates/backend/admin/logs.html.twig @@ -14,18 +14,19 @@ title: 'admin.logs.title'|trans, } only %} + +

{{ 'admin.logs.filters.title'|trans }}

+
- - + {% if log_view.capabilities.level_filter %} + + {% endif %} - + {% if log_view.capabilities.audit_action_filter %} + + {% elseif log_view.capabilities.signal_reason_filter %} + + {% endif %}
{{ 'admin.logs.columns.timestamp'|trans }}{{ log_entry.timestamp|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.level'|trans }}{{ log_entry.level|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.level'|trans }}{{ log_entry.level }}
{{ 'admin.logs.columns.source'|trans }}{{ log_entry.source|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.file'|trans }}{{ log_entry.file|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.message'|trans }}{{ log_entry.message|default('admin.logs.empty_value'|trans) }}
- + {% if log_view.capabilities.level_filter %} + + {% endif %} {% if log_view.selected_source == 'access' %} {% elseif log_view.selected_source == 'audit' %} + {% elseif log_view.selected_source == 'security_signal' %} + + {% else %} {% endif %} @@ -103,13 +114,18 @@ {% for entry in log_view.entries %} - + {% if log_view.capabilities.level_filter %} + + {% endif %} {% if log_view.selected_source == 'access' %} {% elseif log_view.selected_source == 'audit' %} + {% elseif log_view.selected_source == 'security_signal' %} + + {% else %} {% endif %} diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index 36a725d7..f53e5c4b 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -65,6 +65,14 @@ public function testAdminLogsListSourcesAndSourceEntries(): void $payload = $this->jsonPayload($client->getResponse()->getContent()); self::assertGreaterThan(0, $payload['meta']['count']); self::assertSame('log_source', $payload['data'][0]['type']); + $sources = []; + foreach ($payload['data'] as $resource) { + $sources[$resource['id']] = $resource['attributes']['filters']; + } + self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['message']); + self::assertSame(['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['audit']); + self::assertSame(['q', 'match', 'time_window', 'limit', 'page'], $sources['access']); + self::assertSame(['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['security_signal']); $client->request('GET', '/api/v1/admin/logs/message?level=INFO&limit=25', server: [ 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index bde6faa3..e941cd65 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -552,18 +552,51 @@ public function testAdminLogsViewReadsSelectedLogSource(): void { $client = self::createClient(); $this->loginUserWithLevel($client, 8); - $logDir = self::getContainer()->getParameter('kernel.logs_dir'); - $logFile = $logDir.'/test/access-2099-01-01.log'; - - if (!is_dir($logDir.'/test')) { - mkdir($logDir.'/test', 0775, true); - } - foreach (glob($logDir.'/test/access-*.log') ?: [] as $existingLogFile) { - @unlink($existingLogFile); - } - file_put_contents($logFile, '[2099-01-01T10:00:00.000000+00:00] access.INFO: access.request {"method":"GET","path":"/admin/logs","route":"backend_admin_route","http_status":200,"ip":"127.0.0.1","city":"n/a","state":"n/a","country":"n/a","continent":"n/a"} []'.PHP_EOL); $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $connection->delete('access_log_entry', ['request_id' => 'request-admin-logs']); $connection->delete('access_statistic_event', ['route' => 'backend_admin_route']); + $accessLogContext = [ + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'http_status' => 200, + 'client_ip' => '127.0.0.1', + ]; + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000900', + 'occurred_at' => '2099-01-01 10:00:00', + 'request_id' => 'request-admin-logs', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'surface' => 'admin', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => 12, + 'visitor_id' => hash('sha256', 'test-visitor'), + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '127.0.0.1', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Test Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => 100, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => json_encode($accessLogContext, JSON_THROW_ON_ERROR), + ]); $connection->insert('access_statistic_event', [ 'uid' => '99999999-0000-7000-8000-000000000901', 'occurred_at' => '2099-01-01 10:00:00', @@ -598,6 +631,7 @@ public function testAdminLogsViewReadsSelectedLogSource(): void self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Logs'); + self::assertSelectorTextContains('.system-tabs', 'Security signals'); self::assertSelectorTextContains('.system-backend-log-table', 'GET /admin/logs'); self::assertSelectorTextContains('.system-backend-log-table', 'Details'); @@ -606,7 +640,7 @@ public function testAdminLogsViewReadsSelectedLogSource(): void self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Log event'); self::assertSelectorTextContains('body', '127.0.0.1'); - self::assertSelectorTextContains('.system-code-block', 'access.request'); + self::assertSelectorTextContains('body', 'backend_admin_route'); $client->request('GET', '/admin/statistics?statistics_window=all'); @@ -617,7 +651,7 @@ public function testAdminLogsViewReadsSelectedLogSource(): void self::assertSelectorTextContains('body', 'Unique visitors'); self::assertSelectorTextContains('body', 'Top browsers'); } finally { - @unlink($logFile); + $connection->delete('access_log_entry', ['request_id' => 'request-admin-logs']); $connection->delete('access_statistic_event', ['route' => 'backend_admin_route']); } } diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php new file mode 100644 index 00000000..f37574d9 --- /dev/null +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -0,0 +1,131 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE audit_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, user_name VARCHAR(180) NOT NULL, user_uid VARCHAR(36) DEFAULT NULL, user_access_level INTEGER NOT NULL, action VARCHAR(160) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, requested_path VARCHAR(1024) NOT NULL, resolved_route VARCHAR(190) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'level' => 'INFO', + 'message' => 'message.test', + 'code' => 'test.message', + 'context' => '{"code":"test.message"}', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'expires_at' => (new \DateTimeImmutable('+1 day'))->format('Y-m-d H:i:s'), + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.env', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-1', + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-1', + 'visitor_id' => 'visitor-1', + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{"source":"security.probe.env"}', + ]); + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000003', + 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'request_id' => 'request-access', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'surface' => 'admin', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => null, + 'visitor_id' => 'visitor-hidden-search', + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '203.0.113.10', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Example Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => null, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => '{"hidden":"visitor-hidden-search"}', + ]); + $connection->insert('audit_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000004', + 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'user_name' => 'admin', + 'user_uid' => '99999999-0000-7000-8000-000000000100', + 'user_access_level' => 8, + 'action' => 'audit.test', + 'request_id' => 'request-audit', + 'visitor_id' => 'visitor-audit', + 'requested_path' => '/admin/users', + 'resolved_route' => 'backend_admin_route', + 'context' => '{"hidden":"visitor-audit"}', + ]); + + $browser = new DatabaseLogBrowser($connection); + $defaultView = $browser->browse(['source' => 'message']); + self::assertSame(0, $defaultView['pagination']['total']); + + $infoView = $browser->browse(['source' => 'message', 'level' => 'INFO']); + self::assertSame(1, $infoView['pagination']['total']); + self::assertSame(['INFO'], $infoView['filters']['levels']); + + $accessView = $browser->browse(['source' => 'access', 'q' => 'visitor-hidden-search']); + self::assertFalse($accessView['capabilities']['level_filter']); + self::assertSame([], $accessView['filters']['levels']); + self::assertSame(1, $accessView['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000003', $accessView['entries'][0]['id']); + + $auditView = $browser->browse(['source' => 'audit', 'level' => 'ERROR', 'q' => 'visitor-audit']); + self::assertFalse($auditView['capabilities']['level_filter']); + self::assertSame([], $auditView['filters']['levels']); + self::assertSame(1, $auditView['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000004', $auditView['entries'][0]['id']); + + $view = $browser->browse(['source' => 'security_signal', 'q' => 'security.probe.env']); + self::assertTrue($view['capabilities']['level_filter']); + self::assertTrue($view['capabilities']['signal_reason_filter']); + + self::assertSame(['message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); + self::assertSame('security_signal', $view['selected_source']); + self::assertSame(1, $view['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000002', $view['entries'][0]['id']); + self::assertSame('probe: security.probe.env', $view['entries'][0]['summary']); + self::assertSame('visitor-1', $view['entries'][0]['context']['subject_identifier']); + + $entry = $browser->entry('message', '99999999-0000-7000-8000-000000000001'); + + self::assertNotNull($entry); + self::assertSame('message.test', $entry['message']); + } +} From 050f2d441f953806e636e168da5e1a704fdec229 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 09:43:58 +0200 Subject: [PATCH 039/119] Clarify trusted proxy scope for abuse foundation --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 2 +- dev/draft/security-hardening/abuse-foundation.md | 6 ++++-- dev/draft/security-hardening/policy-defaults.md | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index de1c42b5..1094e0b0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -81,6 +81,7 @@ - Expanded the slice to include parallel database log projections for message, audit, and access logs while retaining the 30-day rotating file logs as the raw fallback. Added policy-bounded retention settings/defaults, `security_signal_event` passive signal storage, DB-backed Admin/API log browsing with UUID detail links, source tabs, broad hidden-field search, and source-specific filters where `DEBUG`/`INFO` are hidden by default only for level-aware sources. - Updated the Security hardening master plan, Abuse Foundation detail plan, Logging draft, policy defaults, class map, translations, migration baseline, and setup/default settings coverage for the new logging projection and passive-signal scope. - Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. +- Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP, never parses raw forwarding headers, and keeps Visitor ID as the preferred browser continuity key behind shared/untrusted networks. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index a32292fd..76ff4373 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -288,7 +288,7 @@ Acceptance: - Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. - Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. - Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. -- Client identity must come from one reviewed resolver that respects Symfony trusted-proxy configuration. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, or package-owned client identity logic. +- Client identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, package-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. - TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. - Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. - Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual unban, mail token delivery, and remember-me token rotation. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index ea4b8c71..8f565e29 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -50,7 +50,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. - The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. - The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. -- Client identity must respect Symfony trusted-proxy configuration and must not trust raw forwarding headers outside that configuration. +- Client identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and does not parse raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. +- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. Rate limiting should stay stable across direct and proxied requests; later auto-ban policy may treat IP-only evidence from untrusted/shared-network situations with lower confidence when the resolved request context can distinguish it. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - Probe-path detection is configurable and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. @@ -94,7 +95,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test Admin/API log browsing reads database projections, uses UUID detail links, exposes source tabs, keeps broad free-text matching for hidden identifiers/context, shows only meaningful filters per tab, omits raw-line storage, omits access/audit level filters, and hides `DEBUG`/`INFO` by default for level-aware sources unless selected. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. - Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. -- Test trusted-proxy/client-identity behavior and storage-failure degradation. +- Test client-identity behavior by asserting the foundation uses Symfony's resolved request IP and does not parse raw forwarding headers; do not introduce app-managed trusted-proxy configuration in this slice. +- Test storage-failure degradation. - Test no limiter or ban enforcement occurs in this branch. ## Documentation and tracking diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index c68a794e..3f0d4e3f 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -134,7 +134,8 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. - Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. - Security-signal visibility, IP-bearing access-log visibility, signal cleanup/mutation, and future review actions need explicit Owner/ACL policy in `feat-security-admin-acl-enforcement` instead of relying indefinitely on broad Admin-area access. -- Trusted-proxy configuration is part of the security boundary. Client identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use one trusted client-identity resolver and must not parse raw forwarding headers directly. +- Trusted proxy handling is a deployment/webserver boundary, not an app-level Security settings feature. Client identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use Symfony's resolved request client IP and must not parse raw forwarding headers directly. Operators configure trusted reverse proxies through webserver/Symfony deployment config, for example `mod_remoteip` or equivalent server-level handling. +- Visitor ID remains the preferred browser continuity key. Different browsers behind the same untrusted proxy should still receive separate visitor subjects, while later auto-ban policy may treat IP-only evidence from shared/untrusted-network situations more cautiously to reduce false positives. - HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. ## Auto-Ban Defaults From 5cc51e347092dc3a1f48964dd47601071e8eca11 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 09:47:45 +0200 Subject: [PATCH 040/119] Clarify visitor identity proxy header policy --- dev/WORKLOG.md | 2 +- dev/draft/0.2.x-SecurityHardeningPlan.md | 4 ++-- dev/draft/security-hardening/abuse-foundation.md | 7 ++++--- dev/draft/security-hardening/policy-defaults.md | 6 ++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 1094e0b0..18bc7d90 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -81,7 +81,7 @@ - Expanded the slice to include parallel database log projections for message, audit, and access logs while retaining the 30-day rotating file logs as the raw fallback. Added policy-bounded retention settings/defaults, `security_signal_event` passive signal storage, DB-backed Admin/API log browsing with UUID detail links, source tabs, broad hidden-field search, and source-specific filters where `DEBUG`/`INFO` are hidden by default only for level-aware sources. - Updated the Security hardening master plan, Abuse Foundation detail plan, Logging draft, policy defaults, class map, translations, migration baseline, and setup/default settings coverage for the new logging projection and passive-signal scope. - Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. -- Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP, never parses raw forwarding headers, and keeps Visitor ID as the preferred browser continuity key behind shared/untrusted networks. +- Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP for Security identity, may use raw forwarding headers only as untrusted Visitor-ID differentiation entropy, and keeps IP-ban thresholds laxer than Visitor-ID thresholds to reduce shared/untrusted-network false positives. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 76ff4373..f45ae879 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -288,7 +288,7 @@ Acceptance: - Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. - Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. - Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. -- Client identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, package-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. +- Security identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, package-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. Visitor-ID generation may use raw forwarding-header values only as untrusted differentiation entropy and never as Security subject, GeoIP, ban, or signal evidence. - TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. - Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. - Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual unban, mail token delivery, and remember-me token rotation. @@ -312,7 +312,7 @@ Acceptance: - Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. - Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. - Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to active Admin or Owner sessions must not be banned. -- IP-based enforcement is secondary and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. +- IP-based enforcement is secondary, laxer than Visitor-ID enforcement, and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. - Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. - Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. - Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 8f565e29..f675da98 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -50,8 +50,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. - The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. - The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. -- Client identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and does not parse raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. -- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. Rate limiting should stay stable across direct and proxied requests; later auto-ban policy may treat IP-only evidence from untrusted/shared-network situations with lower confidence when the resolved request context can distinguish it. +- Security identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and security signals, rate-limit subjects, bans, GeoIP, and audit decisions must not trust raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. +- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to reduce accidental visitor merging when the same resolved IP presents different `X-Forwarded-For` chains. Those raw header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. +- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. Rate limiting should stay stable across direct and proxied requests. Later auto-ban policy should keep IP-ban/block thresholds laxer than Visitor-ID thresholds to reduce false positives on shared or untrusted-network IPs while still allowing IP blocking as a secondary cookie-reset bypass defense. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - Probe-path detection is configurable and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. @@ -95,7 +96,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test Admin/API log browsing reads database projections, uses UUID detail links, exposes source tabs, keeps broad free-text matching for hidden identifiers/context, shows only meaningful filters per tab, omits raw-line storage, omits access/audit level filters, and hides `DEBUG`/`INFO` by default for level-aware sources unless selected. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. - Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. -- Test client-identity behavior by asserting the foundation uses Symfony's resolved request IP and does not parse raw forwarding headers; do not introduce app-managed trusted-proxy configuration in this slice. +- Test client-identity behavior by asserting the foundation uses Symfony's resolved request IP for security subjects and does not trust raw forwarding headers for signals, bans, GeoIP, or audit decisions; do not introduce app-managed trusted-proxy configuration in this slice. - Test storage-failure degradation. - Test no limiter or ban enforcement occurs in this branch. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 3f0d4e3f..eccb71e0 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -134,8 +134,9 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. - Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. - Security-signal visibility, IP-bearing access-log visibility, signal cleanup/mutation, and future review actions need explicit Owner/ACL policy in `feat-security-admin-acl-enforcement` instead of relying indefinitely on broad Admin-area access. -- Trusted proxy handling is a deployment/webserver boundary, not an app-level Security settings feature. Client identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use Symfony's resolved request client IP and must not parse raw forwarding headers directly. Operators configure trusted reverse proxies through webserver/Symfony deployment config, for example `mod_remoteip` or equivalent server-level handling. -- Visitor ID remains the preferred browser continuity key. Different browsers behind the same untrusted proxy should still receive separate visitor subjects, while later auto-ban policy may treat IP-only evidence from shared/untrusted-network situations more cautiously to reduce false positives. +- Trusted proxy handling is a deployment/webserver boundary, not an app-level Security settings feature. Security identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use Symfony's resolved request client IP and must not trust raw forwarding headers directly. Operators configure trusted reverse proxies through webserver/Symfony deployment config, for example `mod_remoteip` or equivalent server-level handling. +- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to avoid merging unrelated browsers behind the same resolved IP when their `X-Forwarded-For` chains differ. Raw forwarding-header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. +- Visitor ID remains the preferred browser continuity key. Different browsers behind the same untrusted proxy should still receive separate visitor subjects. IP bans/blocks remain allowed as a secondary cookie-reset bypass defense, but their thresholds should be laxer than Visitor-ID thresholds so shared or untrusted-network IPs have a lower false-positive risk. - HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. ## Auto-Ban Defaults @@ -151,6 +152,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - repeated IP ban within 24 hours: 6 hours; - severe repeated IP abuse: up to 24 hours; - maximum: 7 days. +- IP-ban/block thresholds should stay laxer than Visitor-ID thresholds because one resolved IP may represent multiple users behind shared hosting, NAT, or an untrusted proxy. Use Visitor-ID evidence first where available, and treat IP-only evidence as a secondary escalation signal unless the signal is severe. - API-key bans use key fingerprint/prefix only: - invalid-key probe ban: 15 minutes; - repeated invalid-key probe ban: 1 hour; From 1c97e8e3708270929103519e9d3b57d447a97133 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 09:54:10 +0200 Subject: [PATCH 041/119] Use forwarding entropy for visitor fallback IDs --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/0.4.x-ContactMailLogging.md | 2 +- src/Core/Statistics/VisitorIdGenerator.php | 9 ++++ .../Statistics/VisitorIdGeneratorTest.php | 49 +++++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 5c6994e7..9dddbb6e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,7 @@ | Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | | Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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, 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` | +| Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\State\StateMarkerRecorder` | Upserts current/last lifecycle state markers through Doctrine so marker writes stay atomic with the owning mutation, and reads ordered marker summaries for detail views such as Admin User account history. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/State/StateMarkerRecorderTest.php`, `tests/Controller/UserControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 18bc7d90..d49b5bb5 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -82,6 +82,7 @@ - Updated the Security hardening master plan, Abuse Foundation detail plan, Logging draft, policy defaults, class map, translations, migration baseline, and setup/default settings coverage for the new logging projection and passive-signal scope. - Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. - Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP for Security identity, may use raw forwarding headers only as untrusted Visitor-ID differentiation entropy, and keeps IP-ban thresholds laxer than Visitor-ID thresholds to reduce shared/untrusted-network false positives. +- Implemented the Visitor-ID entropy half of that policy by mixing normalized forwarding-header candidates into cookie-less fallback visitor hashes only; Security identity, GeoIP, ban keys, and signal evidence still use Symfony's resolved client IP rather than raw proxy headers. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index e52105a6..0faee0f9 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -92,7 +92,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** The MaxMind API key is configured through protected admin configuration and stored in database-backed configuration with restricted access. - **Decision recorded:** Missing MaxMind API key disables GeoIP lookup, GeoIP database updates, and Geo-blocking gracefully. Logs should continue with normalized empty GeoIP values such as `N/A`. - **Decision recorded:** Keep statistics operational and privacy-conscious. -- **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. +- **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request; normalized forwarding-header candidates may be mixed into that fallback only as untrusted differentiation entropy and never as Security identity, GeoIP input, ban key, or signal evidence. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent/forwarding-entropy fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. - **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context as the raw fallback, and use database-backed lookup projections for Admin filtering and query-heavy Security review. - **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves the Admin/UI read path to `DatabaseLogBrowser` while retaining file parsing as an operator/fallback helper. - **Decision recorded:** Separate message, audit, and access file channels; live-operation terminal summaries use the message channel with operation-specific message keys, and raw request-derived logs stay separate from anonymized statistics output. Log files use `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` so filenames stay descriptive without product or system owner prefixes. diff --git a/src/Core/Statistics/VisitorIdGenerator.php b/src/Core/Statistics/VisitorIdGenerator.php index 61196d67..473e20db 100644 --- a/src/Core/Statistics/VisitorIdGenerator.php +++ b/src/Core/Statistics/VisitorIdGenerator.php @@ -207,6 +207,7 @@ private function fallbackVisitorId(Request $request): string 'visitor-fallback-id', $this->sourceIp($request), $this->normalizedUserAgent($request), + $this->untrustedForwardingEntropy($request), ]), $this->secret, true, @@ -231,12 +232,20 @@ private function fallbackHash(Request $request): string 'visitor-fallback', $this->sourceIp($request), $this->normalizedUserAgent($request), + $this->untrustedForwardingEntropy($request), ]), $this->secret, true, )); } + private function untrustedForwardingEntropy(Request $request): string + { + $chain = $this->proxyIpChain($request); + + return [] === $chain ? self::PLACEHOLDER : implode(',', $chain); + } + private function packCookieValue(string $token): string { return self::COOKIE_VERSION.'.'.$token.'.'.$this->signature($token); diff --git a/tests/Core/Statistics/VisitorIdGeneratorTest.php b/tests/Core/Statistics/VisitorIdGeneratorTest.php index 576d4393..8159332a 100644 --- a/tests/Core/Statistics/VisitorIdGeneratorTest.php +++ b/tests/Core/Statistics/VisitorIdGeneratorTest.php @@ -92,6 +92,55 @@ public function testItKeepsCookieLessVisitorsStableByIpAndUserAgent(): void ); } + public function testItUsesForwardingHeaderEntropyOnlyForCookieLessVisitorFallbacks(): void + { + $generator = new VisitorIdGenerator('test-secret'); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $firstRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $secondRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ]); + $matchingRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + self::assertSame($generator->generate($firstRequest), $generator->generate($matchingRequest)); + self::assertNotSame($generator->generate($firstRequest), $generator->generate($secondRequest)); + self::assertSame('203.0.113.10', $generator->sourceIp($firstRequest)); + } + + public function testItSeparatesRecentFallbacksBehindSameIpWhenForwardingEntropyDiffers(): void + { + $generator = $this->generator(); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $firstRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $secondRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ]); + $matchingRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + self::assertSame($generator->generate($firstRequest), $generator->generate($matchingRequest)); + self::assertNotSame($generator->generate($firstRequest), $generator->generate($secondRequest)); + } + public function testItUsesPendingCookieVisitorIdsWithoutAStore(): void { $request = Request::create('/docs', server: [ From 0e04bf66e0f26ed497d52602032be0a8497aef64 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:08:19 +0200 Subject: [PATCH 042/119] Add passive abuse request inspection --- config/services.yaml | 4 + dev/CLASSMAP.md | 1 + dev/WORKLOG.md | 1 + .../security-hardening/abuse-foundation.md | 6 +- src/Security/Abuse/AbuseRequestInspector.php | 31 ++++ src/Security/Abuse/AbuseRequestProfile.php | 70 +++++++++ src/Security/Abuse/AbuseSubject.php | 55 +++++++ src/Security/Abuse/AbuseSubjectResolution.php | 54 +++++++ src/Security/Abuse/AbuseSubjectResolver.php | 98 ++++++++++++ src/Security/Abuse/AbuseSubjectType.php | 15 ++ src/Security/Abuse/ActionCost.php | 30 ++++ src/Security/Abuse/ActionCostCatalogue.php | 46 ++++++ src/Security/Abuse/RequestFamily.php | 17 +++ src/Security/Abuse/RequestIntent.php | 35 +++++ .../Abuse/RequestIntentClassifier.php | 139 ++++++++++++++++++ .../Abuse/SuspiciousProbePathMatcher.php | 40 +++++ .../Abuse/AbuseSubjectResolverTest.php | 78 ++++++++++ .../Abuse/ActionCostCatalogueTest.php | 44 ++++++ .../Abuse/RequestIntentClassifierTest.php | 79 ++++++++++ 19 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 src/Security/Abuse/AbuseRequestInspector.php create mode 100644 src/Security/Abuse/AbuseRequestProfile.php create mode 100644 src/Security/Abuse/AbuseSubject.php create mode 100644 src/Security/Abuse/AbuseSubjectResolution.php create mode 100644 src/Security/Abuse/AbuseSubjectResolver.php create mode 100644 src/Security/Abuse/AbuseSubjectType.php create mode 100644 src/Security/Abuse/ActionCost.php create mode 100644 src/Security/Abuse/ActionCostCatalogue.php create mode 100644 src/Security/Abuse/RequestFamily.php create mode 100644 src/Security/Abuse/RequestIntent.php create mode 100644 src/Security/Abuse/RequestIntentClassifier.php create mode 100644 src/Security/Abuse/SuspiciousProbePathMatcher.php create mode 100644 tests/Security/Abuse/AbuseSubjectResolverTest.php create mode 100644 tests/Security/Abuse/ActionCostCatalogueTest.php create mode 100644 tests/Security/Abuse/RequestIntentClassifierTest.php diff --git a/config/services.yaml b/config/services.yaml index b55f9575..66e75a72 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -437,6 +437,10 @@ services: $cacheDir: '%kernel.project_dir%/var/cache' $environment: '%kernel.environment%' + App\Security\Abuse\AbuseSubjectResolver: + arguments: + $secret: '%kernel.secret%' + App\Core\Security\SecretPayloadProtector: arguments: $secret: '%kernel.secret%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 9dddbb6e..80213bb0 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,6 +198,7 @@ | 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 and audits established sessions that 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` | `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\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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and suspicious probes, and assigns symbolic action costs without enforcing limits. | `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/ActionCostCatalogueTest.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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | | Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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 d49b5bb5..258ac30c 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -83,6 +83,7 @@ - Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. - Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP for Security identity, may use raw forwarding headers only as untrusted Visitor-ID differentiation entropy, and keeps IP-ban thresholds laxer than Visitor-ID thresholds to reduce shared/untrusted-network false positives. - Implemented the Visitor-ID entropy half of that policy by mixing normalized forwarding-header candidates into cookie-less fallback visitor hashes only; Security identity, GeoIP, ban keys, and signal evidence still use Symfony's resolved client IP rather than raw proxy headers. +- Added the passive Abuse Foundation facade, subject resolver, request-intent classifier, suspicious-probe matcher, and symbolic action-cost catalogue. These expose visitor/user/API/IP-bucket subjects, `/api/live/**`, prefetch, CORS preflight, scheduler/setup/admin/API intents, and cost metadata for later rate/ban branches without enforcing limits yet. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index f675da98..8661d216 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -38,7 +38,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Public interfaces and data decisions -- Controllers and future packages call a Studio-owned abuse facade instead of Symfony RateLimiter directly. +- Controllers and future packages call the Studio-owned `AbuseRequestInspector` facade instead of Symfony RateLimiter directly. The facade combines `AbuseSubjectResolver`, `RequestIntentClassifier`, and `ActionCostCatalogue`; later branches may add enforcement around this boundary without moving classification logic into controllers. - Existing Monolog file channels remain the raw operational fallback with 30-day retention; database log projection tables are lookup/read-model copies for Admin UI, Admin API, and later abuse correlation. - The first projection uses one table per log family: `message_log_entry`, `audit_log_entry`, and `access_log_entry`; passive security signals use the domain event table `security_signal_event`. - Projection writes must happen after the normal file-log payload has been normalized/redacted and must never store raw secrets, credentials, API keys, visitor-cookie material, captcha answers, unredacted tokenized URLs, or duplicated full raw log lines. @@ -55,7 +55,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. Rate limiting should stay stable across direct and proxied requests. Later auto-ban policy should keep IP-ban/block thresholds laxer than Visitor-ID thresholds to reduce false positives on shared or untrusted-network IPs while still allowing IP blocking as a secondary cookie-reset bypass defense. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. +- 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 and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. +- Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and CORS preflight receive no ordinary enforcement cost in this branch; suspicious probes and mutating admin/API workflows receive higher symbolic costs for later limiter branches. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. @@ -103,7 +105,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Documentation and tracking - Update Security draft with facade and classification names if they become stable public extension points. -- Update class map for the facade and value objects only if they are contributor-facing services. +- Update class map for the facade, resolver, classifier, catalogue, and value objects when they are added. - Update class map for the database log browser/projector, retention policy, and passive-signal recorder if they are added. - Record default cost catalogue decisions in the worklog. - Update Security policy defaults if implementation evidence changes signal retention, subject composition, or suspicious-intent weighting. diff --git a/src/Security/Abuse/AbuseRequestInspector.php b/src/Security/Abuse/AbuseRequestInspector.php new file mode 100644 index 00000000..40c573ff --- /dev/null +++ b/src/Security/Abuse/AbuseRequestInspector.php @@ -0,0 +1,31 @@ +intentClassifier->classify($request); + + return [ + 'profile' => $profile, + 'subjects' => $this->subjectResolver->resolve($request), + 'cost' => $this->costCatalogue->costFor($profile), + ]; + } +} diff --git a/src/Security/Abuse/AbuseRequestProfile.php b/src/Security/Abuse/AbuseRequestProfile.php new file mode 100644 index 00000000..00dc29fc --- /dev/null +++ b/src/Security/Abuse/AbuseRequestProfile.php @@ -0,0 +1,70 @@ +family; + } + + public function intent(): RequestIntent + { + return $this->intent; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function route(): string + { + return $this->route; + } + + public function prefetch(): bool + { + return $this->prefetch; + } + + public function suspiciousProbe(): bool + { + return $this->suspiciousProbe; + } + + /** + * @return array{family: string, intent: string, method: string, path: string, route: string, prefetch: bool, suspicious_probe: bool} + */ + public function toArray(): array + { + return [ + 'family' => $this->family->value, + 'intent' => $this->intent->value, + 'method' => $this->method, + 'path' => $this->path, + 'route' => $this->route, + 'prefetch' => $this->prefetch, + 'suspicious_probe' => $this->suspiciousProbe, + ]; + } +} diff --git a/src/Security/Abuse/AbuseSubject.php b/src/Security/Abuse/AbuseSubject.php new file mode 100644 index 00000000..056c1262 --- /dev/null +++ b/src/Security/Abuse/AbuseSubject.php @@ -0,0 +1,55 @@ + $context + */ + public function __construct( + private AbuseSubjectType $type, + private string $identifier, + private bool $ipDerived = false, + private array $context = [], + ) { + } + + public function type(): AbuseSubjectType + { + return $this->type; + } + + public function identifier(): string + { + return $this->identifier; + } + + public function ipDerived(): bool + { + return $this->ipDerived; + } + + /** + * @return array + */ + public function context(): array + { + return $this->context; + } + + /** + * @return array{type: string, identifier: string, ip_derived: bool, context: array} + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'identifier' => $this->identifier, + 'ip_derived' => $this->ipDerived, + 'context' => $this->context, + ]; + } +} diff --git a/src/Security/Abuse/AbuseSubjectResolution.php b/src/Security/Abuse/AbuseSubjectResolution.php new file mode 100644 index 00000000..3fda0d1e --- /dev/null +++ b/src/Security/Abuse/AbuseSubjectResolution.php @@ -0,0 +1,54 @@ + $subjects + */ + public function __construct(private array $subjects) + { + } + + /** + * @return list + */ + public function subjects(): array + { + return $this->subjects; + } + + public function primary(): ?AbuseSubject + { + foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::User, AbuseSubjectType::ApiKey, AbuseSubjectType::IpBucket] as $type) { + $subject = $this->first($type); + if (null !== $subject) { + return $subject; + } + } + + return $this->subjects[0] ?? null; + } + + public function first(AbuseSubjectType $type): ?AbuseSubject + { + foreach ($this->subjects as $subject) { + if ($subject->type() === $type) { + return $subject; + } + } + + return null; + } + + /** + * @return list}> + */ + public function toArray(): array + { + return array_map(static fn (AbuseSubject $subject): array => $subject->toArray(), $this->subjects); + } +} diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php new file mode 100644 index 00000000..21f07fa7 --- /dev/null +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -0,0 +1,98 @@ +visitorIdGenerator->generate($request); + $subjects[] = new AbuseSubject(AbuseSubjectType::Visitor, $visitorId); + + $sourceIp = $this->visitorIdGenerator->sourceIp($request); + if (self::PLACEHOLDER !== $sourceIp) { + $subjects[] = new AbuseSubject(AbuseSubjectType::IpBucket, $this->bucket('ip', $sourceIp), true); + $subjects[] = new AbuseSubject(AbuseSubjectType::Combined, $this->bucket('visitor_ip', $visitorId.'|'.$sourceIp), true, [ + 'scope' => 'visitor_ip', + ]); + } + + $user = $this->currentUser($request); + if ($user instanceof UserAccount) { + $subjects[] = new AbuseSubject(AbuseSubjectType::User, $user->uid(), false, [ + 'access_level' => $user->accessLevel(), + ]); + $subjects[] = new AbuseSubject(AbuseSubjectType::Combined, $this->bucket('user_visitor', $user->uid().'|'.$visitorId), false, [ + 'scope' => 'user_visitor', + ]); + } + + $apiContext = ApiRequestContext::fromRequest($request); + if ($apiContext instanceof ApiRequestContext && null !== $apiContext->apiKeyUid()) { + $subjects[] = new AbuseSubject(AbuseSubjectType::ApiKey, $apiContext->apiKeyUid(), false, [ + 'prefix' => $apiContext->apiKeyPrefix(), + 'status' => $apiContext->apiKeyStatus()?->value, + ]); + $subjects[] = new AbuseSubject(AbuseSubjectType::Combined, $this->bucket('api_visitor', $apiContext->apiKeyUid().'|'.$visitorId), false, [ + 'scope' => 'api_visitor', + ]); + } else { + $prefix = $this->submittedApiKeyPrefix($request); + if (null !== $prefix) { + $subjects[] = new AbuseSubject(AbuseSubjectType::ApiKeyPrefix, $prefix); + } + } + + return new AbuseSubjectResolution($subjects); + } + + private function currentUser(Request $request): ?UserAccount + { + $apiUser = ApiRequestContext::fromRequest($request)?->user(); + if ($apiUser instanceof UserAccount) { + return $apiUser; + } + + $user = $this->tokenStorage->getToken()?->getUser(); + + return $user instanceof UserAccount ? $user : null; + } + + private function submittedApiKeyPrefix(Request $request): ?string + { + $authorization = $request->headers->get('Authorization'); + if (!is_string($authorization) || 1 !== preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { + return null; + } + + $token = trim($matches[1]); + $dotPosition = strpos($token, '.'); + $prefix = false === $dotPosition ? $token : substr($token, 0, $dotPosition); + + return 1 === preg_match('/^[A-Za-z0-9_-]{4,16}$/', $prefix) ? $prefix : null; + } + + 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 new file mode 100644 index 00000000..df61e4c7 --- /dev/null +++ b/src/Security/Abuse/AbuseSubjectType.php @@ -0,0 +1,15 @@ +bucketFamily; + } + + public function credits(): int + { + return $this->credits; + } + + public function ordinaryEnforcement(): bool + { + return $this->ordinaryEnforcement; + } +} diff --git a/src/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php new file mode 100644 index 00000000..89a0f89c --- /dev/null +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -0,0 +1,46 @@ +intent()) { + RequestIntent::LiveApi => new ActionCost('live_api', 0, false), + RequestIntent::TurboPrefetch => new ActionCost('website_prefetch', 0, false), + RequestIntent::CorsPreflight => new ActionCost('api_preflight', 0, false), + RequestIntent::SuspiciousProbe => new ActionCost('suspicious_probe', 10), + RequestIntent::Login => new ActionCost('login', 1), + RequestIntent::Registration => new ActionCost('registration', 5), + RequestIntent::PasswordReset => new ActionCost('password_reset', 3), + RequestIntent::Contact => new ActionCost('contact', 3), + RequestIntent::SchedulerTrigger => new ActionCost('scheduler', 1), + RequestIntent::ApiRead => new ActionCost('api_read', 1), + RequestIntent::ApiWrite => new ActionCost('api_write', 5), + RequestIntent::SettingsMutation, + RequestIntent::UserAclMutation, + RequestIntent::PackageAdminOperation, + RequestIntent::BackupRestore, + RequestIntent::ImportOperation, + RequestIntent::AdminOperation => new ActionCost('admin_mutation', 8), + RequestIntent::UploadArchiveValidation => new ActionCost('upload_archive', 8), + RequestIntent::ExportDownload, + RequestIntent::DiagnosticsSupport => new ActionCost('download_diagnostics', 4), + RequestIntent::FormSubmit => new ActionCost('website_form', 2), + default => new ActionCost($this->defaultBucket($profile->family()), 1), + }; + } + + private function defaultBucket(RequestFamily $family): string + { + return match ($family) { + RequestFamily::Api => 'api_read', + RequestFamily::Admin, RequestFamily::Editor => 'admin_navigation', + RequestFamily::Setup => 'setup', + default => 'website', + }; + } +} diff --git a/src/Security/Abuse/RequestFamily.php b/src/Security/Abuse/RequestFamily.php new file mode 100644 index 00000000..2d91a7e5 --- /dev/null +++ b/src/Security/Abuse/RequestFamily.php @@ -0,0 +1,17 @@ +getMethod()); + $path = $request->getPathInfo(); + $route = $this->route($request); + $family = $this->family($path); + $prefetch = $this->isPrefetch($request); + $suspiciousProbe = $this->probePathMatcher->isProbe($path); + + return new AbuseRequestProfile( + $family, + $this->intent($request, $method, $path, $route, $family, $prefetch, $suspiciousProbe), + $method, + substr($path, 0, 1024), + $route, + $prefetch, + $suspiciousProbe, + ); + } + + private function family(string $path): RequestFamily + { + return match (true) { + str_starts_with($path, '/api/live') => RequestFamily::LiveApi, + str_starts_with($path, '/api') => RequestFamily::Api, + str_starts_with($path, '/cron') => RequestFamily::Scheduler, + str_starts_with($path, '/setup') => RequestFamily::Setup, + str_starts_with($path, '/admin') => RequestFamily::Admin, + str_starts_with($path, '/editor') => RequestFamily::Editor, + default => RequestFamily::Browser, + }; + } + + private function intent( + Request $request, + string $method, + string $path, + string $route, + RequestFamily $family, + bool $prefetch, + bool $suspiciousProbe, + ): RequestIntent { + if ($suspiciousProbe) { + return RequestIntent::SuspiciousProbe; + } + + if ('OPTIONS' === $method) { + return RequestIntent::CorsPreflight; + } + + if (RequestFamily::Scheduler === $family) { + return RequestIntent::SchedulerTrigger; + } + + if (RequestFamily::LiveApi === $family) { + return RequestIntent::LiveApi; + } + + if (RequestFamily::Api === $family) { + return in_array($method, ['GET', 'HEAD'], true) ? RequestIntent::ApiRead : RequestIntent::ApiWrite; + } + + if ($prefetch && 'GET' === $method) { + return RequestIntent::TurboPrefetch; + } + + return match (true) { + $this->matches($path, $route, 'login') => RequestIntent::Login, + $this->matches($path, $route, 'registration', 'register') => RequestIntent::Registration, + $this->matches($path, $route, 'password', 'recovery', 'reset') => RequestIntent::PasswordReset, + $this->matches($path, $route, 'contact') => RequestIntent::Contact, + RequestFamily::Setup === $family && !$this->safeMethod($method) => RequestIntent::SetupApply, + $this->matches($path, $route, 'captcha', 'refresh') => RequestIntent::CaptchaRefresh, + $this->matches($path, $route, 'captcha', 'failure') => RequestIntent::CaptchaFailure, + $this->matches($path, $route, 'settings') && !$this->safeMethod($method) => RequestIntent::SettingsMutation, + $this->matches($path, $route, 'users', 'acl') && !$this->safeMethod($method) => RequestIntent::UserAclMutation, + $this->matches($path, $route, 'packages') && !$this->safeMethod($method) => RequestIntent::PackageAdminOperation, + $this->matches($path, $route, 'upload', 'archive', 'media') && !$this->safeMethod($method) => RequestIntent::UploadArchiveValidation, + $this->matches($path, $route, 'export', 'download') => RequestIntent::ExportDownload, + $this->matches($path, $route, 'import') => RequestIntent::ImportOperation, + $this->matches($path, $route, 'backup', 'restore') => RequestIntent::BackupRestore, + $this->matches($path, $route, 'diagnostic', 'support') => RequestIntent::DiagnosticsSupport, + RequestFamily::Admin === $family && !$this->safeMethod($method) => RequestIntent::AdminOperation, + !$this->safeMethod($method) => RequestIntent::FormSubmit, + default => RequestIntent::BrowserNavigation, + }; + } + + private function isPrefetch(Request $request): bool + { + foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { + $value = strtolower((string) $request->headers->get($header, '')); + if (str_contains($value, 'prefetch')) { + return true; + } + } + + return false; + } + + private function safeMethod(string $method): bool + { + return in_array($method, ['GET', 'HEAD', 'OPTIONS'], true); + } + + private function route(Request $request): string + { + $route = $request->attributes->get('_route'); + + return is_string($route) && '' !== $route ? substr($route, 0, 190) : 'n/a'; + } + + private function matches(string $path, string $route, string ...$needles): bool + { + $haystack = strtolower($path.' '.$route); + + foreach ($needles as $needle) { + if (str_contains($haystack, $needle)) { + return true; + } + } + + return false; + } +} diff --git a/src/Security/Abuse/SuspiciousProbePathMatcher.php b/src/Security/Abuse/SuspiciousProbePathMatcher.php new file mode 100644 index 00000000..e901c0ae --- /dev/null +++ b/src/Security/Abuse/SuspiciousProbePathMatcher.php @@ -0,0 +1,40 @@ + + */ + private const DEFAULT_PATTERNS = [ + '#/(?:\.env|\.git|\.svn|\.hg)(?:/|$)#i', + '#/(?:wp-admin|wp-login\.php|xmlrpc\.php|phpmyadmin|pma|adminer\.php)(?:/|$)#i', + '#/(?:backup|dump|database|db|site|www|wordpress)[^/]*\.(?:sql|sqlite|db|bak|old|zip|tar|gz|tgz|7z|rar)(?:$|[?\#])#i', + '#/(?:shell|cmd|webshell|wso|c99|r57)\.php(?:$|[?\#])#i', + '#/(?:vendor/phpunit|boaform|cgi-bin|HNAP1|actuator|server-status)(?:/|$)#i', + '#/(?:\.DS_Store|composer\.(?:json|lock)|package-lock\.json)(?:$|[?\#])#i', + ]; + + /** + * @param list $patterns + */ + public function __construct(private array $patterns = self::DEFAULT_PATTERNS) + { + } + + public function isProbe(string $path): bool + { + $path = '/'.ltrim(rawurldecode($path), '/'); + + foreach ($this->patterns as $pattern) { + if (1 === preg_match($pattern, $path)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php new file mode 100644 index 00000000..a5e65553 --- /dev/null +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -0,0 +1,78 @@ + '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + $resolution = $resolver->resolve($request); + $visitor = $resolution->first(AbuseSubjectType::Visitor); + $ipBucket = $resolution->first(AbuseSubjectType::IpBucket); + $encoded = json_encode($resolution->toArray(), JSON_THROW_ON_ERROR); + + self::assertNotNull($visitor); + self::assertSame($visitor, $resolution->primary()); + self::assertNotNull($ipBucket); + self::assertTrue($ipBucket->ipDerived()); + self::assertStringNotContainsString('203.0.113.10', $encoded); + self::assertStringNotContainsString('198.51.100.10', $encoded); + } + + public function testItAddsAuthenticatedApiKeyAndUserSubjectsFromApiContext(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/api/v1/content', server: ['REMOTE_ADDR' => '203.0.113.10']); + $user = new UserAccount('99999999-0000-7000-8000-000000000101', 'owner', 'owner@example.test', 'hash', role: UserRole::Owner); + $apiKey = new ApiKey( + '99999999-0000-7000-8000-000000000201', + 'testkey', + str_repeat('a', 64), + 'encrypted', + $user, + ApiKeyStatus::ReadWrite, + ); + ApiRequestContext::fromApiKey($apiKey)->attachTo($request); + + $resolution = $resolver->resolve($request); + + self::assertSame($user->uid(), $resolution->first(AbuseSubjectType::User)?->identifier()); + self::assertSame($apiKey->uid(), $resolution->first(AbuseSubjectType::ApiKey)?->identifier()); + self::assertSame('testkey', $resolution->first(AbuseSubjectType::ApiKey)?->context()['prefix']); + } + + public function testItKeepsInvalidBearerTokensToSafePrefixSubjects(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/api/v1/content', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer publicPrefix.secret-token-material', + ]); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::ApiKeyPrefix); + + self::assertNotNull($subject); + self::assertSame('publicPrefix', $subject->identifier()); + self::assertStringNotContainsString('secret-token-material', json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php new file mode 100644 index 00000000..5499ccab --- /dev/null +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -0,0 +1,44 @@ +costFor($classifier->classify(Request::create('/api/live/alerts'))); + $prefetch = $catalogue->costFor($classifier->classify(Request::create('/docs', server: [ + 'HTTP_SEC_PURPOSE' => 'prefetch', + ]))); + + self::assertSame('live_api', $live->bucketFamily()); + self::assertSame(0, $live->credits()); + self::assertFalse($live->ordinaryEnforcement()); + self::assertSame('website_prefetch', $prefetch->bucketFamily()); + self::assertFalse($prefetch->ordinaryEnforcement()); + } + + public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic(): void + { + $classifier = new RequestIntentClassifier(); + $catalogue = new ActionCostCatalogue(); + + $probe = $catalogue->costFor($classifier->classify(Request::create('/.env'))); + $apiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/content/items', 'POST'))); + + self::assertSame('suspicious_probe', $probe->bucketFamily()); + self::assertSame(10, $probe->credits()); + self::assertSame('api_write', $apiWrite->bucketFamily()); + self::assertSame(5, $apiWrite->credits()); + } +} diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php new file mode 100644 index 00000000..11f2dfaf --- /dev/null +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -0,0 +1,79 @@ + + */ + public static function requestCases(): iterable + { + yield 'live api cheap json' => [ + Request::create('/api/live/alerts'), + RequestFamily::LiveApi, + RequestIntent::LiveApi, + ]; + yield 'api write' => [ + Request::create('/api/v1/content/items', 'POST'), + RequestFamily::Api, + RequestIntent::ApiWrite, + ]; + yield 'cors preflight' => [ + Request::create('/api/v1/content/items', 'OPTIONS'), + RequestFamily::Api, + RequestIntent::CorsPreflight, + ]; + yield 'turbo prefetch' => [ + Request::create('/docs', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), + RequestFamily::Browser, + RequestIntent::TurboPrefetch, + ]; + yield 'scheduler trigger' => [ + Request::create('/cron/run'), + RequestFamily::Scheduler, + RequestIntent::SchedulerTrigger, + ]; + yield 'setup apply' => [ + Request::create('/setup', 'POST'), + RequestFamily::Setup, + RequestIntent::SetupApply, + ]; + yield 'settings mutation' => [ + Request::create('/admin/settings/security', 'POST'), + RequestFamily::Admin, + RequestIntent::SettingsMutation, + ]; + yield 'suspicious env probe' => [ + Request::create('/.env'), + RequestFamily::Browser, + RequestIntent::SuspiciousProbe, + ]; + } + + #[DataProvider('requestCases')] + public function testItClassifiesRequestIntent(Request $request, RequestFamily $family, RequestIntent $intent): void + { + $profile = (new RequestIntentClassifier())->classify($request); + + self::assertSame($family, $profile->family()); + self::assertSame($intent, $profile->intent()); + } + + public function testItDoesNotTreatOrdinaryUploadRoutesAsProbePaths(): void + { + $profile = (new RequestIntentClassifier())->classify(Request::create('/admin/packages/upload', 'POST')); + + self::assertSame(RequestIntent::PackageAdminOperation, $profile->intent()); + self::assertFalse($profile->suspiciousProbe()); + } +} From a07b2469141ea3ab600311c20e06fc4f09133d5c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:11:22 +0200 Subject: [PATCH 043/119] Record passive abuse signals --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + .../security-hardening/abuse-foundation.md | 4 +- .../Abuse/PassiveAbuseSignalSubscriber.php | 113 ++++++++++++++++++ .../PassiveAbuseSignalSubscriberTest.php | 103 ++++++++++++++++ 5 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 src/Security/Abuse/PassiveAbuseSignalSubscriber.php create mode 100644 tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 80213bb0..08b29fc8 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,7 +198,7 @@ | 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 and audits established sessions that 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` | `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\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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and suspicious probes, and assigns symbolic action costs without enforcing limits. | `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/ActionCostCatalogueTest.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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and suspicious probes, 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/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | | Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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 258ac30c..7c309acb 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,6 +84,7 @@ - Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP for Security identity, may use raw forwarding headers only as untrusted Visitor-ID differentiation entropy, and keeps IP-ban thresholds laxer than Visitor-ID thresholds to reduce shared/untrusted-network false positives. - Implemented the Visitor-ID entropy half of that policy by mixing normalized forwarding-header candidates into cookie-less fallback visitor hashes only; Security identity, GeoIP, ban keys, and signal evidence still use Symfony's resolved client IP rather than raw proxy headers. - Added the passive Abuse Foundation facade, subject resolver, request-intent classifier, suspicious-probe matcher, and symbolic action-cost catalogue. These expose visitor/user/API/IP-bucket subjects, `/api/live/**`, prefetch, CORS preflight, scheduler/setup/admin/API intents, and cost metadata for later rate/ban branches without enforcing limits yet. +- Added best-effort passive signal recording for high-signal probes and unsafe prefetch attempts. Signals carry Visitor-ID plus IP-bucket HMAC context when available and never store raw proxy-header values. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 8661d216..df15ffc0 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -58,6 +58,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 and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. - Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and CORS preflight receive no ordinary enforcement cost in this branch; suspicious probes and mutating admin/API workflows receive higher symbolic costs for later limiter branches. +- `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. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. @@ -80,7 +81,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - During setup and pre-database states, file logging remains available but database projections and signal persistence must be skipped before any DBAL call. - Upload, package, import, backup, and restore paths must not be classified as high-signal probes solely because their filenames resemble archive/database defaults; failed validation results should emit separate upload/archive signals. - Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. -- Passive-signal storage failure records a safe diagnostic and must not change request outcome in this foundation branch. +- Passive-signal storage failure must not change request outcome in this foundation branch. Safe diagnostics may be added later if they do not create logging loops or setup/database readiness problems. +- Passive signal recording must be best-effort and must not change request outcome. - Database log projection storage failure must not break the request or the raw file log write. The UI may show fewer projected rows while file logs remain the operational fallback. - Cleanup must remove or anonymize expired IP-derived signal keys before any Admin export, support bundle, or statistics projection can expose them. diff --git a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php new file mode 100644 index 00000000..4b8510bc --- /dev/null +++ b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php @@ -0,0 +1,113 @@ + ['onKernelResponse', -300], + ]; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest() || $this->shouldSkip($event->getRequest()->getPathInfo())) { + return; + } + + try { + $inspection = $this->inspector->inspect($event->getRequest()); + $profile = $inspection['profile']; + $signal = $this->signalFor($profile); + + if (null === $signal) { + return; + } + + $subjects = $inspection['subjects']; + $subject = $subjects->primary(); + if (null === $subject) { + return; + } + + $visitor = $subjects->first(AbuseSubjectType::Visitor); + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + $cost = $inspection['cost']; + $this->signalRecorder->record( + $signal['type'], + $signal['reason'], + $subject->type()->value, + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: $signal['severity'], + confidence: $signal['confidence'], + requestFamily: $profile->family()->value, + requestIntent: $profile->intent()->value, + requestId: $this->accessRequestMetadata->requestId($event->getRequest()), + visitorId: $visitor?->identifier() ?? 'n/a', + path: $profile->path(), + route: $profile->route(), + httpStatus: $event->getResponse()->getStatusCode(), + context: [ + 'ip_bucket' => $ipBucket?->identifier(), + 'cost_bucket' => $cost->bucketFamily(), + 'cost_credits' => $cost->credits(), + 'ordinary_enforcement' => $cost->ordinaryEnforcement(), + ], + ); + } catch (Throwable) { + return; + } + } + + /** + * @return array{type: string, reason: string, severity: string, confidence: int}|null + */ + private function signalFor(AbuseRequestProfile $profile): ?array + { + if ($profile->suspiciousProbe()) { + return [ + 'type' => 'probe', + 'reason' => 'security.signal.suspicious_probe', + 'severity' => 'WARNING', + 'confidence' => 95, + ]; + } + + if ($profile->prefetch() && !in_array($profile->method(), ['GET', 'HEAD'], true)) { + return [ + 'type' => 'prefetch', + 'reason' => 'security.signal.prefetch_unsafe_method', + 'severity' => 'NOTICE', + 'confidence' => 60, + ]; + } + + return null; + } + + private function shouldSkip(string $path): bool + { + return str_starts_with($path, '/_profiler') + || str_starts_with($path, '/_wdt') + || str_starts_with($path, '/assets/') + || str_starts_with($path, '/build/'); + } +} diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php new file mode 100644 index 00000000..486a5228 --- /dev/null +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -0,0 +1,103 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + $request = Request::create('/.env', server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Scanner/1.0', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $metadata->markStarted($request); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 400), + )); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + + self::assertSame('probe', $row['signal_type']); + self::assertSame('security.signal.suspicious_probe', $row['reason_code']); + self::assertSame('visitor', $row['subject_type']); + self::assertSame($visitorIds->generate($request), $row['visitor_id']); + self::assertSame($row['visitor_id'], $row['subject_identifier']); + self::assertIsString($context['ip_bucket'] ?? null); + self::assertStringNotContainsString('203.0.113.10', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('198.51.100.10', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + } + + public function testItDoesNotRecordOrdinaryNavigationSignals(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create('/docs'), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 200), + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } +} + +final class PassiveAbuseSignalTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} From ec3aba9efe44568ef746910b671e2b9d518d0b67 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:18:44 +0200 Subject: [PATCH 044/119] Make probe path patterns configurable --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 2 + .../security-hardening/abuse-foundation.md | 5 +- .../security-hardening/policy-defaults.md | 3 +- .../Config/Settings/CoreSettingsRegistry.php | 2 + .../Abuse/SuspiciousProbePathMatcher.php | 100 +++++++++++++++++- src/Setup/SetupDefaultSeed.php | 2 + .../Core/Config/CoreSettingsRegistryTest.php | 5 + .../Abuse/SuspiciousProbePathMatcherTest.php | 72 +++++++++++++ tests/Setup/SetupDefaultSeedTest.php | 3 + translations/languages/de/admin.yaml | 3 + translations/languages/en/admin.yaml | 3 + 12 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 tests/Security/Abuse/SuspiciousProbePathMatcherTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 08b29fc8..ae18217d 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,7 +198,7 @@ | 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 and audits established sessions that 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` | `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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and suspicious probes, 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/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects without exposing raw IPs or API secrets, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and configurable suspicious probe path patterns, 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 | `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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | | Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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 7c309acb..9798c2fa 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -82,9 +82,11 @@ - Updated the Security hardening master plan, Abuse Foundation detail plan, Logging draft, policy defaults, class map, translations, migration baseline, and setup/default settings coverage for the new logging projection and passive-signal scope. - Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. - Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP for Security identity, may use raw forwarding headers only as untrusted Visitor-ID differentiation entropy, and keeps IP-ban thresholds laxer than Visitor-ID thresholds to reduce shared/untrusted-network false positives. +- Follow-up for Editor/Content: show a non-blocking warning when a content route or slug would match a configured suspicious probe path so editors can avoid accidental honeypot/probe namespace collisions. - Implemented the Visitor-ID entropy half of that policy by mixing normalized forwarding-header candidates into cookie-less fallback visitor hashes only; Security identity, GeoIP, ban keys, and signal evidence still use Symfony's resolved client IP rather than raw proxy headers. - Added the passive Abuse Foundation facade, subject resolver, request-intent classifier, suspicious-probe matcher, and symbolic action-cost catalogue. These expose visitor/user/API/IP-bucket subjects, `/api/live/**`, prefetch, CORS preflight, scheduler/setup/admin/API intents, and cost metadata for later rate/ban branches without enforcing limits yet. - Added best-effort passive signal recording for high-signal probes and unsafe prefetch attempts. Signals carry Visitor-ID plus IP-bucket HMAC context when available and never store raw proxy-header values. +- Made suspicious probe path patterns configurable as an editable line-based Security setting with CSV-tolerant parsing, protected high-signal defaults, invalid-pattern fallback, setup seed coverage, translations, and focused matcher tests. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index df15ffc0..fabd2c7c 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -56,7 +56,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - 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 and ships with extensive high-signal defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. +- 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 CSV-style imports for convenience. 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. - Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and CORS preflight receive no ordinary enforcement cost in this branch; suspicious probes and mutating admin/API workflows receive higher symbolic costs for later limiter branches. - `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. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. @@ -90,7 +90,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 configurable probe-path defaults and high-signal probe classification. +- Test configurable probe-path defaults, line/CSV pattern parsing, invalid-pattern fallback, 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. - Test redaction in passive signal messages. @@ -113,6 +113,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update Security policy defaults if implementation evidence changes signal retention, subject composition, or suspicious-intent weighting. - Record whether the branch keeps only the passive-signal store or also introduces/reuses a broader security event projection. - Carry a follow-up into `feat-security-admin-acl-enforcement` for Owner/ACL-controlled visibility and mutation of security signals, IP-bearing access projections, exports, cleanup operations, and future signal review actions. +- Carry a follow-up into the Editor/Content slice: when an editor sets or changes a content route/slug that would match a configured suspicious probe path, show a non-blocking warning before saving so legitimate content is not accidentally placed under a high-signal probe namespace. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index eccb71e0..e71c94b4 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -108,12 +108,13 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner ## Probe Path Policy -- Probe paths are configurable and ship with extensive defaults for high-signal requests such as `.env`, `.git`, backup archives, database dumps, common admin panels from other software, shell upload probes, and known scanner paths. +- 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 CSV-style imports. 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. - 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. +- Editor/Content route editing should warn, without blocking the save, when a proposed route or slug would match a configured suspicious probe path. This keeps legitimate content possible while making accidental collisions visible before publication. ## Response Semantics diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 8cee9b60..1b5936a3 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -13,6 +13,7 @@ use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; use App\View\SystemPackageMetadataProvider; @@ -90,6 +91,7 @@ public function allDefinitions(): array new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 80), new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 90), new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_ip_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_ip_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 100), + new CoreSettingDefinition('security', SuspiciousProbePathMatcher::PATTERNS_KEY, 'admin.settings.fields.security_probe_path_patterns.label', SuspiciousProbePathMatcher::defaultPatternText(), ConfigValueType::String, FormInputType::Textarea, help: 'admin.settings.fields.security_probe_path_patterns.help', validation: ['max_length' => 50000], sortOrder: 110), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ diff --git a/src/Security/Abuse/SuspiciousProbePathMatcher.php b/src/Security/Abuse/SuspiciousProbePathMatcher.php index e901c0ae..8d14f2f1 100644 --- a/src/Security/Abuse/SuspiciousProbePathMatcher.php +++ b/src/Security/Abuse/SuspiciousProbePathMatcher.php @@ -4,12 +4,16 @@ namespace App\Security\Abuse; +use App\Core\Config\Config; + final readonly class SuspiciousProbePathMatcher { + public const PATTERNS_KEY = 'security.probe_path_patterns'; + /** * @var list */ - private const DEFAULT_PATTERNS = [ + public const DEFAULT_PATTERNS = [ '#/(?:\.env|\.git|\.svn|\.hg)(?:/|$)#i', '#/(?:wp-admin|wp-login\.php|xmlrpc\.php|phpmyadmin|pma|adminer\.php)(?:/|$)#i', '#/(?:backup|dump|database|db|site|www|wordpress)[^/]*\.(?:sql|sqlite|db|bak|old|zip|tar|gz|tgz|7z|rar)(?:$|[?\#])#i', @@ -18,18 +22,28 @@ '#/(?:\.DS_Store|composer\.(?:json|lock)|package-lock\.json)(?:$|[?\#])#i', ]; + private const MAX_PATTERN_COUNT = 100; + private const MAX_PATTERN_LENGTH = 500; + /** - * @param list $patterns + * @param list|null $patterns */ - public function __construct(private array $patterns = self::DEFAULT_PATTERNS) + public function __construct( + private ?Config $config = null, + private ?array $patterns = null, + ) { + } + + public static function defaultPatternText(): string { + return implode("\n", self::DEFAULT_PATTERNS); } public function isProbe(string $path): bool { $path = '/'.ltrim(rawurldecode($path), '/'); - foreach ($this->patterns as $pattern) { + foreach ($this->activePatterns() as $pattern) { if (1 === preg_match($pattern, $path)) { return true; } @@ -37,4 +51,82 @@ public function isProbe(string $path): bool return false; } + + /** + * @return list + */ + private function activePatterns(): array + { + if (null !== $this->patterns) { + return $this->normalizePatterns($this->patterns); + } + + $configured = $this->config?->get(self::PATTERNS_KEY, self::defaultPatternText()) ?? self::defaultPatternText(); + + return $this->normalizePatterns($configured); + } + + /** + * @param mixed $patterns + * + * @return list + */ + private function normalizePatterns(mixed $patterns): array + { + $candidates = is_array($patterns) + ? $patterns + : $this->parsePatternText(is_string($patterns) ? $patterns : ''); + $valid = []; + + foreach ($candidates as $candidate) { + if (!is_string($candidate)) { + continue; + } + + $pattern = trim($candidate); + + if ('' === $pattern || self::MAX_PATTERN_LENGTH < strlen($pattern)) { + continue; + } + + if (false === @preg_match($pattern, '/probe-test')) { + continue; + } + + $valid[] = $pattern; + + if (self::MAX_PATTERN_COUNT <= count($valid)) { + break; + } + } + + return [] === $valid ? self::DEFAULT_PATTERNS : array_values(array_unique($valid)); + } + + /** + * @return list + */ + private function parsePatternText(string $text): array + { + $patterns = []; + $lines = preg_split('/\R+/', $text); + + foreach (false === $lines ? [$text] : $lines as $line) { + $line = trim($line); + + if ('' === $line) { + continue; + } + + foreach (str_getcsv($line, ',', '"', '\\') as $value) { + $value = trim((string) $value); + + if ('' !== $value) { + $patterns[] = $value; + } + } + } + + return $patterns; + } } diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index f2bb62b7..5e3599a8 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -15,6 +15,7 @@ use App\Core\Statistics\AccessStatisticsPolicy; use App\Localization\LocaleToken; use App\Scheduler\SchedulerSettings; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; final readonly class SetupDefaultSeed @@ -51,6 +52,7 @@ public function configEntries(SetupInput $input): array ['key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS), 'type' => ConfigValueType::Integer], ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => SuspiciousProbePathMatcher::PATTERNS_KEY, 'value' => $this->setting($input, SuspiciousProbePathMatcher::PATTERNS_KEY, SuspiciousProbePathMatcher::defaultPatternText()), 'type' => ConfigValueType::String], ['key' => AccessStatisticsPolicy::ENABLED_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => MaxMindGeoIpConfig::ENABLED_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index f07cf06c..d2676d2e 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -14,6 +14,7 @@ use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; use App\View\SystemPackageMetadataProvider; use PHPUnit\Framework\TestCase; @@ -65,12 +66,15 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_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_LOG_RETENTION_DAYS, $security[5]->defaultValue()); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, $security[9]->defaultValue()); + self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[10]->defaultValue()); + self::assertSame(FormInputType::Textarea, $security[10]->formField()->inputType()); self::assertSame([ AccessStatisticsPolicy::ENABLED_KEY, @@ -118,6 +122,7 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void self::assertSame([], $provider->defaultValue(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY)); 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::assertFalse($provider->hasDefault('security.captcha.preview')); self::assertNull($provider->defaultValue('security.captcha.preview')); } diff --git a/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php new file mode 100644 index 00000000..0d320d04 --- /dev/null +++ b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php @@ -0,0 +1,72 @@ +isProbe('/.env')); + self::assertTrue($matcher->isProbe('/wp-login.php')); + self::assertTrue($matcher->isProbe('/backup-2026.sql')); + self::assertFalse($matcher->isProbe('/admin/packages/upload')); + } + + public function testItUsesConfiguredPatternsWhenProvided(): void + { + $matcher = new SuspiciousProbePathMatcher(patterns: [ + '#/custom-probe(?:/|$)#i', + ]); + + self::assertTrue($matcher->isProbe('/custom-probe')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItFallsBackToDefaultsWhenConfiguredPatternsAreInvalid(): void + { + $matcher = new SuspiciousProbePathMatcher(patterns: [ + '#unterminated', + '', + ]); + + self::assertTrue($matcher->isProbe('/.git/config')); + } + + public function testItParsesConfiguredPatternTextAsNewlineAndCsvValues(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, "#/custom-one(?:/|$)#i\n#/custom-two(?:/|$)#i,#/custom-three(?:/|$)#i", ConfigValueType::String); + $matcher = new SuspiciousProbePathMatcher($config); + + self::assertTrue($matcher->isProbe('/custom-one')); + self::assertTrue($matcher->isProbe('/custom-two')); + self::assertTrue($matcher->isProbe('/custom-three')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testDefaultPatternTextContainsOneEditablePatternPerLine(): void + { + $lines = array_filter(explode("\n", SuspiciousProbePathMatcher::defaultPatternText())); + + self::assertSame(SuspiciousProbePathMatcher::DEFAULT_PATTERNS, array_values($lines)); + } + + 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/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index 237fb789..9f1f0461 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -12,6 +12,7 @@ use App\Setup\SetupDefaultSeed; use App\Setup\SetupInput; use App\Scheduler\SchedulerSettings; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; use PHPUnit\Framework\TestCase; @@ -37,6 +38,7 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY]); + self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]); } public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void @@ -77,6 +79,7 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, + SuspiciousProbePathMatcher::PATTERNS_KEY, \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY, \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, MaxMindGeoIpConfig::ENABLED_KEY, diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 6b7ff2f1..a1d75d54 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -826,6 +826,9 @@ admin: security_signal_ip_retention_days: label: 'Retention für IP-abgeleitete Signale' help: 'Kurze Retention für Signale, die nur über IP-abgeleitete Identifier geschlüsselt sind. Unterhalb der 30-Tage-Datenschutzgrenze halten.' + security_probe_path_patterns: + label: 'Pfadmuster für verdächtige Probes' + help: 'Ein regulärer Ausdruck pro Zeile. CSV-artig kommaseparierte Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' geoip_enabled: label: 'GeoIP-Lookups aktivieren' help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index ccdbb0d9..e4817066 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -826,6 +826,9 @@ admin: security_signal_ip_retention_days: label: 'IP-derived signal retention' help: 'Short retention for signals that are keyed only by IP-derived identifiers. Keep this lower than the 30-day privacy ceiling.' + security_probe_path_patterns: + label: 'Suspicious probe path patterns' + help: 'One regular expression per line. CSV-style comma-separated imports are accepted. Invalid or empty lists fall back to the protected defaults.' geoip_enabled: label: 'Enable GeoIP lookups' help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' From 31dfd5cadb1e5abdf178144b938458c14d0c2faa Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:19:59 +0200 Subject: [PATCH 045/119] Track probe path route warning follow-up --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 9798c2fa..377c1b85 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. +- [ ] Editor/Content follow-up: warn non-blockingly when a proposed content 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. - [ ] 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. From 404369c437aa4c62a2bd9ec939bbab8a1e580c74 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:21:22 +0200 Subject: [PATCH 046/119] Specify follow-up notice --- dev/WORKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 377c1b85..ee7027b9 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -70,7 +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. -- [ ] Editor/Content follow-up: warn non-blockingly when a proposed content 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. +- [ ] 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. - [ ] 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. From de528b95b370762ccb7da581dc48c8f8021a06f3 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:27:37 +0200 Subject: [PATCH 047/119] Record session visitor mismatch signals --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 3 +- .../security-hardening/abuse-foundation.md | 2 + .../security-hardening/policy-defaults.md | 1 + .../SessionVisitorBindingSubscriber.php | 61 +++++++++++++++++ .../SessionVisitorBindingSubscriberTest.php | 66 +++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ae18217d..02ded274 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -197,7 +197,7 @@ | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | -| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates and audits established sessions that 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` | `tests/Security/SessionVisitorBindingSubscriberTest.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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and configurable suspicious probe path patterns, 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 | `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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | | Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ee7027b9..38794239 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -66,7 +66,7 @@ - [ ] Editor/API follow-up: when the final content/editor model lands, replace provisional API content list filtering with a domain-owned actor-aware content list/read resolver covering canonical paths, language, variants, optional version selection, pagination, filtering, and sorting. - [ ] Before production readiness, review public package/developer-facing class, interface, function, and Twig helper names for clarity and ergonomics; decide whether to rename directly or provide stable aliases so extension APIs read as intentional rather than provisional. - [ ] Audit follow-up: add a durable package lifecycle operation journal/coordinator for multi-step activation, deactivation, install, rollback, and cleanup flows. -- [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding intentionally covers visitor changes, not complete cookie-pair duplication. +- [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding now records visitor changes as high-risk signals, but still does not detect complete cookie-pair duplication. - [ ] 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. @@ -88,6 +88,7 @@ - Added the passive Abuse Foundation facade, subject resolver, request-intent classifier, suspicious-probe matcher, and symbolic action-cost catalogue. These expose visitor/user/API/IP-bucket subjects, `/api/live/**`, prefetch, CORS preflight, scheduler/setup/admin/API intents, and cost metadata for later rate/ban branches without enforcing limits yet. - Added best-effort passive signal recording for high-signal probes and unsafe prefetch attempts. Signals carry Visitor-ID plus IP-bucket HMAC context when available and never store raw proxy-header values. - Made suspicious probe path patterns configurable as an editable line-based Security setting with CSV-tolerant parsing, protected high-signal defaults, invalid-pattern fallback, setup seed coverage, translations, and focused matcher tests. +- Added high-risk passive security-signal recording for enforced session/visitor mismatches while preserving the existing forced logout and audit behavior. Complete copied-session plus copied-visitor-cookie risk scoring remains a later Security/remember-me follow-up. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index fabd2c7c..c36098d1 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -59,6 +59,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 CSV-style imports for convenience. 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. - Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and CORS preflight receive no ordinary enforcement cost in this branch; suspicious probes and mutating admin/API workflows receive higher symbolic costs for later limiter branches. - `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. +- `SessionVisitorBindingSubscriber` also records the already-enforced session/visitor mismatch as a high-risk passive signal before terminating the session. This does not solve copied session plus copied visitor-cookie risk scoring by itself; that deeper scoring remains a later Security/remember-me concern. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. @@ -94,6 +95,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - Test redaction in passive signal messages. +- Test session/visitor mismatch signal recording without changing the existing forced logout and audit behavior. - Test database log projection writes for message, audit, and access logs without bypassing the existing file-log path. - Test database projection and signal recorder no-op before touching DBAL while setup/database readiness is false. - Test projection retention purge-after-write behavior and the 30-day maximum for configurable lookup retention. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index e71c94b4..4f1a16e9 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -184,6 +184,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Rotating file logs remain the durable raw operational source. - Database-backed message, audit, and access lookup projections are the Admin/API read model for query-heavy review and abuse correlation. - Passive security signals are stored separately as `security_signal_event` rows with explicit expiry and remain observational until later enforcement branches consume them. +- Session/visitor mismatches that already terminate an authenticated session are high-risk passive signals. They may feed later rate-limit, auto-ban, account-review, or recovery diagnostics, but copied session plus copied visitor-cookie risk scoring still needs additional policy in the later Security/remember-me work. - The projection must duplicate only minimized/redacted structured fields, never full raw log lines, keep IP-derived data within the 30-day limit, purge expired rows after successful writes, and degrade without weakening enforcement or hiding file-log diagnostics. - Level/severity fields are stored only where they support meaningful filtering: message projections keep level, security signals keep severity, and current access/audit projections omit level fields. - Backups, exports, diagnostics, and support bundles must not silently extend IP retention. diff --git a/src/Security/SessionVisitorBindingSubscriber.php b/src/Security/SessionVisitorBindingSubscriber.php index fbeb3e70..7bc4d210 100644 --- a/src/Security/SessionVisitorBindingSubscriber.php +++ b/src/Security/SessionVisitorBindingSubscriber.php @@ -5,9 +5,13 @@ namespace App\Security; use App\Core\Access\AccessActor; +use App\Core\Log\AccessRequestMetadata; use App\Core\Log\AuditLoggerInterface; use App\Core\Statistics\VisitorIdGenerator; use App\Entity\UserAccount; +use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseSubjectType; +use App\Security\Abuse\SecuritySignalRecorder; use DateTimeImmutable; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; @@ -34,6 +38,9 @@ public function __construct( private TokenStorageInterface $tokenStorage, private VisitorIdGenerator $visitorIdGenerator, private AuditLoggerInterface $auditLogger, + private ?AbuseRequestInspector $abuseInspector = null, + private ?SecuritySignalRecorder $securitySignals = null, + private ?AccessRequestMetadata $accessRequestMetadata = null, ) { } @@ -101,6 +108,7 @@ public function onKernelRequest(RequestEvent $event): void 'change_count' => $changeCount, 'changed_at' => $changedAt, ]); + $this->safeSignal($request, $boundVisitorId, $currentVisitorId, $changeCount, $changedAt); $this->tokenStorage->setToken(null); $session->invalidate(); $event->setResponse(new RedirectResponse(self::LOGIN_PATH, Response::HTTP_SEE_OTHER)); @@ -126,6 +134,59 @@ private function safeAudit(UserInterface $user, array $context): void } } + private function safeSignal( + Request $request, + string $previousVisitorId, + string $currentVisitorId, + int $changeCount, + string $changedAt, + ): void { + if (null === $this->abuseInspector || null === $this->securitySignals) { + return; + } + + try { + $inspection = $this->abuseInspector->inspect($request); + $profile = $inspection['profile']; + $subjects = $inspection['subjects']; + $subject = $subjects->first(AbuseSubjectType::User) + ?? $subjects->first(AbuseSubjectType::Visitor) + ?? $subjects->primary(); + + if (null === $subject) { + return; + } + + $visitor = $subjects->first(AbuseSubjectType::Visitor); + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + + $this->securitySignals->record( + 'session', + 'security.signal.session_visitor_mismatch', + $subject->type()->value, + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'ERROR', + confidence: 90, + requestFamily: $profile->family()->value, + requestIntent: $profile->intent()->value, + requestId: $this->accessRequestMetadata?->requestId($request) ?? 'n/a', + visitorId: $visitor?->identifier() ?? $currentVisitorId, + path: $profile->path(), + route: $profile->route(), + context: [ + 'previous_visitor_id' => $previousVisitorId, + 'current_visitor_id' => $currentVisitorId, + 'change_count' => $changeCount, + 'changed_at' => $changedAt, + 'ip_bucket' => $ipBucket?->identifier(), + ], + ); + } catch (Throwable) { + return; + } + } + private function actorFromUser(UserInterface $user): AccessActor { if ($user instanceof UserAccount) { diff --git a/tests/Security/SessionVisitorBindingSubscriberTest.php b/tests/Security/SessionVisitorBindingSubscriberTest.php index c01ad736..fd9087ae 100644 --- a/tests/Security/SessionVisitorBindingSubscriberTest.php +++ b/tests/Security/SessionVisitorBindingSubscriberTest.php @@ -5,10 +5,19 @@ namespace App\Tests\Security; use App\Core\Access\AccessActor; +use App\Core\Log\AccessRequestMetadata; use App\Core\Log\AuditLoggerInterface; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Core\Statistics\VisitorIdGenerator; 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\Abuse\SecuritySignalRecorder; use App\Security\SessionVisitorBindingSubscriber; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -81,6 +90,54 @@ public function testItTerminatesSessionsWhenTheBoundVisitorChanges(): void self::assertSame($currentVisitorId, $auditLogger->records[0]['context']['current_visitor_id']); } + public function testItRecordsSecuritySignalWhenTheBoundVisitorChanges(): void + { + $tokenStorage = new TokenStorage(); + $user = $this->user(); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $auditLogger = new RecordingSessionAuditLogger(); + $generator = new VisitorIdGenerator('test-secret'); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-session-mismatch'); + $session = new Session(new MockArraySessionStorage()); + $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234'); + $request->setSession($session); + $connection = $this->signalConnection(); + + $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + (new SessionVisitorBindingSubscriber( + $tokenStorage, + $generator, + $auditLogger, + new AbuseRequestInspector( + new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ))->onKernelRequest($event); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + self::assertSame('session', $row['signal_type']); + self::assertSame('security.signal.session_visitor_mismatch', $row['reason_code']); + self::assertSame('ERROR', $row['severity']); + self::assertSame(90, (int) $row['confidence']); + self::assertSame('user', $row['subject_type']); + self::assertSame($user->uid(), $row['subject_identifier']); + self::assertSame('request-session-mismatch', $row['request_id']); + self::assertSame($generator->generate($request), $row['visitor_id']); + + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + self::assertSame('previousVisitorId1234', $context['previous_visitor_id']); + self::assertSame($generator->generate($request), $context['current_visitor_id']); + self::assertSame(1, $context['change_count']); + self::assertIsString($context['ip_bucket']); + self::assertNotSame('', $context['ip_bucket']); + } + public function testItKeepsSessionsWhenTheBoundVisitorMatches(): void { $tokenStorage = new TokenStorage(); @@ -119,6 +176,15 @@ private function user(): UserAccount 'hash', ); } + + private function signalConnection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } } final class RecordingSessionAuditLogger implements AuditLoggerInterface From c64a44a0b570cf137e76c0ede7efa52482477654 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:28:38 +0200 Subject: [PATCH 048/119] Clarify security header handoff --- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityHardeningPlan.md | 1 + dev/draft/security-hardening/rate-enforcement.md | 3 +++ 3 files changed, 5 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 38794239..58f0f591 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -89,6 +89,7 @@ - Added best-effort passive signal recording for high-signal probes and unsafe prefetch attempts. Signals carry Visitor-ID plus IP-bucket HMAC context when available and never store raw proxy-header values. - Made suspicious probe path patterns configurable as an editable line-based Security setting with CSV-tolerant parsing, protected high-signal defaults, invalid-pattern fallback, setup seed coverage, translations, and focused matcher tests. - Added high-risk passive security-signal recording for enforced session/visitor mismatches while preserving the existing forced logout and audit behavior. Complete copied-session plus copied-visitor-cookie risk scoring remains a later Security/remember-me follow-up. +- Clarified the rate-enforcement handoff for HTTP security headers: rate/recovery/error responses own tested `no-store`, while the full CSP/frame/referrer/permissions/header policy remains a dedicated response-hardening/frontend-delivery follow-up if still deferred. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index f45ae879..fa9bb8c3 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -154,6 +154,7 @@ Scope: - Use `reset()` for clear successful outcomes such as successful login or verified provider-backed captcha where the reset is scoped and safe. - Return stable HTML or JSON `429` responses depending on request family. - Keep `/api/live/**` excluded from ordinary rate-limit responses. +- Implement and test `no-store` for rate-limit, recovery, and error responses touched in this slice, and carry the broader production HTTP security-header follow-up into a dedicated response-hardening/frontend-delivery slice if CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, and documented route exceptions are still deferred. Non-goals: diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 69c229d6..33cb7560 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -51,6 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Limiter storage degradation must be explicit and tested, including safe diagnostics and Owner recovery behavior. - 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. - 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. - 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. @@ -83,6 +84,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test `/api/live/**` never receives ordinary rate-limit `429`. - Test browser HTML and API JSON `429` shapes. - 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 configured limiter service wiring with `lint:container`. @@ -92,6 +94,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 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. - Record focused test commands and any threshold changes in the worklog. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. From f282d89142434d68acc9befd05a4721229ce732a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:32:11 +0200 Subject: [PATCH 049/119] Use clock for security signal retention --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/abuse-foundation.md | 2 +- src/Security/Abuse/SecuritySignalRecorder.php | 8 +++++--- tests/Security/Abuse/SecuritySignalRecorderTest.php | 10 ++++++++-- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 02ded274..7ad53c17 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,7 +199,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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and configurable suspicious probe path patterns, 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 | `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 expiry, purges expired projection/signal rows after writes, and powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `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 powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | | Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 58f0f591..723a0114 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -90,6 +90,7 @@ - Made suspicious probe path patterns configurable as an editable line-based Security setting with CSV-tolerant parsing, protected high-signal defaults, invalid-pattern fallback, setup seed coverage, translations, and focused matcher tests. - Added high-risk passive security-signal recording for enforced session/visitor mismatches while preserving the existing forced logout and audit behavior. Complete copied-session plus copied-visitor-cookie risk scoring remains a later Security/remember-me follow-up. - Clarified the rate-enforcement handoff for HTTP security headers: rate/recovery/error responses own tested `no-store`, while the full CSP/frame/referrer/permissions/header policy remains a dedicated response-hardening/frontend-delivery follow-up if still deferred. +- Switched passive security-signal expiry and cleanup to Symfony Clock so retention behavior is deterministic in tests and matches the Abuse Foundation time-boundary plan. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index c36098d1..02e4f21e 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. - IP subjects and stable IP-derived hashes must expire within 30 days. Longer-lived passive signals must use visitor ID, authenticated user ID, API key fingerprint, or aggregate keys without retaining the IP-derived subject. -- TTL and expiry use an injectable clock/time boundary for deterministic tests. +- TTL and expiry use Symfony's injectable clock/time boundary for deterministic tests. - Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. - Probe-path configuration uses anchored, normalized patterns and must be tested against normal app/package/media/editor routes to avoid false positives. - High-impact operation intents must exist even when their first implementation only records passive signals: setup apply, settings mutation, user/ACL mutation, package lifecycle, backup/restore, import apply, export/download, self-update, scheduler run-now, diagnostics/support bundles, and upload/archive validation. diff --git a/src/Security/Abuse/SecuritySignalRecorder.php b/src/Security/Abuse/SecuritySignalRecorder.php index 893addab..254ec5f0 100644 --- a/src/Security/Abuse/SecuritySignalRecorder.php +++ b/src/Security/Abuse/SecuritySignalRecorder.php @@ -8,8 +8,9 @@ use App\Core\Log\DatabaseLogRetentionPolicy; use App\Database\DatabaseReadyState; use DateInterval; -use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\NativeClock; use Throwable; final readonly class SecuritySignalRecorder @@ -22,6 +23,7 @@ public function __construct( private DatabaseLogRetentionPolicy $retentionPolicy, private ?DatabaseReadyState $databaseReadyState = null, private UuidFactory $uuidFactory = new UuidFactory(), + private ClockInterface $clock = new NativeClock(), ) { } @@ -49,7 +51,7 @@ public function record( return; } - $now = new DateTimeImmutable(); + $now = $this->clock->now(); $expiresAt = $now->add(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSignal($ipDerived).'D')); $context = [ ...$context, @@ -93,7 +95,7 @@ public function purgeExpired(): int { try { return $this->connection->executeStatement('DELETE FROM '.self::TABLE.' WHERE expires_at <= ?', [ - (new DateTimeImmutable())->format('Y-m-d H:i:s'), + $this->clock->now()->format('Y-m-d H:i:s'), ]); } catch (Throwable) { return 0; diff --git a/tests/Security/Abuse/SecuritySignalRecorderTest.php b/tests/Security/Abuse/SecuritySignalRecorderTest.php index 9b0abb4d..2b96314f 100644 --- a/tests/Security/Abuse/SecuritySignalRecorderTest.php +++ b/tests/Security/Abuse/SecuritySignalRecorderTest.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\MockClock; final class SecuritySignalRecorderTest extends TestCase { @@ -71,7 +72,11 @@ public function testItRecordsSignalsWithShortIpDerivedRetentionAndPurgesExpiredR 'context' => '{}', ]); - $recorder = new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)); + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: new MockClock('2026-06-16 12:00:00'), + ); $recorder->record( 'probe', 'security.probe.env', @@ -90,7 +95,8 @@ public function testItRecordsSignalsWithShortIpDerivedRetentionAndPurgesExpiredR self::assertSame('security.probe.env', $connection->fetchOne('SELECT reason_code FROM security_signal_event')); self::assertSame(100, (int) $connection->fetchOne('SELECT confidence FROM security_signal_event')); self::assertSame(1, (int) $connection->fetchOne('SELECT ip_derived FROM security_signal_event')); - self::assertGreaterThan(new \DateTimeImmutable(), new \DateTimeImmutable((string) $connection->fetchOne('SELECT expires_at FROM security_signal_event'))); + self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM security_signal_event')); + self::assertSame('2026-06-17 12:00:00', $connection->fetchOne('SELECT expires_at FROM security_signal_event')); } /** From 0892c9fc0284eeab4c326dedf69969fedcec62f3 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:35:51 +0200 Subject: [PATCH 050/119] Harden log projection readiness --- dev/WORKLOG.md | 1 + src/Api/Admin/AdminOperationalApiEndpointProvider.php | 2 +- src/Core/Log/DatabaseLogProjector.php | 8 +++++--- tests/Controller/ApiAdminOperationalControllerTest.php | 4 ++++ tests/Core/Log/DatabaseLogProjectorTest.php | 8 +++++++- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 723a0114..bda5700d 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -91,6 +91,7 @@ - Added high-risk passive security-signal recording for enforced session/visitor mismatches while preserving the existing forced logout and audit behavior. Complete copied-session plus copied-visitor-cookie risk scoring remains a later Security/remember-me follow-up. - Clarified the rate-enforcement handoff for HTTP security headers: rate/recovery/error responses own tested `no-store`, while the full CSP/frame/referrer/permissions/header policy remains a dedicated response-hardening/frontend-delivery follow-up if still deferred. - Switched passive security-signal expiry and cleanup to Symfony Clock so retention behavior is deterministic in tests and matches the Abuse Foundation time-boundary plan. +- Hardened PR-readiness findings before final checks: database log projection retention now uses Symfony Clock, and the Admin Logs OpenAPI enum documents the database-backed sources including `security_signal`. - Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. ### Archived Compacted Branch History diff --git a/src/Api/Admin/AdminOperationalApiEndpointProvider.php b/src/Api/Admin/AdminOperationalApiEndpointProvider.php index 8dc12c79..bee86d8c 100644 --- a/src/Api/Admin/AdminOperationalApiEndpointProvider.php +++ b/src/Api/Admin/AdminOperationalApiEndpointProvider.php @@ -76,7 +76,7 @@ private function endpoint( private function logParameters(): array { return [ - ['name' => 'log', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['application', 'message', 'audit', 'access']]], + ['name' => 'log', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['message', 'audit', 'access', 'security_signal']]], ['name' => 'level', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']], ['name' => 'q', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']], ['name' => 'match', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string', 'enum' => ['contains', 'equals']]], diff --git a/src/Core/Log/DatabaseLogProjector.php b/src/Core/Log/DatabaseLogProjector.php index 75cb24d2..2cdee06c 100644 --- a/src/Core/Log/DatabaseLogProjector.php +++ b/src/Core/Log/DatabaseLogProjector.php @@ -7,8 +7,9 @@ use App\Core\Id\UuidFactory; use App\Database\DatabaseReadyState; use DateInterval; -use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\NativeClock; use Throwable; final readonly class DatabaseLogProjector @@ -20,6 +21,7 @@ public function __construct( private DatabaseLogRetentionPolicy $retentionPolicy, private ?DatabaseReadyState $databaseReadyState = null, private UuidFactory $uuidFactory = new UuidFactory(), + private ClockInterface $clock = new NativeClock(), ) { } @@ -119,7 +121,7 @@ private function write(string $source, string $table, array $values): void private function purge(string $source, string $table): void { - $cutoff = (new DateTimeImmutable())->sub(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSource($source).'D')); + $cutoff = $this->clock->now()->sub(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSource($source).'D')); $this->connection->executeStatement('DELETE FROM '.$table.' WHERE occurred_at < ?', [ $cutoff->format('Y-m-d H:i:s'), ]); @@ -127,7 +129,7 @@ private function purge(string $source, string $table): void private function now(): string { - return (new DateTimeImmutable())->format('Y-m-d H:i:s'); + return $this->clock->now()->format('Y-m-d H:i:s'); } private function short(mixed $value, int $length): string diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index f53e5c4b..8f4fb0cc 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -230,6 +230,10 @@ public function testOpenApiIncludesAdminOperationalEndpoints(): void self::assertSame(['backend-admin', 'backend-admin-scheduler'], $payload['paths']['/admin/scheduler']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-statistics'], $payload['paths']['/admin/statistics']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-themes'], $payload['paths']['/admin/themes']['get']['tags']); + self::assertSame( + ['message', 'audit', 'access', 'security_signal'], + $payload['paths']['/admin/logs/{log}']['get']['parameters'][0]['schema']['enum'], + ); self::assertContains([ 'name' => 'backend-admin-operations', 'summary' => 'Backend Admin Operations', diff --git a/tests/Core/Log/DatabaseLogProjectorTest.php b/tests/Core/Log/DatabaseLogProjectorTest.php index a374add7..758c109b 100644 --- a/tests/Core/Log/DatabaseLogProjectorTest.php +++ b/tests/Core/Log/DatabaseLogProjectorTest.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\MockClock; final class DatabaseLogProjectorTest extends TestCase { @@ -87,7 +88,11 @@ public function testItWritesAndPurgesDatabaseLogProjectionRows(): void 'context' => '{}', ]); - $projector = new DatabaseLogProjector($connection, new DatabaseLogRetentionPolicy($connection)); + $projector = new DatabaseLogProjector( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: new MockClock('2026-06-16 12:00:00'), + ); $projector->recordAccess([ 'request_id' => 'current', 'method' => 'GET', @@ -102,6 +107,7 @@ public function testItWritesAndPurgesDatabaseLogProjectionRows(): void self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM access_log_entry')); self::assertSame('current', $connection->fetchOne('SELECT request_id FROM access_log_entry')); + self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM access_log_entry')); self::assertSame(80, strlen((string) $connection->fetchOne('SELECT city FROM access_log_entry'))); } From ac0ee3f9c520f5d54e84425748effabe2bf209d3 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:39:42 +0200 Subject: [PATCH 051/119] Record abuse foundation final verification --- dev/WORKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index bda5700d..27affc40 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -92,7 +92,7 @@ - Clarified the rate-enforcement handoff for HTTP security headers: rate/recovery/error responses own tested `no-store`, while the full CSP/frame/referrer/permissions/header policy remains a dedicated response-hardening/frontend-delivery follow-up if still deferred. - Switched passive security-signal expiry and cleanup to Symfony Clock so retention behavior is deterministic in tests and matches the Abuse Foundation time-boundary plan. - Hardened PR-readiness findings before final checks: database log projection retention now uses Symfony Clock, and the Admin Logs OpenAPI enum documents the database-backed sources including `security_signal`. -- Verification so far: focused PHPUnit for database log browser/projector, security signal recorder, affected log/settings/setup tests passed; `php bin/console lint:container` passed; focused `bin/lint` for changed Twig/translations/drafts passed. +- Final verification: `bin/phpunit` passed with 1339 tests and 8632 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From c0d0594ba145e82d8158a7ba8078f388babffe74 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 10:54:40 +0200 Subject: [PATCH 052/119] Restore application log browsing source --- config/services.yaml | 4 ++ dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 3 +- dev/draft/0.4.x-ContactMailLogging.md | 2 +- .../security-hardening/abuse-foundation.md | 2 +- src/Api/Admin/AdminLogApiHandler.php | 1 + .../AdminOperationalApiEndpointProvider.php | 2 +- src/Controller/BackendController.php | 2 +- src/Core/Log/DatabaseLogBrowser.php | 49 ++++++++++++++++++- .../ApiAdminOperationalControllerTest.php | 3 +- tests/Controller/BackendControllerTest.php | 27 ++++++++++ tests/Core/Log/DatabaseLogBrowserTest.php | 30 +++++++++++- 12 files changed, 117 insertions(+), 12 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 66e75a72..b5d2a85d 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -459,6 +459,10 @@ services: $logDir: '%kernel.logs_dir%' $environment: '%kernel.environment%' + App\Core\Log\DatabaseLogBrowser: + arguments: + $fileBrowser: '@App\Core\Log\LogFileBrowser' + App\Core\Statistics\AccessStatisticsRecorderInterface: alias: App\Core\Statistics\DatabaseAccessStatisticsRecorder diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 7ad53c17..ad3d2a31 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,8 @@ | 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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and configurable suspicious probe path patterns, 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 | `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 powers Admin/API log browsing with source tabs, UUID detail links, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | -| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps rotated file logs inspectable as an operator/fallback helper while the Admin/API read path uses database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.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 powers Admin/API log browsing with source tabs, UUID detail links for database projections, synthetic file-line detail IDs for the Symfony application log, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | +| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps the Symfony application log available as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs while structured message, audit, access, and security-signal sources use database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Core/Log/DatabaseLogBrowserTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 27affc40..819f5d53 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -92,7 +92,8 @@ - Clarified the rate-enforcement handoff for HTTP security headers: rate/recovery/error responses own tested `no-store`, while the full CSP/frame/referrer/permissions/header policy remains a dedicated response-hardening/frontend-delivery follow-up if still deferred. - Switched passive security-signal expiry and cleanup to Symfony Clock so retention behavior is deterministic in tests and matches the Abuse Foundation time-boundary plan. - Hardened PR-readiness findings before final checks: database log projection retention now uses Symfony Clock, and the Admin Logs OpenAPI enum documents the database-backed sources including `security_signal`. -- Final verification: `bin/phpunit` passed with 1339 tests and 8632 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Reintroduced the Symfony `application` log as an explicit file-backed Admin/API source while keeping message, audit, access, and security-signal browsing database-backed; application detail links use the existing synthetic file-line hash IDs because Symfony Monolog lines do not carry database UUIDs. +- Final verification: `bin/phpunit` passed with 1339 tests and 8649 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index 0faee0f9..4e8ad45e 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -94,7 +94,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Keep statistics operational and privacy-conscious. - **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request; normalized forwarding-header candidates may be mixed into that fallback only as untrusted differentiation entropy and never as Security identity, GeoIP input, ban key, or signal evidence. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent/forwarding-entropy fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. - **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context as the raw fallback, and use database-backed lookup projections for Admin filtering and query-heavy Security review. -- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves the Admin/UI read path to `DatabaseLogBrowser` while retaining file parsing as an operator/fallback helper. +- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves structured message, audit, access, and security-signal browsing to `DatabaseLogBrowser` while retaining the Symfony application log as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs. - **Decision recorded:** Separate message, audit, and access file channels; live-operation terminal summaries use the message channel with operation-specific message keys, and raw request-derived logs stay separate from anonymized statistics output. Log files use `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` so filenames stay descriptive without product or system owner prefixes. - **Decision recorded:** Access statistics run as a separate database-backed model in parallel to raw `access` files. Each request may write a short-lived raw access-log entry plus one `access_statistic_event` row with an internally generated request id, first-party cookie-derived anonymized visitor id, method, requested path, resolved route, surface, status, duration, referrer host, preferred language, content metadata, coarse browser family, device type, bot classification, and GeoIP placeholder fields. Raw access logs keep the full user-agent, IP/proxy hints, and an optional safe inbound `correlation_id` for operational review, but IP addresses, user-agents, inbound correlation ids, and raw visitor-cookie tokens are not stored in the statistics table. `X-Request-ID` and `X-Correlation-ID` are never reused as the internal request id. Request id, visitor id, requested path, and resolved route are exposed to Twig error pages as a visitor-shareable debug reference. The latest Admin Logs snapshot is still written below `var/statistics/{environment}/access/latest.json` as a cache/output boundary so a later scheduler or richer statistics module can replace the producer without changing the Admin Logs UI. - **Decision recorded:** The first Admin Logs statistics view supports fixed windows (`1h`, `24h`, `7d`, `30d`, `all`) over stored anonymized statistic events. Long-term compaction into daily/monthly aggregates remains deferred until the statistics feature grows beyond this foundation slice. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 02e4f21e..13a53cd8 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -45,7 +45,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Message projections keep a level because message logs carry meaningful severities. Security signals keep severity. Current access and audit projections omit level fields because their existing writers only emit one operational level and the value would not support useful filtering. - Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. - Setup requests may already write file logs before the database exists. Database log projections and passive-signal storage must check the existing database-ready boundary before any DBAL read/write, including retention-setting lookups; when `APP_SETUP_COMPLETED` is not truthy and no explicit unready override is active, they must no-op instead of touching Doctrine/DBAL. -- Admin Logs and `/api/v1/admin/logs/**` read from database projections, not from rotated files. File logs stay inspectable for operators and useful during database degradation, but they are not the primary UI read path. +- Admin Logs and `/api/v1/admin/logs/**` read structured message, audit, access, and security-signal sources from database projections. The Symfony application log remains a deliberate file-backed source because it is not projected into the database; it is limited to the existing reverse-tail window and uses stable synthetic file-line IDs for detail links. - This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. - Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. - The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. diff --git a/src/Api/Admin/AdminLogApiHandler.php b/src/Api/Admin/AdminLogApiHandler.php index 7d46698d..b3f6aed2 100644 --- a/src/Api/Admin/AdminLogApiHandler.php +++ b/src/Api/Admin/AdminLogApiHandler.php @@ -93,6 +93,7 @@ private function sourceResources(array $sources): array private function filtersForSource(string $source): array { return match ($source) { + 'application' => ['level', 'q', 'match', 'time_window', 'limit', 'page'], 'message' => ['level', 'q', 'match', 'time_window', 'limit', 'page'], 'audit' => ['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], 'security_signal' => ['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], diff --git a/src/Api/Admin/AdminOperationalApiEndpointProvider.php b/src/Api/Admin/AdminOperationalApiEndpointProvider.php index bee86d8c..21a739f6 100644 --- a/src/Api/Admin/AdminOperationalApiEndpointProvider.php +++ b/src/Api/Admin/AdminOperationalApiEndpointProvider.php @@ -76,7 +76,7 @@ private function endpoint( private function logParameters(): array { return [ - ['name' => 'log', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['message', 'audit', 'access', 'security_signal']]], + ['name' => 'log', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['application', 'message', 'audit', 'access', 'security_signal']]], ['name' => 'level', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']], ['name' => 'q', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']], ['name' => 'match', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string', 'enum' => ['contains', 'equals']]], diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 292742c7..d92bc716 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -55,7 +55,7 @@ public function adminIndex(Request $request): Response return $this->handle($request, BackendArea::Admin); } - #[Route('/admin/logs/{entryId}', name: 'backend_admin_log_detail', requirements: ['entryId' => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'], methods: ['GET'])] + #[Route('/admin/logs/{entryId}', name: 'backend_admin_log_detail', requirements: ['entryId' => '(?:[0-9a-fA-F]{24}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})'], methods: ['GET'])] public function logDetail(Request $request, string $entryId): Response { $access = $this->adminAccessResponse($request); diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index 4856a71e..236ddae9 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -8,6 +8,7 @@ final readonly class DatabaseLogBrowser { + private const APPLICATION_SOURCE = ['application' => ['label' => 'admin.logs.sources.application']]; private const SOURCES = [ 'message' => ['label' => 'admin.logs.sources.message', 'table' => 'message_log_entry'], 'audit' => ['label' => 'admin.logs.sources.audit', 'table' => 'audit_log_entry'], @@ -17,6 +18,7 @@ public function __construct( private Connection $connection, + private ?LogFileBrowser $fileBrowser = null, private LogEntryFilter $entryFilter = new LogEntryFilter(), private LogPagination $pagination = new LogPagination(), ) { @@ -30,6 +32,10 @@ public function __construct( public function browse(array $query): array { $source = $this->source($query['source'] ?? null); + if ('application' === $source) { + return $this->browseApplication($query); + } + $filters = $this->entryFilter->filters($query); if (!$this->supportsLevelFilter($source)) { $filters['level'] = ''; @@ -59,6 +65,10 @@ public function browse(array $query): array public function entry(string $source, string $id): ?array { $source = $this->source($source); + if ('application' === $source) { + return $this->fileBrowser?->entry($source, $id); + } + $row = $this->connection->fetchAssociative(sprintf( 'SELECT * FROM %s WHERE uid = ?', self::SOURCES[$source]['table'], @@ -74,7 +84,7 @@ public function sourceOptions(): array { $options = []; - foreach (self::SOURCES as $key => $source) { + foreach ([...self::APPLICATION_SOURCE, ...self::SOURCES] as $key => $source) { $options[] = ['key' => $key, 'label' => $source['label']]; } @@ -83,6 +93,10 @@ public function sourceOptions(): array private function source(mixed $source): string { + if ('application' === $source) { + return 'application'; + } + return is_string($source) && isset(self::SOURCES[$source]) ? $source : 'message'; } @@ -100,7 +114,38 @@ private function capabilities(string $source): array private function supportsLevelFilter(string $source): bool { - return in_array($source, ['message', 'security_signal'], true); + return in_array($source, ['application', 'message', 'security_signal'], true); + } + + /** + * @param array $query + * + * @return array + */ + private function browseApplication(array $query): array + { + if (null === $this->fileBrowser) { + $filters = $this->entryFilter->filters($query); + + return [ + 'sources' => $this->sourceOptions(), + 'selected_source' => 'application', + 'capabilities' => $this->capabilities('application'), + 'filters' => $filters, + 'entries' => [], + 'files' => [], + 'pagination' => $this->pagination->pagination($filters, 0), + 'per_page_options' => $this->pagination->perPageOptions(), + 'time_window_options' => $this->pagination->timeWindowOptions(), + 'match_options' => $this->pagination->matchOptions(), + ]; + } + + $view = $this->fileBrowser->browse([...$query, 'source' => 'application']); + $view['sources'] = $this->sourceOptions(); + $view['capabilities'] = $this->capabilities('application'); + + return $view; } /** diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index 8f4fb0cc..1c78e902 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -69,6 +69,7 @@ public function testAdminLogsListSourcesAndSourceEntries(): void foreach ($payload['data'] as $resource) { $sources[$resource['id']] = $resource['attributes']['filters']; } + self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['application']); self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['message']); self::assertSame(['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['audit']); self::assertSame(['q', 'match', 'time_window', 'limit', 'page'], $sources['access']); @@ -231,7 +232,7 @@ public function testOpenApiIncludesAdminOperationalEndpoints(): void self::assertSame(['backend-admin', 'backend-admin-statistics'], $payload['paths']['/admin/statistics']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-themes'], $payload['paths']['/admin/themes']['get']['tags']); self::assertSame( - ['message', 'audit', 'access', 'security_signal'], + ['application', 'message', 'audit', 'access', 'security_signal'], $payload['paths']['/admin/logs/{log}']['get']['parameters'][0]['schema']['enum'], ); self::assertContains([ diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index e941cd65..aa63e699 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -625,12 +625,23 @@ public function testAdminLogsViewReadsSelectedLogSource(): void 'continent' => 'n/a', 'metadata' => '{}', ]); + $logDir = (string) self::getContainer()->getParameter('kernel.logs_dir'); + $applicationLog = $logDir.'/test.log'; + $previousApplicationLog = is_file($applicationLog) ? file_get_contents($applicationLog) : null; + $applicationLine = '[2099-01-01T10:02:00.000000+00:00] app.ERROR: app.functional_failure {"code":"app.functional_failure","request_id":"functional-application-request"} []'; + if (!is_dir($logDir)) { + mkdir($logDir, 0777, true); + } + $applicationPrefix = is_string($previousApplicationLog) && '' !== $previousApplicationLog ? rtrim($previousApplicationLog).PHP_EOL : ''; + file_put_contents($applicationLog, $applicationPrefix.$applicationLine.PHP_EOL); + $applicationEntryId = substr(hash('sha256', "application\0test.log\0".$applicationLine), 0, 24); try { $client->request('GET', '/admin/logs?source=access&q=/admin/logs'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Logs'); + self::assertSelectorTextContains('.system-tabs', 'Application'); self::assertSelectorTextContains('.system-tabs', 'Security signals'); self::assertSelectorTextContains('.system-backend-log-table', 'GET /admin/logs'); self::assertSelectorTextContains('.system-backend-log-table', 'Details'); @@ -642,6 +653,17 @@ public function testAdminLogsViewReadsSelectedLogSource(): void self::assertSelectorTextContains('body', '127.0.0.1'); self::assertSelectorTextContains('body', 'backend_admin_route'); + $client->request('GET', '/admin/logs?source=application&level%5B0%5D=ERROR&q=functional-application-request'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.system-backend-log-table', 'app.functional_failure'); + + $client->request('GET', '/admin/logs/'.$applicationEntryId.'?source=application'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Log event'); + self::assertSelectorTextContains('body', 'functional-application-request'); + $client->request('GET', '/admin/statistics?statistics_window=all'); self::assertResponseIsSuccessful(); @@ -653,6 +675,11 @@ public function testAdminLogsViewReadsSelectedLogSource(): void } finally { $connection->delete('access_log_entry', ['request_id' => 'request-admin-logs']); $connection->delete('access_statistic_event', ['route' => 'backend_admin_route']); + if (is_string($previousApplicationLog)) { + file_put_contents($applicationLog, $previousApplicationLog); + } else { + @unlink($applicationLog); + } } } diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index f37574d9..004b78b8 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -5,11 +5,27 @@ namespace App\Tests\Core\Log; use App\Core\Log\DatabaseLogBrowser; +use App\Core\Log\LogFileBrowser; +use App\Tests\Support\FilesystemTestHelper; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; final class DatabaseLogBrowserTest extends TestCase { + use FilesystemTestHelper; + + private string $logDir; + + protected function setUp(): void + { + $this->logDir = $this->createTemporaryDirectory('system-database-log-browser'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->logDir); + } + public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); @@ -91,8 +107,18 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void 'resolved_route' => 'backend_admin_route', 'context' => '{"hidden":"visitor-audit"}', ]); + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure","request_id":"application-request"} []'.PHP_EOL); + + $browser = new DatabaseLogBrowser($connection, new LogFileBrowser($this->logDir, 'test')); + $applicationView = $browser->browse(['source' => 'application', 'level' => 'ERROR', 'q' => 'application-request']); + self::assertTrue($applicationView['capabilities']['level_filter']); + self::assertSame(1, $applicationView['pagination']['total']); + self::assertSame('application', $applicationView['entries'][0]['source']); + self::assertSame('app.failure', $applicationView['entries'][0]['message']); + $applicationEntry = $browser->entry('application', $applicationView['entries'][0]['id']); + self::assertNotNull($applicationEntry); + self::assertSame('app.failure', $applicationEntry['message']); - $browser = new DatabaseLogBrowser($connection); $defaultView = $browser->browse(['source' => 'message']); self::assertSame(0, $defaultView['pagination']['total']); @@ -116,7 +142,7 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void self::assertTrue($view['capabilities']['level_filter']); self::assertTrue($view['capabilities']['signal_reason_filter']); - self::assertSame(['message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); + self::assertSame(['application', 'message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); self::assertSame('security_signal', $view['selected_source']); self::assertSame(1, $view['pagination']['total']); self::assertSame('99999999-0000-7000-8000-000000000002', $view['entries'][0]['id']); From 924fdc970608897dcf3d3b28362c0fa2cff57a30 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 11:05:24 +0200 Subject: [PATCH 053/119] Split admin log browsing facade --- config/services.yaml | 4 -- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 4 +- dev/draft/0.4.x-ContactMailLogging.md | 2 +- .../security-hardening/abuse-foundation.md | 2 +- src/Api/Admin/AdminLogApiHandler.php | 4 +- src/Backend/AdminViewContextProvider.php | 4 +- src/Controller/BackendController.php | 4 +- src/Core/Log/AdminLogBrowser.php | 71 +++++++++++++++++++ src/Core/Log/DatabaseLogBrowser.php | 49 +------------ tests/Core/Log/AdminLogBrowserTest.php | 52 ++++++++++++++ tests/Core/Log/DatabaseLogBrowserTest.php | 31 +------- 12 files changed, 140 insertions(+), 91 deletions(-) create mode 100644 src/Core/Log/AdminLogBrowser.php create mode 100644 tests/Core/Log/AdminLogBrowserTest.php diff --git a/config/services.yaml b/config/services.yaml index b5d2a85d..66e75a72 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -459,10 +459,6 @@ services: $logDir: '%kernel.logs_dir%' $environment: '%kernel.environment%' - App\Core\Log\DatabaseLogBrowser: - arguments: - $fileBrowser: '@App\Core\Log\LogFileBrowser' - App\Core\Statistics\AccessStatisticsRecorderInterface: alias: App\Core\Statistics\DatabaseAccessStatisticsRecorder diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ad3d2a31..1b0fa459 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,8 +199,8 @@ | 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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and configurable suspicious probe path patterns, 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 | `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 powers Admin/API log browsing with source tabs, UUID detail links for database projections, synthetic file-line detail IDs for the Symfony application log, broad hidden-field search, source-specific filters, 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`, `tests/Controller/BackendControllerTest.php` | -| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Parses Monolog line output and keeps the Symfony application log available as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs while structured message, audit, access, and security-signal sources use database projections. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Core/Log/DatabaseLogBrowserTest.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 hidden-field search, source-specific filters, 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 and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 819f5d53..2971d97d 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. +- [ ] 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. - [ ] 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. - [ ] 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. @@ -93,7 +94,8 @@ - Switched passive security-signal expiry and cleanup to Symfony Clock so retention behavior is deterministic in tests and matches the Abuse Foundation time-boundary plan. - Hardened PR-readiness findings before final checks: database log projection retention now uses Symfony Clock, and the Admin Logs OpenAPI enum documents the database-backed sources including `security_signal`. - Reintroduced the Symfony `application` log as an explicit file-backed Admin/API source while keeping message, audit, access, and security-signal browsing database-backed; application detail links use the existing synthetic file-line hash IDs because Symfony Monolog lines do not carry database UUIDs. -- Final verification: `bin/phpunit` passed with 1339 tests and 8649 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- #57 audit follow-up applied before final review: split hybrid log browsing into an `AdminLogBrowser` facade so `DatabaseLogBrowser` remains responsible only for database-backed projections and the file-backed `application` source keeps its own parser boundary. +- Final verification: `bin/phpunit` passed with 1340 tests and 8650 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index 4e8ad45e..cf3e759b 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -94,7 +94,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Keep statistics operational and privacy-conscious. - **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request; normalized forwarding-header candidates may be mixed into that fallback only as untrusted differentiation entropy and never as Security identity, GeoIP input, ban key, or signal evidence. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent/forwarding-entropy fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. - **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context as the raw fallback, and use database-backed lookup projections for Admin filtering and query-heavy Security review. -- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves structured message, audit, access, and security-signal browsing to `DatabaseLogBrowser` while retaining the Symfony application log as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs. +- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves structured message, audit, access, and security-signal browsing to `DatabaseLogBrowser`, then exposes them through `AdminLogBrowser` alongside the Symfony application log as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs. - **Decision recorded:** Separate message, audit, and access file channels; live-operation terminal summaries use the message channel with operation-specific message keys, and raw request-derived logs stay separate from anonymized statistics output. Log files use `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` so filenames stay descriptive without product or system owner prefixes. - **Decision recorded:** Access statistics run as a separate database-backed model in parallel to raw `access` files. Each request may write a short-lived raw access-log entry plus one `access_statistic_event` row with an internally generated request id, first-party cookie-derived anonymized visitor id, method, requested path, resolved route, surface, status, duration, referrer host, preferred language, content metadata, coarse browser family, device type, bot classification, and GeoIP placeholder fields. Raw access logs keep the full user-agent, IP/proxy hints, and an optional safe inbound `correlation_id` for operational review, but IP addresses, user-agents, inbound correlation ids, and raw visitor-cookie tokens are not stored in the statistics table. `X-Request-ID` and `X-Correlation-ID` are never reused as the internal request id. Request id, visitor id, requested path, and resolved route are exposed to Twig error pages as a visitor-shareable debug reference. The latest Admin Logs snapshot is still written below `var/statistics/{environment}/access/latest.json` as a cache/output boundary so a later scheduler or richer statistics module can replace the producer without changing the Admin Logs UI. - **Decision recorded:** The first Admin Logs statistics view supports fixed windows (`1h`, `24h`, `7d`, `30d`, `all`) over stored anonymized statistic events. Long-term compaction into daily/monthly aggregates remains deferred until the statistics feature grows beyond this foundation slice. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 13a53cd8..1c5b7d68 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -45,7 +45,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Message projections keep a level because message logs carry meaningful severities. Security signals keep severity. Current access and audit projections omit level fields because their existing writers only emit one operational level and the value would not support useful filtering. - Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. - Setup requests may already write file logs before the database exists. Database log projections and passive-signal storage must check the existing database-ready boundary before any DBAL read/write, including retention-setting lookups; when `APP_SETUP_COMPLETED` is not truthy and no explicit unready override is active, they must no-op instead of touching Doctrine/DBAL. -- Admin Logs and `/api/v1/admin/logs/**` read structured message, audit, access, and security-signal sources from database projections. The Symfony application log remains a deliberate file-backed source because it is not projected into the database; it is limited to the existing reverse-tail window and uses stable synthetic file-line IDs for detail links. +- Admin Logs and `/api/v1/admin/logs/**` read through `AdminLogBrowser`: structured message, audit, access, and security-signal sources come from database projections, while the Symfony application log remains a deliberate file-backed source because it is not projected into the database. The file-backed source is limited to the existing reverse-tail window and uses stable synthetic file-line IDs for detail links. - This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. - Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. - The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. diff --git a/src/Api/Admin/AdminLogApiHandler.php b/src/Api/Admin/AdminLogApiHandler.php index b3f6aed2..8a054bde 100644 --- a/src/Api/Admin/AdminLogApiHandler.php +++ b/src/Api/Admin/AdminLogApiHandler.php @@ -12,7 +12,7 @@ use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Core\Access\AccessLevel; -use App\Core\Log\DatabaseLogBrowser; +use App\Core\Log\AdminLogBrowser; use App\Core\Message\Message; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -20,7 +20,7 @@ final readonly class AdminLogApiHandler implements ApiEndpointHandlerInterface { public function __construct( - private DatabaseLogBrowser $logs, + private AdminLogBrowser $logs, private ApiListQueryNormalizer $listQueries, private ApiAccessGuard $accessGuard, private ApiResponder $responder, diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index e3db372d..f649ed73 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -8,7 +8,7 @@ use App\Core\Diagnostics\SystemInfoProvider; use App\Core\Geo\GeoIpResolverInterface; use App\Core\Geo\MaxMindGeoIpConfig; -use App\Core\Log\DatabaseLogBrowser; +use App\Core\Log\AdminLogBrowser; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Statistics\AccessStatisticsSnapshotProvider; use App\Entity\UserAccount; @@ -19,7 +19,7 @@ { public function __construct( private LiveOperationRunStore $liveOperationRunStore, - private DatabaseLogBrowser $logBrowser, + private AdminLogBrowser $logBrowser, private AccessStatisticsSnapshotProvider $accessStatisticsSnapshotProvider, private SystemInfoProvider $systemInfoProvider, private MaxMindGeoIpConfig $maxMindGeoIpConfig, diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index d92bc716..0f18c854 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -13,7 +13,7 @@ use App\Core\Access\AccessActor; use App\Core\Message\Message; use App\Core\Config\Settings\CoreSettingsFormHandler; -use App\Core\Log\DatabaseLogBrowser; +use App\Core\Log\AdminLogBrowser; use App\Core\Log\AuditLoggerInterface; use App\Core\Package\Settings\PackageSettingsFormHandler; use App\Entity\UserAccount; @@ -42,7 +42,7 @@ public function __construct( private readonly PackageSettingsFormHandler $packageSettingsFormHandler, private readonly AdminViewContextProvider $adminViewContextProvider, private readonly BackendActionResponder $backendActionResponder, - private readonly DatabaseLogBrowser $logBrowser, + private readonly AdminLogBrowser $logBrowser, private readonly AuditLoggerInterface $auditLogger, private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, diff --git a/src/Core/Log/AdminLogBrowser.php b/src/Core/Log/AdminLogBrowser.php new file mode 100644 index 00000000..da5ff5d4 --- /dev/null +++ b/src/Core/Log/AdminLogBrowser.php @@ -0,0 +1,71 @@ + ['label' => 'admin.logs.sources.application']]; + + public function __construct( + private DatabaseLogBrowser $databaseBrowser, + private LogFileBrowser $fileBrowser, + ) { + } + + /** + * @param array $query + * + * @return array + */ + public function browse(array $query): array + { + $source = $query['source'] ?? null; + if ('application' === $source) { + $view = $this->fileBrowser->browse([...$query, 'source' => 'application']); + $view['sources'] = $this->sourceOptions(); + $view['capabilities'] = $this->capabilities('application'); + + return $view; + } + + $view = $this->databaseBrowser->browse($query); + $view['sources'] = $this->sourceOptions(); + + return $view; + } + + /** + * @return array|null + */ + public function entry(string $source, string $id): ?array + { + return 'application' === $source + ? $this->fileBrowser->entry($source, $id) + : $this->databaseBrowser->entry($source, $id); + } + + /** + * @return list + */ + public function sourceOptions(): array + { + return [ + ['key' => 'application', 'label' => self::APPLICATION_SOURCE['application']['label']], + ...$this->databaseBrowser->sourceOptions(), + ]; + } + + /** + * @return array{level_filter: bool, audit_action_filter: bool, signal_reason_filter: bool} + */ + private function capabilities(string $source): array + { + return [ + 'level_filter' => 'application' === $source, + 'audit_action_filter' => false, + 'signal_reason_filter' => false, + ]; + } +} diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index 236ddae9..4856a71e 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -8,7 +8,6 @@ final readonly class DatabaseLogBrowser { - private const APPLICATION_SOURCE = ['application' => ['label' => 'admin.logs.sources.application']]; private const SOURCES = [ 'message' => ['label' => 'admin.logs.sources.message', 'table' => 'message_log_entry'], 'audit' => ['label' => 'admin.logs.sources.audit', 'table' => 'audit_log_entry'], @@ -18,7 +17,6 @@ public function __construct( private Connection $connection, - private ?LogFileBrowser $fileBrowser = null, private LogEntryFilter $entryFilter = new LogEntryFilter(), private LogPagination $pagination = new LogPagination(), ) { @@ -32,10 +30,6 @@ public function __construct( public function browse(array $query): array { $source = $this->source($query['source'] ?? null); - if ('application' === $source) { - return $this->browseApplication($query); - } - $filters = $this->entryFilter->filters($query); if (!$this->supportsLevelFilter($source)) { $filters['level'] = ''; @@ -65,10 +59,6 @@ public function browse(array $query): array public function entry(string $source, string $id): ?array { $source = $this->source($source); - if ('application' === $source) { - return $this->fileBrowser?->entry($source, $id); - } - $row = $this->connection->fetchAssociative(sprintf( 'SELECT * FROM %s WHERE uid = ?', self::SOURCES[$source]['table'], @@ -84,7 +74,7 @@ public function sourceOptions(): array { $options = []; - foreach ([...self::APPLICATION_SOURCE, ...self::SOURCES] as $key => $source) { + foreach (self::SOURCES as $key => $source) { $options[] = ['key' => $key, 'label' => $source['label']]; } @@ -93,10 +83,6 @@ public function sourceOptions(): array private function source(mixed $source): string { - if ('application' === $source) { - return 'application'; - } - return is_string($source) && isset(self::SOURCES[$source]) ? $source : 'message'; } @@ -114,38 +100,7 @@ private function capabilities(string $source): array private function supportsLevelFilter(string $source): bool { - return in_array($source, ['application', 'message', 'security_signal'], true); - } - - /** - * @param array $query - * - * @return array - */ - private function browseApplication(array $query): array - { - if (null === $this->fileBrowser) { - $filters = $this->entryFilter->filters($query); - - return [ - 'sources' => $this->sourceOptions(), - 'selected_source' => 'application', - 'capabilities' => $this->capabilities('application'), - 'filters' => $filters, - 'entries' => [], - 'files' => [], - 'pagination' => $this->pagination->pagination($filters, 0), - 'per_page_options' => $this->pagination->perPageOptions(), - 'time_window_options' => $this->pagination->timeWindowOptions(), - 'match_options' => $this->pagination->matchOptions(), - ]; - } - - $view = $this->fileBrowser->browse([...$query, 'source' => 'application']); - $view['sources'] = $this->sourceOptions(); - $view['capabilities'] = $this->capabilities('application'); - - return $view; + return in_array($source, ['message', 'security_signal'], true); } /** diff --git a/tests/Core/Log/AdminLogBrowserTest.php b/tests/Core/Log/AdminLogBrowserTest.php new file mode 100644 index 00000000..17d46e7e --- /dev/null +++ b/tests/Core/Log/AdminLogBrowserTest.php @@ -0,0 +1,52 @@ +logDir = $this->createTemporaryDirectory('system-admin-log-browser'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->logDir); + } + + public function testItCombinesApplicationFileLogWithDatabaseSources(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure","request_id":"application-request"} []'.PHP_EOL); + + $browser = new AdminLogBrowser( + new DatabaseLogBrowser($connection), + new LogFileBrowser($this->logDir, 'test'), + ); + + $applicationView = $browser->browse(['source' => 'application', 'level' => 'ERROR', 'q' => 'application-request']); + self::assertSame(['application', 'message', 'audit', 'access', 'security_signal'], array_column($applicationView['sources'], 'key')); + self::assertSame('application', $applicationView['selected_source']); + self::assertTrue($applicationView['capabilities']['level_filter']); + self::assertSame(1, $applicationView['pagination']['total']); + self::assertSame('app.failure', $applicationView['entries'][0]['message']); + + $applicationEntry = $browser->entry('application', $applicationView['entries'][0]['id']); + self::assertNotNull($applicationEntry); + self::assertSame('app.failure', $applicationEntry['message']); + } +} diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index 004b78b8..fd51fffb 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -5,27 +5,11 @@ namespace App\Tests\Core\Log; use App\Core\Log\DatabaseLogBrowser; -use App\Core\Log\LogFileBrowser; -use App\Tests\Support\FilesystemTestHelper; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; final class DatabaseLogBrowserTest extends TestCase { - use FilesystemTestHelper; - - private string $logDir; - - protected function setUp(): void - { - $this->logDir = $this->createTemporaryDirectory('system-database-log-browser'); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->logDir); - } - public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); @@ -107,18 +91,7 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void 'resolved_route' => 'backend_admin_route', 'context' => '{"hidden":"visitor-audit"}', ]); - $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure","request_id":"application-request"} []'.PHP_EOL); - - $browser = new DatabaseLogBrowser($connection, new LogFileBrowser($this->logDir, 'test')); - $applicationView = $browser->browse(['source' => 'application', 'level' => 'ERROR', 'q' => 'application-request']); - self::assertTrue($applicationView['capabilities']['level_filter']); - self::assertSame(1, $applicationView['pagination']['total']); - self::assertSame('application', $applicationView['entries'][0]['source']); - self::assertSame('app.failure', $applicationView['entries'][0]['message']); - $applicationEntry = $browser->entry('application', $applicationView['entries'][0]['id']); - self::assertNotNull($applicationEntry); - self::assertSame('app.failure', $applicationEntry['message']); - + $browser = new DatabaseLogBrowser($connection); $defaultView = $browser->browse(['source' => 'message']); self::assertSame(0, $defaultView['pagination']['total']); @@ -142,7 +115,7 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void self::assertTrue($view['capabilities']['level_filter']); self::assertTrue($view['capabilities']['signal_reason_filter']); - self::assertSame(['application', 'message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); + self::assertSame(['message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); self::assertSame('security_signal', $view['selected_source']); self::assertSame(1, $view['pagination']['total']); self::assertSame('99999999-0000-7000-8000-000000000002', $view['entries'][0]['id']); From 273a7be3df05bd4571c67b8f8f1e3842b4aa8303 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 11:57:46 +0200 Subject: [PATCH 054/119] Harden database log browsing filters --- src/Core/Log/DatabaseLogBrowser.php | 54 ++++++++++-- src/Core/Log/LogEntryFilter.php | 10 +-- src/Core/Log/LogFileBrowser.php | 4 +- src/Core/Log/LogPagination.php | 23 ++--- tests/Core/Log/DatabaseLogBrowserTest.php | 101 +++++++++++++++++++--- 5 files changed, 149 insertions(+), 43 deletions(-) diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index 4856a71e..af5fe23b 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -5,6 +5,9 @@ namespace App\Core\Log; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\NativeClock; final readonly class DatabaseLogBrowser { @@ -19,6 +22,7 @@ public function __construct( private Connection $connection, private LogEntryFilter $entryFilter = new LogEntryFilter(), private LogPagination $pagination = new LogPagination(), + private ClockInterface $clock = new NativeClock(), ) { } @@ -59,10 +63,19 @@ public function browse(array $query): array public function entry(string $source, string $id): ?array { $source = $this->source($source); + $where = ['uid = ?']; + $params = [$id]; + + if ('security_signal' === $source) { + $where[] = 'expires_at > ?'; + $params[] = $this->now(); + } + $row = $this->connection->fetchAssociative(sprintf( - 'SELECT * FROM %s WHERE uid = ?', + 'SELECT * FROM %s WHERE %s', self::SOURCES[$source]['table'], - ), [$id]); + implode(' AND ', $where), + ), $params); return is_array($row) ? $this->present($source, $row) : null; } @@ -104,7 +117,7 @@ private function supportsLevelFilter(string $source): bool } /** - * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters * * @return array{where: list, params: list} */ @@ -129,6 +142,11 @@ private function criteria(string $source, array $filters): array $params[] = $filters['audit_action']; } + if ('security_signal' === $source) { + $where[] = 'expires_at > ?'; + $params[] = $this->now(); + } + if ('' !== $filters['search']) { $columns = match ($source) { 'access' => ['context', 'request_id', 'correlation_id', 'path', 'requested_path', 'route', 'resolved_route', 'client_ip', 'proxy_client_ip', 'visitor_id', 'host', 'user_agent', 'referrer_host'], @@ -138,7 +156,7 @@ private function criteria(string $source, array $filters): array }; $operator = 'equals' === $filters['match'] ? '= ?' : 'LIKE ?'; $needle = 'equals' === $filters['match'] ? $filters['search'] : '%'.$filters['search'].'%'; - $where[] = '('.implode(' OR ', array_map(static fn (string $column): string => $column.' '.$operator, $columns)).')'; + $where[] = '('.implode(' OR ', array_map(fn (string $column): string => $this->searchExpression($column).' '.$operator, $columns)).')'; foreach ($columns as $_) { $params[] = $needle; @@ -164,14 +182,14 @@ private function count(string $source, array $criteria): int /** * @param array{where: list, params: list} $criteria - * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters * * @return list> */ private function entries(string $source, array $criteria, array $filters): array { - $limit = 'all' === $filters['per_page'] ? 500 : (int) $filters['per_page']; - $offset = 'all' === $filters['per_page'] ? 0 : ($filters['page'] - 1) * (int) $filters['per_page']; + $limit = $filters['per_page']; + $offset = ($filters['page'] - 1) * $filters['per_page']; $sql = sprintf( 'SELECT * FROM %s WHERE %s ORDER BY occurred_at DESC, uid DESC LIMIT %d OFFSET %d', self::SOURCES[$source]['table'], @@ -305,6 +323,26 @@ private function cutoff(string $window): string default => '-24 hours', }; - return (new \DateTimeImmutable($modifier))->format('Y-m-d H:i:s'); + return $this->clock->now()->modify($modifier)->format('Y-m-d H:i:s'); + } + + private function now(): string + { + return $this->clock->now()->format('Y-m-d H:i:s'); + } + + private function searchExpression(string $column): string + { + if ('context' !== $column) { + return $column; + } + + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof AbstractMySQLPlatform) { + return 'CAST(context AS CHAR)'; + } + + return 'CAST(context AS TEXT)'; } } diff --git a/src/Core/Log/LogEntryFilter.php b/src/Core/Log/LogEntryFilter.php index 9f9c937c..8dfa48ce 100644 --- a/src/Core/Log/LogEntryFilter.php +++ b/src/Core/Log/LogEntryFilter.php @@ -13,7 +13,7 @@ /** * @param array $query * - * @return array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} + * @return array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} */ public function filters(array $query): array { @@ -33,7 +33,7 @@ public function filters(array $query): array /** * @param array $entry - * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters */ public function matches(array $entry, array $filters): bool { @@ -115,15 +115,15 @@ private function timeWindow(mixed $window): string return in_array($window, ['1h', '24h', '7d', '30d'], true) ? $window : '24h'; } - private function perPage(mixed $perPage): int|string + private function perPage(mixed $perPage): int { if ('all' === $perPage) { - return 'all'; + return 500; } $perPage = is_numeric($perPage) ? (int) $perPage : self::DEFAULT_PER_PAGE; - return in_array($perPage, [25, 50, 100, 150], true) ? $perPage : self::DEFAULT_PER_PAGE; + return in_array($perPage, [25, 50, 100, 150, 500], true) ? $perPage : self::DEFAULT_PER_PAGE; } private function page(mixed $page): int diff --git a/src/Core/Log/LogFileBrowser.php b/src/Core/Log/LogFileBrowser.php index 91defc5d..bb7f89b2 100644 --- a/src/Core/Log/LogFileBrowser.php +++ b/src/Core/Log/LogFileBrowser.php @@ -34,8 +34,8 @@ public function browse(array $query): array $files = $this->sourceRegistry->files($this->logDir, $this->environment, $source); $entries = []; $matched = 0; - $offset = 'all' === $filters['per_page'] ? 0 : ($filters['page'] - 1) * (int) $filters['per_page']; - $limit = 'all' === $filters['per_page'] ? PHP_INT_MAX : (int) $filters['per_page']; + $offset = ($filters['page'] - 1) * $filters['per_page']; + $limit = $filters['per_page']; foreach ($files as $file) { foreach ($this->lineReader->readLines($file) as $line) { diff --git a/src/Core/Log/LogPagination.php b/src/Core/Log/LogPagination.php index 195bcc4d..e14a3fd7 100644 --- a/src/Core/Log/LogPagination.php +++ b/src/Core/Log/LogPagination.php @@ -7,26 +7,13 @@ final readonly class LogPagination { /** - * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters * - * @return array{page: int, per_page: int|string, total: int, total_pages: int, has_previous: bool, has_next: bool, previous_page: int, next_page: int} + * @return array{page: int, per_page: int, total: int, total_pages: int, has_previous: bool, has_next: bool, previous_page: int, next_page: int} */ public function pagination(array $filters, int $matched): array { - if ('all' === $filters['per_page']) { - return [ - 'page' => 1, - 'per_page' => 'all', - 'total' => $matched, - 'total_pages' => 1, - 'has_previous' => false, - 'has_next' => false, - 'previous_page' => 1, - 'next_page' => 1, - ]; - } - - $perPage = (int) $filters['per_page']; + $perPage = $filters['per_page']; $totalPages = max(1, (int) ceil($matched / $perPage)); $page = min($filters['page'], $totalPages); @@ -43,7 +30,7 @@ public function pagination(array $filters, int $matched): array } /** - * @return list + * @return list */ public function perPageOptions(): array { @@ -52,7 +39,7 @@ public function perPageOptions(): array ['key' => 50, 'label' => '50'], ['key' => 100, 'label' => '100'], ['key' => 150, 'label' => '150'], - ['key' => 'all', 'label' => 'admin.logs.filters.all_entries'], + ['key' => 500, 'label' => '500'], ]; } diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index fd51fffb..e09757b6 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -6,20 +6,21 @@ use App\Core\Log\DatabaseLogBrowser; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\MockClock; final class DatabaseLogBrowserTest extends TestCase { public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); - $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); - $connection->executeStatement('CREATE TABLE audit_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, user_name VARCHAR(180) NOT NULL, user_uid VARCHAR(36) DEFAULT NULL, user_access_level INTEGER NOT NULL, action VARCHAR(160) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, requested_path VARCHAR(1024) NOT NULL, resolved_route VARCHAR(190) NOT NULL, context CLOB NOT NULL)'); - $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); - $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $this->createTables($connection); + $now = '2026-06-16 12:00:00'; $connection->insert('message_log_entry', [ 'uid' => '99999999-0000-7000-8000-000000000001', - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'occurred_at' => $now, 'level' => 'INFO', 'message' => 'message.test', 'code' => 'test.message', @@ -27,8 +28,8 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void ]); $connection->insert('security_signal_event', [ 'uid' => '99999999-0000-7000-8000-000000000002', - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - 'expires_at' => (new \DateTimeImmutable('+1 day'))->format('Y-m-d H:i:s'), + 'occurred_at' => $now, + 'expires_at' => '2026-06-17 12:00:00', 'signal_type' => 'probe', 'reason_code' => 'security.probe.env', 'severity' => 'WARNING', @@ -45,9 +46,29 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void 'http_status' => 400, 'context' => '{"source":"security.probe.env"}', ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000005', + 'occurred_at' => $now, + 'expires_at' => '2026-06-16 11:59:59', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.expired', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-expired', + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-expired', + 'visitor_id' => 'visitor-expired', + 'path' => '/.git/config', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{"source":"security.probe.expired"}', + ]); $connection->insert('access_log_entry', [ 'uid' => '99999999-0000-7000-8000-000000000003', - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'occurred_at' => $now, 'request_id' => 'request-access', 'correlation_id' => 'n/a', 'method' => 'GET', @@ -80,7 +101,7 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void ]); $connection->insert('audit_log_entry', [ 'uid' => '99999999-0000-7000-8000-000000000004', - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'occurred_at' => $now, 'user_name' => 'admin', 'user_uid' => '99999999-0000-7000-8000-000000000100', 'user_access_level' => 8, @@ -91,7 +112,7 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void 'resolved_route' => 'backend_admin_route', 'context' => '{"hidden":"visitor-audit"}', ]); - $browser = new DatabaseLogBrowser($connection); + $browser = new DatabaseLogBrowser($connection, clock: new MockClock($now)); $defaultView = $browser->browse(['source' => 'message']); self::assertSame(0, $defaultView['pagination']['total']); @@ -121,10 +142,70 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void self::assertSame('99999999-0000-7000-8000-000000000002', $view['entries'][0]['id']); self::assertSame('probe: security.probe.env', $view['entries'][0]['summary']); self::assertSame('visitor-1', $view['entries'][0]['context']['subject_identifier']); + self::assertSame(0, $browser->browse(['source' => 'security_signal', 'q' => 'security.probe.expired'])['pagination']['total']); + self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000005')); $entry = $browser->entry('message', '99999999-0000-7000-8000-000000000001'); self::assertNotNull($entry); self::assertSame('message.test', $entry['message']); } + + public function testItCapsFormerAllPageSizeAtFiveHundredRowsWithPagination(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $now = '2026-06-16 12:00:00'; + + for ($i = 1; $i <= 501; ++$i) { + $connection->insert('message_log_entry', [ + 'uid' => sprintf('99999999-0000-7000-8000-%012d', $i), + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.test', + 'code' => 'test.message', + 'context' => '{"code":"test.message"}', + ]); + } + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'per_page' => 'all', + ]); + + self::assertSame(500, $view['filters']['per_page']); + self::assertSame(501, $view['pagination']['total']); + self::assertSame(2, $view['pagination']['total_pages']); + self::assertTrue($view['pagination']['has_next']); + self::assertCount(500, $view['entries']); + } + + public function testItCastsJsonContextBeforeSearchingOnPostgreSql(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with(self::stringContains('CAST(context AS TEXT) LIKE ?'), self::anything()) + ->willReturn(0); + $connection + ->expects(self::once()) + ->method('fetchAllAssociative') + ->with(self::stringContains('CAST(context AS TEXT) LIKE ?'), self::anything()) + ->willReturn([]); + + (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ + 'source' => 'message', + 'q' => 'request-id', + ]); + } + + private function createTables(\Doctrine\DBAL\Connection $connection): void + { + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE audit_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, user_name VARCHAR(180) NOT NULL, user_uid VARCHAR(36) DEFAULT NULL, user_access_level INTEGER NOT NULL, action VARCHAR(160) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, requested_path VARCHAR(1024) NOT NULL, resolved_route VARCHAR(190) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + } } From 06ece0565a670200955c5c2f3e0591365bca2769 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 11:57:52 +0200 Subject: [PATCH 055/119] Clarify abuse identity and intent policies --- dev/CLASSMAP.md | 6 ++-- dev/WORKLOG.md | 1 + .../security-hardening/abuse-foundation.md | 6 +++- .../security-hardening/policy-defaults.md | 2 +- .../Abuse/RequestIntentClassifier.php | 28 +++++++++++++++---- .../Abuse/AbuseSubjectResolverTest.php | 26 +++++++++++++++++ .../Abuse/RequestIntentClassifierTest.php | 20 +++++++++++++ 7 files changed, 79 insertions(+), 10 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 1b0fa459..d31122ec 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,9 +198,9 @@ | 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, classifies request family and intent including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations, and configurable suspicious probe path patterns, 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 | `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 hidden-field search, source-specific filters, 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 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` | +| 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 including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations before broad public reset/password keywords, and configurable suspicious probe path patterns, 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 | `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 hidden-field search that casts JSON context columns before matching, source-specific filters, explicit bounded page sizes up to 500 rows, 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, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 2971d97d..5efb13b8 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -95,6 +95,7 @@ - Hardened PR-readiness findings before final checks: database log projection retention now uses Symfony Clock, and the Admin Logs OpenAPI enum documents the database-backed sources including `security_signal`. - Reintroduced the Symfony `application` log as an explicit file-backed Admin/API source while keeping message, audit, access, and security-signal browsing database-backed; application detail links use the existing synthetic file-line hash IDs because Symfony Monolog lines do not carry database UUIDs. - #57 audit follow-up applied before final review: split hybrid log browsing into an `AdminLogBrowser` facade so `DatabaseLogBrowser` remains responsible only for database-backed projections and the file-backed `application` source keeps its own parser boundary. +- Addressed Cloud Review follow-ups: database log searches now cast JSON context before matching for PostgreSQL portability, log page sizes use an explicit 500-row maximum with pagination instead of the misleading `all` option, expired security signals are filtered from list/detail reads, Admin package/user reset routes classify as package/ACL mutations before broad public password-reset keywords, and the Visitor-ID forwarding-entropy policy is covered with a stable IP-bucket regression test. - Final verification: `bin/phpunit` passed with 1340 tests and 8650 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 1c5b7d68..6a9ccdf9 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -49,10 +49,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. - Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. - The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. +- JSON context search must stay portable across SQLite, MariaDB/MySQL, and PostgreSQL. Database-backed log browsing casts JSON context columns to text for broad free-text matching instead of applying string operators directly to JSON-typed columns or materializing the full result set in PHP. +- Log browsing must use explicit bounded page sizes only. The former `all` option is intentionally treated as a 500-row page with normal pagination so large logs cannot silently exhaust memory/time or hide rows behind a misleading one-page view. - The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. - Security identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and security signals, rate-limit subjects, bans, GeoIP, and audit decisions must not trust raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. - Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to reduce accidental visitor merging when the same resolved IP presents different `X-Forwarded-For` chains. Those raw header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. -- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. Rate limiting should stay stable across direct and proxied requests. Later auto-ban policy should keep IP-ban/block thresholds laxer than Visitor-ID thresholds to reduce false positives on shared or untrusted-network IPs while still allowing IP blocking as a secondary cookie-reset bypass defense. +- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. This is a deliberate trade-off: spoofable forwarding entropy may change a cookie-less fallback Visitor-ID, but it must not change the stable IP-bucket HMAC derived from Symfony's resolved client IP. Later rate-limit and auto-ban enforcement must evaluate Visitor-ID and IP-bucket evidence together, with IP thresholds kept laxer than Visitor-ID thresholds to reduce false positives on shared or untrusted-network IPs while still catching clients that rotate cookies or forwarding entropy. - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - 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. @@ -65,9 +67,11 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. - IP subjects and stable IP-derived hashes must expire within 30 days. Longer-lived passive signals must use visitor ID, authenticated user ID, API key fingerprint, or aggregate keys without retaining the IP-derived subject. - TTL and expiry use Symfony's injectable clock/time boundary for deterministic tests. +- Security-signal list and detail reads must filter expired rows by `expires_at` as well as the selected time window, so short-retention signals stop being visible even on quiet sites where no later write has triggered purge cleanup. - Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. - Probe-path configuration uses anchored, normalized patterns and must be tested against normal app/package/media/editor routes to avoid false positives. - High-impact operation intents must exist even when their first implementation only records passive signals: setup apply, settings mutation, user/ACL mutation, package lifecycle, backup/restore, import apply, export/download, self-update, scheduler run-now, diagnostics/support bundles, and upload/archive validation. +- Admin-family unsafe requests must be classified before broad public keyword rules such as `password` or `reset`, so Admin user password-reset actions and package `reset-fault` lifecycle actions are assigned to Admin/ACL/package buckets instead of public password-reset buckets. - Classification should include the resolved Admin/Owner authority outcome for high-impact operations so rate/ban diagnostics can distinguish a denied delegated Admin action from anonymous/API abuse. - CORS preflight classification must distinguish allowed preflights from invalid origin/method/header combinations so the API layer can stay cheap for valid browser clients while still recording suspicious probing. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 4f1a16e9..5f7f583a 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -136,7 +136,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. - Security-signal visibility, IP-bearing access-log visibility, signal cleanup/mutation, and future review actions need explicit Owner/ACL policy in `feat-security-admin-acl-enforcement` instead of relying indefinitely on broad Admin-area access. - Trusted proxy handling is a deployment/webserver boundary, not an app-level Security settings feature. Security identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use Symfony's resolved request client IP and must not trust raw forwarding headers directly. Operators configure trusted reverse proxies through webserver/Symfony deployment config, for example `mod_remoteip` or equivalent server-level handling. -- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to avoid merging unrelated browsers behind the same resolved IP when their `X-Forwarded-For` chains differ. Raw forwarding-header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. +- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to avoid merging unrelated browsers behind the same resolved IP when their `X-Forwarded-For` chains differ. Raw forwarding-header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. Because clients can spoof those headers, enforcement must not rely on the fallback Visitor-ID alone for anonymous cookie-less abuse; it must evaluate the stable IP-bucket HMAC alongside Visitor-ID evidence. - Visitor ID remains the preferred browser continuity key. Different browsers behind the same untrusted proxy should still receive separate visitor subjects. IP bans/blocks remain allowed as a secondary cookie-reset bypass defense, but their thresholds should be laxer than Visitor-ID thresholds so shared or untrusted-network IPs have a lower false-positive risk. - HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 42ea05f9..703ab2e8 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -78,28 +78,46 @@ private function intent( return RequestIntent::TurboPrefetch; } + if (RequestFamily::Setup === $family && !$this->safeMethod($method)) { + return RequestIntent::SetupApply; + } + + if (RequestFamily::Admin === $family && !$this->safeMethod($method)) { + return $this->adminMutationIntent($path, $route); + } + return match (true) { $this->matches($path, $route, 'login') => RequestIntent::Login, $this->matches($path, $route, 'registration', 'register') => RequestIntent::Registration, $this->matches($path, $route, 'password', 'recovery', 'reset') => RequestIntent::PasswordReset, $this->matches($path, $route, 'contact') => RequestIntent::Contact, - RequestFamily::Setup === $family && !$this->safeMethod($method) => RequestIntent::SetupApply, $this->matches($path, $route, 'captcha', 'refresh') => RequestIntent::CaptchaRefresh, $this->matches($path, $route, 'captcha', 'failure') => RequestIntent::CaptchaFailure, - $this->matches($path, $route, 'settings') && !$this->safeMethod($method) => RequestIntent::SettingsMutation, - $this->matches($path, $route, 'users', 'acl') && !$this->safeMethod($method) => RequestIntent::UserAclMutation, - $this->matches($path, $route, 'packages') && !$this->safeMethod($method) => RequestIntent::PackageAdminOperation, $this->matches($path, $route, 'upload', 'archive', 'media') && !$this->safeMethod($method) => RequestIntent::UploadArchiveValidation, $this->matches($path, $route, 'export', 'download') => RequestIntent::ExportDownload, $this->matches($path, $route, 'import') => RequestIntent::ImportOperation, $this->matches($path, $route, 'backup', 'restore') => RequestIntent::BackupRestore, $this->matches($path, $route, 'diagnostic', 'support') => RequestIntent::DiagnosticsSupport, - RequestFamily::Admin === $family && !$this->safeMethod($method) => RequestIntent::AdminOperation, !$this->safeMethod($method) => RequestIntent::FormSubmit, default => RequestIntent::BrowserNavigation, }; } + private function adminMutationIntent(string $path, string $route): RequestIntent + { + return match (true) { + $this->matches($path, $route, 'settings') => RequestIntent::SettingsMutation, + $this->matches($path, $route, 'users', 'acl') => RequestIntent::UserAclMutation, + $this->matches($path, $route, 'packages') => RequestIntent::PackageAdminOperation, + $this->matches($path, $route, 'upload', 'archive', 'media') => RequestIntent::UploadArchiveValidation, + $this->matches($path, $route, 'export', 'download') => RequestIntent::ExportDownload, + $this->matches($path, $route, 'import') => RequestIntent::ImportOperation, + $this->matches($path, $route, 'backup', 'restore') => RequestIntent::BackupRestore, + $this->matches($path, $route, 'diagnostic', 'support') => RequestIntent::DiagnosticsSupport, + default => RequestIntent::AdminOperation, + }; + } + private function isPrefetch(Request $request): bool { foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php index a5e65553..44527aad 100644 --- a/tests/Security/Abuse/AbuseSubjectResolverTest.php +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -40,6 +40,32 @@ public function testItResolvesVisitorAndIpBucketSubjectsWithoutTrustingForwardin self::assertStringNotContainsString('198.51.100.10', $encoded); } + public function testItKeepsIpBucketStableWhenCookieLessForwardingEntropyChanges(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $first = $resolver->resolve(Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ])); + $second = $resolver->resolve(Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ])); + + self::assertNotSame( + $first->first(AbuseSubjectType::Visitor)?->identifier(), + $second->first(AbuseSubjectType::Visitor)?->identifier(), + ); + self::assertSame( + $first->first(AbuseSubjectType::IpBucket)?->identifier(), + $second->first(AbuseSubjectType::IpBucket)?->identifier(), + ); + } + public function testItAddsAuthenticatedApiKeyAndUserSubjectsFromApiContext(): 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 11f2dfaf..02caedda 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -53,6 +53,26 @@ public static function requestCases(): iterable RequestFamily::Admin, RequestIntent::SettingsMutation, ]; + yield 'admin user password reset is acl mutation' => [ + Request::create('/admin/users/details/example-user/password-reset', 'POST'), + RequestFamily::Admin, + RequestIntent::UserAclMutation, + ]; + yield 'admin package reset fault is package mutation' => [ + Request::create('/admin/packages/demo/reset-fault', 'POST'), + RequestFamily::Admin, + RequestIntent::PackageAdminOperation, + ]; + yield 'admin operation post is generic admin mutation' => [ + Request::create('/admin/operations', 'POST'), + RequestFamily::Admin, + RequestIntent::AdminOperation, + ]; + yield 'public password reset stays public reset intent' => [ + Request::create('/user/password-reset', 'POST'), + RequestFamily::Browser, + RequestIntent::PasswordReset, + ]; yield 'suspicious env probe' => [ Request::create('/.env'), RequestFamily::Browser, From 19e0d363ab2cc28d2c6e085163a9dabbaf12634a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 12:07:03 +0200 Subject: [PATCH 056/119] Update dependencies and support guidelines --- SECURITY.md | 8 ++++---- composer.lock | 38 +++++++++++++++++++------------------- dev/WORKLOG.md | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 825202c3..3e71d031 100755 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,21 @@ # Security Policy > **Status**: Active -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-16 > **Owner**: Dominik Letica > **Purpose:** Support and reporting policy. ## Supported Versions -### main-Channel -Stable builds (`main`-branch or stable releases, where `main` always reflects the latest stable release) are supported at least until the next version is publicly available. +### stable-Channel +Stable builds (`stable-*`-branch or tagless releases) are supported at least until the next version is publicly available. Please consider to keep your environment always up to date for security patches to apply. If you're facing any problems, please feel free to [report](https://github.com/aavion/studio/issues) them to get assistance. **Note:** Versions prior to the first major release are considered `dev` (see below). | Version | Supported | | ------- | ------------------ | -| < 1.0.0 | :x: (see `dev`) | +| < 1.0.0 | :x: | ### beta-Channel Beta builds (`beta-*` branch or releases with `beta`-tag) receive limited support via GitHub Issues at least until the next version is publicly available. diff --git a/composer.lock b/composer.lock index b6c17c59..eb3b553d 100755 --- a/composer.lock +++ b/composer.lock @@ -1536,16 +1536,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.11.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/640e2897bbee822dbc8af761d49e1a29b1f2a6b1", + "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1", "shasum": "" }, "require": { @@ -1635,7 +1635,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.11.1" }, "funding": [ { @@ -1651,7 +1651,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-06-12T21:50:12+00:00" }, { "name": "intervention/image", @@ -11707,16 +11707,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -11767,9 +11767,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -12394,16 +12394,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.2.0", + "version": "13.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3796ea973f1e7698f0d432c1c66662af9764fd9a" + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3796ea973f1e7698f0d432c1c66662af9764fd9a", - "reference": "3796ea973f1e7698f0d432c1c66662af9764fd9a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba", "shasum": "" }, "require": { @@ -12417,7 +12417,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.2", + "phpunit/php-code-coverage": "^14.2.2", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", @@ -12474,7 +12474,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.0" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1" }, "funding": [ { @@ -12482,7 +12482,7 @@ "type": "other" } ], - "time": "2026-06-05T03:13:07+00:00" + "time": "2026-06-15T13:14:22+00:00" }, { "name": "sebastian/cli-parser", diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 5efb13b8..f07e0bf5 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -96,7 +96,7 @@ - Reintroduced the Symfony `application` log as an explicit file-backed Admin/API source while keeping message, audit, access, and security-signal browsing database-backed; application detail links use the existing synthetic file-line hash IDs because Symfony Monolog lines do not carry database UUIDs. - #57 audit follow-up applied before final review: split hybrid log browsing into an `AdminLogBrowser` facade so `DatabaseLogBrowser` remains responsible only for database-backed projections and the file-backed `application` source keeps its own parser boundary. - Addressed Cloud Review follow-ups: database log searches now cast JSON context before matching for PostgreSQL portability, log page sizes use an explicit 500-row maximum with pagination instead of the misleading `all` option, expired security signals are filtered from list/detail reads, Admin package/user reset routes classify as package/ACL mutations before broad public password-reset keywords, and the Visitor-ID forwarding-entropy policy is covered with a stable IP-bucket regression test. -- Final verification: `bin/phpunit` passed with 1340 tests and 8650 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Final verification: `bin/phpunit` passed with 1347 tests and 8672 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports the intentional Markdown metadata hardbreak that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From dde0488d87f5c41290b0eb0ccc24e641dc86e84d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 13:06:31 +0200 Subject: [PATCH 057/119] Address log browsing review gaps --- dev/CLASSMAP.md | 6 +-- dev/WORKLOG.md | 1 + .../security-hardening/abuse-foundation.md | 8 +-- src/Core/Log/DatabaseLogBrowser.php | 32 +++++++++--- src/Core/Log/LogFileBrowser.php | 26 +++++----- src/Security/Abuse/ActionCostCatalogue.php | 1 + tests/Core/Log/DatabaseLogBrowserTest.php | 51 ++++++++++++++++--- tests/Core/Log/LogFileBrowserTest.php | 21 ++++++++ .../Abuse/ActionCostCatalogueTest.php | 3 ++ 9 files changed, 115 insertions(+), 34 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index d31122ec..b12c1aa8 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,9 +198,9 @@ | 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 including `/api/live/**`, prefetch, CORS preflight, scheduler, setup, admin mutations before broad public reset/password keywords, and configurable suspicious probe path patterns, 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 | `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 hidden-field search that casts JSON context columns before matching, source-specific filters, explicit bounded page sizes up to 500 rows, 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, 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` | +| 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 including `/api/live/**`, prefetch, CORS preflight, scheduler, setup apply, admin mutations before broad public reset/password keywords, and configurable suspicious probe path patterns, 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 | `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, source-specific filters, 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, clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f07e0bf5..bbc070d5 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -96,6 +96,7 @@ - Reintroduced the Symfony `application` log as an explicit file-backed Admin/API source while keeping message, audit, access, and security-signal browsing database-backed; application detail links use the existing synthetic file-line hash IDs because Symfony Monolog lines do not carry database UUIDs. - #57 audit follow-up applied before final review: split hybrid log browsing into an `AdminLogBrowser` facade so `DatabaseLogBrowser` remains responsible only for database-backed projections and the file-backed `application` source keeps its own parser boundary. - Addressed Cloud Review follow-ups: database log searches now cast JSON context before matching for PostgreSQL portability, log page sizes use an explicit 500-row maximum with pagination instead of the misleading `all` option, expired security signals are filtered from list/detail reads, Admin package/user reset routes classify as package/ACL mutations before broad public password-reset keywords, and the Visitor-ID forwarding-entropy policy is covered with a stable IP-bucket regression test. +- Addressed additional Cloud Review follow-ups: database and file-backed log browsing now clamp the effective page before fetching rows, database log reads honor configured message/audit/access retention settings in addition to purge-after-write cleanup, DB-backed free-text search is case-insensitive across supported databases, and setup apply has its own higher-cost symbolic action bucket. - Final verification: `bin/phpunit` passed with 1347 tests and 8672 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports the intentional Markdown metadata hardbreak that project lint accepts. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 6a9ccdf9..5ebc4b37 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -43,14 +43,14 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - The first projection uses one table per log family: `message_log_entry`, `audit_log_entry`, and `access_log_entry`; passive security signals use the domain event table `security_signal_event`. - Projection writes must happen after the normal file-log payload has been normalized/redacted and must never store raw secrets, credentials, API keys, visitor-cookie material, captcha answers, unredacted tokenized URLs, or duplicated full raw log lines. - Message projections keep a level because message logs carry meaningful severities. Security signals keep severity. Current access and audit projections omit level fields because their existing writers only emit one operational level and the value would not support useful filtering. -- Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. +- Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. Read paths must also bound selected time windows by the configured retention so quiet sites or recently lowered retention settings cannot expose older projected rows until the next write. - Setup requests may already write file logs before the database exists. Database log projections and passive-signal storage must check the existing database-ready boundary before any DBAL read/write, including retention-setting lookups; when `APP_SETUP_COMPLETED` is not truthy and no explicit unready override is active, they must no-op instead of touching Doctrine/DBAL. - Admin Logs and `/api/v1/admin/logs/**` read through `AdminLogBrowser`: structured message, audit, access, and security-signal sources come from database projections, while the Symfony application log remains a deliberate file-backed source because it is not projected into the database. The file-backed source is limited to the existing reverse-tail window and uses stable synthetic file-line IDs for detail links. - This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. - Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. - The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. -- JSON context search must stay portable across SQLite, MariaDB/MySQL, and PostgreSQL. Database-backed log browsing casts JSON context columns to text for broad free-text matching instead of applying string operators directly to JSON-typed columns or materializing the full result set in PHP. -- Log browsing must use explicit bounded page sizes only. The former `all` option is intentionally treated as a 500-row page with normal pagination so large logs cannot silently exhaust memory/time or hide rows behind a misleading one-page view. +- JSON context search must stay portable across SQLite, MariaDB/MySQL, and PostgreSQL. Database-backed log browsing casts JSON context columns to text for broad case-insensitive free-text matching instead of applying string operators directly to JSON-typed columns or materializing the full result set in PHP. +- Log browsing must use explicit bounded page sizes only. The former `all` option is intentionally treated as a 500-row page with normal pagination so large logs cannot silently exhaust memory/time or hide rows behind a misleading one-page view. The effective page must be clamped before fetching rows so UI/API metadata and row contents stay consistent after filters reduce the total. - The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. - Security identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and security signals, rate-limit subjects, bans, GeoIP, and audit decisions must not trust raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. - Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to reduce accidental visitor merging when the same resolved IP presents different `X-Forwarded-For` chains. Those raw header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. @@ -59,7 +59,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - 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 CSV-style imports for convenience. 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. -- Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and CORS preflight receive no ordinary enforcement cost in this branch; suspicious probes 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, setup apply, and mutating admin/API workflows receive higher symbolic costs for later limiter branches. - `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. - `SessionVisitorBindingSubscriber` also records the already-enforced session/visitor mismatch as a high-risk passive signal before terminating the session. This does not solve copied session plus copied visitor-cookie risk scoring by itself; that deeper scoring remains a later Security/remember-me concern. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index af5fe23b..02c5dea7 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -18,12 +18,16 @@ 'security_signal' => ['label' => 'admin.logs.sources.security_signal', 'table' => 'security_signal_event'], ]; + private DatabaseLogRetentionPolicy $retentionPolicy; + public function __construct( private Connection $connection, private LogEntryFilter $entryFilter = new LogEntryFilter(), private LogPagination $pagination = new LogPagination(), private ClockInterface $clock = new NativeClock(), + ?DatabaseLogRetentionPolicy $retentionPolicy = null, ) { + $this->retentionPolicy = $retentionPolicy ?? new DatabaseLogRetentionPolicy($connection); } /** @@ -41,6 +45,8 @@ public function browse(array $query): array } $criteria = $this->criteria($source, $filters); $matched = $this->count($source, $criteria); + $pagination = $this->pagination->pagination($filters, $matched); + $filters['page'] = $pagination['page']; $entries = $this->entries($source, $criteria, $filters); return [ @@ -50,7 +56,7 @@ public function browse(array $query): array 'filters' => $filters, 'entries' => $entries, 'files' => [], - 'pagination' => $this->pagination->pagination($filters, $matched), + 'pagination' => $pagination, 'per_page_options' => $this->pagination->perPageOptions(), 'time_window_options' => $this->pagination->timeWindowOptions(), 'match_options' => $this->pagination->matchOptions(), @@ -124,7 +130,7 @@ private function supportsLevelFilter(string $source): bool private function criteria(string $source, array $filters): array { $where = ['occurred_at >= ?']; - $params = [$this->cutoff($filters['time_window'])]; + $params = [$this->cutoff($source, $filters['time_window'])]; if ($this->supportsLevelFilter($source) && [] !== $filters['levels']) { $levelColumn = 'security_signal' === $source ? 'severity' : 'level'; @@ -155,8 +161,9 @@ private function criteria(string $source, array $filters): array default => ['context', 'message', 'code'], }; $operator = 'equals' === $filters['match'] ? '= ?' : 'LIKE ?'; - $needle = 'equals' === $filters['match'] ? $filters['search'] : '%'.$filters['search'].'%'; - $where[] = '('.implode(' OR ', array_map(fn (string $column): string => $this->searchExpression($column).' '.$operator, $columns)).')'; + $needle = mb_strtolower($filters['search']); + $needle = 'equals' === $filters['match'] ? $needle : '%'.$needle.'%'; + $where[] = '('.implode(' OR ', array_map(fn (string $column): string => $this->caseInsensitiveSearchExpression($column).' '.$operator, $columns)).')'; foreach ($columns as $_) { $params[] = $needle; @@ -314,7 +321,7 @@ private function decode(mixed $encoded): array return is_array($decoded) ? $decoded : []; } - private function cutoff(string $window): string + private function cutoff(string $source, string $window): string { $modifier = match ($window) { '1h' => '-1 hour', @@ -322,8 +329,16 @@ private function cutoff(string $window): string '30d' => '-30 days', default => '-24 hours', }; + $cutoff = $this->clock->now()->modify($modifier); + + if (in_array($source, ['message', 'audit', 'access'], true)) { + $retentionCutoff = $this->clock->now()->modify(sprintf('-%d days', $this->retentionPolicy->retentionDaysForSource($source))); + if ($retentionCutoff > $cutoff) { + $cutoff = $retentionCutoff; + } + } - return $this->clock->now()->modify($modifier)->format('Y-m-d H:i:s'); + return $cutoff->format('Y-m-d H:i:s'); } private function now(): string @@ -345,4 +360,9 @@ private function searchExpression(string $column): string return 'CAST(context AS TEXT)'; } + + private function caseInsensitiveSearchExpression(string $column): string + { + return 'LOWER('.$this->searchExpression($column).')'; + } } diff --git a/src/Core/Log/LogFileBrowser.php b/src/Core/Log/LogFileBrowser.php index bb7f89b2..a2157b95 100644 --- a/src/Core/Log/LogFileBrowser.php +++ b/src/Core/Log/LogFileBrowser.php @@ -32,10 +32,7 @@ public function browse(array $query): array $filters['levels'] = []; } $files = $this->sourceRegistry->files($this->logDir, $this->environment, $source); - $entries = []; - $matched = 0; - $offset = ($filters['page'] - 1) * $filters['per_page']; - $limit = $filters['per_page']; + $matches = []; foreach ($files as $file) { foreach ($this->lineReader->readLines($file) as $line) { @@ -45,25 +42,26 @@ public function browse(array $query): array continue; } - ++$matched; - - if ($matched <= $offset) { - continue; - } - - if (count($entries) < $limit) { - $entries[] = $entry; - } + $matches[] = $entry; } } + $matched = count($matches); + $pagination = $this->pagination->pagination($filters, $matched); + $filters['page'] = $pagination['page']; + $entries = array_slice( + $matches, + ($filters['page'] - 1) * $filters['per_page'], + $filters['per_page'], + ); + return [ 'sources' => $this->sourceRegistry->sourceOptions(), 'selected_source' => $source, 'filters' => $filters, 'entries' => $entries, 'files' => array_map('basename', $files), - 'pagination' => $this->pagination->pagination($filters, $matched), + 'pagination' => $pagination, 'per_page_options' => $this->pagination->perPageOptions(), 'time_window_options' => $this->pagination->timeWindowOptions(), 'match_options' => $this->pagination->matchOptions(), diff --git a/src/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php index 89a0f89c..7cb1afdf 100644 --- a/src/Security/Abuse/ActionCostCatalogue.php +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -18,6 +18,7 @@ public function costFor(AbuseRequestProfile $profile): ActionCost RequestIntent::PasswordReset => new ActionCost('password_reset', 3), RequestIntent::Contact => new ActionCost('contact', 3), RequestIntent::SchedulerTrigger => new ActionCost('scheduler', 1), + RequestIntent::SetupApply => new ActionCost('setup_apply', 8), RequestIntent::ApiRead => new ActionCost('api_read', 1), RequestIntent::ApiWrite => new ActionCost('api_write', 5), RequestIntent::SettingsMutation, diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index e09757b6..7ee6abf4 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -171,33 +171,70 @@ public function testItCapsFormerAllPageSizeAtFiveHundredRowsWithPagination(): vo $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ 'source' => 'message', 'per_page' => 'all', + 'page' => 999, ]); self::assertSame(500, $view['filters']['per_page']); + self::assertSame(2, $view['filters']['page']); self::assertSame(501, $view['pagination']['total']); self::assertSame(2, $view['pagination']['total_pages']); - self::assertTrue($view['pagination']['has_next']); - self::assertCount(500, $view['entries']); + self::assertFalse($view['pagination']['has_next']); + self::assertCount(1, $view['entries']); } - public function testItCastsJsonContextBeforeSearchingOnPostgreSql(): void + public function testItHonorsConfiguredDatabaseRetentionWhenBrowsing(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => 'logging.database.message_retention_days', + 'value' => '1', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2026-06-16 12:00:00', + 'level' => 'NOTICE', + 'message' => 'message.current', + 'code' => 'test.current', + 'context' => '{}', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => '2026-06-14 12:00:00', + 'level' => 'NOTICE', + 'message' => 'message.expired_by_setting', + 'code' => 'test.expired', + 'context' => '{}', + ]); + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ + 'source' => 'message', + 'time_window' => '30d', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('message.current', $view['entries'][0]['message']); + } + + public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql(): void { $connection = $this->createMock(Connection::class); $connection->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); $connection ->expects(self::once()) ->method('fetchOne') - ->with(self::stringContains('CAST(context AS TEXT) LIKE ?'), self::anything()) + ->with(self::stringContains('LOWER(CAST(context AS TEXT)) LIKE ?'), self::anything()) ->willReturn(0); $connection ->expects(self::once()) ->method('fetchAllAssociative') - ->with(self::stringContains('CAST(context AS TEXT) LIKE ?'), self::anything()) + ->with(self::stringContains('LOWER(CAST(context AS TEXT)) LIKE ?'), self::callback(static fn (array $params): bool => in_array('%scanner%', $params, true))) ->willReturn([]); (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ - 'source' => 'message', - 'q' => 'request-id', + 'source' => 'security_signal', + 'q' => 'Scanner', ]); } diff --git a/tests/Core/Log/LogFileBrowserTest.php b/tests/Core/Log/LogFileBrowserTest.php index d1b836f0..6f9cfb7a 100644 --- a/tests/Core/Log/LogFileBrowserTest.php +++ b/tests/Core/Log/LogFileBrowserTest.php @@ -67,4 +67,25 @@ public function testItReadsAccessContextColumns(): void self::assertSame('/admin/logs', $view['entries'][0]['context']['path']); self::assertSame('n/a', $view['entries'][0]['context']['country']); } + + public function testItUsesClampedPaginationPageWhenReadingEntries(): void + { + $lines = []; + for ($i = 1; $i <= 26; ++$i) { + $lines[] = sprintf('[2099-01-01T10:%02d:00.000000+00:00] message.ERROR: message.%02d [] []', $i, $i); + } + $this->writeTestFile($this->logDir, 'test/message-2099-01-01.log', implode(PHP_EOL, [...$lines, ''])); + + $view = (new LogFileBrowser($this->logDir, 'test'))->browse([ + 'source' => 'message', + 'level' => 'ERROR', + 'per_page' => 25, + 'page' => 999, + ]); + + self::assertSame(2, $view['filters']['page']); + self::assertSame(2, $view['pagination']['page']); + self::assertCount(1, $view['entries']); + self::assertSame('message.01', $view['entries'][0]['message']); + } } diff --git a/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php index 5499ccab..b87c3dc8 100644 --- a/tests/Security/Abuse/ActionCostCatalogueTest.php +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -35,10 +35,13 @@ public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic() $probe = $catalogue->costFor($classifier->classify(Request::create('/.env'))); $apiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/content/items', 'POST'))); + $setupApply = $catalogue->costFor($classifier->classify(Request::create('/setup', 'POST'))); self::assertSame('suspicious_probe', $probe->bucketFamily()); self::assertSame(10, $probe->credits()); self::assertSame('api_write', $apiWrite->bucketFamily()); self::assertSame(5, $apiWrite->credits()); + self::assertSame('setup_apply', $setupApply->bucketFamily()); + self::assertSame(8, $setupApply->credits()); } } From 317d8208ce1535730bdd8e78fc1778afb41cb903 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 13:14:50 +0200 Subject: [PATCH 058/119] Document retention-bound enforcement windows --- dev/WORKLOG.md | 3 ++- dev/draft/security-hardening/auto-ban.md | 2 ++ dev/draft/security-hardening/policy-defaults.md | 1 + dev/draft/security-hardening/rate-enforcement.md | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index bbc070d5..ec6f5ff0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -97,7 +97,8 @@ - #57 audit follow-up applied before final review: split hybrid log browsing into an `AdminLogBrowser` facade so `DatabaseLogBrowser` remains responsible only for database-backed projections and the file-backed `application` source keeps its own parser boundary. - Addressed Cloud Review follow-ups: database log searches now cast JSON context before matching for PostgreSQL portability, log page sizes use an explicit 500-row maximum with pagination instead of the misleading `all` option, expired security signals are filtered from list/detail reads, Admin package/user reset routes classify as package/ACL mutations before broad public password-reset keywords, and the Visitor-ID forwarding-entropy policy is covered with a stable IP-bucket regression test. - Addressed additional Cloud Review follow-ups: database and file-backed log browsing now clamp the effective page before fetching rows, database log reads honor configured message/audit/access retention settings in addition to purge-after-write cleanup, DB-backed free-text search is case-insensitive across supported databases, and setup apply has its own higher-cost symbolic action bucket. -- Final verification: `bin/phpunit` passed with 1347 tests and 8672 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports the intentional Markdown metadata hardbreak that project lint accepts. +- Clarified follow-on enforcement policy: configurable limiter, escalation, review, and ban-decision windows must reject, clamp, or diagnose values that exceed the retention of the underlying signals, projected logs, IP-derived evidence, or audit context they need. +- Final verification: `bin/phpunit` passed with 1349 tests and 8681 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index bf10aac9..5a1b0e5d 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -51,6 +51,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Ban decisions follow the Security policy enforcement order so Admin/Owner context and recovery-login rendering are resolved before visitor/IP bans can deny access. - Active temporary ban responses default to generic `403` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. - Auto-ban enablement, TTLs, and escalation windows should use named bounded policy descriptors. Disabling auto-ban must not disable passive signal recording, audit, manual review, or recovery protections. +- Configurable escalation/review windows must not exceed the retention of the signals or log projections used to justify a ban. When evidence retention is shorter than a requested ban-decision window, validation must reject or clamp the setting and surface a clear diagnostic so bans are never based on unavailable historical evidence. - Invalid CORS/API probing, repeated failed setup apply attempts, upload/archive abuse, and repeated diagnostic/export probing may feed auto-ban decisions for anonymous or API subjects when the underlying signals are high confidence. ## Edge cases @@ -79,6 +80,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test ban response status, retry metadata, cache headers, and route-existence redaction. - Test Admin manual unban writes audit entries. - Test repeat-ban TTL escalation stays bounded and does not create permanent bans. +- Test escalation/review-window validation against the retention limits of the underlying signal, IP-derived, and projected-log evidence. - Test disabling auto-ban preserves passive signals, diagnostics, and recovery behavior. - Test IP-derived ban TTL validation rejects or clamps values at 30 days and cleanup removes expired IP-derived records from review/export surfaces. - Test trusted-proxy/client-identity behavior, ban-store degradation, and concurrent create/unban/cleanup behavior. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 5f7f583a..c7fdf3a3 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -139,6 +139,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to avoid merging unrelated browsers behind the same resolved IP when their `X-Forwarded-For` chains differ. Raw forwarding-header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. Because clients can spoof those headers, enforcement must not rely on the fallback Visitor-ID alone for anonymous cookie-less abuse; it must evaluate the stable IP-bucket HMAC alongside Visitor-ID evidence. - Visitor ID remains the preferred browser continuity key. Different browsers behind the same untrusted proxy should still receive separate visitor subjects. IP bans/blocks remain allowed as a secondary cookie-reset bypass defense, but their thresholds should be laxer than Visitor-ID thresholds so shared or untrusted-network IPs have a lower false-positive risk. - HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. +- Configurable enforcement windows, thresholds, escalation windows, and review horizons must respect the retention of the underlying evidence. A limiter, auto-ban, or review policy may not evaluate signals, projected logs, IP-derived buckets, or other evidence beyond the configured retention window for that data. If an operator configures an enforcement window longer than the available retained evidence, the implementation must reject, clamp, or clearly diagnose the mismatch instead of pretending older evidence can still be considered. ## Auto-Ban Defaults diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 33cb7560..c2aba44b 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -53,6 +53,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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. @@ -78,6 +79,7 @@ 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 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 captcha-on-`429` is unavailable without an active provider and falls back to retry-after behavior. From 3b44ed3b964909c0e7f35926d0bb2ed9b91246a1 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 14:40:39 +0200 Subject: [PATCH 059/119] Simplify security signal retention settings --- .../security-hardening/abuse-foundation.md | 4 ++-- .../security-hardening/policy-defaults.md | 4 ++-- src/Backend/CoreBackendViewProvider.php | 16 ++++++++++++++ .../Config/Settings/CoreSettingsRegistry.php | 12 +++++------ src/Core/Log/DatabaseLogRetentionPolicy.php | 12 ++++------- src/Security/Abuse/SecuritySignalRecorder.php | 2 +- src/Setup/SetupDefaultSeed.php | 1 - tests/Controller/BackendControllerTest.php | 10 +++++++++ .../Core/Config/CoreSettingsRegistryTest.php | 21 ++++++++++++------- .../Abuse/SecuritySignalRecorderTest.php | 8 +++---- tests/Setup/SetupDefaultSeedTest.php | 2 -- translations/languages/de/admin.yaml | 12 ++++++----- translations/languages/en/admin.yaml | 12 ++++++----- 13 files changed, 72 insertions(+), 44 deletions(-) diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 5ebc4b37..d1f21054 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -65,7 +65,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. - Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. - Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. -- IP subjects and stable IP-derived hashes must expire within 30 days. Longer-lived passive signals must use visitor ID, authenticated user ID, API key fingerprint, or aggregate keys without retaining the IP-derived subject. +- Security signals use one shared retention setting, bounded to 1-30 days. Signal rows may include an IP-bucket HMAC for review/correlation, but never raw IP addresses or raw forwarding-header values, and the entire row must stop being visible once the shared expiry is reached. - TTL and expiry use Symfony's injectable clock/time boundary for deterministic tests. - Security-signal list and detail reads must filter expired rows by `expires_at` as well as the selected time window, so short-retention signals stop being visible even on quiet sites where no later write has triggered purge cleanup. - Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. @@ -105,7 +105,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test projection retention purge-after-write behavior and the 30-day maximum for configurable lookup retention. - Test Admin/API log browsing reads database projections, uses UUID detail links, exposes source tabs, keeps broad free-text matching for hidden identifiers/context, shows only meaningful filters per tab, omits raw-line storage, omits access/audit level filters, and hides `DEBUG`/`INFO` by default for level-aware sources unless selected. - Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. -- Test IP-derived signal retention stays below 30 days and that longer-lived visitor-based signals do not keep recoverable IP material. +- Test shared security-signal retention stays below 30 days, applies consistently to Visitor-ID and IP-bucket context, and filters expired rows from list and detail reads even when no later write has purged them yet. - Test client-identity behavior by asserting the foundation uses Symfony's resolved request IP for security subjects and does not trust raw forwarding headers for signals, bans, GeoIP, or audit decisions; do not introduce app-managed trusted-proxy configuration in this slice. - Test storage-failure degradation. - Test no limiter or ban enforcement occurs in this branch. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index c7fdf3a3..12abf78e 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -194,7 +194,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - First implementations may ship policy defaults as code-level constants or configuration values with tests. - Admin-configurable settings require bounded validation, safe defaults, documentation, and tests for disabled/missing settings. -- Security policy bounds must prevent accidental lockout and privacy drift. Configuration must not allow IP-derived retention above 30 days, IP-ban TTLs above the documented maximum, disabling Owner recovery, disabling the recovery-login bypass without an equivalent path, or treating captcha `none` auto-success as verified human success. +- Security policy bounds must prevent accidental lockout and privacy drift. Configuration must not allow log projection or shared security-signal retention above 30 days, IP-ban TTLs above the documented maximum, disabling Owner recovery, disabling the recovery-login bypass without an equivalent path, or treating captcha `none` auto-success as verified human success. - More permissive settings for public entry points should require an explicit policy update, not only a local configuration change. - More restrictive settings that affect login, account recovery, scheduler operation, captcha, mail delivery, or Owner/Admin access need tests for recovery behavior and false-positive handling. - User-facing copy is required whenever a configurable policy affects public behavior, recovery, captcha, mail delivery, remember-me, account access, or data retention. @@ -208,7 +208,7 @@ These are first soft decisions for which values should stay fixed, become protec | Enforcement order, Owner recovery, Admin/Owner lockout protection | Code-level policy and tests | No ordinary Admin setting | Requires a policy update and explicit recovery tests to change | | IP privacy ceiling and raw-secret redaction | Code-level policy and tests | No increase allowed | IP-derived data max 30 days; raw credentials, API keys, visitor tokens, session IDs, captcha answers, and full user agents are never policy records | | Raw file-log retention | Existing log configuration or code default | Yes, bounded | Default 30 days; IP-bearing logs must not become queryable beyond 30 days through archives, projections, exports, or support bundles | -| Database log projections and security signals | Config-backed defaults in Abuse Foundation | Yes, bounded | Message/audit/access projections default to 30 days; visitor/user/API security signals default to 7 days; IP-derived signals default to 1 day; all IP-derived/queryable projection data remains capped at 30 days | +| Database log projections and security signals | Config-backed defaults in Abuse Foundation | Yes, bounded | Message/audit/access projections default to 30 days; all passive security signals default to 7 days through one shared signal-retention setting; all IP-derived/queryable projection data remains capped at 30 days | | GeoIP enablement, database path, license key, and update task | Protected config/Admin setting with null fallback | Yes, protected and audited | License key never public; disabled/unconfigured state uses `NullGeoIpResolver`; no geo-blocking | | GeoIP license key | Secret/protected setting | Yes, protected only | Never rendered, exported, logged, or included in diagnostics | | Probe-path defaults | Code defaults plus config descriptor | Yes, audited | Defaults remain broad; patterns are anchored/normalized and tested against false positives | diff --git a/src/Backend/CoreBackendViewProvider.php b/src/Backend/CoreBackendViewProvider.php index f0584431..da96ef37 100644 --- a/src/Backend/CoreBackendViewProvider.php +++ b/src/Backend/CoreBackendViewProvider.php @@ -235,6 +235,22 @@ public function backendViews(): array 'settings_section' => 'statistics', ], ), + new BackendViewDefinition( + 'backend-admin-settings-logging', + BackendArea::Admin, + 'settings/logging', + 'admin.navigation.logging_settings', + '@backend/admin/settings/section.html.twig', + 57, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'title_key' => 'admin.settings.logging.title', + 'foundation_title_key' => 'admin.settings.logging.foundation_title', + 'foundation_text_key' => 'admin.settings.logging.foundation_text', + 'settings_section' => 'logging', + ], + ), new BackendViewDefinition( 'backend-admin-settings-packages', BackendArea::Admin, diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 1b5936a3..5562b0d3 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -86,12 +86,12 @@ public function allDefinitions(): array ConfigAuditLogPolicy::CATEGORY_SETTINGS => 'admin.settings.options.audit.settings', ConfigAuditLogPolicy::CATEGORY_OTHER => 'admin.settings.options.audit.other', ], sortOrder: 50), - new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.message_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.message_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 60), - new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.audit_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.audit_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 70), - new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 80), - new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 90), - new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_ip_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_ip_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_IP_DERIVED_RETENTION_DAYS], sortOrder: 100), - new CoreSettingDefinition('security', SuspiciousProbePathMatcher::PATTERNS_KEY, 'admin.settings.fields.security_probe_path_patterns.label', SuspiciousProbePathMatcher::defaultPatternText(), ConfigValueType::String, FormInputType::Textarea, help: 'admin.settings.fields.security_probe_path_patterns.help', validation: ['max_length' => 50000], sortOrder: 110), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 60), + new CoreSettingDefinition('security', SuspiciousProbePathMatcher::PATTERNS_KEY, 'admin.settings.fields.security_probe_path_patterns.label', SuspiciousProbePathMatcher::defaultPatternText(), ConfigValueType::String, FormInputType::Textarea, help: 'admin.settings.fields.security_probe_path_patterns.help', validation: ['max_length' => 50000], sortOrder: 70), + + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.message_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.message_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 10), + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.audit_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.audit_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 20), + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 30), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ diff --git a/src/Core/Log/DatabaseLogRetentionPolicy.php b/src/Core/Log/DatabaseLogRetentionPolicy.php index 8c018c53..3077e7b4 100644 --- a/src/Core/Log/DatabaseLogRetentionPolicy.php +++ b/src/Core/Log/DatabaseLogRetentionPolicy.php @@ -13,11 +13,9 @@ public const AUDIT_LOG_RETENTION_DAYS_KEY = 'logging.database.audit_retention_days'; public const ACCESS_LOG_RETENTION_DAYS_KEY = 'logging.database.access_retention_days'; public const SECURITY_SIGNAL_RETENTION_DAYS_KEY = 'security.signals.retention_days'; - public const SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY = 'security.signals.ip_retention_days'; public const DEFAULT_LOG_RETENTION_DAYS = 30; public const DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS = 7; - public const DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS = 1; - public const MAX_IP_DERIVED_RETENTION_DAYS = 30; + public const MAX_RETENTION_DAYS = 30; public function __construct(private Connection $connection) { @@ -33,11 +31,9 @@ public function retentionDaysForSource(string $source): int }; } - public function retentionDaysForSignal(bool $ipDerived): int + public function retentionDaysForSignal(): int { - return $ipDerived - ? $this->days(self::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, self::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS) - : $this->days(self::SECURITY_SIGNAL_RETENTION_DAYS_KEY, self::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS); + return $this->days(self::SECURITY_SIGNAL_RETENTION_DAYS_KEY, self::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS); } private function days(string $key, int $default): int @@ -60,6 +56,6 @@ private function days(string $key, int $default): int $days = is_int($value) ? $value : (is_numeric($value) ? (int) $value : $default); - return max(1, min(self::MAX_IP_DERIVED_RETENTION_DAYS, $days)); + return max(1, min(self::MAX_RETENTION_DAYS, $days)); } } diff --git a/src/Security/Abuse/SecuritySignalRecorder.php b/src/Security/Abuse/SecuritySignalRecorder.php index 254ec5f0..1a5aca17 100644 --- a/src/Security/Abuse/SecuritySignalRecorder.php +++ b/src/Security/Abuse/SecuritySignalRecorder.php @@ -52,7 +52,7 @@ public function record( } $now = $this->clock->now(); - $expiresAt = $now->add(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSignal($ipDerived).'D')); + $expiresAt = $now->add(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSignal().'D')); $context = [ ...$context, 'signal_type' => $this->short($signalType, 80), diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index 5e3599a8..92c0e438 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -51,7 +51,6 @@ public function configEntries(SetupInput $input): array ['key' => DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], ['key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS), 'type' => ConfigValueType::Integer], - ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS), 'type' => ConfigValueType::Integer], ['key' => SuspiciousProbePathMatcher::PATTERNS_KEY, 'value' => $this->setting($input, SuspiciousProbePathMatcher::PATTERNS_KEY, SuspiciousProbePathMatcher::defaultPatternText()), 'type' => ConfigValueType::String], ['key' => AccessStatisticsPolicy::ENABLED_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, true), 'type' => ConfigValueType::Boolean], diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index aa63e699..509b8e91 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -996,6 +996,16 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists('select[name="security.captcha.provider"]'); 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"]'); + + $client->request('GET', '/admin/settings/logging'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Log settings'); + self::assertSelectorExists('form#admin-settings-logging'); + self::assertSelectorExists('input[name="logging.database.message_retention_days"]'); + self::assertSelectorExists('input[name="logging.database.audit_retention_days"]'); + self::assertSelectorExists('input[name="logging.database.access_retention_days"]'); $config = self::getContainer()->get(Config::class); self::assertInstanceOf(Config::class, $config); diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index d2676d2e..c2db1520 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -28,6 +28,7 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void $general = $registry->definitions('general'); $users = $registry->definitions('users'); $security = $registry->definitions('security'); + $logging = $registry->definitions('logging'); $statistics = $registry->definitions('statistics'); $api = $registry->definitions('api'); @@ -61,20 +62,24 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.preview', ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, - DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, - DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, - DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, - DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_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_LOG_RETENTION_DAYS, $security[5]->defaultValue()); - self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, $security[9]->defaultValue()); - self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[10]->defaultValue()); - self::assertSame(FormInputType::Textarea, $security[10]->formField()->inputType()); + 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([ + DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $logging)); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $logging[0]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $logging[1]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $logging[2]->defaultValue()); self::assertSame([ AccessStatisticsPolicy::ENABLED_KEY, diff --git a/tests/Security/Abuse/SecuritySignalRecorderTest.php b/tests/Security/Abuse/SecuritySignalRecorderTest.php index 2b96314f..c252a67e 100644 --- a/tests/Security/Abuse/SecuritySignalRecorderTest.php +++ b/tests/Security/Abuse/SecuritySignalRecorderTest.php @@ -38,14 +38,14 @@ public function testItDoesNotTouchDatabaseWhenSetupIsNotComplete(): void } } - public function testItRecordsSignalsWithShortIpDerivedRetentionAndPurgesExpiredRows(): void + public function testItRecordsSignalsWithSharedRetentionAndPurgesExpiredRows(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); $connection->insert('config_entry', [ - 'config_key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, - 'value' => '1', + 'config_key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, + 'value' => '7', 'value_type' => 'integer', 'sensitive' => 0, 'modified_at' => '2026-06-16 00:00:00', @@ -96,7 +96,7 @@ public function testItRecordsSignalsWithShortIpDerivedRetentionAndPurgesExpiredR self::assertSame(100, (int) $connection->fetchOne('SELECT confidence FROM security_signal_event')); self::assertSame(1, (int) $connection->fetchOne('SELECT ip_derived FROM security_signal_event')); self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM security_signal_event')); - self::assertSame('2026-06-17 12:00:00', $connection->fetchOne('SELECT expires_at FROM security_signal_event')); + self::assertSame('2026-06-23 12:00:00', $connection->fetchOne('SELECT expires_at FROM security_signal_event')); } /** diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index 9f1f0461..b800beaa 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -37,7 +37,6 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); - self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_IP_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY]); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]); } @@ -78,7 +77,6 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, - DatabaseLogRetentionPolicy::SECURITY_SIGNAL_IP_RETENTION_DAYS_KEY, SuspiciousProbePathMatcher::PATTERNS_KEY, \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY, \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index a1d75d54..26f38ea4 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -29,6 +29,7 @@ admin: user_settings: 'Benutzer' mail_settings: 'Mail' security_settings: 'Sicherheit' + logging_settings: 'Logs' package_settings: 'Pakete' statistics_settings: 'Statistiken' api_settings: 'API' @@ -703,7 +704,11 @@ admin: security: title: 'Sicherheits-Einstellungen' foundation_title: 'Sicherheits-Konfigurationsfundament' - foundation_text: 'Sicherheits-, Captcha-, Logging- und Missbrauchsschutz-Einstellungen docken hier an.' + foundation_text: 'Sicherheits-, Captcha-, passive Signal-Retention- und Missbrauchsschutz-Einstellungen docken hier an.' + logging: + title: 'Log-Einstellungen' + foundation_title: 'Datenbank-Log-Projektionen' + foundation_text: 'Message-, Audit- und Access-Logs behalten separat ihre festen rotierenden File-Logs. Diese Einstellungen steuern, wie lange die durchsuchbaren Datenbank-Kopien verfügbar bleiben.' statistics: title: 'Statistik-Einstellungen' foundation_title: 'Statistik-Konfiguration' @@ -822,10 +827,7 @@ admin: help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Access-Logs IP-abgeleitete Daten enthalten können.' security_signal_retention_days: label: 'Retention für Security-Signale' - help: 'Standard-Retention für passive Security-Signale, die über Visitor-, User- oder API-Identität geschlüsselt sind.' - security_signal_ip_retention_days: - label: 'Retention für IP-abgeleitete Signale' - help: 'Kurze Retention für Signale, die nur über IP-abgeleitete Identifier geschlüsselt sind. Unterhalb der 30-Tage-Datenschutzgrenze halten.' + 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. CSV-artig kommaseparierte Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index e4817066..c1c98427 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -29,6 +29,7 @@ admin: user_settings: 'Users' mail_settings: 'Mail' security_settings: 'Security' + logging_settings: 'Logs' package_settings: 'Packages' statistics_settings: 'Statistics' api_settings: 'API' @@ -703,7 +704,11 @@ admin: security: title: 'Security settings' foundation_title: 'Security configuration foundation' - foundation_text: 'Security, captcha, logging, and abuse-control settings will attach here.' + foundation_text: 'Security, captcha, passive-signal retention, and abuse-control settings will attach here.' + logging: + title: 'Log settings' + foundation_title: 'Database log projections' + foundation_text: 'Message, audit, and access logs keep their fixed rotating file logs separately. These settings control how long the searchable database copies stay available.' statistics: title: 'Statistics settings' foundation_title: 'Statistics configuration' @@ -822,10 +827,7 @@ admin: help: 'Database lookup copy retention in days. Values above 30 days are not allowed because access logs can contain IP-derived data.' security_signal_retention_days: label: 'Security signal retention' - help: 'Default retention for passive security signals that are keyed by visitor, user, or API identity.' - security_signal_ip_retention_days: - label: 'IP-derived signal retention' - help: 'Short retention for signals that are keyed only by IP-derived identifiers. Keep this lower than the 30-day privacy ceiling.' + 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. CSV-style comma-separated imports are accepted. Invalid or empty lists fall back to the protected defaults.' From c1f77580365aac7e77005aec484e9d9f79ea5d56 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 14:40:46 +0200 Subject: [PATCH 060/119] Harden log browsing retention and pagination --- src/Core/Log/DatabaseLogBrowser.php | 37 ++++++++++- src/Core/Log/LogFileBrowser.php | 79 +++++++++++++++++------ tests/Core/Log/DatabaseLogBrowserTest.php | 4 ++ 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index 02c5dea7..a059ee35 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -75,6 +75,9 @@ public function entry(string $source, string $id): ?array if ('security_signal' === $source) { $where[] = 'expires_at > ?'; $params[] = $this->now(); + } elseif (in_array($source, ['message', 'audit', 'access'], true)) { + $where[] = 'occurred_at >= ?'; + $params[] = $this->retentionCutoff($source); } $row = $this->connection->fetchAssociative(sprintf( @@ -248,6 +251,28 @@ private function present(string $source, array $row): array private function displayContext(string $source, array $row, array $context): array { return match ($source) { + 'access' => [ + ...$context, + 'request_id' => $row['request_id'] ?? 'n/a', + 'correlation_id' => $row['correlation_id'] ?? 'n/a', + 'method' => $row['method'] ?? 'n/a', + 'path' => $row['path'] ?? 'n/a', + 'requested_path' => $row['requested_path'] ?? $row['path'] ?? 'n/a', + 'route' => $row['route'] ?? 'n/a', + 'resolved_route' => $row['resolved_route'] ?? $row['route'] ?? 'n/a', + 'surface' => $row['surface'] ?? 'n/a', + 'http_status' => $row['http_status'] ?? null, + 'visitor_id' => $row['visitor_id'] ?? 'n/a', + 'client_ip' => $row['client_ip'] ?? 'n/a', + 'proxy_client_ip' => $row['proxy_client_ip'] ?? 'n/a', + 'host' => $row['host'] ?? 'n/a', + 'user_agent' => $row['user_agent'] ?? 'n/a', + 'referrer_host' => $row['referrer_host'] ?? 'n/a', + 'city' => $row['city'] ?? 'n/a', + 'state' => $row['state'] ?? 'n/a', + 'country' => $row['country'] ?? 'n/a', + 'continent' => $row['continent'] ?? 'n/a', + ], 'audit' => [ ...$context, 'user' => $row['user_name'] ?? 'anonymous', @@ -332,7 +357,7 @@ private function cutoff(string $source, string $window): string $cutoff = $this->clock->now()->modify($modifier); if (in_array($source, ['message', 'audit', 'access'], true)) { - $retentionCutoff = $this->clock->now()->modify(sprintf('-%d days', $this->retentionPolicy->retentionDaysForSource($source))); + $retentionCutoff = $this->clock->now()->modify($this->retentionModifier($source)); if ($retentionCutoff > $cutoff) { $cutoff = $retentionCutoff; } @@ -341,6 +366,16 @@ private function cutoff(string $source, string $window): string return $cutoff->format('Y-m-d H:i:s'); } + private function retentionCutoff(string $source): string + { + return $this->clock->now()->modify($this->retentionModifier($source))->format('Y-m-d H:i:s'); + } + + private function retentionModifier(string $source): string + { + return sprintf('-%d days', $this->retentionPolicy->retentionDaysForSource($source)); + } + private function now(): string { return $this->clock->now()->format('Y-m-d H:i:s'); diff --git a/src/Core/Log/LogFileBrowser.php b/src/Core/Log/LogFileBrowser.php index a2157b95..462e2ebe 100644 --- a/src/Core/Log/LogFileBrowser.php +++ b/src/Core/Log/LogFileBrowser.php @@ -32,28 +32,10 @@ public function browse(array $query): array $filters['levels'] = []; } $files = $this->sourceRegistry->files($this->logDir, $this->environment, $source); - $matches = []; - - foreach ($files as $file) { - foreach ($this->lineReader->readLines($file) as $line) { - $entry = $this->entryPresenter->enrich($source, $this->lineParser->parse($line, $file)); - - if (!$this->entryFilter->matches($entry, $filters)) { - continue; - } - - $matches[] = $entry; - } - } - - $matched = count($matches); + $matched = $this->countMatches($source, $files, $filters); $pagination = $this->pagination->pagination($filters, $matched); $filters['page'] = $pagination['page']; - $entries = array_slice( - $matches, - ($filters['page'] - 1) * $filters['per_page'], - $filters['per_page'], - ); + $entries = $this->readPage($source, $files, $filters); return [ 'sources' => $this->sourceRegistry->sourceOptions(), @@ -68,6 +50,63 @@ public function browse(array $query): array ]; } + /** + * @param list $files + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + */ + private function countMatches(string $source, array $files, array $filters): int + { + $matched = 0; + + foreach ($files as $file) { + foreach ($this->lineReader->readLines($file) as $line) { + if ($this->entryFilter->matches($this->entryPresenter->enrich($source, $this->lineParser->parse($line, $file)), $filters)) { + ++$matched; + } + } + } + + return $matched; + } + + /** + * @param list $files + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + * + * @return list> + */ + private function readPage(string $source, array $files, array $filters): array + { + $entries = []; + $matched = 0; + $offset = ($filters['page'] - 1) * $filters['per_page']; + $limit = $filters['per_page']; + + foreach ($files as $file) { + foreach ($this->lineReader->readLines($file) as $line) { + $entry = $this->entryPresenter->enrich($source, $this->lineParser->parse($line, $file)); + + if (!$this->entryFilter->matches($entry, $filters)) { + continue; + } + + ++$matched; + + if ($matched <= $offset) { + continue; + } + + if (count($entries) >= $limit) { + return $entries; + } + + $entries[] = $entry; + } + } + + return $entries; + } + /** * @return array|null */ diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index 7ee6abf4..cc935a4d 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -125,6 +125,8 @@ public function testItBrowsesDatabaseBackedLogSourcesAndEntries(): void self::assertSame([], $accessView['filters']['levels']); self::assertSame(1, $accessView['pagination']['total']); self::assertSame('99999999-0000-7000-8000-000000000003', $accessView['entries'][0]['id']); + self::assertSame('/admin/logs', $accessView['entries'][0]['context']['requested_path']); + self::assertSame('backend_admin_route', $accessView['entries'][0]['context']['resolved_route']); $auditView = $browser->browse(['source' => 'audit', 'level' => 'ERROR', 'q' => 'visitor-audit']); self::assertFalse($auditView['capabilities']['level_filter']); @@ -215,6 +217,8 @@ public function testItHonorsConfiguredDatabaseRetentionWhenBrowsing(): void self::assertSame(1, $view['pagination']['total']); self::assertSame('message.current', $view['entries'][0]['message']); + self::assertNotNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000001')); + self::assertNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000002')); } public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql(): void From d04c98b25c2b1c266e83aa4632bec1ee8011b13d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 14:40:52 +0200 Subject: [PATCH 061/119] Tighten abuse classification path handling --- src/Core/Log/AccessRequestMetadata.php | 73 +++++++++++- .../Abuse/PassiveAbuseSignalSubscriber.php | 2 +- .../Abuse/RequestIntentClassifier.php | 110 +++++++++++++++--- .../SessionVisitorBindingSubscriber.php | 2 +- tests/Core/Log/AccessRequestMetadataTest.php | 4 + .../PassiveAbuseSignalSubscriberTest.php | 37 ++++++ .../Abuse/RequestIntentClassifierTest.php | 47 +++++++- 7 files changed, 250 insertions(+), 25 deletions(-) diff --git a/src/Core/Log/AccessRequestMetadata.php b/src/Core/Log/AccessRequestMetadata.php index baa6cb06..7f5b6978 100644 --- a/src/Core/Log/AccessRequestMetadata.php +++ b/src/Core/Log/AccessRequestMetadata.php @@ -4,6 +4,7 @@ namespace App\Core\Log; +use App\Localization\TranslationLanguageCatalog; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,6 +19,10 @@ private const REQUEST_ID_PATTERN = '/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/'; private const REDACTED_SEGMENT = '[redacted]'; + public function __construct(private ?TranslationLanguageCatalog $languageCatalog = null) + { + } + public function markStarted(Request $request): void { if (!$request->attributes->has(self::STARTED_AT_ATTRIBUTE)) { @@ -71,13 +76,13 @@ public function durationMs(Request $request): ?int public function surface(Request $request): string { - $path = $request->getPathInfo(); + $segments = $this->segments($request); return match (true) { - str_starts_with($path, '/admin') => 'admin', - str_starts_with($path, '/editor') => 'editor', - str_starts_with($path, '/api') => 'api', - str_starts_with($path, '/setup') => 'setup', + $this->matchesSegments($segments, 'admin') => 'admin', + $this->matchesSegments($segments, 'editor') => 'editor', + $this->matchesSegments($segments, 'api') => 'api', + $this->matchesSegments($segments, 'setup') => 'setup', default => 'public', }; } @@ -179,6 +184,64 @@ public function trace(Request $request, string $visitorId): array ]; } + /** + * @return list + */ + private function segments(Request $request): array + { + $segments = array_values(array_filter(explode('/', trim($request->getPathInfo(), '/')), static fn (string $segment): bool => '' !== $segment)); + $locale = $this->localePrefix($request); + + if (is_string($locale) && '' !== $locale && ($segments[0] ?? null) === $locale) { + array_shift($segments); + } + + return $segments; + } + + 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->languageCatalog && '' !== $firstSegment && in_array($firstSegment, $this->languageCatalog->availableLanguages(), true)) { + return $firstSegment; + } + + return null; + } + + /** + * @param list $pathSegments + */ + private function matchesSegments(array $pathSegments, string ...$segments): bool + { + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== $segment) { + return false; + } + } + + 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/Security/Abuse/PassiveAbuseSignalSubscriber.php b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php index 4b8510bc..05de629c 100644 --- a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php +++ b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php @@ -62,7 +62,7 @@ public function onKernelResponse(ResponseEvent $event): void requestIntent: $profile->intent()->value, requestId: $this->accessRequestMetadata->requestId($event->getRequest()), visitorId: $visitor?->identifier() ?? 'n/a', - path: $profile->path(), + path: $this->accessRequestMetadata->sanitizedPath($event->getRequest()), route: $profile->route(), httpStatus: $event->getResponse()->getStatusCode(), context: [ diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 703ab2e8..0bf5fc85 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -4,26 +4,30 @@ namespace App\Security\Abuse; +use App\Localization\TranslationLanguageCatalog; use Symfony\Component\HttpFoundation\Request; final readonly class RequestIntentClassifier { - public function __construct(private SuspiciousProbePathMatcher $probePathMatcher = new SuspiciousProbePathMatcher()) - { + public function __construct( + private SuspiciousProbePathMatcher $probePathMatcher = new SuspiciousProbePathMatcher(), + private ?TranslationLanguageCatalog $languageCatalog = null, + ) { } public function classify(Request $request): AbuseRequestProfile { $method = strtoupper($request->getMethod()); $path = $request->getPathInfo(); + $segments = $this->segments($request); $route = $this->route($request); - $family = $this->family($path); + $family = $this->family($segments); $prefetch = $this->isPrefetch($request); $suspiciousProbe = $this->probePathMatcher->isProbe($path); return new AbuseRequestProfile( $family, - $this->intent($request, $method, $path, $route, $family, $prefetch, $suspiciousProbe), + $this->intent($request, $method, $path, $segments, $route, $family, $prefetch, $suspiciousProbe), $method, substr($path, 0, 1024), $route, @@ -32,15 +36,18 @@ public function classify(Request $request): AbuseRequestProfile ); } - private function family(string $path): RequestFamily + /** + * @param list $segments + */ + private function family(array $segments): RequestFamily { return match (true) { - str_starts_with($path, '/api/live') => RequestFamily::LiveApi, - str_starts_with($path, '/api') => RequestFamily::Api, - str_starts_with($path, '/cron') => RequestFamily::Scheduler, - str_starts_with($path, '/setup') => RequestFamily::Setup, - str_starts_with($path, '/admin') => RequestFamily::Admin, - str_starts_with($path, '/editor') => RequestFamily::Editor, + $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->matchesSegments($segments, 'admin') => RequestFamily::Admin, + $this->matchesSegments($segments, 'editor') => RequestFamily::Editor, default => RequestFamily::Browser, }; } @@ -49,6 +56,7 @@ private function intent( Request $request, string $method, string $path, + array $segments, string $route, RequestFamily $family, bool $prefetch, @@ -87,12 +95,12 @@ private function intent( } return match (true) { - $this->matches($path, $route, 'login') => RequestIntent::Login, - $this->matches($path, $route, 'registration', 'register') => RequestIntent::Registration, - $this->matches($path, $route, 'password', 'recovery', 'reset') => RequestIntent::PasswordReset, - $this->matches($path, $route, 'contact') => RequestIntent::Contact, - $this->matches($path, $route, 'captcha', 'refresh') => RequestIntent::CaptchaRefresh, - $this->matches($path, $route, 'captcha', 'failure') => RequestIntent::CaptchaFailure, + $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->routeContains($route, 'contact') || $this->matchesSegments($segments, 'contact') => RequestIntent::Contact, + $this->routeContains($route, 'captcha_refresh') || ($this->matchesSegments($segments, 'captcha') && $this->matches($path, $route, 'refresh')) => RequestIntent::CaptchaRefresh, + $this->routeContains($route, 'captcha_failure') || ($this->matchesSegments($segments, 'captcha') && $this->matches($path, $route, 'failure')) => RequestIntent::CaptchaFailure, $this->matches($path, $route, 'upload', 'archive', 'media') && !$this->safeMethod($method) => RequestIntent::UploadArchiveValidation, $this->matches($path, $route, 'export', 'download') => RequestIntent::ExportDownload, $this->matches($path, $route, 'import') => RequestIntent::ImportOperation, @@ -154,4 +162,72 @@ private function matches(string $path, string $route, string ...$needles): bool return false; } + + private function routeIs(string $route, string ...$routes): bool + { + return in_array($route, $routes, true); + } + + private function routeContains(string $route, string $needle): bool + { + return str_contains(strtolower($route), strtolower($needle)); + } + + /** + * @return list + */ + private function segments(Request $request): array + { + $segments = array_values(array_filter(explode('/', trim($request->getPathInfo(), '/')), static fn (string $segment): bool => '' !== $segment)); + $locale = $this->localePrefix($request); + + if (is_string($locale) && '' !== $locale && ($segments[0] ?? null) === $locale) { + array_shift($segments); + } + + return $segments; + } + + 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->languageCatalog && '' !== $firstSegment && in_array($firstSegment, $this->languageCatalog->availableLanguages(), true)) { + return $firstSegment; + } + + return null; + } + + /** + * @param list $pathSegments + */ + private function matchesSegments(array $pathSegments, string ...$segments): bool + { + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== $segment) { + return false; + } + } + + return [] !== $segments; + } + + /** + * @param list $segments + */ + private function hasLocalizedReservedPath(array $segments): bool + { + return in_array($segments[1] ?? '', ['admin', 'api', 'captcha', 'contact', 'cron', 'editor', 'setup', 'user'], true); + } } diff --git a/src/Security/SessionVisitorBindingSubscriber.php b/src/Security/SessionVisitorBindingSubscriber.php index 7bc4d210..139eccf4 100644 --- a/src/Security/SessionVisitorBindingSubscriber.php +++ b/src/Security/SessionVisitorBindingSubscriber.php @@ -172,7 +172,7 @@ private function safeSignal( requestIntent: $profile->intent()->value, requestId: $this->accessRequestMetadata?->requestId($request) ?? 'n/a', visitorId: $visitor?->identifier() ?? $currentVisitorId, - path: $profile->path(), + path: $this->accessRequestMetadata?->sanitizedPath($request) ?? $profile->path(), route: $profile->route(), context: [ 'previous_visitor_id' => $previousVisitorId, diff --git a/tests/Core/Log/AccessRequestMetadataTest.php b/tests/Core/Log/AccessRequestMetadataTest.php index bfa401a2..513c91ce 100644 --- a/tests/Core/Log/AccessRequestMetadataTest.php +++ b/tests/Core/Log/AccessRequestMetadataTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Core\Log; use App\Core\Log\AccessRequestMetadata; +use App\Localization\TranslationLanguageCatalog; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -28,6 +29,9 @@ public function testItDerivesOperationalRequestMetadata(): void self::assertIsInt($metadata->durationMs($request)); self::assertSame('admin', $metadata->surface($request)); self::assertSame('api', $metadata->surface(Request::create('/api/v1/status'))); + self::assertSame('public', $metadata->surface(Request::create('/apiary'))); + self::assertSame('public', $metadata->surface(Request::create('/docs/api/reference'))); + self::assertSame('admin', (new AccessRequestMetadata(new TranslationLanguageCatalog(dirname(__DIR__, 3))))->surface(Request::create('/de/admin/logs'))); self::assertSame('backend_admin_route', $metadata->resolvedRoute($request)); self::assertSame('https://example.org/source', $metadata->referrer($request)); self::assertSame('example.org', $metadata->referrerHost($request)); diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php index 486a5228..0860ad25 100644 --- a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -92,6 +92,43 @@ public function testItDoesNotRecordOrdinaryNavigationSignals(): void self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); } + + public function testItSanitizesTokenizedPathsBeforeRecordingSignals(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + $token = str_repeat('a', 64); + $request = Request::create('/user/reset-password/'.$token, 'POST', server: [ + 'HTTP_SEC_PURPOSE' => 'prefetch', + ]); + $request->attributes->set('_route', 'user_password_reset_token'); + $request->attributes->set('token', $token); + $metadata->markStarted($request); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 200), + )); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + self::assertSame('/user/reset-password/[redacted]', $row['path']); + self::assertStringNotContainsString($token, json_encode($row, JSON_THROW_ON_ERROR)); + } } final class PassiveAbuseSignalTestKernel implements HttpKernelInterface diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 02caedda..33c1af6e 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -7,6 +7,7 @@ use App\Security\Abuse\RequestFamily; use App\Security\Abuse\RequestIntent; use App\Security\Abuse\RequestIntentClassifier; +use App\Localization\TranslationLanguageCatalog; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -23,11 +24,36 @@ public static function requestCases(): iterable RequestFamily::LiveApi, RequestIntent::LiveApi, ]; + yield 'localized live api cheap json' => [ + Request::create('/de/api/live/alerts'), + RequestFamily::LiveApi, + RequestIntent::LiveApi, + ]; yield 'api write' => [ Request::create('/api/v1/content/items', 'POST'), RequestFamily::Api, RequestIntent::ApiWrite, ]; + yield 'apiary public content is not api' => [ + Request::create('/apiary'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'administer public content is not admin' => [ + Request::create('/administer', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'localized admin is admin' => [ + Request::create('/de/admin/settings/security', 'POST'), + RequestFamily::Admin, + RequestIntent::SettingsMutation, + ]; + yield 'public path containing reserved segment is public' => [ + Request::create('/docs/api/reference', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; yield 'cors preflight' => [ Request::create('/api/v1/content/items', 'OPTIONS'), RequestFamily::Api, @@ -73,6 +99,20 @@ public static function requestCases(): iterable RequestFamily::Browser, RequestIntent::PasswordReset, ]; + $tokenReset = Request::create('/user/reset-password/'.str_repeat('a', 64), 'POST'); + $tokenReset->attributes->set('_route', 'user_password_reset_token'); + yield 'public reset token route is password reset' => [ + $tokenReset, + RequestFamily::Browser, + RequestIntent::PasswordReset, + ]; + $invitation = Request::create('/user/invitation/'.str_repeat('b', 64), 'POST'); + $invitation->attributes->set('_route', 'user_invitation_accept'); + yield 'public invitation token route is registration' => [ + $invitation, + RequestFamily::Browser, + RequestIntent::Registration, + ]; yield 'suspicious env probe' => [ Request::create('/.env'), RequestFamily::Browser, @@ -83,7 +123,7 @@ public static function requestCases(): iterable #[DataProvider('requestCases')] public function testItClassifiesRequestIntent(Request $request, RequestFamily $family, RequestIntent $intent): void { - $profile = (new RequestIntentClassifier())->classify($request); + $profile = (new RequestIntentClassifier(languageCatalog: $this->languageCatalog()))->classify($request); self::assertSame($family, $profile->family()); self::assertSame($intent, $profile->intent()); @@ -96,4 +136,9 @@ public function testItDoesNotTreatOrdinaryUploadRoutesAsProbePaths(): void self::assertSame(RequestIntent::PackageAdminOperation, $profile->intent()); self::assertFalse($profile->suspiciousProbe()); } + + private function languageCatalog(): TranslationLanguageCatalog + { + return new TranslationLanguageCatalog(dirname(__DIR__, 3)); + } } From d0b859c612c3d042531ea09d9938f277fbfa88c5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 14:40:56 +0200 Subject: [PATCH 062/119] Document abuse foundation review fixes --- dev/CLASSMAP.md | 8 ++++---- dev/WORKLOG.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index b12c1aa8..de1ec707 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -128,7 +128,7 @@ |------|--------|---------|------|-------| | Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | -| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit policy controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | +| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit/signal policy controls, Log Settings database-retention controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | | Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, password inputs for sensitive settings, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | | Entity | `App\Entity\PackageSettingEntry` | Doctrine entity for package-scoped settings stored separately from global configuration so purge can remove package-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | @@ -193,14 +193,14 @@ | Value object | `App\Core\Workflow\WorkflowResult` | Value object for recoverable workflow results with success, invalid, review, blocked, failed states, message-backed issues, messages, and context. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel and the database lookup projection without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | -| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, locale-aware exact-segment surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects 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 including `/api/live/**`, prefetch, CORS preflight, scheduler, setup apply, admin mutations before broad public reset/password keywords, and configurable suspicious probe path patterns, assigns symbolic action costs without enforcing limits, and records clear passive signals for high-signal probes and unsafe prefetch attempts. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects 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 locale-aware exact route/segment boundaries including `/api/live/**`, prefetch, CORS preflight, scheduler, setup apply, admin mutations before broad public reset/password keywords, and configurable suspicious probe path patterns, 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 | `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, source-specific filters, 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, 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` | +| 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, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ec6f5ff0..505d84bc 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -98,6 +98,7 @@ - Addressed Cloud Review follow-ups: database log searches now cast JSON context before matching for PostgreSQL portability, log page sizes use an explicit 500-row maximum with pagination instead of the misleading `all` option, expired security signals are filtered from list/detail reads, Admin package/user reset routes classify as package/ACL mutations before broad public password-reset keywords, and the Visitor-ID forwarding-entropy policy is covered with a stable IP-bucket regression test. - Addressed additional Cloud Review follow-ups: database and file-backed log browsing now clamp the effective page before fetching rows, database log reads honor configured message/audit/access retention settings in addition to purge-after-write cleanup, DB-backed free-text search is case-insensitive across supported databases, and setup apply has its own higher-cost symbolic action bucket. - Clarified follow-on enforcement policy: configurable limiter, escalation, review, and ban-decision windows must reject, clamp, or diagnose values that exceed the retention of the underlying signals, projected logs, IP-derived evidence, or audit context they need. +- Addressed the next review pass with the simplified shared signal-retention policy: removed the separate IP-signal retention setting, kept per-log database retention under a dedicated Log Settings section, filtered projected-log detail reads by configured retention, streamed file-log pagination instead of buffering all matches, sanitized tokenized paths before writing passive signals, and tightened request family/intent classification to locale-aware exact route/segment boundaries. - Final verification: `bin/phpunit` passed with 1349 tests and 8681 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History From af12036c18805db7c7ef9ba48db184096add6dad Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 14:43:38 +0200 Subject: [PATCH 063/119] Cover log settings navigation --- tests/Navigation/NavigationBuilderTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Navigation/NavigationBuilderTest.php b/tests/Navigation/NavigationBuilderTest.php index 4e03e473..1d903467 100644 --- a/tests/Navigation/NavigationBuilderTest.php +++ b/tests/Navigation/NavigationBuilderTest.php @@ -396,6 +396,7 @@ public function testItBuildsBackendViewsFromRegistry(): void 'admin.navigation.mail_settings', 'admin.navigation.security_settings', 'admin.navigation.statistics_settings', + 'admin.navigation.logging_settings', 'admin.navigation.api_settings', 'admin.navigation.package_settings', 'admin.navigation.scheduler_settings', From 2da44275a32df4a1b8fadfbf749a691c165e745b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 14:47:34 +0200 Subject: [PATCH 064/119] Update abuse foundation verification notes --- dev/WORKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 505d84bc..7021d34b 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -99,7 +99,7 @@ - Addressed additional Cloud Review follow-ups: database and file-backed log browsing now clamp the effective page before fetching rows, database log reads honor configured message/audit/access retention settings in addition to purge-after-write cleanup, DB-backed free-text search is case-insensitive across supported databases, and setup apply has its own higher-cost symbolic action bucket. - Clarified follow-on enforcement policy: configurable limiter, escalation, review, and ban-decision windows must reject, clamp, or diagnose values that exceed the retention of the underlying signals, projected logs, IP-derived evidence, or audit context they need. - Addressed the next review pass with the simplified shared signal-retention policy: removed the separate IP-signal retention setting, kept per-log database retention under a dedicated Log Settings section, filtered projected-log detail reads by configured retention, streamed file-log pagination instead of buffering all matches, sanitized tokenized paths before writing passive signals, and tightened request family/intent classification to locale-aware exact route/segment boundaries. -- Final verification: `bin/phpunit` passed with 1349 tests and 8681 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Final verification: `bin/phpunit` passed with 1357 tests and 8714 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From 41aa8e371e1cd1b008b38ecbb9f84878119c0e2b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 15:02:34 +0200 Subject: [PATCH 065/119] Harden abuse foundation review edges --- dev/WORKLOG.md | 3 +- .../security-hardening/abuse-foundation.md | 1 + .../security-hardening/policy-defaults.md | 1 + src/Core/Log/DatabaseLogBrowser.php | 8 +- .../Abuse/RequestIntentClassifier.php | 89 ++++++++++++------- tests/Core/Log/DatabaseLogBrowserTest.php | 61 ++++++++++++- .../Abuse/RequestIntentClassifierTest.php | 36 ++++++++ 7 files changed, 161 insertions(+), 38 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 7021d34b..3a407ec9 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -99,7 +99,8 @@ - Addressed additional Cloud Review follow-ups: database and file-backed log browsing now clamp the effective page before fetching rows, database log reads honor configured message/audit/access retention settings in addition to purge-after-write cleanup, DB-backed free-text search is case-insensitive across supported databases, and setup apply has its own higher-cost symbolic action bucket. - Clarified follow-on enforcement policy: configurable limiter, escalation, review, and ban-decision windows must reject, clamp, or diagnose values that exceed the retention of the underlying signals, projected logs, IP-derived evidence, or audit context they need. - Addressed the next review pass with the simplified shared signal-retention policy: removed the separate IP-signal retention setting, kept per-log database retention under a dedicated Log Settings section, filtered projected-log detail reads by configured retention, streamed file-log pagination instead of buffering all matches, sanitized tokenized paths before writing passive signals, and tightened request family/intent classification to locale-aware exact route/segment boundaries. -- Final verification: `bin/phpunit` passed with 1357 tests and 8714 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Follow-up self-review hardening: security-signal reads now also respect the current shared signal-retention setting when operators lower it after rows were written, and public/content POST fallbacks are documented and tested as the dedicated `website_form`/`FormSubmit` bucket for future package-owned forms such as comments or forum posts. +- Final verification: `bin/phpunit` passed with 1363 tests and 8727 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index d1f21054..778a6950 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -60,6 +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 CSV-style imports for convenience. 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. - 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. +- 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. - `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. - `SessionVisitorBindingSubscriber` also records the already-enforced session/visitor mismatch as a high-risk passive signal before terminating the session. This does not solve copied session plus copied visitor-cookie risk scoring by itself; that deeper scoring remains a later Security/remember-me concern. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 12abf78e..e8879e92 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -133,6 +133,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. - 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. - 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. - Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. - Security-signal visibility, IP-bearing access-log visibility, signal cleanup/mutation, and future review actions need explicit Owner/ACL policy in `feat-security-admin-acl-enforcement` instead of relying indefinitely on broad Admin-area access. - Trusted proxy handling is a deployment/webserver boundary, not an app-level Security settings feature. Security identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use Symfony's resolved request client IP and must not trust raw forwarding headers directly. Operators configure trusted reverse proxies through webserver/Symfony deployment config, for example `mod_remoteip` or equivalent server-level handling. diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index a059ee35..2fda3a61 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -75,6 +75,8 @@ public function entry(string $source, string $id): ?array if ('security_signal' === $source) { $where[] = 'expires_at > ?'; $params[] = $this->now(); + $where[] = 'occurred_at >= ?'; + $params[] = $this->retentionCutoff($source); } elseif (in_array($source, ['message', 'audit', 'access'], true)) { $where[] = 'occurred_at >= ?'; $params[] = $this->retentionCutoff($source); @@ -356,7 +358,7 @@ private function cutoff(string $source, string $window): string }; $cutoff = $this->clock->now()->modify($modifier); - if (in_array($source, ['message', 'audit', 'access'], true)) { + if (in_array($source, ['message', 'audit', 'access', 'security_signal'], true)) { $retentionCutoff = $this->clock->now()->modify($this->retentionModifier($source)); if ($retentionCutoff > $cutoff) { $cutoff = $retentionCutoff; @@ -373,6 +375,10 @@ private function retentionCutoff(string $source): string private function retentionModifier(string $source): string { + if ('security_signal' === $source) { + return sprintf('-%d days', $this->retentionPolicy->retentionDaysForSignal()); + } + return sprintf('-%d days', $this->retentionPolicy->retentionDaysForSource($source)); } diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 0bf5fc85..e23e54da 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($request, $method, $path, $segments, $route, $family, $prefetch, $suspiciousProbe), + $this->intent($method, $segments, $route, $family, $prefetch, $suspiciousProbe), $method, substr($path, 0, 1024), $route, @@ -53,9 +53,7 @@ private function family(array $segments): RequestFamily } private function intent( - Request $request, string $method, - string $path, array $segments, string $route, RequestFamily $family, @@ -91,37 +89,32 @@ private function intent( } if (RequestFamily::Admin === $family && !$this->safeMethod($method)) { - return $this->adminMutationIntent($path, $route); + return $this->adminMutationIntent($segments, $route); } 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->routeContains($route, 'contact') || $this->matchesSegments($segments, 'contact') => RequestIntent::Contact, - $this->routeContains($route, 'captcha_refresh') || ($this->matchesSegments($segments, 'captcha') && $this->matches($path, $route, 'refresh')) => RequestIntent::CaptchaRefresh, - $this->routeContains($route, 'captcha_failure') || ($this->matchesSegments($segments, 'captcha') && $this->matches($path, $route, 'failure')) => RequestIntent::CaptchaFailure, - $this->matches($path, $route, 'upload', 'archive', 'media') && !$this->safeMethod($method) => RequestIntent::UploadArchiveValidation, - $this->matches($path, $route, 'export', 'download') => RequestIntent::ExportDownload, - $this->matches($path, $route, 'import') => RequestIntent::ImportOperation, - $this->matches($path, $route, 'backup', 'restore') => RequestIntent::BackupRestore, - $this->matches($path, $route, 'diagnostic', 'support') => RequestIntent::DiagnosticsSupport, + $this->routeHasToken($route, 'contact') || $this->matchesSegments($segments, 'contact') => RequestIntent::Contact, + $this->routeHasTokens($route, 'captcha', 'refresh') || $this->matchesSegments($segments, 'captcha', 'refresh') => RequestIntent::CaptchaRefresh, + $this->routeHasTokens($route, 'captcha', 'failure') || $this->matchesSegments($segments, 'captcha', 'failure') => RequestIntent::CaptchaFailure, !$this->safeMethod($method) => RequestIntent::FormSubmit, default => RequestIntent::BrowserNavigation, }; } - private function adminMutationIntent(string $path, string $route): RequestIntent + private function adminMutationIntent(array $segments, string $route): RequestIntent { return match (true) { - $this->matches($path, $route, 'settings') => RequestIntent::SettingsMutation, - $this->matches($path, $route, 'users', 'acl') => RequestIntent::UserAclMutation, - $this->matches($path, $route, 'packages') => RequestIntent::PackageAdminOperation, - $this->matches($path, $route, 'upload', 'archive', 'media') => RequestIntent::UploadArchiveValidation, - $this->matches($path, $route, 'export', 'download') => RequestIntent::ExportDownload, - $this->matches($path, $route, 'import') => RequestIntent::ImportOperation, - $this->matches($path, $route, 'backup', 'restore') => RequestIntent::BackupRestore, - $this->matches($path, $route, 'diagnostic', 'support') => RequestIntent::DiagnosticsSupport, + $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->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, default => RequestIntent::AdminOperation, }; } @@ -150,27 +143,43 @@ private function route(Request $request): string return is_string($route) && '' !== $route ? substr($route, 0, 190) : 'n/a'; } - private function matches(string $path, string $route, string ...$needles): bool + private function routeIs(string $route, string ...$routes): bool { - $haystack = strtolower($path.' '.$route); + return in_array($route, $routes, true); + } - foreach ($needles as $needle) { - if (str_contains($haystack, $needle)) { - return true; - } - } + private function routeHasToken(string $route, string $token): bool + { + return in_array($token, $this->routeTokens($route), true); + } - return false; + private function routeHasAnyToken(string $route, string ...$tokens): bool + { + return [] !== array_intersect($tokens, $this->routeTokens($route)); } - private function routeIs(string $route, string ...$routes): bool + private function routeHasTokens(string $route, string ...$tokens): bool { - return in_array($route, $routes, true); + $routeTokens = $this->routeTokens($route); + + foreach ($tokens as $token) { + if (!in_array($token, $routeTokens, true)) { + return false; + } + } + + return [] !== $tokens; } - private function routeContains(string $route, string $needle): bool + /** + * @return list + */ + private function routeTokens(string $route): array { - return str_contains(strtolower($route), strtolower($needle)); + return array_values(array_filter( + preg_split('/[^a-z0-9]+/', strtolower($route)) ?: [], + static fn (string $token): bool => '' !== $token, + )); } /** @@ -223,6 +232,20 @@ 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) { + if (in_array($segment, $pathSegments, true)) { + return true; + } + } + + return false; + } + /** * @param list $segments */ diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index cc935a4d..97de5e0b 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -221,15 +221,46 @@ public function testItHonorsConfiguredDatabaseRetentionWhenBrowsing(): void self::assertNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000002')); } + public function testItHonorsConfiguredSecuritySignalRetentionWhenBrowsing(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => 'security.signals.retention_days', + 'value' => '1', + ]); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000001', '2026-06-16 12:00:00', '2026-06-23 12:00:00', 'current'); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000002', '2026-06-14 12:00:00', '2026-06-23 12:00:00', 'expired_by_setting'); + + $browser = new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')); + $view = $browser->browse([ + 'source' => 'security_signal', + 'time_window' => '30d', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('security.probe.current', $view['entries'][0]['message']); + self::assertNotNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000001')); + self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000002')); + } + public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql(): void { $connection = $this->createMock(Connection::class); $connection->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); $connection - ->expects(self::once()) + ->expects(self::exactly(2)) ->method('fetchOne') - ->with(self::stringContains('LOWER(CAST(context AS TEXT)) LIKE ?'), self::anything()) - ->willReturn(0); + ->willReturnCallback(static function (string $sql): mixed { + if (str_contains($sql, 'config_entry')) { + return false; + } + + self::assertStringContainsString('LOWER(CAST(context AS TEXT)) LIKE ?', $sql); + + return 0; + }); $connection ->expects(self::once()) ->method('fetchAllAssociative') @@ -249,4 +280,28 @@ private function createTables(\Doctrine\DBAL\Connection $connection): void $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); } + + private function insertSignal(Connection $connection, string $uid, string $occurredAt, string $expiresAt, string $reason): void + { + $connection->insert('security_signal_event', [ + 'uid' => $uid, + 'occurred_at' => $occurredAt, + 'expires_at' => $expiresAt, + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.'.$reason, + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-'.$reason, + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-'.$reason, + 'visitor_id' => 'visitor-'.$reason, + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{}', + ]); + } } diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 33c1af6e..e33f54cd 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -54,6 +54,31 @@ public static function requestCases(): iterable RequestFamily::Browser, RequestIntent::FormSubmit, ]; + yield 'future content download path is ordinary navigation' => [ + self::contentRequest('/download'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'future localized content export path is ordinary form submit' => [ + self::contentRequest('/de/export', 'POST', 'de'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'future public package form post gets website form intent' => [ + self::contentRequest('/forum/thread/welcome', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'contact-like content slug is ordinary navigation' => [ + self::contentRequest('/contact-us'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'admin path containing settings only as part of a segment is generic admin' => [ + Request::create('/admin/content/site-settings', 'POST'), + RequestFamily::Admin, + RequestIntent::AdminOperation, + ]; yield 'cors preflight' => [ Request::create('/api/v1/content/items', 'OPTIONS'), RequestFamily::Api, @@ -141,4 +166,15 @@ private function languageCatalog(): TranslationLanguageCatalog { return new TranslationLanguageCatalog(dirname(__DIR__, 3)); } + + private static function contentRequest(string $path, string $method = 'GET', ?string $locale = null): Request + { + $request = Request::create($path, $method); + $request->attributes->set('_route', 'content_show'); + if (null !== $locale) { + $request->attributes->set('_locale', $locale); + } + + return $request; + } } From 7e7d27ce4955214e5e58b89d9dfba9146ad07f4e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 16:02:56 +0200 Subject: [PATCH 066/119] Harden abuse foundation review findings --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 3 +- dev/draft/0.4.x-FrontendDeliveryCaching.md | 3 +- .../security-hardening/abuse-foundation.md | 5 +- .../security-hardening/policy-defaults.md | 2 +- .../Settings/CoreSettingsFormHandler.php | 6 ++ .../Abuse/RequestIntentClassifier.php | 22 +++++- .../Abuse/SuspiciousProbePathMatcher.php | 67 ++++++++++++++++- .../Config/CoreSettingsFormHandlerTest.php | 35 +++++++++ .../Abuse/ActionCostCatalogueTest.php | 3 + .../Abuse/RequestIntentClassifierTest.php | 74 ++++++++++++++++++- .../Abuse/SuspiciousProbePathMatcherTest.php | 27 ++++++- translations/languages/de/admin.yaml | 2 +- translations/languages/en/admin.yaml | 2 +- 14 files changed, 233 insertions(+), 20 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index de1ec707..190ac54a 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,7 +198,7 @@ | 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 locale-aware exact route/segment boundaries including `/api/live/**`, prefetch, CORS preflight, scheduler, setup apply, admin mutations before broad public reset/password keywords, and configurable suspicious probe path patterns, assigns symbolic action costs without enforcing limits, and records clear passive signals for high-signal probes and unsafe prefetch attempts. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects 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, and cached configurable suspicious probe path patterns, 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 | `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, source-specific filters, 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, 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 3a407ec9..dc10e360 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -100,7 +100,8 @@ - Clarified follow-on enforcement policy: configurable limiter, escalation, review, and ban-decision windows must reject, clamp, or diagnose values that exceed the retention of the underlying signals, projected logs, IP-derived evidence, or audit context they need. - Addressed the next review pass with the simplified shared signal-retention policy: removed the separate IP-signal retention setting, kept per-log database retention under a dedicated Log Settings section, filtered projected-log detail reads by configured retention, streamed file-log pagination instead of buffering all matches, sanitized tokenized paths before writing passive signals, and tightened request family/intent classification to locale-aware exact route/segment boundaries. - Follow-up self-review hardening: security-signal reads now also respect the current shared signal-retention setting when operators lower it after rows were written, and public/content POST fallbacks are documented and tested as the dedicated `website_form`/`FormSubmit` bucket for future package-owned forms such as comments or forum posts. -- Final verification: `bin/phpunit` passed with 1363 tests and 8727 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Addressed the latest review pass: suspicious probe-pattern parsing now preserves commas inside one-line regex syntax and only treats quoted comma lines as CSV imports, locale stripping is gated by actual `_locale` route attributes or enabled content route prefixes, mutating `/api/v1/admin/**` requests classify as Admin mutations before generic API writes, and normalized probe patterns use a short Symfony cache with settings-save invalidation plus a follow-up note for the unified caching strategy. +- Final verification: `bin/phpunit` passed with 1371 tests and 8752 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### 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 24ed1741..1ff3a068 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-05-20 +> **Updated**: 2026-06-16 > **Owner**: Core > **Purpose:** Draft for public content delivery, snapshot/cache boundaries, HTTP caching, invalidation, and performance-oriented rendering. @@ -44,5 +44,6 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - **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. +- **Open:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache, when the unified caching strategy is implemented. Move them to the shared cache namespace/invalidation model if that produces clearer ownership, diagnostics, or operational controls. - **Open:** Decide whether the first release needs persisted snapshot artifacts or only cache-backed read models. - **Open:** Define default cache TTLs and invalidation namespaces after the first public rendering slice exists. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 778a6950..16ecf001 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -58,7 +58,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. - Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. - 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 CSV-style imports for convenience. 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. +- 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. - 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. - `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. @@ -96,7 +97,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 configurable probe-path defaults, line/CSV pattern parsing, invalid-pattern fallback, and high-signal probe classification. +- 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. - Test redaction in passive signal messages. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index e8879e92..e6cd671a 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -108,7 +108,7 @@ Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner ## 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 CSV-style imports. 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. +- 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. - 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. diff --git a/src/Core/Config/Settings/CoreSettingsFormHandler.php b/src/Core/Config/Settings/CoreSettingsFormHandler.php index e860f733..10882796 100644 --- a/src/Core/Config/Settings/CoreSettingsFormHandler.php +++ b/src/Core/Config/Settings/CoreSettingsFormHandler.php @@ -14,6 +14,7 @@ use App\Form\FormFieldDefinition; use App\Form\FormSubmissionHandler; use App\Form\FormSubmissionResult; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; use Doctrine\ORM\EntityManagerInterface; @@ -26,6 +27,7 @@ public function __construct( private Config $config, private FormSubmissionHandler $submissionHandler, private EntityManagerInterface $entityManager, + private ?SuspiciousProbePathMatcher $probePathMatcher = null, ) { } @@ -72,6 +74,10 @@ public function submit(string $section, array $submitted, ?string $modifiedBy = '__form' => [FormErrorKey::SAVE_FAILED], ]); } + + if (SuspiciousProbePathMatcher::PATTERNS_KEY === $definition->key()) { + $this->probePathMatcher?->resetCache(); + } } return $result; diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index e23e54da..b5668f46 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -4,14 +4,14 @@ namespace App\Security\Abuse; -use App\Localization\TranslationLanguageCatalog; +use App\Content\Routing\ContentRouteLocalization; use Symfony\Component\HttpFoundation\Request; final readonly class RequestIntentClassifier { public function __construct( private SuspiciousProbePathMatcher $probePathMatcher = new SuspiciousProbePathMatcher(), - private ?TranslationLanguageCatalog $languageCatalog = null, + private ?ContentRouteLocalization $routeLocalization = null, ) { } @@ -77,6 +77,10 @@ private function intent( } if (RequestFamily::Api === $family) { + 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; } @@ -211,7 +215,7 @@ private function localePrefix(Request $request): ?string return $firstSegment; } - if (null !== $this->languageCatalog && '' !== $firstSegment && in_array($firstSegment, $this->languageCatalog->availableLanguages(), true)) { + if (null !== $this->routeLocalization && $this->routeLocalization->isEnabled() && in_array($firstSegment, $this->routeLocalization->availableLanguages(), true)) { return $firstSegment; } @@ -246,6 +250,18 @@ 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') + ? ['admin', ...array_slice($segments, 3)] + : $segments; + } + /** * @param list $segments */ diff --git a/src/Security/Abuse/SuspiciousProbePathMatcher.php b/src/Security/Abuse/SuspiciousProbePathMatcher.php index 8d14f2f1..89c8218d 100644 --- a/src/Security/Abuse/SuspiciousProbePathMatcher.php +++ b/src/Security/Abuse/SuspiciousProbePathMatcher.php @@ -5,10 +5,14 @@ namespace App\Security\Abuse; use App\Core\Config\Config; +use Psr\Cache\CacheItemInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Throwable; -final readonly class SuspiciousProbePathMatcher +final class SuspiciousProbePathMatcher { public const PATTERNS_KEY = 'security.probe_path_patterns'; + public const CACHE_KEY = 'security.suspicious_probe_path_patterns.v1'; /** * @var list @@ -24,16 +28,23 @@ private const MAX_PATTERN_COUNT = 100; private const MAX_PATTERN_LENGTH = 500; + private const CACHE_TTL_SECONDS = 300; /** * @param list|null $patterns */ public function __construct( - private ?Config $config = null, - private ?array $patterns = null, + private readonly ?Config $config = null, + private readonly ?array $patterns = null, + private readonly ?CacheInterface $cache = null, ) { } + /** + * @var list|null + */ + private ?array $activePatterns = null; + public static function defaultPatternText(): string { return implode("\n", self::DEFAULT_PATTERNS); @@ -57,10 +68,47 @@ public function isProbe(string $path): bool */ private function activePatterns(): array { + if (null !== $this->activePatterns) { + return $this->activePatterns; + } + if (null !== $this->patterns) { - return $this->normalizePatterns($this->patterns); + return $this->activePatterns = $this->normalizePatterns($this->patterns); + } + + if (null !== $this->cache) { + try { + return $this->activePatterns = $this->cache->get( + self::CACHE_KEY, + function (CacheItemInterface $item): array { + $item->expiresAfter(self::CACHE_TTL_SECONDS); + + return $this->loadConfiguredPatterns(); + }, + ); + } catch (Throwable) { + return $this->activePatterns = $this->loadConfiguredPatterns(); + } + } + + return $this->activePatterns = $this->loadConfiguredPatterns(); + } + + public function resetCache(): void + { + $this->activePatterns = null; + + try { + $this->cache?->delete(self::CACHE_KEY); + } catch (Throwable) { } + } + /** + * @return list + */ + private function loadConfiguredPatterns(): array + { $configured = $this->config?->get(self::PATTERNS_KEY, self::defaultPatternText()) ?? self::defaultPatternText(); return $this->normalizePatterns($configured); @@ -118,6 +166,12 @@ private function parsePatternText(string $text): array continue; } + if (!$this->looksLikeQuotedCsv($line)) { + $patterns[] = $line; + + continue; + } + foreach (str_getcsv($line, ',', '"', '\\') as $value) { $value = trim((string) $value); @@ -129,4 +183,9 @@ private function parsePatternText(string $text): array return $patterns; } + + private function looksLikeQuotedCsv(string $line): bool + { + return str_contains($line, ',') && str_starts_with(ltrim($line), '"'); + } } diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index 10499f09..19bea0c4 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -11,13 +11,17 @@ use App\Core\Config\Settings\CoreSettingsFormHandler; use App\Core\Config\Settings\CoreSettingsRegistry; use App\Core\Geo\MaxMindGeoIpConfig; +use App\Core\Log\ConfigAuditLogPolicy; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Form\FormSubmissionHandler; use App\Localization\TranslationLanguageCatalog; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\View\SystemPackageMetadataProvider; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; final class CoreSettingsFormHandlerTest extends TestCase { @@ -69,6 +73,37 @@ public function testItPreservesExistingSensitiveSettingsWhenSubmittedProtectedPl self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); } + public function testItInvalidatesSuspiciousProbePatternCacheWhenSecuritySettingsChange(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, '#/old-probe$#', ConfigValueType::String); + $cache = new ArrayAdapter(); + $matcher = new SuspiciousProbePathMatcher($config, cache: $cache); + + self::assertTrue($matcher->isProbe('/old-probe')); + + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + $matcher, + ); + + $result = $handler->submit('security', [ + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + ConfigAuditLogPolicy::ENABLED_KEY => '1', + ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', + SuspiciousProbePathMatcher::PATTERNS_KEY => '#/new-probe$#', + ], 'test'); + + self::assertTrue($result->isValid()); + self::assertTrue((new SuspiciousProbePathMatcher($config, cache: $cache))->isProbe('/new-probe')); + self::assertFalse((new SuspiciousProbePathMatcher($config, cache: $cache))->isProbe('/old-probe')); + } + private function registry(): CoreSettingsRegistry { $projectDir = dirname(__DIR__, 3); diff --git a/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php index b87c3dc8..3a1f4429 100644 --- a/tests/Security/Abuse/ActionCostCatalogueTest.php +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -35,12 +35,15 @@ 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'))); self::assertSame('suspicious_probe', $probe->bucketFamily()); self::assertSame(10, $probe->credits()); self::assertSame('api_write', $apiWrite->bucketFamily()); self::assertSame(5, $apiWrite->credits()); + self::assertSame('admin_mutation', $adminApiWrite->bucketFamily()); + self::assertSame(8, $adminApiWrite->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 e33f54cd..dbbc0b7f 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -4,10 +4,15 @@ namespace App\Tests\Security\Abuse; +use App\Content\Routing\ContentRouteLocalization; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\RequestFamily; use App\Security\Abuse\RequestIntent; use App\Security\Abuse\RequestIntentClassifier; -use App\Localization\TranslationLanguageCatalog; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -25,7 +30,7 @@ public static function requestCases(): iterable RequestIntent::LiveApi, ]; yield 'localized live api cheap json' => [ - Request::create('/de/api/live/alerts'), + self::localizedRequest('/de/api/live/alerts', 'GET', 'de'), RequestFamily::LiveApi, RequestIntent::LiveApi, ]; @@ -34,6 +39,26 @@ public static function requestCases(): iterable RequestFamily::Api, RequestIntent::ApiWrite, ]; + yield 'admin api operation mutation is admin mutation' => [ + Request::create('/api/v1/admin/operations/cleanup', 'POST'), + RequestFamily::Api, + RequestIntent::AdminOperation, + ]; + yield 'admin api settings mutation is settings mutation' => [ + Request::create('/api/v1/admin/settings/security', 'PATCH'), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'admin api scheduler mutation is admin mutation' => [ + Request::create('/api/v1/admin/scheduler/system.live_operation_cleanup', 'PATCH'), + RequestFamily::Api, + RequestIntent::AdminOperation, + ]; + yield 'localized admin api package mutation is package admin mutation' => [ + self::localizedRequest('/de/api/v1/admin/packages/demo/reset-fault', 'POST', 'de'), + RequestFamily::Api, + RequestIntent::PackageAdminOperation, + ]; yield 'apiary public content is not api' => [ Request::create('/apiary'), RequestFamily::Browser, @@ -45,7 +70,7 @@ public static function requestCases(): iterable RequestIntent::FormSubmit, ]; yield 'localized admin is admin' => [ - Request::create('/de/admin/settings/security', 'POST'), + self::localizedRequest('/de/admin/settings/security', 'POST', 'de'), RequestFamily::Admin, RequestIntent::SettingsMutation, ]; @@ -148,7 +173,7 @@ public static function requestCases(): iterable #[DataProvider('requestCases')] public function testItClassifiesRequestIntent(Request $request, RequestFamily $family, RequestIntent $intent): void { - $profile = (new RequestIntentClassifier(languageCatalog: $this->languageCatalog()))->classify($request); + $profile = (new RequestIntentClassifier(routeLocalization: $this->routeLocalization()))->classify($request); self::assertSame($family, $profile->family()); self::assertSame($intent, $profile->intent()); @@ -162,11 +187,44 @@ public function testItDoesNotTreatOrdinaryUploadRoutesAsProbePaths(): void self::assertFalse($profile->suspiciousProbe()); } + public function testItDoesNotStripLanguageSlugsWhenRoutePrefixesAreDisabled(): void + { + $classifier = new RequestIntentClassifier(routeLocalization: $this->disabledRouteLocalization()); + $profile = $classifier->classify(self::contentRequest('/de/admin', 'POST')); + $apiProfile = $classifier->classify(self::contentRequest('/de/api/v1/content/items', 'POST')); + + self::assertSame(RequestFamily::Browser, $profile->family()); + self::assertSame(RequestIntent::FormSubmit, $profile->intent()); + self::assertSame(RequestFamily::Browser, $apiProfile->family()); + self::assertSame(RequestIntent::FormSubmit, $apiProfile->intent()); + } + + private function routeLocalization(): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, true, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, $this->languageCatalog()); + } + + private function disabledRouteLocalization(): ContentRouteLocalization + { + return new ContentRouteLocalization(new Config($this->connection()), $this->languageCatalog()); + } + private function languageCatalog(): TranslationLanguageCatalog { return 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; + } + private static function contentRequest(string $path, string $method = 'GET', ?string $locale = null): Request { $request = Request::create($path, $method); @@ -177,4 +235,12 @@ private static function contentRequest(string $path, string $method = 'GET', ?st return $request; } + + private static function localizedRequest(string $path, string $method, string $locale): Request + { + $request = Request::create($path, $method); + $request->attributes->set('_locale', $locale); + + return $request; + } } diff --git a/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php index 0d320d04..a36f7b3b 100644 --- a/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php +++ b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; final class SuspiciousProbePathMatcherTest extends TestCase { @@ -46,7 +47,7 @@ public function testItFallsBackToDefaultsWhenConfiguredPatternsAreInvalid(): voi public function testItParsesConfiguredPatternTextAsNewlineAndCsvValues(): void { $config = new Config($this->connection()); - $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, "#/custom-one(?:/|$)#i\n#/custom-two(?:/|$)#i,#/custom-three(?:/|$)#i", ConfigValueType::String); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, "#/custom-one(?:/|$)#i\n\"#/custom-two(?:/|$)#i\",\"#/custom-three(?:/|$)#i\"", ConfigValueType::String); $matcher = new SuspiciousProbePathMatcher($config); self::assertTrue($matcher->isProbe('/custom-one')); @@ -55,6 +56,30 @@ public function testItParsesConfiguredPatternTextAsNewlineAndCsvValues(): void self::assertFalse($matcher->isProbe('/.env')); } + public function testItPreservesCommasInsideOneLineRegexPatterns(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, '#/dump-[0-9]{4,6}\.sql$#', ConfigValueType::String); + $matcher = new SuspiciousProbePathMatcher($config); + + self::assertTrue($matcher->isProbe('/dump-2026.sql')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItCachesConfiguredPatternsForTheServiceLifetime(): void + { + $connection = $this->createMock(Connection::class); + $cache = new ArrayAdapter(); + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with('SELECT value FROM config_entry WHERE config_key = ?', [SuspiciousProbePathMatcher::PATTERNS_KEY]) + ->willReturn(json_encode('#/cached-probe$#', JSON_THROW_ON_ERROR)); + + self::assertTrue((new SuspiciousProbePathMatcher(new Config($connection), cache: $cache))->isProbe('/cached-probe')); + self::assertTrue((new SuspiciousProbePathMatcher(new Config($connection), cache: $cache))->isProbe('/cached-probe')); + } + public function testDefaultPatternTextContainsOneEditablePatternPerLine(): void { $lines = array_filter(explode("\n", SuspiciousProbePathMatcher::defaultPatternText())); diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 26f38ea4..04949d56 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -830,7 +830,7 @@ admin: 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. CSV-artig kommaseparierte Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' + 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.' geoip_enabled: label: 'GeoIP-Lookups aktivieren' help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index c1c98427..f3ce2f28 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -830,7 +830,7 @@ admin: 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. CSV-style comma-separated imports are accepted. Invalid or empty lists fall back to the protected defaults.' + help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.' geoip_enabled: label: 'Enable GeoIP lookups' help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' From 272aab581d268cb93db77abe3033d000e348ae58 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 16:24:13 +0200 Subject: [PATCH 067/119] Gate access surface locale prefixes --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 3 +- src/Core/Log/AccessRequestMetadata.php | 6 ++-- tests/Core/Log/AccessRequestMetadataTest.php | 36 +++++++++++++++++++- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 190ac54a..ceb13d78 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -193,7 +193,7 @@ | Value object | `App\Core\Workflow\WorkflowResult` | Value object for recoverable workflow results with success, invalid, review, blocked, failed states, message-backed issues, messages, and context. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel and the database lookup projection without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | -| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, locale-aware exact-segment surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index dc10e360..98468a9b 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -101,7 +101,8 @@ - Addressed the next review pass with the simplified shared signal-retention policy: removed the separate IP-signal retention setting, kept per-log database retention under a dedicated Log Settings section, filtered projected-log detail reads by configured retention, streamed file-log pagination instead of buffering all matches, sanitized tokenized paths before writing passive signals, and tightened request family/intent classification to locale-aware exact route/segment boundaries. - Follow-up self-review hardening: security-signal reads now also respect the current shared signal-retention setting when operators lower it after rows were written, and public/content POST fallbacks are documented and tested as the dedicated `website_form`/`FormSubmit` bucket for future package-owned forms such as comments or forum posts. - Addressed the latest review pass: suspicious probe-pattern parsing now preserves commas inside one-line regex syntax and only treats quoted comma lines as CSV imports, locale stripping is gated by actual `_locale` route attributes or enabled content route prefixes, mutating `/api/v1/admin/**` requests classify as Admin mutations before generic API writes, and normalized probe patterns use a short Symfony cache with settings-save invalidation plus a follow-up note for the unified caching strategy. -- Final verification: `bin/phpunit` passed with 1371 tests and 8752 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Addressed the access-surface locale follow-up by moving `AccessRequestMetadata` to the same content-route localization gate as the intent classifier, so access logs, DB log projections, and access-statistic rows do not classify public `/de/admin`-style content paths as Admin/API surfaces while route prefixes are disabled. +- Final verification: `bin/phpunit` passed with 1372 tests and 8756 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Log/AccessRequestMetadata.php b/src/Core/Log/AccessRequestMetadata.php index 7f5b6978..c9ea4ffd 100644 --- a/src/Core/Log/AccessRequestMetadata.php +++ b/src/Core/Log/AccessRequestMetadata.php @@ -4,7 +4,7 @@ namespace App\Core\Log; -use App\Localization\TranslationLanguageCatalog; +use App\Content\Routing\ContentRouteLocalization; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -19,7 +19,7 @@ private const REQUEST_ID_PATTERN = '/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/'; private const REDACTED_SEGMENT = '[redacted]'; - public function __construct(private ?TranslationLanguageCatalog $languageCatalog = null) + public function __construct(private ?ContentRouteLocalization $routeLocalization = null) { } @@ -213,7 +213,7 @@ private function localePrefix(Request $request): ?string return $firstSegment; } - if (null !== $this->languageCatalog && '' !== $firstSegment && in_array($firstSegment, $this->languageCatalog->availableLanguages(), true)) { + if (null !== $this->routeLocalization && $this->routeLocalization->isEnabled() && in_array($firstSegment, $this->routeLocalization->availableLanguages(), true)) { return $firstSegment; } diff --git a/tests/Core/Log/AccessRequestMetadataTest.php b/tests/Core/Log/AccessRequestMetadataTest.php index 513c91ce..0d510b0d 100644 --- a/tests/Core/Log/AccessRequestMetadataTest.php +++ b/tests/Core/Log/AccessRequestMetadataTest.php @@ -4,8 +4,13 @@ namespace App\Tests\Core\Log; +use App\Content\Routing\ContentRouteLocalization; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Core\Log\AccessRequestMetadata; use App\Localization\TranslationLanguageCatalog; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,7 +36,6 @@ public function testItDerivesOperationalRequestMetadata(): void self::assertSame('api', $metadata->surface(Request::create('/api/v1/status'))); self::assertSame('public', $metadata->surface(Request::create('/apiary'))); self::assertSame('public', $metadata->surface(Request::create('/docs/api/reference'))); - self::assertSame('admin', (new AccessRequestMetadata(new TranslationLanguageCatalog(dirname(__DIR__, 3))))->surface(Request::create('/de/admin/logs'))); self::assertSame('backend_admin_route', $metadata->resolvedRoute($request)); self::assertSame('https://example.org/source', $metadata->referrer($request)); self::assertSame('example.org', $metadata->referrerHost($request)); @@ -93,4 +97,34 @@ public function testItRedactsSensitiveReferrerPathSegments(): void self::assertSame('https://example.org/user/invitation/[redacted]', $metadata->referrer($request)); } + + public function testItGatesLocalizedSurfacePrefixesByRouteLocaleOrEnabledRoutePrefixes(): void + { + $disabled = new AccessRequestMetadata($this->routeLocalization(false)); + $enabled = new AccessRequestMetadata($this->routeLocalization(true)); + $localizedRoute = Request::create('/de/admin/logs'); + $localizedRoute->attributes->set('_locale', 'de'); + + self::assertSame('public', $disabled->surface(Request::create('/de/admin'))); + 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'))); + } + + 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; + } } From 7c960a5496aefdcac2a1abcd92e4847952053046 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 19:14:14 +0200 Subject: [PATCH 068/119] Harden log filters and request intents --- dev/CLASSMAP.md | 6 +-- dev/WORKLOG.md | 3 +- .../security-hardening/abuse-foundation.md | 3 +- src/Core/Log/DatabaseLogBrowser.php | 22 +++++++--- src/Core/Log/LogEntryFilter.php | 19 ++++++++ src/Core/Log/LogFileBrowser.php | 9 ++-- .../Abuse/RequestIntentClassifier.php | 18 +------- templates/backend/admin/logs.html.twig | 27 ++++++++++-- tests/Core/Log/AdminLogBrowserTest.php | 8 ++++ tests/Core/Log/DatabaseLogBrowserTest.php | 43 ++++++++++++++++++- tests/Core/Log/LogFileBrowserTest.php | 15 +++++++ .../Abuse/RequestIntentClassifierTest.php | 20 +++++++++ 12 files changed, 156 insertions(+), 37 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ceb13d78..6e98bab2 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -198,9 +198,9 @@ | 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, and cached configurable suspicious probe path patterns, 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 | `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, source-specific filters, 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, 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` | +| 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 | `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` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 98468a9b..b44bc58a 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -102,7 +102,8 @@ - Follow-up self-review hardening: security-signal reads now also respect the current shared signal-retention setting when operators lower it after rows were written, and public/content POST fallbacks are documented and tested as the dedicated `website_form`/`FormSubmit` bucket for future package-owned forms such as comments or forum posts. - Addressed the latest review pass: suspicious probe-pattern parsing now preserves commas inside one-line regex syntax and only treats quoted comma lines as CSV imports, locale stripping is gated by actual `_locale` route attributes or enabled content route prefixes, mutating `/api/v1/admin/**` requests classify as Admin mutations before generic API writes, and normalized probe patterns use a short Symfony cache with settings-save invalidation plus a follow-up note for the unified caching strategy. - Addressed the access-surface locale follow-up by moving `AccessRequestMetadata` to the same content-route localization gate as the intent classifier, so access logs, DB log projections, and access-statistic rows do not classify public `/de/admin`-style content paths as Admin/API surfaces while route prefixes are disabled. -- Final verification: `bin/phpunit` passed with 1372 tests and 8756 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +- Addressed the current review pass: Admin log tab/pagination links now preserve only source-neutral or visible filters, file/database log browsers sanitize unsupported source-specific filters server-side, database log search escapes SQL `LIKE` wildcards literally, and request-intent classification no longer invents special Contact/Captcha path intents for valid public content slugs. Reviewed adjacent query-parameter consumers and found no comparable hidden source-switch filter surface outside Admin Logs. +- Final verification: `bin/phpunit` passed with 1378 tests and 8773 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md index 16ecf001..e52ec5ec 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, captcha refresh, captcha failure, login, registration, password reset, contact, setup apply, package/admin operation, upload/archive validation, export/download, import, and suspicious probe. +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. 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. @@ -62,6 +62,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. - 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. - `SessionVisitorBindingSubscriber` also records the already-enforced session/visitor mismatch as a high-risk passive signal before terminating the session. This does not solve copied session plus copied visitor-cookie risk scoring by itself; that deeper scoring remains a later Security/remember-me concern. - First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php index 2fda3a61..eb607257 100644 --- a/src/Core/Log/DatabaseLogBrowser.php +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -39,10 +39,11 @@ public function browse(array $query): array { $source = $this->source($query['source'] ?? null); $filters = $this->entryFilter->filters($query); - if (!$this->supportsLevelFilter($source)) { - $filters['level'] = ''; - $filters['levels'] = []; - } + $filters = $this->entryFilter->filtersForSource( + $filters, + $this->supportsLevelFilter($source), + in_array($source, ['audit', 'security_signal'], true), + ); $criteria = $this->criteria($source, $filters); $matched = $this->count($source, $criteria); $pagination = $this->pagination->pagination($filters, $matched); @@ -165,9 +166,9 @@ private function criteria(string $source, array $filters): array 'security_signal' => ['context', 'signal_type', 'reason_code', 'subject_type', 'subject_identifier', 'request_id', 'visitor_id', 'path', 'route'], default => ['context', 'message', 'code'], }; - $operator = 'equals' === $filters['match'] ? '= ?' : 'LIKE ?'; + $operator = 'equals' === $filters['match'] ? '= ?' : "LIKE ? ESCAPE '!'"; $needle = mb_strtolower($filters['search']); - $needle = 'equals' === $filters['match'] ? $needle : '%'.$needle.'%'; + $needle = 'equals' === $filters['match'] ? $needle : '%'.$this->escapeLikeNeedle($needle).'%'; $where[] = '('.implode(' OR ', array_map(fn (string $column): string => $this->caseInsensitiveSearchExpression($column).' '.$operator, $columns)).')'; foreach ($columns as $_) { @@ -368,6 +369,15 @@ private function cutoff(string $source, string $window): string return $cutoff->format('Y-m-d H:i:s'); } + private function escapeLikeNeedle(string $needle): string + { + return strtr($needle, [ + '!' => '!!', + '%' => '!%', + '_' => '!_', + ]); + } + private function retentionCutoff(string $source): string { return $this->clock->now()->modify($this->retentionModifier($source))->format('Y-m-d H:i:s'); diff --git a/src/Core/Log/LogEntryFilter.php b/src/Core/Log/LogEntryFilter.php index 8dfa48ce..79e0f12e 100644 --- a/src/Core/Log/LogEntryFilter.php +++ b/src/Core/Log/LogEntryFilter.php @@ -31,6 +31,25 @@ public function filters(array $query): array ]; } + /** + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + * + * @return array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} + */ + public function filtersForSource(array $filters, bool $supportsLevelFilter, bool $supportsAuditActionFilter): array + { + if (!$supportsLevelFilter) { + $filters['level'] = ''; + $filters['levels'] = []; + } + + if (!$supportsAuditActionFilter) { + $filters['audit_action'] = ''; + } + + return $filters; + } + /** * @param array $entry * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters diff --git a/src/Core/Log/LogFileBrowser.php b/src/Core/Log/LogFileBrowser.php index 462e2ebe..dd41a6c8 100644 --- a/src/Core/Log/LogFileBrowser.php +++ b/src/Core/Log/LogFileBrowser.php @@ -27,10 +27,11 @@ public function browse(array $query): array { $source = $this->sourceRegistry->source($query['source'] ?? null); $filters = $this->entryFilter->filters($query); - if (in_array($source, ['access', 'audit'], true)) { - $filters['level'] = ''; - $filters['levels'] = []; - } + $filters = $this->entryFilter->filtersForSource( + $filters, + !in_array($source, ['access', 'audit'], true), + 'audit' === $source, + ); $files = $this->sourceRegistry->files($this->logDir, $this->environment, $source); $matched = $this->countMatches($source, $files, $filters); $pagination = $this->pagination->pagination($filters, $matched); diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index b5668f46..40ab0a74 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -100,9 +100,6 @@ private function intent( $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->routeHasToken($route, 'contact') || $this->matchesSegments($segments, 'contact') => RequestIntent::Contact, - $this->routeHasTokens($route, 'captcha', 'refresh') || $this->matchesSegments($segments, 'captcha', 'refresh') => RequestIntent::CaptchaRefresh, - $this->routeHasTokens($route, 'captcha', 'failure') || $this->matchesSegments($segments, 'captcha', 'failure') => RequestIntent::CaptchaFailure, !$this->safeMethod($method) => RequestIntent::FormSubmit, default => RequestIntent::BrowserNavigation, }; @@ -162,19 +159,6 @@ private function routeHasAnyToken(string $route, string ...$tokens): bool return [] !== array_intersect($tokens, $this->routeTokens($route)); } - private function routeHasTokens(string $route, string ...$tokens): bool - { - $routeTokens = $this->routeTokens($route); - - foreach ($tokens as $token) { - if (!in_array($token, $routeTokens, true)) { - return false; - } - } - - return [] !== $tokens; - } - /** * @return list */ @@ -267,6 +251,6 @@ private function apiAdminSegments(array $segments): array */ private function hasLocalizedReservedPath(array $segments): bool { - return in_array($segments[1] ?? '', ['admin', 'api', 'captcha', 'contact', 'cron', 'editor', 'setup', 'user'], true); + return in_array($segments[1] ?? '', ['admin', 'api', 'cron', 'editor', 'setup', 'user'], true); } } diff --git a/templates/backend/admin/logs.html.twig b/templates/backend/admin/logs.html.twig index 8bb8414c..7098e3c2 100644 --- a/templates/backend/admin/logs.html.twig +++ b/templates/backend/admin/logs.html.twig @@ -15,13 +15,34 @@ } only %} + {% set log_page_query = { + source: log_view.selected_source, + q: log_view.filters.search, + match: log_view.filters.match, + time_window: log_view.filters.time_window, + per_page: log_view.filters.per_page, + } %} + {% if log_view.capabilities.level_filter %} + {% set log_page_query = log_page_query|merge({level: log_view.filters.levels}) %} + {% endif %} + {% if log_view.capabilities.audit_action_filter or log_view.capabilities.signal_reason_filter %} + {% set log_page_query = log_page_query|merge({audit_action: log_view.filters.audit_action}) %} + {% endif %} +

{{ 'admin.logs.filters.title'|trans }}

@@ -138,10 +159,10 @@
{{ 'admin.logs.entries.page_summary'|trans({'%page%': log_view.pagination.page, '%pages%': log_view.pagination.total_pages, '%total%': log_view.pagination.total}) }} {% if log_view.pagination.has_previous %} - {{ 'admin.logs.entries.previous'|trans }} + {{ 'admin.logs.entries.previous'|trans }} {% endif %} {% if log_view.pagination.has_next %} - {{ 'admin.logs.entries.next'|trans }} + {{ 'admin.logs.entries.next'|trans }} {% endif %}
{% endif %} diff --git a/tests/Core/Log/AdminLogBrowserTest.php b/tests/Core/Log/AdminLogBrowserTest.php index 17d46e7e..2643018e 100644 --- a/tests/Core/Log/AdminLogBrowserTest.php +++ b/tests/Core/Log/AdminLogBrowserTest.php @@ -48,5 +48,13 @@ public function testItCombinesApplicationFileLogWithDatabaseSources(): void $applicationEntry = $browser->entry('application', $applicationView['entries'][0]['id']); self::assertNotNull($applicationEntry); self::assertSame('app.failure', $applicationEntry['message']); + + $staleFilterView = $browser->browse([ + 'source' => 'application', + 'level' => 'ERROR', + 'audit_action' => 'audit.unrelated', + ]); + self::assertSame('', $staleFilterView['filters']['audit_action']); + self::assertSame(1, $staleFilterView['pagination']['total']); } } diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index 97de5e0b..dc1e2efd 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -245,6 +245,45 @@ public function testItHonorsConfiguredSecuritySignalRetentionWhenBrowsing(): voi self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000002')); } + public function testItTreatsSqlLikeWildcardsAsLiteralSearchText(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $now = '2026-06-16 12:00:00'; + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.literal_percent_%', + 'code' => 'literal.percent', + 'context' => '{}', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.unrelated', + 'code' => 'unrelated', + 'context' => '{}', + ]); + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'q' => '%', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('message.literal_percent_%', $view['entries'][0]['message']); + + $underscoreView = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'q' => '_', + ]); + + self::assertSame(1, $underscoreView['pagination']['total']); + self::assertSame('message.literal_percent_%', $underscoreView['entries'][0]['message']); + } + public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql(): void { $connection = $this->createMock(Connection::class); @@ -257,14 +296,14 @@ public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql() return false; } - self::assertStringContainsString('LOWER(CAST(context AS TEXT)) LIKE ?', $sql); + self::assertStringContainsString("LOWER(CAST(context AS TEXT)) LIKE ? ESCAPE '!'", $sql); return 0; }); $connection ->expects(self::once()) ->method('fetchAllAssociative') - ->with(self::stringContains('LOWER(CAST(context AS TEXT)) LIKE ?'), self::callback(static fn (array $params): bool => in_array('%scanner%', $params, true))) + ->with(self::stringContains("LOWER(CAST(context AS TEXT)) LIKE ? ESCAPE '!'"), self::callback(static fn (array $params): bool => in_array('%scanner%', $params, true))) ->willReturn([]); (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ diff --git a/tests/Core/Log/LogFileBrowserTest.php b/tests/Core/Log/LogFileBrowserTest.php index 6f9cfb7a..f9687654 100644 --- a/tests/Core/Log/LogFileBrowserTest.php +++ b/tests/Core/Log/LogFileBrowserTest.php @@ -68,6 +68,21 @@ public function testItReadsAccessContextColumns(): void self::assertSame('n/a', $view['entries'][0]['context']['country']); } + public function testItIgnoresAuditActionFiltersForApplicationLogs(): void + { + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure"} []'.PHP_EOL); + + $view = (new LogFileBrowser($this->logDir, 'test'))->browse([ + 'source' => 'application', + 'level' => 'ERROR', + 'audit_action' => 'audit.unrelated', + ]); + + self::assertSame('', $view['filters']['audit_action']); + self::assertSame(1, $view['pagination']['total']); + self::assertSame('app.failure', $view['entries'][0]['message']); + } + public function testItUsesClampedPaginationPageWhenReadingEntries(): void { $lines = []; diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index dbbc0b7f..45b79024 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -99,6 +99,26 @@ public static function requestCases(): iterable RequestFamily::Browser, RequestIntent::BrowserNavigation, ]; + yield 'contact content slug is ordinary navigation' => [ + self::contentRequest('/contact'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'contact content post is ordinary form submit' => [ + self::contentRequest('/contact', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'captcha refresh content slug is ordinary navigation' => [ + self::contentRequest('/captcha/refresh'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'localized captcha refresh content slug is ordinary navigation' => [ + self::contentRequest('/de/captcha/refresh', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; yield 'admin path containing settings only as part of a segment is generic admin' => [ Request::create('/admin/content/site-settings', 'POST'), RequestFamily::Admin, From 1ddaea18a52ff2f071a16af5585d04172b606f87 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 21:54:56 +0200 Subject: [PATCH 069/119] Add admin ACL feature matrix --- assets/styles/system/base.css | 18 ++ config/services.yaml | 7 + src/Backend/AdminViewContextProvider.php | 87 ++++++++ src/Backend/BackendNavigationSubscriber.php | 1 + src/Backend/BackendViewDefinition.php | 7 + src/Backend/CoreBackendViewProvider.php | 42 ++++ src/Controller/BackendController.php | 43 +++- .../AdminAcl/AdminAclSettingsFormHandler.php | 105 +++++++++ .../AdminAcl/AdminFeatureAccessPolicy.php | 207 ++++++++++++++++++ src/Core/AdminAcl/AdminFeatureDefaults.php | 42 ++++ src/Core/AdminAcl/AdminFeatureDefinition.php | 65 ++++++ .../AdminAcl/AdminFeatureOverrideStore.php | 139 ++++++++++++ .../AdminFeatureProviderInterface.php | 13 ++ src/Core/AdminAcl/AdminFeatureRegistry.php | 124 +++++++++++ src/Core/AdminAcl/AdminPermissionState.php | 41 ++++ src/Core/AdminAcl/AdminPermissionSurface.php | 39 ++++ .../AdminAcl/CoreAdminFeatureProvider.php | 148 +++++++++++++ src/Navigation/NavigationAccessFilter.php | 12 +- .../backend/admin/settings/acl.html.twig | 113 ++++++++++ tests/Controller/BackendControllerTest.php | 120 +++++++++- .../AdminAcl/AdminFeatureAccessPolicyTest.php | 102 +++++++++ tests/Core/AdminAcl/AdminFeatureCacheTest.php | 73 ++++++ translations/languages/de/admin.yaml | 96 ++++++++ translations/languages/en/admin.yaml | 96 ++++++++ 24 files changed, 1731 insertions(+), 9 deletions(-) create mode 100644 src/Core/AdminAcl/AdminAclSettingsFormHandler.php create mode 100644 src/Core/AdminAcl/AdminFeatureAccessPolicy.php create mode 100644 src/Core/AdminAcl/AdminFeatureDefaults.php create mode 100644 src/Core/AdminAcl/AdminFeatureDefinition.php create mode 100644 src/Core/AdminAcl/AdminFeatureOverrideStore.php create mode 100644 src/Core/AdminAcl/AdminFeatureProviderInterface.php create mode 100644 src/Core/AdminAcl/AdminFeatureRegistry.php create mode 100644 src/Core/AdminAcl/AdminPermissionState.php create mode 100644 src/Core/AdminAcl/AdminPermissionSurface.php create mode 100644 src/Core/AdminAcl/CoreAdminFeatureProvider.php create mode 100644 templates/backend/admin/settings/acl.html.twig create mode 100644 tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php create mode 100644 tests/Core/AdminAcl/AdminFeatureCacheTest.php diff --git a/assets/styles/system/base.css b/assets/styles/system/base.css index a04b3eef..31c56b0d 100644 --- a/assets/styles/system/base.css +++ b/assets/styles/system/base.css @@ -685,6 +685,24 @@ html { border-bottom: 0; } +.system-acl-group-grid { + display: grid; + gap: 0.5rem; + min-width: min(100%, 22rem); +} + +.system-acl-group-row { + align-items: center; + display: grid; + gap: 0.5rem; + grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 12rem); +} + +.system-acl-group-row code { + display: block; + margin-top: 0.125rem; +} + .system-markdown, .system-rich-text { @apply text-base leading-7; diff --git a/config/services.yaml b/config/services.yaml index 66e75a72..cac21fd7 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -74,6 +74,9 @@ services: App\Security\AclGroupReferenceProviderInterface: tags: - { name: 'system.acl_group_reference_provider', priority: 0 } + App\Core\AdminAcl\AdminFeatureProviderInterface: + tags: + - { name: 'system.admin_feature_provider', priority: 0 } # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -215,6 +218,10 @@ services: arguments: $providers: !tagged_iterator { tag: system.backend_view_provider } + App\Core\AdminAcl\AdminFeatureRegistry: + arguments: + $providers: !tagged_iterator { tag: system.admin_feature_provider } + App\View\Injection\ViewInjectionRegistry: arguments: $staticProviders: !tagged_iterator { tag: system.static_view_injection_provider } diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index f649ed73..4e6b7ed7 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -5,6 +5,12 @@ namespace App\Backend; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminFeatureDefinition; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminFeatureRegistry; +use App\Core\AdminAcl\AdminPermissionState; +use App\Core\AdminAcl\AdminPermissionSurface; use App\Core\Diagnostics\SystemInfoProvider; use App\Core\Geo\GeoIpResolverInterface; use App\Core\Geo\MaxMindGeoIpConfig; @@ -26,6 +32,9 @@ public function __construct( private GeoIpResolverInterface $geoIpResolver, private Security $security, private BackendActions $backendActions, + private AdminFeatureRegistry $adminFeatureRegistry, + private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, + private AdminFeatureOverrideStore $adminFeatureOverrideStore, ) { } @@ -57,6 +66,9 @@ public function variables(Request $request, ?BackendViewDefinition $view): array 'status' => $this->geoIpResolver->status()->toSafeArray(), ], ], + 'backend-admin-settings-acl' => [ + 'acl_matrix' => $this->aclMatrix(), + ], default => [], }; } @@ -78,4 +90,79 @@ private function actor(): AccessActor return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); } + + /** + * @return array + */ + private function aclMatrix(): array + { + $overrides = $this->adminFeatureOverrideStore->overrides(); + $defaults = $this->adminFeatureOverrideStore->defaultOverrides(); + $surfaces = []; + + foreach (AdminPermissionSurface::cases() as $surface) { + $groups = $this->adminFeatureAccessPolicy->availableGroups($surface); + $surfaces[] = [ + 'key' => $surface->value, + 'label_key' => $surface->labelKey(), + 'groups' => $groups, + 'rows' => array_map( + fn (AdminFeatureDefinition $definition): array => $this->aclMatrixRow($definition, $overrides[$definition->identifier()] ?? [], $defaults[$definition->identifier()] ?? [], $groups), + $this->adminFeatureRegistry->definitions($surface), + ), + ]; + } + + return [ + 'states' => array_map( + static fn (AdminPermissionState $state): array => [ + 'value' => $state->value, + 'label_key' => 'admin.acl.states.'.$state->value, + ], + AdminPermissionState::cases(), + ), + 'group_states' => [ + [ + 'value' => '', + 'label_key' => 'admin.acl.states.inherit', + ], + ...array_map( + static fn (AdminPermissionState $state): array => [ + 'value' => $state->value, + 'label_key' => 'admin.acl.states.'.$state->value, + ], + AdminPermissionState::cases(), + ), + ], + 'surfaces' => $surfaces, + ]; + } + + /** + * @param array $override + * @param list $groups + * + * @return array + */ + private function aclMatrixRow(AdminFeatureDefinition $definition, array $override, array $defaultOverride, array $groups): array + { + $groupOverrides = is_array($override['groups'] ?? null) ? $override['groups'] : []; + + return [ + 'identifier' => $definition->identifier(), + 'label_key' => $definition->labelKey(), + 'description_key' => $definition->descriptionKey(), + 'category_key' => $definition->categoryKey(), + 'configurable' => $definition->configurable(), + 'default_state' => AdminPermissionState::fromMixed($defaultOverride['state'] ?? null, $definition->defaultState())->value, + 'state' => AdminPermissionState::fromMixed($override['state'] ?? null, $definition->defaultState())->value, + 'groups' => array_map( + static fn (array $group): array => [ + ...$group, + 'state' => is_string($groupOverrides[$group['identifier']] ?? null) ? (string) $groupOverrides[$group['identifier']] : '', + ], + $groups, + ), + ]; + } } diff --git a/src/Backend/BackendNavigationSubscriber.php b/src/Backend/BackendNavigationSubscriber.php index 0c28006f..54d8fcc0 100644 --- a/src/Backend/BackendNavigationSubscriber.php +++ b/src/Backend/BackendNavigationSubscriber.php @@ -44,6 +44,7 @@ public function onNavigationBuilder(NavigationBuilderEvent $event): void [ 'min_access_level' => $view->minimumAccessLevel(), 'access_groups' => $view->accessGroups(), + 'access_feature' => $view->accessFeature(), 'route_parameters' => $view->routeParameters(), 'link_attributes' => $view->linkAttributes(), ], diff --git a/src/Backend/BackendViewDefinition.php b/src/Backend/BackendViewDefinition.php index 94273b80..d3ec5823 100644 --- a/src/Backend/BackendViewDefinition.php +++ b/src/Backend/BackendViewDefinition.php @@ -106,4 +106,11 @@ public function context(): array { return $this->context; } + + public function accessFeature(): ?string + { + $feature = $this->context['access_feature'] ?? null; + + return is_string($feature) && '' !== $feature ? $feature : null; + } } diff --git a/src/Backend/CoreBackendViewProvider.php b/src/Backend/CoreBackendViewProvider.php index da96ef37..f29ccce5 100644 --- a/src/Backend/CoreBackendViewProvider.php +++ b/src/Backend/CoreBackendViewProvider.php @@ -31,6 +31,9 @@ public function backendViews(): array '@backend/admin/packages.html.twig', 20, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.packages', + ], ), new BackendViewDefinition( 'backend-admin-themes', @@ -40,6 +43,9 @@ public function backendViews(): array '@backend/admin/themes.html.twig', 30, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.packages', + ], ), new BackendViewDefinition( 'backend-admin-users', @@ -53,6 +59,7 @@ public function backendViews(): array 'title_key' => 'admin.users.title', 'foundation_title_key' => 'admin.users.foundation_title', 'foundation_text_key' => 'admin.users.foundation_text', + 'access_feature' => 'admin.users', ], ), new BackendViewDefinition( @@ -64,6 +71,9 @@ public function backendViews(): array 10, parentUid: 'backend-admin-users', minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.users.acl', + ], ), new BackendViewDefinition( 'backend-admin-user-reviews', @@ -74,6 +84,9 @@ public function backendViews(): array 20, parentUid: 'backend-admin-users', minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.users.review', + ], ), new BackendViewDefinition( 'backend-admin-scheduler', @@ -87,6 +100,7 @@ public function backendViews(): array 'title_key' => 'admin.scheduler.title', 'foundation_title_key' => 'admin.scheduler.foundation_title', 'foundation_text_key' => 'admin.scheduler.foundation_text', + 'access_feature' => 'admin.scheduler', ], ), new BackendViewDefinition( @@ -101,6 +115,7 @@ public function backendViews(): array 'title_key' => 'admin.backups.title', 'foundation_title_key' => 'admin.backups.foundation_title', 'foundation_text_key' => 'admin.backups.foundation_text', + 'access_feature' => 'admin.backup_restore', ], ), new BackendViewDefinition( @@ -111,6 +126,9 @@ public function backendViews(): array '@backend/admin/operations.html.twig', 70, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.operations', + ], ), new BackendViewDefinition( 'backend-admin-logs', @@ -120,6 +138,9 @@ public function backendViews(): array '@backend/admin/logs.html.twig', 800, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.logs', + ], ), new BackendViewDefinition( 'backend-admin-statistics', @@ -129,6 +150,9 @@ public function backendViews(): array '@backend/admin/statistics.html.twig', 810, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.settings.statistics', + ], ), new BackendViewDefinition( 'backend-admin-settings', @@ -217,6 +241,7 @@ public function backendViews(): array 'foundation_title_key' => 'admin.settings.security.foundation_title', 'foundation_text_key' => 'admin.settings.security.foundation_text', 'settings_section' => 'security', + 'access_feature' => 'admin.settings.security', ], ), new BackendViewDefinition( @@ -233,6 +258,7 @@ public function backendViews(): array 'foundation_title_key' => 'admin.settings.statistics.foundation_title', 'foundation_text_key' => 'admin.settings.statistics.foundation_text', 'settings_section' => 'statistics', + 'access_feature' => 'admin.settings.statistics', ], ), new BackendViewDefinition( @@ -249,6 +275,7 @@ public function backendViews(): array 'foundation_title_key' => 'admin.settings.logging.foundation_title', 'foundation_text_key' => 'admin.settings.logging.foundation_text', 'settings_section' => 'logging', + 'access_feature' => 'admin.settings.logging', ], ), new BackendViewDefinition( @@ -260,6 +287,9 @@ public function backendViews(): array 70, parentUid: 'backend-admin-settings', minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.settings.packages', + ], ), new BackendViewDefinition( 'backend-admin-settings-api', @@ -275,8 +305,19 @@ public function backendViews(): array 'foundation_title_key' => 'admin.settings.api.foundation_title', 'foundation_text_key' => 'admin.settings.api.foundation_text', 'settings_section' => 'api', + 'access_feature' => 'admin.settings.api', ], ), + new BackendViewDefinition( + 'backend-admin-settings-acl', + BackendArea::Admin, + 'settings/acl', + 'admin.navigation.acl_settings', + '@backend/admin/settings/acl.html.twig', + 65, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::OWNER, + ), new BackendViewDefinition( 'backend-admin-settings-system-info', BackendArea::Admin, @@ -301,6 +342,7 @@ public function backendViews(): array 'foundation_title_key' => 'admin.settings.scheduler.foundation_title', 'foundation_text_key' => 'admin.settings.scheduler.foundation_text', 'settings_section' => 'scheduler', + 'access_feature' => 'admin.settings.scheduler', ], ), new BackendViewDefinition( diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 0f18c854..64535a60 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -11,9 +11,11 @@ use App\Backend\BackendRouteResolver; use App\Backend\BackendViewDefinition; use App\Core\Access\AccessActor; -use App\Core\Message\Message; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminAclSettingsFormHandler; use App\Core\Config\Settings\CoreSettingsFormHandler; use App\Core\Log\AdminLogBrowser; +use App\Core\Message\Message; use App\Core\Log\AuditLoggerInterface; use App\Core\Package\Settings\PackageSettingsFormHandler; use App\Entity\UserAccount; @@ -39,6 +41,8 @@ public function __construct( private readonly NavigationBuilder $navigationBuilder, private readonly HttpErrorRenderer $httpError, private readonly CoreSettingsFormHandler $coreSettingsFormHandler, + private readonly AdminFeatureAccessPolicy $adminAcl, + private readonly AdminAclSettingsFormHandler $adminAclSettingsFormHandler, private readonly PackageSettingsFormHandler $packageSettingsFormHandler, private readonly AdminViewContextProvider $adminViewContextProvider, private readonly BackendActionResponder $backendActionResponder, @@ -187,6 +191,12 @@ private function actor(): AccessActor private function viewAllows(BackendViewDefinition $view, AccessActor $actor): bool { + $feature = $view->accessFeature(); + + if (is_string($feature) && !$this->adminAcl->isVisible($feature, $actor)) { + return false; + } + if ($actor->accessLevel() >= $view->minimumAccessLevel()) { return true; } @@ -218,6 +228,9 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): $expectedFormId = 'admin-settings-'.$context['settings_section']; $auditAction = 'settings.core.save'; $auditContext = ['section' => $context['settings_section']]; + if ($response = $this->mutationDeniedResponse($request, $view)) { + return $response; + } $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) ? $this->coreSettingsFormHandler->submit($context['settings_section'], $request->request->all(), $this->actor()->userUid(), $this->actor()) : $this->invalidCsrfResult($request); @@ -225,13 +238,26 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): $expectedFormId = 'admin-settings-packages'; $auditAction = 'settings.core.save'; $auditContext = ['section' => 'packages']; + if ($response = $this->mutationDeniedResponse($request, $view)) { + return $response; + } $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) ? $this->coreSettingsFormHandler->submit('packages', $request->request->all(), $this->actor()->userUid(), $this->actor()) : $this->invalidCsrfResult($request); + } elseif ('backend-admin-settings-acl' === $view->uid()) { + $expectedFormId = 'admin-settings-acl'; + $auditAction = 'settings.acl.save'; + $auditContext = ['section' => 'acl']; + $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) + ? $this->adminAclSettingsFormHandler->submit($request->request->all(), $this->actor()->userUid()) + : $this->invalidCsrfResult($request); } elseif (isset($context['package_name']) && is_string($context['package_name'])) { $expectedFormId = 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($context['package_name'])); $auditAction = 'settings.package.save'; $auditContext = ['package' => $context['package_name']]; + if ($response = $this->mutationDeniedResponse($request, $view)) { + return $response; + } $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) ? $this->packageSettingsFormHandler->submit($context['package_name'], $request->request->all(), $this->actor()->userUid()) : $this->invalidCsrfResult($request); @@ -263,6 +289,21 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): return null; } + private function mutationDeniedResponse(Request $request, BackendViewDefinition $view): ?Response + { + $feature = $view->accessFeature(); + + if (!is_string($feature) || $this->adminAcl->isMutable($feature, $this->actor())) { + return null; + } + + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => $view->area()->value, + 'view' => $view->uid(), + 'access_feature' => $feature, + ]); + } + /** * @param array $context */ diff --git a/src/Core/AdminAcl/AdminAclSettingsFormHandler.php b/src/Core/AdminAcl/AdminAclSettingsFormHandler.php new file mode 100644 index 00000000..d03b2e6e --- /dev/null +++ b/src/Core/AdminAcl/AdminAclSettingsFormHandler.php @@ -0,0 +1,105 @@ + $submitted + */ + public function submit(array $submitted, ?string $modifiedBy = null): FormSubmissionResult + { + $values = $submitted['acl'] ?? []; + + if (!is_array($values)) { + return new FormSubmissionResult($submitted, ['__form' => [FormErrorKey::INVALID]]); + } + + $currentDefinitions = $this->registry->definitions(AdminPermissionSurface::Admin); + $currentIdentifiers = array_fill_keys(array_map( + static fn (AdminFeatureDefinition $definition): string => $definition->identifier(), + $currentDefinitions, + ), true); + $overrides = []; + + foreach ($this->store->overrides() as $feature => $override) { + if (!isset($currentIdentifiers[$feature])) { + $overrides[$feature] = $override; + } + } + + foreach ($currentDefinitions as $definition) { + if (!$definition->configurable()) { + continue; + } + + $row = $values[$definition->identifier()] ?? []; + if (!is_array($row)) { + continue; + } + + $state = AdminPermissionState::fromMixed($row['state'] ?? null, $definition->defaultState()); + $groups = $this->groupOverrides($row, $definition->surface()); + + $overrides[$definition->identifier()] = [ + 'state' => $state->value, + 'groups' => $groups, + ]; + } + + if (!$this->store->save($overrides, $modifiedBy)) { + return new FormSubmissionResult($submitted, ['__form' => [FormErrorKey::SAVE_FAILED]]); + } + + $this->registry->resetCache(); + + return new FormSubmissionResult(['acl' => $overrides], []); + } + + /** + * @param array $row + * + * @return array + */ + private function groupOverrides(array $row, AdminPermissionSurface $surface): array + { + $submittedGroups = $row['groups'] ?? []; + + if (!is_array($submittedGroups)) { + return []; + } + + $allowed = array_fill_keys(array_map( + static fn (array $group): string => $group['identifier'], + $this->policy->availableGroups($surface), + ), true); + $groups = []; + + foreach ($submittedGroups as $identifier => $state) { + if (!is_string($identifier) || !isset($allowed[$identifier])) { + continue; + } + + $groupState = is_string($state) ? AdminPermissionState::tryFrom($state) : null; + if ($groupState instanceof AdminPermissionState) { + $groups[$identifier] = $groupState->value; + } + } + + ksort($groups); + + return $groups; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureAccessPolicy.php b/src/Core/AdminAcl/AdminFeatureAccessPolicy.php new file mode 100644 index 00000000..2072494d --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureAccessPolicy.php @@ -0,0 +1,207 @@ +> + */ + private array $allowedGroupsBySurface = []; + + public function __construct( + private readonly AdminFeatureRegistry $registry, + private readonly AdminFeatureOverrideStore $overrides, + private readonly EntityManagerInterface $entityManager, + private readonly ?CacheInterface $cache = null, + ) { + } + + public function state(string $feature, AccessActor $actor): AdminPermissionState + { + $definition = $this->registry->find($feature); + + if (!$definition instanceof AdminFeatureDefinition) { + return AdminPermissionState::Denied; + } + + if (!$this->parentFeaturesVisible($feature, $actor)) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() < $definition->surface()->gateAccessLevel()) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() >= AccessLevel::OWNER) { + return $definition->ownerState(); + } + + $override = $definition->configurable() ? ($this->overrides->overrides()[$feature] ?? []) : []; + return $this->stateWithGroupOverrides($definition, $actor, $override); + } + + private function parentFeaturesVisible(string $feature, AccessActor $actor): bool + { + $parts = explode('.', $feature); + + while (count($parts) > 2) { + array_pop($parts); + $parent = implode('.', $parts); + $definition = $this->registry->find($parent); + + if ($definition instanceof AdminFeatureDefinition && !$this->stateWithoutParentGate($parent, $actor)->isVisible()) { + return false; + } + } + + return true; + } + + private function stateWithoutParentGate(string $feature, AccessActor $actor): AdminPermissionState + { + $definition = $this->registry->find($feature); + + if (!$definition instanceof AdminFeatureDefinition) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() < $definition->surface()->gateAccessLevel()) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() >= AccessLevel::OWNER) { + return $definition->ownerState(); + } + + $override = $definition->configurable() ? ($this->overrides->overrides()[$feature] ?? []) : []; + return $this->stateWithGroupOverrides($definition, $actor, $override); + } + + /** + * @param array $override + */ + private function stateWithGroupOverrides(AdminFeatureDefinition $definition, AccessActor $actor, array $override): AdminPermissionState + { + $roleState = AdminPermissionState::fromMixed($override['state'] ?? null, $definition->defaultState()); + $groups = is_array($override['groups'] ?? null) ? $override['groups'] : []; + $allowedGroups = $this->allowedGroupIdentifiers($definition->surface()); + $effectiveGroupState = null; + + foreach ($groups as $identifier => $submittedState) { + if ( + is_string($identifier) + && in_array($identifier, $allowedGroups, true) + && $actor->hasGroupIdentifier($identifier) + ) { + $candidate = AdminPermissionState::fromMixed($submittedState); + $effectiveGroupState = null === $effectiveGroupState ? $candidate : AdminPermissionState::max($effectiveGroupState, $candidate); + } + } + + return $effectiveGroupState ?? $roleState; + } + + public function isVisible(string $feature, AccessActor $actor): bool + { + return $this->state($feature, $actor)->isVisible(); + } + + public function isMutable(string $feature, AccessActor $actor): bool + { + return $this->state($feature, $actor)->isMutable(); + } + + /** + * @return list + */ + public function availableGroups(AdminPermissionSurface $surface): array + { + $key = $this->groupsCacheKey($surface); + + if (null !== $this->cache) { + try { + return $this->cache->get( + $key, + function (CacheItemInterface $item) use ($surface): array { + $item->expiresAfter(300); + + return $this->loadAvailableGroups($surface); + }, + ); + } catch (Throwable) { + return $this->loadAvailableGroups($surface); + } + } + + return $this->loadAvailableGroups($surface); + } + + public function resetCache(): void + { + $this->allowedGroupsBySurface = []; + + foreach (AdminPermissionSurface::cases() as $surface) { + try { + $this->cache?->delete($this->groupsCacheKey($surface)); + } catch (Throwable) { + } + } + } + + /** + * @return list + */ + private function loadAvailableGroups(AdminPermissionSurface $surface): array + { + $groups = $this->entityManager->createQueryBuilder() + ->select('aclGroup') + ->from(AclGroup::class, 'aclGroup') + ->andWhere('aclGroup.minRole >= :minRole') + ->setParameter('minRole', $surface->gateAccessLevel()) + ->orderBy('aclGroup.identifier', 'ASC') + ->getQuery() + ->getResult(); + + return array_values(array_map( + static fn (AclGroup $group): array => [ + 'identifier' => $group->identifier(), + 'name' => $group->name(), + 'min_role' => $group->minRole(), + ], + $groups, + )); + } + + private function groupsCacheKey(AdminPermissionSurface $surface): string + { + return 'admin_acl.available_groups.'.$surface->value.'.v1'; + } + + /** + * @return list + */ + private function allowedGroupIdentifiers(AdminPermissionSurface $surface): array + { + $key = $surface->value; + + if (!isset($this->allowedGroupsBySurface[$key])) { + $this->allowedGroupsBySurface[$key] = array_map( + static fn (array $group): string => $group['identifier'], + $this->availableGroups($surface), + ); + } + + return $this->allowedGroupsBySurface[$key]; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureDefaults.php b/src/Core/AdminAcl/AdminFeatureDefaults.php new file mode 100644 index 00000000..657f151b --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureDefaults.php @@ -0,0 +1,42 @@ +}> + */ + public function overrides(): array + { + return [ + 'admin.settings.logging' => $this->row(AdminPermissionState::Visible), + 'admin.settings.statistics' => $this->row(AdminPermissionState::Mutable), + 'admin.settings.statistics.geoip' => $this->row(AdminPermissionState::Visible), + 'admin.settings.api' => $this->row(AdminPermissionState::Denied), + 'admin.settings.scheduler' => $this->row(AdminPermissionState::Visible), + 'admin.logs' => $this->row(AdminPermissionState::Visible), + 'admin.packages' => $this->row(AdminPermissionState::Visible), + 'admin.operations' => $this->row(AdminPermissionState::Visible), + 'admin.actions.maintenance' => $this->row(AdminPermissionState::Mutable), + 'admin.scheduler' => $this->row(AdminPermissionState::Visible), + 'admin.settings.packages' => $this->row(AdminPermissionState::Visible), + 'admin.users' => $this->row(AdminPermissionState::Mutable), + 'admin.users.acl' => $this->row(AdminPermissionState::Mutable), + 'admin.users.review' => $this->row(AdminPermissionState::Mutable), + ]; + } + + /** + * @return array{state: string, groups: array} + */ + private function row(AdminPermissionState $state): array + { + return [ + 'state' => $state->value, + 'groups' => [], + ]; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureDefinition.php b/src/Core/AdminAcl/AdminFeatureDefinition.php new file mode 100644 index 00000000..251c1c7c --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureDefinition.php @@ -0,0 +1,65 @@ +identifier; + } + + public function surface(): AdminPermissionSurface + { + return AdminPermissionSurface::fromFeatureIdentifier($this->identifier); + } + + public function labelKey(): string + { + return $this->labelKey; + } + + public function descriptionKey(): string + { + return $this->descriptionKey; + } + + public function categoryKey(): string + { + return $this->categoryKey; + } + + public function defaultState(): AdminPermissionState + { + return $this->defaultState; + } + + public function ownerState(): AdminPermissionState + { + return $this->ownerState; + } + + public function configurable(): bool + { + return $this->configurable; + } + + public function sortOrder(): int + { + return $this->sortOrder; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureOverrideStore.php b/src/Core/AdminAcl/AdminFeatureOverrideStore.php new file mode 100644 index 00000000..bd924b1d --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureOverrideStore.php @@ -0,0 +1,139 @@ +}>|null + */ + private ?array $overrides = null; + + public function __construct( + private readonly Config $config, + private readonly ?AdminFeatureDefaults $defaults = null, + private readonly ?CacheInterface $cache = null, + ) + { + } + + /** + * @return array}> + */ + public function overrides(): array + { + if (null !== $this->overrides) { + return $this->overrides; + } + + if (null !== $this->cache) { + try { + return $this->overrides = $this->cache->get( + self::CACHE_KEY, + function (CacheItemInterface $item): array { + $item->expiresAfter(self::CACHE_TTL_SECONDS); + + return $this->loadOverrides(); + }, + ); + } catch (Throwable) { + return $this->overrides = $this->loadOverrides(); + } + } + + return $this->overrides = $this->loadOverrides(); + } + + /** + * @return array}> + */ + private function loadOverrides(): array + { + $value = $this->config->get(self::CONFIG_KEY, $this->defaultOverrides()); + + $overrides = $this->defaultOverrides(); + + if (!is_array($value)) { + return $overrides; + } + + foreach ($value as $feature => $override) { + if (!is_string($feature) || !is_array($override)) { + continue; + } + + $groups = $override['groups'] ?? []; + $overrides[$feature] = [ + 'state' => is_string($override['state'] ?? null) ? $override['state'] : AdminPermissionState::Denied->value, + 'groups' => is_array($groups) ? $this->normalizeGroups($groups) : [], + ]; + } + + return $overrides; + } + + /** + * @return array}> + */ + public function defaultOverrides(): array + { + return $this->defaults?->overrides() ?? []; + } + + /** + * @param array}> $overrides + */ + public function save(array $overrides, ?string $modifiedBy = null): bool + { + $saved = $this->config->set(self::CONFIG_KEY, $overrides, ConfigValueType::Json, modifiedBy: $modifiedBy); + + if ($saved) { + $this->resetCache(); + } + + return $saved; + } + + public function resetCache(): void + { + $this->overrides = null; + + try { + $this->cache?->delete(self::CACHE_KEY); + } catch (Throwable) { + } + } + + /** + * @param array $groups + * + * @return array + */ + private function normalizeGroups(array $groups): array + { + $normalized = []; + + foreach ($groups as $identifier => $state) { + if (is_string($identifier) && is_string($state) && null !== AdminPermissionState::tryFrom($state)) { + $normalized[$identifier] = $state; + } + } + + ksort($normalized); + + return $normalized; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureProviderInterface.php b/src/Core/AdminAcl/AdminFeatureProviderInterface.php new file mode 100644 index 00000000..27bf0d33 --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureProviderInterface.php @@ -0,0 +1,13 @@ + + */ + public function adminFeatures(): array; +} diff --git a/src/Core/AdminAcl/AdminFeatureRegistry.php b/src/Core/AdminAcl/AdminFeatureRegistry.php new file mode 100644 index 00000000..bb2059c8 --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureRegistry.php @@ -0,0 +1,124 @@ +|null + */ + private ?array $allDefinitions = null; + + /** + * @param iterable $providers + */ + public function __construct(private readonly iterable $providers, private readonly ?CacheInterface $cache = null) + { + } + + /** + * @return list + */ + public function definitions(?AdminPermissionSurface $surface = null): array + { + $definitions = array_filter( + $this->allDefinitions(), + static fn (AdminFeatureDefinition $definition): bool => null === $surface || $definition->surface() === $surface, + ); + + usort( + $definitions, + static fn (AdminFeatureDefinition $left, AdminFeatureDefinition $right): int => [ + $left->surface()->value, + $left->sortOrder(), + $left->identifier(), + ] <=> [ + $right->surface()->value, + $right->sortOrder(), + $right->identifier(), + ], + ); + + return array_values($definitions); + } + + public function find(string $identifier): ?AdminFeatureDefinition + { + foreach ($this->allDefinitions() as $definition) { + if ($definition->identifier() === $identifier) { + return $definition; + } + } + + return null; + } + + /** + * @return list + */ + private function allDefinitions(): array + { + if (null !== $this->allDefinitions) { + return $this->allDefinitions; + } + + if (null !== $this->cache) { + try { + return $this->allDefinitions = $this->cache->get( + self::CACHE_KEY, + function (CacheItemInterface $item): array { + $item->expiresAfter(self::CACHE_TTL_SECONDS); + + return $this->loadDefinitions(); + }, + ); + } catch (Throwable) { + return $this->allDefinitions = $this->loadDefinitions(); + } + } + + return $this->allDefinitions = $this->loadDefinitions(); + } + + public function resetCache(): void + { + $this->allDefinitions = null; + + try { + $this->cache?->delete(self::CACHE_KEY); + } catch (Throwable) { + } + } + + /** + * @return list + */ + private function loadDefinitions(): array + { + $definitions = []; + $seen = []; + + foreach ($this->providers as $provider) { + foreach ($provider->adminFeatures() as $definition) { + if (isset($seen[$definition->identifier()])) { + continue; + } + + $seen[$definition->identifier()] = true; + $definitions[] = $definition; + } + } + + return $definitions; + } +} diff --git a/src/Core/AdminAcl/AdminPermissionState.php b/src/Core/AdminAcl/AdminPermissionState.php new file mode 100644 index 00000000..ad198128 --- /dev/null +++ b/src/Core/AdminAcl/AdminPermissionState.php @@ -0,0 +1,41 @@ + 0, + self::Visible => 1, + self::Mutable => 2, + }; + } + + public function isVisible(): bool + { + return $this->rank() >= self::Visible->rank(); + } + + public function isMutable(): bool + { + return self::Mutable === $this; + } + + public static function fromMixed(mixed $value, self $default = self::Denied): self + { + return is_string($value) ? (self::tryFrom($value) ?? $default) : $default; + } + + public static function max(self $left, self $right): self + { + return $left->rank() >= $right->rank() ? $left : $right; + } +} diff --git a/src/Core/AdminAcl/AdminPermissionSurface.php b/src/Core/AdminAcl/AdminPermissionSurface.php new file mode 100644 index 00000000..1f4a9ef9 --- /dev/null +++ b/src/Core/AdminAcl/AdminPermissionSurface.php @@ -0,0 +1,39 @@ + AccessLevel::ADMIN, + self::Editor => AccessLevel::AUTHOR, + self::Frontend => AccessLevel::PUBLIC, + }; + } + + public function labelKey(): string + { + return 'admin.acl.surfaces.'.$this->value; + } + + public static function fromFeatureIdentifier(string $identifier): self + { + $prefix = strtok($identifier, '.'); + + return match ($prefix) { + 'editor' => self::Editor, + 'frontend' => self::Frontend, + default => self::Admin, + }; + } +} diff --git a/src/Core/AdminAcl/CoreAdminFeatureProvider.php b/src/Core/AdminAcl/CoreAdminFeatureProvider.php new file mode 100644 index 00000000..1d662b5f --- /dev/null +++ b/src/Core/AdminAcl/CoreAdminFeatureProvider.php @@ -0,0 +1,148 @@ + + */ + public function adminFeatures(): array + { + return [ + new AdminFeatureDefinition( + 'admin.settings.security', + 'admin.acl.features.admin_settings_security.label', + 'admin.acl.features.admin_settings_security.description', + 'admin.acl.categories.settings', + configurable: false, + sortOrder: 10, + ), + new AdminFeatureDefinition( + 'admin.settings.logging', + 'admin.acl.features.admin_settings_logging.label', + 'admin.acl.features.admin_settings_logging.description', + 'admin.acl.categories.settings', + sortOrder: 20, + ), + new AdminFeatureDefinition( + 'admin.settings.statistics', + 'admin.acl.features.admin_settings_statistics.label', + 'admin.acl.features.admin_settings_statistics.description', + 'admin.acl.categories.settings', + sortOrder: 30, + ), + new AdminFeatureDefinition( + 'admin.settings.statistics.geoip', + 'admin.acl.features.admin_settings_statistics_geoip.label', + 'admin.acl.features.admin_settings_statistics_geoip.description', + 'admin.acl.categories.settings', + sortOrder: 40, + ), + new AdminFeatureDefinition( + 'admin.settings.api', + 'admin.acl.features.admin_settings_api.label', + 'admin.acl.features.admin_settings_api.description', + 'admin.acl.categories.settings', + sortOrder: 50, + ), + new AdminFeatureDefinition( + 'admin.settings.scheduler', + 'admin.acl.features.admin_settings_scheduler.label', + 'admin.acl.features.admin_settings_scheduler.description', + 'admin.acl.categories.settings', + sortOrder: 60, + ), + new AdminFeatureDefinition( + 'admin.settings.packages', + 'admin.acl.features.admin_settings_packages.label', + 'admin.acl.features.admin_settings_packages.description', + 'admin.acl.categories.settings', + sortOrder: 70, + ), + new AdminFeatureDefinition( + 'admin.logs', + 'admin.acl.features.admin_logs.label', + 'admin.acl.features.admin_logs.description', + 'admin.acl.categories.diagnostics', + sortOrder: 100, + ), + new AdminFeatureDefinition( + 'admin.packages', + 'admin.acl.features.admin_packages.label', + 'admin.acl.features.admin_packages.description', + 'admin.acl.categories.packages', + sortOrder: 110, + ), + new AdminFeatureDefinition( + 'admin.packages.self_update', + 'admin.acl.features.admin_packages_self_update.label', + 'admin.acl.features.admin_packages_self_update.description', + 'admin.acl.categories.packages', + configurable: false, + sortOrder: 120, + ), + new AdminFeatureDefinition( + 'admin.backup_restore', + 'admin.acl.features.admin_backup_restore.label', + 'admin.acl.features.admin_backup_restore.description', + 'admin.acl.categories.system', + defaultState: AdminPermissionState::Visible, + configurable: false, + sortOrder: 130, + ), + new AdminFeatureDefinition( + 'admin.operations', + 'admin.acl.features.admin_operations.label', + 'admin.acl.features.admin_operations.description', + 'admin.acl.categories.operations', + sortOrder: 140, + ), + new AdminFeatureDefinition( + 'admin.actions.maintenance', + 'admin.acl.features.admin_actions_maintenance.label', + 'admin.acl.features.admin_actions_maintenance.description', + 'admin.acl.categories.operations', + sortOrder: 150, + ), + new AdminFeatureDefinition( + 'admin.scheduler', + 'admin.acl.features.admin_scheduler.label', + 'admin.acl.features.admin_scheduler.description', + 'admin.acl.categories.operations', + sortOrder: 160, + ), + new AdminFeatureDefinition( + 'admin.users', + 'admin.acl.features.admin_users.label', + 'admin.acl.features.admin_users.description', + 'admin.acl.categories.users', + sortOrder: 170, + ), + new AdminFeatureDefinition( + 'admin.users.acl', + 'admin.acl.features.admin_users_acl.label', + 'admin.acl.features.admin_users_acl.description', + 'admin.acl.categories.users', + sortOrder: 180, + ), + new AdminFeatureDefinition( + 'admin.users.review', + 'admin.acl.features.admin_users_review.label', + 'admin.acl.features.admin_users_review.description', + 'admin.acl.categories.users', + sortOrder: 190, + ), + new AdminFeatureDefinition( + 'admin.support', + 'admin.acl.features.admin_support.label', + 'admin.acl.features.admin_support.description', + 'admin.acl.categories.system', + configurable: false, + sortOrder: 200, + ), + ]; + } +} diff --git a/src/Navigation/NavigationAccessFilter.php b/src/Navigation/NavigationAccessFilter.php index c54c662c..bda28978 100644 --- a/src/Navigation/NavigationAccessFilter.php +++ b/src/Navigation/NavigationAccessFilter.php @@ -5,9 +5,14 @@ namespace App\Navigation; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; final readonly class NavigationAccessFilter { + public function __construct(private ?AdminFeatureAccessPolicy $adminAcl = null) + { + } + /** * @param list $items * @@ -19,10 +24,15 @@ public function filter(array $items, ?AccessActor $actor): array return $items; } - return array_values(array_filter($items, static function (NavigationItem $item) use ($actor): bool { + return array_values(array_filter($items, function (NavigationItem $item) use ($actor): bool { $minLevel = $item->metadata()['min_access_level'] ?? null; $accessGroups = $item->metadata()['access_groups'] ?? []; $anonymousOnly = $item->metadata()['anonymous_only'] ?? false; + $feature = $item->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl && !$this->adminAcl->isVisible($feature, $actor)) { + return false; + } if (true === $anonymousOnly && null !== $actor->userUid()) { return false; diff --git a/templates/backend/admin/settings/acl.html.twig b/templates/backend/admin/settings/acl.html.twig new file mode 100644 index 00000000..5cdd5e64 --- /dev/null +++ b/templates/backend/admin/settings/acl.html.twig @@ -0,0 +1,113 @@ +{% extends '@backend/admin.html.twig' %} + +{% block title %}{{ 'admin.settings.acl.title'|trans }}{% endblock %} + +{% block admin_sidebar %} + +{% endblock %} + +{% block admin_body %} + {% include '@backend/admin/partials/_page-header.html.twig' with { + eyebrow: 'admin.settings.title'|trans, + title: 'admin.settings.acl.title'|trans, + } only %} + + + + + + + + {% for surface in acl_matrix.surfaces %} +
+
+

{{ surface.label_key|trans }}

+
+ + {% if surface.rows is empty %} +

{{ 'admin.acl.empty'|trans }}

+ {% else %} +
+
{{ 'admin.logs.columns.timestamp'|trans }}{{ 'admin.logs.columns.level'|trans }}{{ 'admin.logs.columns.level'|trans }}{{ 'admin.logs.columns.request'|trans }} {{ 'admin.logs.columns.http_status'|trans }}{{ 'admin.logs.columns.user'|trans }} {{ 'admin.logs.columns.action'|trans }}{{ 'admin.logs.columns.security_signal'|trans }}{{ 'admin.logs.columns.subject'|trans }}{{ 'admin.logs.columns.message'|trans }}
{{ entry.timestamp|default('') }}{{ entry.level|default('') }}{{ entry.level|default('') }}{{ entry.context.method|default('admin.logs.empty_value'|trans) }} {{ entry.context.requested_path|default(entry.context.path|default('admin.logs.empty_value'|trans)) }}
{{ entry.context.resolved_route|default(entry.context.route|default('admin.logs.empty_value'|trans)) }}
{{ entry.context.http_status|default('admin.logs.empty_value'|trans) }}{{ entry.context.user|default('admin.logs.empty_value'|trans) }}
{{ entry.context.user_access_level|default('0') }}
{{ entry.context.action|default(entry.message) }}{{ entry.context.signal_type|default(entry.summary|default(entry.message)) }}
{{ entry.context.reason_code|default('admin.logs.empty_value'|trans) }}
{{ entry.context.subject_type|default('admin.logs.empty_value'|trans) }}
{{ entry.context.subject_identifier|default('admin.logs.empty_value'|trans) }}
{{ entry.summary|default(entry.message|default(entry.raw)) }}
+ + + + + + + + + + {% for row in surface.rows %} + + + + + + + {% endfor %} + +
{{ 'admin.acl.table.feature'|trans }}{{ 'admin.acl.table.default'|trans }}{{ 'admin.acl.table.admin_state'|trans }}{{ 'admin.acl.table.groups'|trans }}
+ {{ row.category_key|trans }} + {{ row.label_key|trans }} + {{ row.identifier }} + {% if not row.configurable %} + {{ 'admin.acl.non_configurable'|trans }} + {% endif %} + {{ ('admin.acl.states.' ~ row.default_state)|trans }} + + + {% if surface.groups is empty %} + {{ 'admin.acl.groups.empty'|trans }} + {% else %} +
+ {% for group in row.groups %} + + {% endfor %} +
+ {% endif %} +
+ + {% endif %} + + {% endfor %} + + + +{% endblock %} diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 509b8e91..29932f0f 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -7,6 +7,7 @@ use App\Core\Access\AccessLevel; use App\Core\ActionLog\ActionLogEntry; use App\Core\ActionLog\ActionLogStatus; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\Geo\MaxMindGeoIpConfig; @@ -394,7 +395,7 @@ public function testAdminRegisteredBackendViewRouteRendersThroughRegistry(): voi { $manifest = $this->rootManifest(); $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/packages'); self::assertResponseIsSuccessful(); @@ -732,7 +733,7 @@ public function testAdminBackendActionFormsRunPackageDiscoveryImmediately(): voi } try { - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $crawler = $client->request('GET', '/admin/packages'); self::assertSelectorNotExists('.system-table tr[data-package-name="demo-module"]'); @@ -764,6 +765,58 @@ public function testAdminBackendActionFormsRunPackageDiscoveryImmediately(): voi } } + public function testDelegatedAdminsSeeDisabledPackageLifecycleActionsByDefault(): void + { + $client = self::createClient(); + $this->removePackageByName('test-admin-hidden-lifecycle'); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $package = new ExtensionPackage( + '00000000-0000-7000-8000-000000000496', + [PackageScope::Module], + 'test-admin-hidden-lifecycle', + 'packages/test-admin-hidden-lifecycle', + ExtensionPackageStatus::Inactive, + [ + 'display_name' => 'Test Admin Hidden Lifecycle', + 'description' => 'Lifecycle ACL fixture', + 'manifest' => [ + 'PACKAGE_NAME' => 'Test Admin Hidden Lifecycle', + 'PACKAGE_VERSION' => '1.0.0', + ], + ], + manifestVersion: '1.0.0', + ); + $entityManager->persist($package); + $entityManager->flush(); + + try { + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $client->request('GET', '/admin/packages'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('a[href="/admin/packages/test-admin-hidden-lifecycle"]'); + self::assertSelectorNotExists('a[href="/admin/packages/test-admin-hidden-lifecycle/activate"]'); + + $client->request('GET', '/admin/packages/test-admin-hidden-lifecycle'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Test Admin Hidden Lifecycle'); + self::assertSelectorNotExists('a[href="/admin/packages/test-admin-hidden-lifecycle/activate"]'); + self::assertSelectorNotExists('a[href="/admin/packages/test-admin-hidden-lifecycle/delete"]'); + self::assertSelectorExists('button[disabled]'); + self::assertStringContainsString('Activate', (string) $client->getResponse()->getContent()); + self::assertStringContainsString('Delete', (string) $client->getResponse()->getContent()); + + $client->request('GET', '/admin/packages/test-admin-hidden-lifecycle/activate'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.system-alert-warning', 'You may review this action, but mutation is not allowed by the current ACL policy.'); + self::assertStringNotContainsString('Activate package', (string) $client->getResponse()->getContent()); + } finally { + $this->removePackageByName('test-admin-hidden-lifecycle'); + } + } + public function testAdminPackageDetailAndLifecycleReviewRoutesRender(): void { $client = self::createClient(); @@ -790,7 +843,7 @@ public function testAdminPackageDetailAndLifecycleReviewRoutesRender(): void file_put_contents($packageDir.'/README.md', "# Lifecycle README\n\nThis package has **markdown** docs."); file_put_contents($assetsDir.'/preview.svg', ''); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $entityManager = self::getContainer()->get(EntityManagerInterface::class); $package = new ExtensionPackage( '00000000-0000-7000-8000-000000000498', @@ -875,7 +928,7 @@ public function testAdminPackageDeactivationReviewIncludesActiveDependents(): vo $this->removePackageByName('test-dependent-theme'); $this->removePackageByName('test-dependent-captcha'); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $entityManager = self::getContainer()->get(EntityManagerInterface::class); $theme = new ExtensionPackage( '00000000-0000-7000-8000-000000000596', @@ -988,6 +1041,7 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists('form#admin-settings-users'); self::assertSelectorExists(sprintf('input[name="%s"][min="1"][max="3650"]', UserFlowConfig::DELETED_USER_RETENTION_DAYS_KEY)); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/settings/security'); self::assertResponseIsSuccessful(); @@ -1036,10 +1090,11 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertResponseIsSuccessful(); self::assertSelectorExists('form#admin-settings-statistics'); self::assertSelectorExists('input[name="statistics.enabled"]'); - self::assertSelectorNotExists('input[name="statistics.geoip.enabled"]'); - self::assertSelectorNotExists('input[name="statistics.geoip.maxmind.license_key"]'); - self::assertSelectorNotExists('input[name="_backend_action"][value="geoip_database_update"]'); + self::assertSelectorExists('input[name="statistics.geoip.enabled"][disabled]'); + self::assertSelectorExists('input[name="statistics.geoip.maxmind.license_key"][disabled]'); + self::assertSelectorExists('input[name="_backend_action"][value="geoip_database_update"] + input + button[disabled]'); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/settings/scheduler'); self::assertResponseIsSuccessful(); @@ -1059,6 +1114,57 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertStringNotContainsString('$_SERVER', (string) $client->getResponse()->getContent()); } + public function testAclSettingsMatrixIsOwnerGatedAndRendersFeatureRegistry(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $adminAcl = self::getContainer()->get(AdminFeatureAccessPolicy::class); + self::assertInstanceOf(AdminFeatureAccessPolicy::class, $adminAcl); + $existingGroup = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => 'acl_matrix_test']); + if ($existingGroup instanceof AclGroup) { + $entityManager->remove($existingGroup); + $entityManager->flush(); + $adminAcl->resetCache(); + } + $group = new AclGroup( + '72000000-0000-7000-8000-000000000715', + 'acl_matrix_test', + 'ACL Matrix Test', + AccessLevel::ADMIN, + ); + $entityManager->persist($group); + $entityManager->flush(); + $adminAcl->resetCache(); + + $this->loginUserWithLevel($client, AccessLevel::OWNER); + $crawler = $client->request('GET', '/admin/settings/acl'); + + try { + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'ACL'); + self::assertSelectorExists('form#admin-settings-acl'); + self::assertSelectorTextContains('#acl-surface-admin', 'Packages and themes'); + self::assertSelectorExists('select[name="acl[admin.packages][state]"]'); + self::assertSelectorExists('select[name="acl[admin.settings.statistics.geoip][state]"]'); + self::assertGreaterThan(0, $crawler->filter('option[value=""]')->count()); + self::assertSelectorTextContains('#acl-surface-admin', 'Read-only'); + self::assertGreaterThan(0, $crawler->filter('select[disabled]')->count()); + + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $client->request('GET', '/admin/settings/acl'); + + self::assertResponseStatusCodeSame(401); + } finally { + $cleanupGroup = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => $group->identifier()]); + if ($cleanupGroup instanceof AclGroup) { + $entityManager->remove($cleanupGroup); + $entityManager->flush(); + $adminAcl->resetCache(); + } + } + } + public function testAdminSettingsFormsPersistCoreSettings(): void { $client = self::createClient(); diff --git a/tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php b/tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php new file mode 100644 index 00000000..486c1bf4 --- /dev/null +++ b/tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php @@ -0,0 +1,102 @@ +get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $overrides); + $policy = self::getContainer()->get(AdminFeatureAccessPolicy::class); + self::assertInstanceOf(AdminFeatureAccessPolicy::class, $policy); + + $adminGroup = $this->upsertGroup($entityManager, 'admin_acl_grant', AccessLevel::ADMIN); + $userGroup = $this->upsertGroup($entityManager, 'user_acl_grant', AccessLevel::USER); + $policy->resetCache(); + $overrides->save([ + 'admin.settings.api' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [ + $adminGroup->identifier() => AdminPermissionState::Visible->value, + $userGroup->identifier() => AdminPermissionState::Mutable->value, + ], + ], + ], 'test'); + + try { + self::assertSame( + AdminPermissionState::Visible, + $policy->state('admin.settings.api', AccessActor::fromAccess(AccessLevel::ADMIN, [$adminGroup->identifier()])), + ); + self::assertSame( + AdminPermissionState::Denied, + $policy->state('admin.settings.api', AccessActor::fromAccess(AccessLevel::DIRECTOR, [$adminGroup->identifier()])), + ); + self::assertSame( + AdminPermissionState::Denied, + $policy->state('admin.settings.api', AccessActor::fromAccess(AccessLevel::ADMIN, [$userGroup->identifier()])), + ); + + $overrides->save([ + 'admin.settings.statistics' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [ + $adminGroup->identifier() => AdminPermissionState::Visible->value, + ], + ], + ], 'test'); + + self::assertSame( + AdminPermissionState::Visible, + $policy->state('admin.settings.statistics', AccessActor::fromAccess(AccessLevel::ADMIN, [$adminGroup->identifier()])), + ); + self::assertSame( + AdminPermissionState::Mutable, + $policy->state('admin.settings.statistics', AccessActor::fromAccess(AccessLevel::ADMIN)), + ); + } finally { + $overrides->save([], 'test'); + $entityManager->remove($adminGroup); + $entityManager->remove($userGroup); + $entityManager->flush(); + $policy->resetCache(); + } + } + + private function upsertGroup(EntityManagerInterface $entityManager, string $identifier, int $minRole): AclGroup + { + $existing = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => $identifier]); + if ($existing instanceof AclGroup) { + $existing->changeMinRole($minRole); + $entityManager->flush(); + + return $existing; + } + + $group = new AclGroup( + '71000000-0000-7000-8000-'.substr(md5($identifier), 0, 12), + $identifier, + ucfirst(str_replace('_', ' ', $identifier)), + $minRole, + ); + $entityManager->persist($group); + $entityManager->flush(); + + return $group; + } +} diff --git a/tests/Core/AdminAcl/AdminFeatureCacheTest.php b/tests/Core/AdminAcl/AdminFeatureCacheTest.php new file mode 100644 index 00000000..6d208938 --- /dev/null +++ b/tests/Core/AdminAcl/AdminFeatureCacheTest.php @@ -0,0 +1,73 @@ +definitions()); + self::assertCount(1, (new AdminFeatureRegistry([$provider], $cache))->definitions()); + self::assertSame(1, $provider->calls); + + (new AdminFeatureRegistry([$provider], $cache))->resetCache(); + + self::assertCount(1, (new AdminFeatureRegistry([$provider], $cache))->definitions()); + self::assertSame(2, $provider->calls); + } + + public function testFeatureOverrideStoreCachesConfiguredOverrides(): void + { + $connection = $this->createMock(Connection::class); + $cache = new ArrayAdapter(); + $payload = [ + 'admin.settings.api' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ]; + + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with('SELECT value FROM config_entry WHERE config_key = ?', [AdminFeatureOverrideStore::CONFIG_KEY]) + ->willReturn(json_encode($payload, JSON_THROW_ON_ERROR)); + + self::assertSame($payload, (new AdminFeatureOverrideStore(new Config($connection), cache: $cache))->overrides()); + self::assertSame($payload, (new AdminFeatureOverrideStore(new Config($connection), cache: $cache))->overrides()); + } +} + +final class CountingFeatureProvider implements AdminFeatureProviderInterface +{ + public int $calls = 0; + + public function adminFeatures(): array + { + ++$this->calls; + + return [ + new AdminFeatureDefinition( + 'admin.settings.api', + 'admin.acl.features.admin_settings_api.label', + 'admin.acl.features.admin_settings_api.description', + 'admin.acl.categories.settings', + ), + ]; + } +} diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 04949d56..6665041d 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -33,6 +33,7 @@ admin: package_settings: 'Pakete' statistics_settings: 'Statistiken' api_settings: 'API' + acl_settings: 'ACL' scheduler_settings: 'Scheduler' system_info: 'System-Info' actions: @@ -683,6 +684,10 @@ admin: all: 'Alle gespeicherten Events' settings: title: 'Einstellungen' + acl: + title: 'ACL' + foundation_title: 'ACL-Matrix' + foundation_text: 'Owner-gesteuerte Feature-Berechtigungen für Admin-, Editor- und zukünftige Frontend-Surfaces.' redirect_title: 'Einstellungsbereiche' redirect_text: 'Allgemeine Einstellungen sind der erste eingebaute Einstellungsbereich. Aktive Pakete mit einfachen Setting-Definitionen erscheinen unter den Paket-Einstellungen.' general: @@ -764,6 +769,97 @@ 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.' + acl: + actions: + save: 'ACL-Matrix speichern' + categories: + diagnostics: 'Diagnostik' + operations: 'Operationen' + packages: 'Pakete' + package_settings: 'Paket-Einstellungen' + settings: 'Einstellungen' + system: 'System' + users: 'Benutzer' + empty: 'Für diese Surface sind noch keine Feature-Flags registriert.' + groups: + empty: 'Keine ACL-Gruppen können diese Surface gewähren.' + non_configurable: 'Read-only' + read_only_action: 'Du darfst diese Aktion prüfen, aber die aktuelle ACL-Policy erlaubt keine Mutation.' + states: + inherit: 'Erben' + denied: 'Verweigert' + visible: 'Sichtbar' + mutable: 'Mutierbar' + surfaces: + label: 'ACL-Surfaces' + admin: 'Admin' + admin_text: 'Administrative Feature-Flags. ACL-Gruppen können Berechtigungen nur an Benutzer vergeben, die bereits das Admin-Surface-Gate passieren.' + editor: 'Editor' + editor_text: 'Für zukünftige Editor-Feature-Flags vorbereitet. Editor-Provider können hier später Zeilen registrieren.' + frontend: 'Frontend' + frontend_text: 'Reserviert für ausdrücklich entworfene zukünftige Frontend-Features wie Inline-Editing.' + table: + admin_state: 'Admin' + default: 'Default' + feature: 'Feature' + groups: 'ACL-Gruppen-Grants' + features: + admin_settings_security: + label: 'Sicherheits-Einstellungen' + description: 'Security-Policy, Audit-Kategorien, Signal-Retention und verdächtige Probe-Pfade.' + admin_settings_logging: + label: 'Log-Retention-Einstellungen' + description: 'Datenbank-Lookup-Retention für Message-, Audit- und Access-Log-Projektionen.' + admin_settings_statistics: + label: 'Statistik-Einstellungen' + description: 'Aufzeichnung und Anzeige von Zugriffsstatistiken.' + admin_settings_statistics_geoip: + label: 'GeoIP-Einstellungen' + description: 'GeoIP-Aktivierung, lokaler Datenbankpfad, geschützter MaxMind-License-Key und Status.' + admin_settings_api: + label: 'API-Einstellungen' + description: 'Globale API-Verfügbarkeit und CORS-Erweiterungen.' + admin_settings_scheduler: + label: 'Scheduler-Einstellungen' + description: 'Scheduler-Aktivierung, Web-Triggering, GET-Authentifizierung und Package-Action-Queues.' + admin_settings_packages: + label: 'Paket-Einstellungen' + description: 'Core-Einstellungen für Paket-Updates und paket-eigene Einstellungsseiten.' + admin_settings_package: + description: 'Paket-eigene Einstellungsseite für ein aktives Paket.' + admin_logs: + label: 'Logs' + description: 'Administrative Log-Review-Flächen.' + admin_packages: + label: 'Pakete und Themes' + description: 'Paket- und Themeverwaltung inklusive Installation, Aktivierung, Deaktivierung, Reparatur, Löschung und Purge-Workflows.' + admin_packages_self_update: + label: 'System-Paket-Self-Update' + description: 'Owner-only Self-Update-Workflows für das System-Paket.' + admin_backup_restore: + label: 'Backup, Export und Restore' + description: 'Full-Data-Backup, Export, Download und Restore-Workflows.' + admin_operations: + label: 'Operationen' + description: 'Live-Operation-Inspektion und operative Statusflächen.' + admin_actions_maintenance: + label: 'Wartungsaktionen' + description: 'Manuelle Wartungsaktionen wie Cache-Clear und Asset-Rebuild.' + admin_scheduler: + label: 'Scheduler' + description: 'Scheduler-Task-Inspektion und Run-Controls.' + admin_users: + label: 'Benutzer' + description: 'Benutzeradministration und Account-Verwaltung.' + admin_users_acl: + label: 'Benutzer-ACL-Gruppen' + description: 'ACL-Gruppenadministration und Membership-Review.' + admin_users_review: + label: 'Benutzer-Reviews' + description: 'Queues für Einladungen, Registrierungen und Benutzer-Reviews.' + admin_support: + label: 'Support-Bundles' + description: 'Erzeugung und Download von Diagnose- oder Support-Bundles.' fields: site_title: label: 'Seitentitel' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index f3ce2f28..89f72725 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -33,6 +33,7 @@ admin: package_settings: 'Packages' statistics_settings: 'Statistics' api_settings: 'API' + acl_settings: 'ACL' scheduler_settings: 'Scheduler' system_info: 'System information' actions: @@ -683,6 +684,10 @@ admin: all: 'All stored events' settings: title: 'Settings' + acl: + title: 'ACL' + foundation_title: 'ACL matrix' + foundation_text: 'Owner-controlled feature permissions for administrative, editor, and future frontend surfaces.' redirect_title: 'Settings sections' redirect_text: 'General settings are the first built-in settings section. Active packages with simple setting definitions appear under package settings.' general: @@ -764,6 +769,97 @@ 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.' + acl: + actions: + save: 'Save ACL matrix' + categories: + diagnostics: 'Diagnostics' + operations: 'Operations' + packages: 'Packages' + package_settings: 'Package settings' + settings: 'Settings' + system: 'System' + users: 'Users' + empty: 'No feature flags are registered for this surface yet.' + groups: + empty: 'No ACL groups can grant this surface.' + non_configurable: 'Read-only' + read_only_action: 'You may review this action, but mutation is not allowed by the current ACL policy.' + states: + inherit: 'Inherit' + denied: 'Denied' + visible: 'Visible' + mutable: 'Mutable' + surfaces: + label: 'ACL surfaces' + admin: 'Admin' + admin_text: 'Administrative feature flags. ACL groups can grant permissions only to users that already pass the Admin surface gate.' + editor: 'Editor' + editor_text: 'Prepared for future editor feature flags. Editor providers can register rows here later.' + frontend: 'Frontend' + frontend_text: 'Reserved for explicitly designed future frontend features such as inline editing.' + table: + admin_state: 'Admin' + default: 'Default' + feature: 'Feature' + groups: 'ACL group grants' + features: + admin_settings_security: + label: 'Security settings' + description: 'Security policy, audit categories, signal retention, and suspicious probe patterns.' + admin_settings_logging: + label: 'Log retention settings' + description: 'Database lookup retention for message, audit, and access log projections.' + admin_settings_statistics: + label: 'Statistics settings' + description: 'Access statistics recording and display settings.' + admin_settings_statistics_geoip: + label: 'GeoIP settings' + description: 'GeoIP enablement, local database path, protected MaxMind license key, and status.' + admin_settings_api: + label: 'API settings' + description: 'Global API availability and CORS expansion settings.' + admin_settings_scheduler: + label: 'Scheduler settings' + description: 'Scheduler enablement, web triggering, GET authentication, and package action queues.' + admin_settings_packages: + label: 'Package settings' + description: 'Core package-update settings and package-owned settings pages.' + admin_settings_package: + description: 'Package-owned settings page for an active package.' + admin_logs: + label: 'Logs' + description: 'Administrative log review surfaces.' + admin_packages: + label: 'Packages and themes' + description: 'Package and theme management, including install, activation, deactivation, repair, deletion, and purge workflows.' + admin_packages_self_update: + label: 'System package self-update' + description: 'Owner-only system package self-update workflows.' + admin_backup_restore: + label: 'Backup, export, and restore' + description: 'Full-data backup, export, download, and restore workflows.' + admin_operations: + label: 'Operations' + description: 'Live operation inspection and operational status surfaces.' + admin_actions_maintenance: + label: 'Maintenance actions' + description: 'Manual maintenance actions such as cache clear and asset rebuild.' + admin_scheduler: + label: 'Scheduler' + description: 'Scheduler task inspection and run controls.' + admin_users: + label: 'Users' + description: 'User administration and account management.' + admin_users_acl: + label: 'User ACL groups' + description: 'ACL group administration and membership review.' + admin_users_review: + label: 'User reviews' + description: 'User invitation, registration, and review queues.' + admin_support: + label: 'Support bundles' + description: 'Diagnostic or support-bundle generation and download.' fields: site_title: label: 'Site title' From 449c8eda80c1c3d1e8f8ffd2bacca4c59480bfca Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 21:55:07 +0200 Subject: [PATCH 070/119] Gate settings through admin ACL features --- src/Core/Config/Api/SettingsApiReadModel.php | 49 ++++++++++- .../Settings/CoreConfigDefaultProvider.php | 10 ++- .../Config/Settings/CoreSettingDefinition.php | 20 +++++ .../Settings/CoreSettingsFormHandler.php | 15 +++- .../Config/Settings/CoreSettingsRegistry.php | 84 +++++++++++++------ src/Setup/SetupDefaultSeed.php | 3 + .../backend/partials/forms/_dynamic.html.twig | 10 ++- .../partials/forms/_dynamic_fields.html.twig | 5 ++ .../forms/fields/checkbox-group.html.twig | 1 + .../partials/forms/fields/input.html.twig | 1 + .../partials/forms/fields/select.html.twig | 1 + .../partials/forms/fields/textarea.html.twig | 1 + .../partials/forms/fields/toggle.html.twig | 1 + .../Controller/ApiSettingsControllerTest.php | 4 +- .../Core/Config/CoreSettingsRegistryTest.php | 3 + tests/Setup/SetupDefaultSeedTest.php | 4 + 16 files changed, 176 insertions(+), 36 deletions(-) diff --git a/src/Core/Config/Api/SettingsApiReadModel.php b/src/Core/Config/Api/SettingsApiReadModel.php index cbb1a520..514736eb 100644 --- a/src/Core/Config/Api/SettingsApiReadModel.php +++ b/src/Core/Config/Api/SettingsApiReadModel.php @@ -6,7 +6,9 @@ use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Config\Config; +use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreSettingsRegistry; final readonly class SettingsApiReadModel @@ -14,6 +16,7 @@ public function __construct( private CoreSettingsRegistry $settings, private Config $config, + private ?AdminFeatureAccessPolicy $adminAcl = null, ) { } @@ -26,7 +29,7 @@ public function sections(?AccessActor $actor = null): array $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); foreach ($this->settings->allDefinitions() as $definition) { - if (!$definition->allows($actor)) { + if (!$this->definitionVisible($definition, $actor)) { continue; } @@ -65,11 +68,11 @@ public function settings(?string $section = null, ?AccessActor $actor = null): a continue; } - if (!$definition->allows($actor)) { + if (!$this->definitionVisible($definition, $actor)) { continue; } - $field = $definition->formField(); + $field = $this->decorateDefinition($definition, $actor)->formField(); if (false === ($field->metadata()['persist'] ?? true)) { continue; @@ -111,7 +114,7 @@ public function values(string $section, ?AccessActor $actor = null): array continue; } - if (!$definition->allows($actor)) { + if (!$this->definitionMutable($definition, $actor)) { continue; } @@ -139,4 +142,42 @@ private function apiValue(array $metadata, mixed $value): mixed return is_string($value) && '' !== trim($value) ? '[protected]' : ''; } + + private function decorateDefinition(CoreSettingDefinition $definition, AccessActor $actor): CoreSettingDefinition + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (!is_string($feature) || null === $this->adminAcl) { + return $definition; + } + + $state = $this->adminAcl->state($feature, $actor); + + return $definition->withMetadata([ + 'access_state' => $state->value, + 'read_only' => !$state->isMutable(), + ]); + } + + private function definitionVisible(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isVisible($feature, $actor); + } + + return $definition->allows($actor); + } + + private function definitionMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } } diff --git a/src/Core/Config/Settings/CoreConfigDefaultProvider.php b/src/Core/Config/Settings/CoreConfigDefaultProvider.php index 7f98b5e2..1e1f2a03 100644 --- a/src/Core/Config/Settings/CoreConfigDefaultProvider.php +++ b/src/Core/Config/Settings/CoreConfigDefaultProvider.php @@ -4,6 +4,8 @@ namespace App\Core\Config\Settings; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\ConfigDefaultProviderInterface; final class CoreConfigDefaultProvider implements ConfigDefaultProviderInterface @@ -13,8 +15,10 @@ final class CoreConfigDefaultProvider implements ConfigDefaultProviderInterface */ private ?array $defaults = null; - public function __construct(private readonly CoreSettingsRegistry $registry) - { + public function __construct( + private readonly CoreSettingsRegistry $registry, + private readonly ?AdminFeatureDefaults $adminFeatureDefaults = null, + ) { } public function hasDefault(string $key): bool @@ -46,6 +50,8 @@ private function defaults(): array $defaults[$definition->key()] = $definition->defaultValue(); } + $defaults[AdminFeatureOverrideStore::CONFIG_KEY] = ($this->adminFeatureDefaults ?? new AdminFeatureDefaults())->overrides(); + return $this->defaults = $defaults; } } diff --git a/src/Core/Config/Settings/CoreSettingDefinition.php b/src/Core/Config/Settings/CoreSettingDefinition.php index ff979a42..27f416fb 100644 --- a/src/Core/Config/Settings/CoreSettingDefinition.php +++ b/src/Core/Config/Settings/CoreSettingDefinition.php @@ -61,6 +61,26 @@ public function metadata(): array return $this->metadata; } + /** + * @param array $metadata + */ + public function withMetadata(array $metadata): self + { + return new self( + $this->section, + $this->key, + $this->label, + $this->defaultValue, + $this->valueType, + $this->inputType, + $this->help, + $this->options, + $this->validation, + [...$this->metadata, ...$metadata], + $this->sortOrder, + ); + } + public function minimumAccessLevel(): int { $level = $this->metadata['minimum_access_level'] ?? AccessLevel::ADMIN; diff --git a/src/Core/Config/Settings/CoreSettingsFormHandler.php b/src/Core/Config/Settings/CoreSettingsFormHandler.php index 10882796..929dd172 100644 --- a/src/Core/Config/Settings/CoreSettingsFormHandler.php +++ b/src/Core/Config/Settings/CoreSettingsFormHandler.php @@ -7,6 +7,7 @@ use App\Api\ApiFeaturePolicy; use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Config\Config; use App\Core\Validation\EmailAddress; use App\Entity\AclGroup; @@ -28,6 +29,7 @@ public function __construct( private FormSubmissionHandler $submissionHandler, private EntityManagerInterface $entityManager, private ?SuspiciousProbePathMatcher $probePathMatcher = null, + private ?AdminFeatureAccessPolicy $adminAcl = null, ) { } @@ -90,10 +92,21 @@ private function definitionsForActor(string $section, AccessActor $actor): array { return array_values(array_filter( $this->registry->definitions($section), - static fn (CoreSettingDefinition $definition): bool => $definition->allows($actor), + fn (CoreSettingDefinition $definition): bool => $this->definitionMutable($definition, $actor), )); } + private function definitionMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } + private function validateDomainSettings(string $section, FormSubmissionResult $result): ?FormSubmissionResult { if ('api' === $section) { diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 5562b0d3..83bef982 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -77,7 +77,9 @@ public function allDefinitions(): array new CoreSettingDefinition('security', 'security.captcha.enabled', 'admin.settings.fields.captcha_enabled.label', false, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('security', 'security.captcha.provider', 'admin.settings.fields.captcha_provider.label', 'none', ConfigValueType::String, FormInputType::Select, options: ['none' => 'admin.settings.options.captcha.none'], validation: ['required' => true], sortOrder: 20), new CoreSettingDefinition('security', 'security.captcha.preview', 'admin.settings.fields.captcha_preview.label', null, ConfigValueType::String, FormInputType::Captcha, metadata: ['persist' => false], sortOrder: 30), - new CoreSettingDefinition('security', ConfigAuditLogPolicy::ENABLED_KEY, 'admin.settings.fields.audit_enabled.label', true, ConfigValueType::Boolean, sortOrder: 40), + new CoreSettingDefinition('security', ConfigAuditLogPolicy::ENABLED_KEY, 'admin.settings.fields.audit_enabled.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 40), new CoreSettingDefinition('security', ConfigAuditLogPolicy::EVENTS_KEY, 'admin.settings.fields.audit_events.label', ConfigAuditLogPolicy::DEFAULT_CATEGORIES, ConfigValueType::Json, FormInputType::MultiSelect, options: [ ConfigAuditLogPolicy::CATEGORY_AUTHENTICATION => 'admin.settings.options.audit.authentication', ConfigAuditLogPolicy::CATEGORY_BACKEND_ACTIONS => 'admin.settings.options.audit.backend_actions', @@ -85,51 +87,85 @@ public function allDefinitions(): array ConfigAuditLogPolicy::CATEGORY_PACKAGES => 'admin.settings.options.audit.packages', ConfigAuditLogPolicy::CATEGORY_SETTINGS => 'admin.settings.options.audit.settings', ConfigAuditLogPolicy::CATEGORY_OTHER => 'admin.settings.options.audit.other', + ], metadata: [ + 'access_feature' => 'admin.settings.security', ], sortOrder: 50), - new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 60), - new CoreSettingDefinition('security', SuspiciousProbePathMatcher::PATTERNS_KEY, 'admin.settings.fields.security_probe_path_patterns.label', SuspiciousProbePathMatcher::defaultPatternText(), ConfigValueType::String, FormInputType::Textarea, help: 'admin.settings.fields.security_probe_path_patterns.help', validation: ['max_length' => 50000], sortOrder: 70), - - new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.message_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.message_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 10), - new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.audit_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.audit_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 20), - new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], sortOrder: 30), - new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), - new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 60), + new CoreSettingDefinition('security', SuspiciousProbePathMatcher::PATTERNS_KEY, 'admin.settings.fields.security_probe_path_patterns.label', SuspiciousProbePathMatcher::defaultPatternText(), ConfigValueType::String, FormInputType::Textarea, help: 'admin.settings.fields.security_probe_path_patterns.help', validation: ['max_length' => 50000], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 70), + + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.message_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.message_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.logging', + ], sortOrder: 10), + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.audit_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.audit_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.logging', + ], sortOrder: 20), + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.logging', + ], sortOrder: 30), + new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.statistics', + ], sortOrder: 10), + new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.statistics', + ], sortOrder: 20), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ - 'access_feature' => 'settings.statistics.geoip', - 'access_configurable' => false, + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, 'minimum_access_level' => AccessLevel::OWNER, 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', ], sortOrder: 30), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], metadata: [ - 'access_feature' => 'settings.statistics.geoip', - 'access_configurable' => false, + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, 'minimum_access_level' => AccessLevel::OWNER, ], sortOrder: 40), new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'admin.settings.fields.geoip_license_key.label', '', ConfigValueType::String, FormInputType::Password, help: 'admin.settings.fields.geoip_license_key.help', validation: ['max_length' => 180], metadata: [ - 'access_feature' => 'settings.statistics.geoip', - 'access_configurable' => false, + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, 'minimum_access_level' => AccessLevel::OWNER, 'sensitive' => true, 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', ], sortOrder: 50), - new CoreSettingDefinition('api', ApiFeaturePolicy::ENABLED_KEY, 'admin.settings.fields.api_enabled.label', true, ConfigValueType::Boolean, help: 'admin.settings.fields.api_enabled.help', sortOrder: 10), - new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ENABLED_KEY, 'admin.settings.fields.api_cors_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.api_cors_enabled.help', sortOrder: 20), - new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, 'admin.settings.fields.api_cors_allowed_origins.label', [], ConfigValueType::Json, help: 'admin.settings.fields.api_cors_allowed_origins.help', sortOrder: 30), + new CoreSettingDefinition('api', ApiFeaturePolicy::ENABLED_KEY, 'admin.settings.fields.api_enabled.label', true, ConfigValueType::Boolean, help: 'admin.settings.fields.api_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.api', + ], sortOrder: 10), + new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ENABLED_KEY, 'admin.settings.fields.api_cors_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.api_cors_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.api', + ], sortOrder: 20), + new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, 'admin.settings.fields.api_cors_allowed_origins.label', [], ConfigValueType::Json, help: 'admin.settings.fields.api_cors_allowed_origins.help', metadata: [ + 'access_feature' => 'admin.settings.api', + ], sortOrder: 30), new CoreSettingDefinition('packages', 'packages.update_check_interval', 'admin.settings.fields.package_update_interval.label', 'daily', ConfigValueType::String, FormInputType::Select, options: [ 'manual' => 'admin.settings.options.interval.manual', 'daily' => 'admin.settings.options.interval.daily', 'weekly' => 'admin.settings.options.interval.weekly', - ], validation: ['required' => true], sortOrder: 10), - new CoreSettingDefinition('packages', 'packages.auto_updates.enabled', 'admin.settings.fields.package_auto_updates.label', false, ConfigValueType::Boolean, sortOrder: 20), + ], validation: ['required' => true], metadata: [ + 'access_feature' => 'admin.settings.packages', + ], sortOrder: 10), + new CoreSettingDefinition('packages', 'packages.auto_updates.enabled', 'admin.settings.fields.package_auto_updates.label', false, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.packages', + ], sortOrder: 20), - new CoreSettingDefinition('scheduler', 'scheduler.enabled', 'admin.settings.fields.scheduler_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), - new CoreSettingDefinition('scheduler', 'scheduler.get_auth_enabled', 'admin.settings.fields.scheduler_get_auth_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_get_auth_enabled.help', sortOrder: 20), - new CoreSettingDefinition('scheduler', 'scheduler.package_action_queues_enabled', 'admin.settings.fields.scheduler_package_action_queues_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_package_action_queues_enabled.help', sortOrder: 30), - new CoreSettingDefinition('scheduler', 'scheduler.web_trigger_enabled', 'admin.settings.fields.scheduler_web_trigger_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_web_trigger_enabled.help', sortOrder: 40), + new CoreSettingDefinition('scheduler', 'scheduler.enabled', 'admin.settings.fields.scheduler_enabled.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 10), + new CoreSettingDefinition('scheduler', 'scheduler.get_auth_enabled', 'admin.settings.fields.scheduler_get_auth_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_get_auth_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 20), + new CoreSettingDefinition('scheduler', 'scheduler.package_action_queues_enabled', 'admin.settings.fields.scheduler_package_action_queues_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_package_action_queues_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 30), + new CoreSettingDefinition('scheduler', 'scheduler.web_trigger_enabled', 'admin.settings.fields.scheduler_web_trigger_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_web_trigger_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 40), ]; } diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index 92c0e438..bee9453d 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -6,6 +6,8 @@ use App\Api\ApiFeaturePolicy; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\ConfigDefaultProviderInterface; use App\Core\Config\ConfigValueType; use App\Core\Geo\MaxMindGeoIpConfig; @@ -64,6 +66,7 @@ public function configEntries(SetupInput $input): array ['key' => SchedulerSettings::GET_AUTH_ENABLED_KEY, 'value' => $this->setting($input, SchedulerSettings::GET_AUTH_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], ['key' => SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY, 'value' => $this->setting($input, SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], ['key' => SchedulerSettings::WEB_TRIGGER_ENABLED_KEY, 'value' => $this->setting($input, SchedulerSettings::WEB_TRIGGER_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], + ['key' => AdminFeatureOverrideStore::CONFIG_KEY, 'value' => $this->setting($input, AdminFeatureOverrideStore::CONFIG_KEY, (new AdminFeatureDefaults())->overrides()), 'type' => ConfigValueType::Json], ]; } diff --git a/templates/backend/partials/forms/_dynamic.html.twig b/templates/backend/partials/forms/_dynamic.html.twig index dc0211ee..5f7cc9ca 100644 --- a/templates/backend/partials/forms/_dynamic.html.twig +++ b/templates/backend/partials/forms/_dynamic.html.twig @@ -6,9 +6,11 @@ {% include '@backend/partials/forms/_dynamic_fields.html.twig' with {fields: form.fields} only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with { - label: 'admin.settings.form.save'|trans, - variant: 'primary', - } only %} + {% if not form.metadata.read_only|default(false) %} + {% include '@backend/partials/forms/fields/submit.html.twig' with { + label: 'admin.settings.form.save'|trans, + variant: 'primary', + } only %} + {% endif %} {% endif %} diff --git a/templates/backend/partials/forms/_dynamic_fields.html.twig b/templates/backend/partials/forms/_dynamic_fields.html.twig index 9988ed57..4e989681 100644 --- a/templates/backend/partials/forms/_dynamic_fields.html.twig +++ b/templates/backend/partials/forms/_dynamic_fields.html.twig @@ -13,6 +13,7 @@ name: field.name, label: field_label, checked: field.value, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} @@ -25,6 +26,7 @@ options: field_options, required: field.required, attributes: field.attributes, + disabled: field.metadata.disabled|default(false), autocomplete_enabled: field.metadata.autocomplete_enabled|default(false), autocomplete_options: field.metadata.autocomplete_options|default({}), help: field_help, @@ -37,6 +39,7 @@ label: field_label, values: field.value is iterable ? field.value : [], options: field_options, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} @@ -48,6 +51,7 @@ value: field.value, required: field.required, attributes: field.attributes, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} @@ -69,6 +73,7 @@ value: field.value, required: field.required, attributes: field.attributes, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} diff --git a/templates/backend/partials/forms/fields/checkbox-group.html.twig b/templates/backend/partials/forms/fields/checkbox-group.html.twig index 8eee077e..d88f461a 100644 --- a/templates/backend/partials/forms/fields/checkbox-group.html.twig +++ b/templates/backend/partials/forms/fields/checkbox-group.html.twig @@ -12,6 +12,7 @@ name="{{ name }}[]" value="{{ option_value }}" {% if option_value in selected_values %}checked{% endif %} + {% if disabled|default(false) %}disabled{% endif %} > {{ option_label }} diff --git a/templates/backend/partials/forms/fields/input.html.twig b/templates/backend/partials/forms/fields/input.html.twig index db2acfe7..dcfbb958 100644 --- a/templates/backend/partials/forms/fields/input.html.twig +++ b/templates/backend/partials/forms/fields/input.html.twig @@ -7,6 +7,7 @@ name="{{ name }}" value="{{ value|default('') }}" {% if required|default(false) %}required{% endif %} + {% if disabled|default(false) %}disabled{% endif %} {{ html_attributes(attributes|default({})) }} > {% endset %} diff --git a/templates/backend/partials/forms/fields/select.html.twig b/templates/backend/partials/forms/fields/select.html.twig index 3e68ad9c..0d2f6403 100644 --- a/templates/backend/partials/forms/fields/select.html.twig +++ b/templates/backend/partials/forms/fields/select.html.twig @@ -9,6 +9,7 @@ class="system-backend-select" name="{{ name }}" {% if required|default(false) %}required{% endif %} + {% if disabled|default(false) %}disabled{% endif %} {{ html_attributes(attributes|default({})) }} {{ autocomplete_attributes }} > diff --git a/templates/backend/partials/forms/fields/textarea.html.twig b/templates/backend/partials/forms/fields/textarea.html.twig index 224ad311..cba868a7 100644 --- a/templates/backend/partials/forms/fields/textarea.html.twig +++ b/templates/backend/partials/forms/fields/textarea.html.twig @@ -6,6 +6,7 @@ name="{{ name }}" rows="{{ rows|default(6) }}" {% if required|default(false) %}required{% endif %} + {% if disabled|default(false) %}disabled{% endif %} {{ html_attributes(attributes|default({})) }} >{{ value|default('') }} {% endset %} diff --git a/templates/backend/partials/forms/fields/toggle.html.twig b/templates/backend/partials/forms/fields/toggle.html.twig index 6e9cba2d..1f357a97 100644 --- a/templates/backend/partials/forms/fields/toggle.html.twig +++ b/templates/backend/partials/forms/fields/toggle.html.twig @@ -9,6 +9,7 @@ name="{{ name }}" value="{{ value|default('1') }}" {% if checked|default(false) %}checked{% endif %} + {% if disabled|default(false) %}disabled{% endif %} > {{ label }} diff --git a/tests/Controller/ApiSettingsControllerTest.php b/tests/Controller/ApiSettingsControllerTest.php index 493b9d23..90f9e826 100644 --- a/tests/Controller/ApiSettingsControllerTest.php +++ b/tests/Controller/ApiSettingsControllerTest.php @@ -141,7 +141,9 @@ public function testGeoIpSettingsAreHiddenAndRejectedForDelegatedAdminApiKeys(): self::assertResponseIsSuccessful(); $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertNull($this->optionalResourceById($payload['data'], MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + $licenseKey = $this->resourceById($payload['data'], MaxMindGeoIpConfig::LICENSE_KEY_KEY); + self::assertSame('[protected]', $licenseKey['attributes']['value']); + self::assertTrue($licenseKey['attributes']['metadata']['read_only']); $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index c2db1520..228143f0 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Core\Config; use App\Api\ApiFeaturePolicy; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreConfigDefaultProvider; use App\Core\Config\Settings\CoreSettingsRegistry; @@ -128,6 +130,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((new AdminFeatureDefaults())->overrides(), $provider->defaultValue(AdminFeatureOverrideStore::CONFIG_KEY)); self::assertFalse($provider->hasDefault('security.captcha.preview')); self::assertNull($provider->defaultValue('security.captcha.preview')); } diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index b800beaa..c73e11fe 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Setup; use App\Api\ApiFeaturePolicy; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\ConfigDefaultProviderInterface; use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\DatabaseLogRetentionPolicy; @@ -38,6 +40,7 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]); + self::assertSame((new AdminFeatureDefaults())->overrides(), $settings[AdminFeatureOverrideStore::CONFIG_KEY]); } public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void @@ -90,6 +93,7 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( SchedulerSettings::GET_AUTH_ENABLED_KEY, SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY, SchedulerSettings::WEB_TRIGGER_ENABLED_KEY, + AdminFeatureOverrideStore::CONFIG_KEY, ]; $inputOnlyKeys = [ 'site.title', From 6dee2e67250fd6c99fe8505200b699b0c6016b3f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 21:55:16 +0200 Subject: [PATCH 071/119] Enforce admin ACL on package workflows --- src/Backend/BackendActions.php | 32 +++-- src/Backend/PackageAdminDetailProvider.php | 33 ++++- src/Controller/AdminPackageController.php | 89 +++++++++++++ src/Core/Package/Api/PackageApiHandler.php | 86 +++++++++++-- src/Core/Package/PackageAdminOverview.php | 33 ++++- .../Package/PackageLifecycleCleanupRunner.php | 32 ++++- .../Package/PackageLifecycleFinalizer.php | 4 + .../Package/PackageRegistrySyncFinalizer.php | 5 + .../PackageSettingsAdminFeatureProvider.php | 38 ++++++ .../PackageSettingsBackendViewProvider.php | 1 + src/Core/Package/ThemeAdminOverview.php | 42 ++++-- src/View/Twig/AdminViewTwigExtension.php | 120 +++++++++++++++++- templates/backend/admin/packages.html.twig | 12 +- .../backend/admin/packages/detail.html.twig | 12 +- .../admin/packages/lifecycle.html.twig | 35 +++-- .../admin/partials/_backend-actions.html.twig | 2 +- tests/Controller/ApiPackageControllerTest.php | 61 +++++++-- .../PackageLifecycleCleanupRunnerTest.php | 25 +++- 18 files changed, 581 insertions(+), 81 deletions(-) create mode 100644 src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php diff --git a/src/Backend/BackendActions.php b/src/Backend/BackendActions.php index d2a4058e..ca188ce1 100644 --- a/src/Backend/BackendActions.php +++ b/src/Backend/BackendActions.php @@ -10,7 +10,7 @@ use App\Core\Access\AccessLevel; use App\Core\Access\AccessMessageCode; use App\Core\Access\AccessMessageKey; -use App\Core\Access\AccessRule; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\Message; use App\Core\Operation\ActionQueue; use App\Core\Operation\Live\LiveOperationQueueFactory; @@ -38,6 +38,7 @@ public function __construct( private OperationExecutor $operationExecutor, private LiveOperationStarter $liveOperationStarter, private PhpCliBinaryManager $phpCliBinaryManager, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -55,33 +56,35 @@ public function definitions(array $ids = [], ?AccessActor $actor = null): array 'label_key' => 'admin.actions.package_discovery.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'admin.packages', ], self::ASSET_REBUILD => [ 'id' => self::ASSET_REBUILD, 'label_key' => 'admin.actions.asset_rebuild.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'admin.actions.maintenance', ], self::CACHE_CLEAR => [ 'id' => self::CACHE_CLEAR, 'label_key' => 'admin.actions.cache_clear.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'admin.actions.maintenance', ], self::GEOIP_DATABASE_UPDATE => [ 'id' => self::GEOIP_DATABASE_UPDATE, 'label_key' => 'admin.actions.geoip_database_update.label', 'variant' => 'secondary', 'live' => true, - 'access_feature' => 'backend.action.geoip_database_update', - 'access_configurable' => false, - 'access_rule' => AccessRule::from(AccessLevel::OWNER), + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, ], ]; if ([] === $ids) { return array_values(array_map( - $this->publicDefinition(...), + fn (array $definition): array => $this->publicDefinition($definition, $actor), array_filter( $definitions, fn (array $definition): bool => $this->definitionAllows($definition, $actor), @@ -94,7 +97,7 @@ public function definitions(array $ids = [], ?AccessActor $actor = null): array $definition = $definitions[$id] ?? null; if ($this->definitionAllows($definition, $actor)) { - $visible[] = $this->publicDefinition($definition); + $visible[] = $this->publicDefinition($definition, $actor); } } @@ -180,9 +183,9 @@ private function definitionAllows(?array $definition, AccessActor $actor): bool return false; } - $rule = $definition['access_rule'] ?? null; + $feature = $definition['access_feature'] ?? null; - return !$rule instanceof AccessRule || $rule->allows($actor); + return !is_string($feature) || $this->adminAcl->isVisible($feature, $actor); } /** @@ -190,9 +193,13 @@ private function definitionAllows(?array $definition, AccessActor $actor): bool * * @return array */ - private function publicDefinition(array $definition): array + private function publicDefinition(array $definition, AccessActor $actor): array { - unset($definition['access_rule']); + $feature = $definition['access_feature'] ?? null; + + if (is_string($feature)) { + $definition['disabled'] = !$this->adminAcl->isMutable($feature, $actor); + } return $definition; } @@ -208,7 +215,10 @@ private function actionAllows(string $action, AccessActor $actor): bool return true; } - return [] !== $this->definitions([$action], $actor); + $definitions = $this->definitions([$action], $actor); + $definition = $definitions[0] ?? null; + + return is_array($definition) && true !== ($definition['disabled'] ?? false); } /** diff --git a/src/Backend/PackageAdminDetailProvider.php b/src/Backend/PackageAdminDetailProvider.php index 39721c85..4cdb3863 100644 --- a/src/Backend/PackageAdminDetailProvider.php +++ b/src/Backend/PackageAdminDetailProvider.php @@ -4,10 +4,15 @@ namespace App\Backend; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Package\ExtensionPackageStatus; use App\Entity\ExtensionPackage; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; final readonly class PackageAdminDetailProvider { @@ -17,6 +22,8 @@ public function __construct( private PackageAdminFileReader $fileReader, private PackageAdminLinkResolver $linkResolver, private PackageDependencyLabelParser $dependencyLabelParser, + private Security $security, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -114,31 +121,38 @@ private function scopeRows(array $scopes): array */ private function actions(ExtensionPackage $package): array { + $state = $this->adminAcl->state('admin.packages', $this->actor()); + + if (!$state->isVisible()) { + return []; + } + $stateActions = match ($package->status()) { - ExtensionPackageStatus::Inactive => [$this->action($package, PackageLifecycleAdmin::ACTION_ACTIVATE, 'primary')], - ExtensionPackageStatus::Active => [$this->action($package, PackageLifecycleAdmin::ACTION_DEACTIVATE, 'secondary')], - ExtensionPackageStatus::Faulty => [$this->action($package, PackageLifecycleAdmin::ACTION_RESET_FAULT, 'secondary')], + ExtensionPackageStatus::Inactive => [$this->action($package, PackageLifecycleAdmin::ACTION_ACTIVATE, 'primary', $state)], + ExtensionPackageStatus::Active => [$this->action($package, PackageLifecycleAdmin::ACTION_DEACTIVATE, 'secondary', $state)], + ExtensionPackageStatus::Faulty => [$this->action($package, PackageLifecycleAdmin::ACTION_RESET_FAULT, 'secondary', $state)], ExtensionPackageStatus::Removed => [], }; $cleanupActions = ExtensionPackageStatus::Removed === $package->status() - ? [$this->action($package, PackageLifecycleAdmin::ACTION_PURGE, 'danger')] + ? [$this->action($package, PackageLifecycleAdmin::ACTION_PURGE, 'danger', $state)] : []; if (ExtensionPackageStatus::Removed !== $package->status()) { - $cleanupActions[] = $this->action($package, PackageLifecycleAdmin::ACTION_DELETE, 'danger'); + $cleanupActions[] = $this->action($package, PackageLifecycleAdmin::ACTION_DELETE, 'danger', $state); } return [...$stateActions, ...$cleanupActions]; } - private function action(ExtensionPackage $package, string $action, string $variant): array + private function action(ExtensionPackage $package, string $action, string $variant, AdminPermissionState $state): array { return [ 'id' => $action, 'label_key' => 'admin.packages.lifecycle.'.$this->actionKey($action).'.label', 'path' => $this->actionPath($package->packageName(), $action), 'variant' => $variant, + 'disabled' => !$state->isMutable(), ]; } @@ -172,4 +186,11 @@ private function statusTone(ExtensionPackageStatus $status): string }; } + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + } diff --git a/src/Controller/AdminPackageController.php b/src/Controller/AdminPackageController.php index 2846a519..c0671eb0 100644 --- a/src/Controller/AdminPackageController.php +++ b/src/Controller/AdminPackageController.php @@ -9,6 +9,11 @@ use App\Backend\BackendArea; use App\Backend\BackendMessageKey; use App\Backend\PackageLifecycleAdmin; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\Access\AccessMessageCode; +use App\Core\Access\AccessMessageKey; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationHttpResponder; @@ -16,6 +21,7 @@ use App\Core\Operation\Live\LiveOperationStarter; use App\Core\Package\Install\PackageZipInstaller; use App\Core\Workflow\WorkflowResult; +use App\Entity\UserAccount; use App\Form\FormTokenValidator; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; @@ -29,6 +35,8 @@ final class AdminPackageController extends AbstractController { + private const PACKAGE_LIFECYCLE_FEATURE = 'admin.packages'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -40,6 +48,7 @@ public function __construct( private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, private readonly WorkflowResultAlertSelector $alertSelector, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -52,6 +61,18 @@ public function install(Request $request): Response return $access; } + if (!$this->adminAcl->isMutable(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { + $result = $this->accessDeniedResult('package_install'); + + if ('1' === $this->stringField($request, '_operation_live')) { + return $this->liveOperationResponder->render($result); + } + + $this->flashResult($result); + + return $this->redirect('/admin/packages'); + } + $validToken = $this->formTokenValidator->isValid('package-install', $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token')); if (!$validToken) { @@ -115,6 +136,14 @@ public function detail(Request $request, string $packageName): Response return $access; } + if (!$this->adminAcl->isVisible(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => BackendArea::Admin->value, + 'package' => $packageName, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + ]); + } + if ($request->isMethod('POST')) { if ($this->backendActionResponder->supports($request)) { return $this->backendActionResponder->respond($request, $this->getUser()); @@ -151,6 +180,17 @@ public function lifecycle(Request $request, string $packageName, string $action) return $access; } + $lifecycleState = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor()); + + if (!$lifecycleState->isVisible()) { + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => BackendArea::Admin->value, + 'package' => $packageName, + 'action' => $action, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + ]); + } + if ($request->isMethod('POST') && $this->backendActionResponder->supports($request)) { return $this->backendActionResponder->respond($request, $this->getUser()); } @@ -166,6 +206,17 @@ public function lifecycle(Request $request, string $packageName, string $action) } if ($request->isMethod('POST')) { + if (!$lifecycleState->isMutable()) { + $result = $this->accessDeniedResult('package_lifecycle_'.$action); + $this->flashResult($result); + + if ('1' === $this->stringField($request, '_operation_live')) { + return $this->liveOperationResponder->render($result); + } + + return $this->redirect($request->getPathInfo()); + } + $formId = 'package-lifecycle-'.$action.'-'.$packageName; if (!$this->formTokenValidator->isValid($formId, $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token'))) { @@ -200,11 +251,16 @@ public function lifecycle(Request $request, string $packageName, string $action) 'area' => BackendArea::Admin, 'navigation' => $this->adminContext->navigation($request, $this->getUser()), 'review' => $review, + 'lifecycle_mutable' => $lifecycleState->isMutable(), ]); } private function handleLivePackageLifecycle(string $packageName, string $action): Response { + if (!$this->adminAcl->isMutable(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { + return $this->liveOperationResponder->render($this->accessDeniedResult('package_lifecycle_'.$action)); + } + $label = sprintf('Package %s %s', $packageName, $action); $result = $this->liveOperationStarter->start( LiveOperationQueueFactory::PACKAGE_LIFECYCLE, @@ -240,6 +296,39 @@ private function flashResult(WorkflowResult $result): void $this->alerts->addAlert($this->alertSelector->fromResult($result), UiAlertDelivery::Direct); } + /** + * @return WorkflowResult + */ + private function accessDeniedResult(string $capability): WorkflowResult + { + $actor = $this->actor(); + + return WorkflowResult::invalid([ + Message::warning( + AccessMessageCode::ACCESS_DENIED, + AccessMessageKey::ACCESS_DENIED, + [ + '%capability%' => $capability, + '%required_level%' => AccessLevel::OWNER, + '%actor_level%' => $actor->accessLevel(), + ], + [ + ...$actor->toContext(), + 'capability' => $capability, + 'required_access_level' => AccessLevel::OWNER, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + ], + ), + ]); + } + + private function actor(): AccessActor + { + $user = $this->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + private function stringField(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Core/Package/Api/PackageApiHandler.php b/src/Core/Package/Api/PackageApiHandler.php index 56e3959d..04042502 100644 --- a/src/Core/Package/Api/PackageApiHandler.php +++ b/src/Core/Package/Api/PackageApiHandler.php @@ -9,10 +9,13 @@ use App\Api\Admin\LiveOperationApiResourceFactory; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; +use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Backend\PackageLifecycleAdmin; +use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationQueueFactory; @@ -22,6 +25,8 @@ final readonly class PackageApiHandler implements ApiEndpointHandlerInterface { + private const PACKAGE_LIFECYCLE_FEATURE = 'admin.packages'; + public function __construct( private PackageApiReadModel $readModel, private PackageLifecycleAdmin $lifecycleAdmin, @@ -29,6 +34,7 @@ public function __construct( private LiveOperationApiResourceFactory $operationResources, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -44,6 +50,20 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + $actor = ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + if (!$this->adminAcl->isVisible(self::PACKAGE_LIFECYCLE_FEATURE, $actor)) { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => 'packagesRead', + ], [ + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + 'reason' => 'feature_hidden', + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } + $packageSlug = $this->packageSlugFromPath($request->getPathInfo()); $packageName = null === $packageSlug ? null : $this->readModel->packageNameForSlug($packageSlug); if (null !== $packageSlug && null === $packageName) { @@ -73,11 +93,29 @@ private function package(Request $request, string $packageName): Response return $this->notFound($request, $packageName); } - return $this->responder->data($this->packageResource($package)); + return $this->responder->data($this->packageResource($request, $package)); } private function lifecycle(Request $request, string $packageName, string $action): Response { + $actor = ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + $state = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $actor); + + if (!$state->isVisible()) { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => 'package'.str_replace(' ', '', ucwords(str_replace('-', ' ', $action))), + ], [ + 'package' => $packageName, + 'action' => $action, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + 'reason' => 'feature_hidden', + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } + $review = $this->lifecycleAdmin->review($packageName, $action); if (null === $review['package']) { return $this->notFound($request, $packageName); @@ -95,6 +133,21 @@ private function lifecycle(Request $request, string $packageName, string $action ]); } + if (!$state->isMutable()) { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => 'package'.str_replace(' ', '', ucwords(str_replace('-', ' ', $action))), + ], [ + 'package' => $packageName, + 'action' => $action, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + 'reason' => 'feature_read_only', + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } + if (!$this->reviewAllowsConfirmation($review)) { return $this->operationUnavailable($request, 'package'.str_replace(' ', '', ucwords(str_replace('-', ' ', $action))), [ 'package' => $packageName, @@ -132,10 +185,11 @@ private function lifecycle(Request $request, string $packageName, string $action * * @return array */ - private function packageResource(array $package): array + private function packageResource(Request $request, array $package): array { $packageName = (string) $package['package_name']; $packageSlug = $this->readModel->packageSlug($packageName); + $actor = ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); return [ 'type' => 'package', @@ -144,33 +198,41 @@ private function packageResource(array $package): array ...$package, 'package_slug' => $packageSlug, 'api_path' => '/api/v1/admin/packages/'.$packageSlug, - 'api_actions' => $this->apiActions($package['actions'] ?? [], $packageSlug), + 'api_actions' => $this->apiActionsForPackage($package, $packageSlug, $actor), ], ]; } /** - * @param mixed $actions + * @param array $package * * @return list> */ - private function apiActions(mixed $actions, string $packageSlug): array + private function apiActionsForPackage(array $package, string $packageSlug, AccessActor $actor): array { - if (!is_array($actions)) { + $state = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $actor); + + if (!$state->isVisible() || true === ($package['immutable'] ?? false)) { return []; } + $status = (string) ($package['status'] ?? ''); + $actions = match ($status) { + 'inactive' => ['activate', 'delete'], + 'active' => ['deactivate', 'delete'], + 'faulty' => ['reset-fault', 'delete'], + 'removed' => ['purge'], + default => [], + }; + $resources = []; foreach ($actions as $action) { - if (!is_array($action) || !is_string($action['id'] ?? null)) { - continue; - } - $resources[] = [ - ...$action, + 'id' => $action, 'method' => Request::METHOD_POST, - 'api_path' => '/api/v1/admin/packages/'.$packageSlug.'/'.$action['id'], + 'api_path' => '/api/v1/admin/packages/'.$packageSlug.'/'.$action, 'requires_confirmation' => true, + 'disabled' => !$state->isMutable(), ]; } diff --git a/src/Core/Package/PackageAdminOverview.php b/src/Core/Package/PackageAdminOverview.php index 353175c1..a3c549c2 100644 --- a/src/Core/Package/PackageAdminOverview.php +++ b/src/Core/Package/PackageAdminOverview.php @@ -4,10 +4,15 @@ namespace App\Core\Package; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Package\Settings\PackageSettingRegistry; use App\Entity\ExtensionPackage; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; final readonly class PackageAdminOverview { @@ -15,6 +20,8 @@ public function __construct( private EntityManagerInterface $entityManager, private PackageSettingRegistry $settingRegistry, private SystemPackageMetadataProvider $systemPackageMetadata, + private Security $security, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -119,31 +126,38 @@ private function detailPath(string $packageName): string */ private function actions(ExtensionPackage $package): array { + $state = $this->adminAcl->state('admin.packages', $this->actor()); + + if (!$state->isVisible()) { + return []; + } + $stateActions = match ($package->status()) { - ExtensionPackageStatus::Inactive => [$this->action($package, 'activate', 'primary')], - ExtensionPackageStatus::Active => [$this->action($package, 'deactivate', 'secondary')], - ExtensionPackageStatus::Faulty => [$this->action($package, 'reset-fault', 'secondary')], + ExtensionPackageStatus::Inactive => [$this->action($package, 'activate', 'primary', $state)], + ExtensionPackageStatus::Active => [$this->action($package, 'deactivate', 'secondary', $state)], + ExtensionPackageStatus::Faulty => [$this->action($package, 'reset-fault', 'secondary', $state)], ExtensionPackageStatus::Removed => [], }; $cleanupActions = ExtensionPackageStatus::Removed === $package->status() - ? [$this->action($package, 'purge', 'danger')] + ? [$this->action($package, 'purge', 'danger', $state)] : []; if (ExtensionPackageStatus::Removed !== $package->status()) { - $cleanupActions[] = $this->action($package, 'delete', 'danger'); + $cleanupActions[] = $this->action($package, 'delete', 'danger', $state); } return [...$stateActions, ...$cleanupActions]; } - private function action(ExtensionPackage $package, string $action, string $variant): array + private function action(ExtensionPackage $package, string $action, string $variant, AdminPermissionState $state): array { return [ 'id' => $action, 'label_key' => 'admin.packages.lifecycle.'.str_replace('-', '_', $action).'.label', 'path' => $this->detailPath($package->packageName()).'/'.$action, 'variant' => $variant, + 'disabled' => !$state->isMutable(), ]; } @@ -166,4 +180,11 @@ private function statusTone(ExtensionPackageStatus $status): string ExtensionPackageStatus::Faulty => 'error', }; } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } } diff --git a/src/Core/Package/PackageLifecycleCleanupRunner.php b/src/Core/Package/PackageLifecycleCleanupRunner.php index e6e3852d..f1d26f60 100644 --- a/src/Core/Package/PackageLifecycleCleanupRunner.php +++ b/src/Core/Package/PackageLifecycleCleanupRunner.php @@ -4,6 +4,8 @@ namespace App\Core\Package; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminFeatureRegistry; use App\Core\Message\Message; use App\Core\Message\MessageLevel; use App\Core\Package\PackageMessageCode; @@ -13,8 +15,11 @@ final readonly class PackageLifecycleCleanupRunner implements PackageLifecycleCleanupRunnerInterface { - public function __construct(private Settings\PackageSettings $packageSettings) - { + public function __construct( + private Settings\PackageSettings $packageSettings, + private AdminFeatureOverrideStore $adminFeatureOverrideStore, + private ?AdminFeatureRegistry $adminFeatureRegistry = null, + ) { } /** @@ -32,6 +37,15 @@ public function cleanup(ExtensionPackage $package): WorkflowResult ]; } + if ($this->removePackageAclOverride($package->packageName())) { + $actions[] = [ + 'action' => 'delete_package_acl_override', + 'count' => 1, + ]; + } + + $this->adminFeatureRegistry?->resetCache(); + return WorkflowResult::success([ 'package' => $package->packageName(), 'actions' => $actions, @@ -48,4 +62,18 @@ public function cleanup(ExtensionPackage $package): WorkflowResult ), ]); } + + private function removePackageAclOverride(string $packageName): bool + { + $feature = 'admin.settings.packages.'.$packageName; + $overrides = $this->adminFeatureOverrideStore->overrides(); + + if (!isset($overrides[$feature])) { + return false; + } + + unset($overrides[$feature]); + + return $this->adminFeatureOverrideStore->save($overrides, 'package_lifecycle_cleanup'); + } } diff --git a/src/Core/Package/PackageLifecycleFinalizer.php b/src/Core/Package/PackageLifecycleFinalizer.php index 6397ca0f..5f428439 100644 --- a/src/Core/Package/PackageLifecycleFinalizer.php +++ b/src/Core/Package/PackageLifecycleFinalizer.php @@ -4,6 +4,7 @@ namespace App\Core\Package; +use App\Core\AdminAcl\AdminFeatureRegistry; use App\Core\Message\Message; use App\Core\Package\PackageMessageCode; use App\Core\Package\PackageMessageKey; @@ -16,6 +17,7 @@ public function __construct( private EntityManagerInterface $entityManager, private PackageLifecycleStore $store, private PackageLifecycleAssetRebuilderInterface $assetRebuilder, + private ?AdminFeatureRegistry $adminFeatureRegistry = null, ) { } @@ -33,6 +35,7 @@ public function finalize(array $snapshots, array $changes, array $messages, stri } $this->entityManager->flush(); + $this->adminFeatureRegistry?->resetCache(); if (!$rebuildAssets) { return $this->success($changes, false, false, $messages); @@ -55,6 +58,7 @@ public function finalize(array $snapshots, array $changes, array $messages, stri $this->store->restoreStatuses($snapshots); $this->entityManager->flush(); + $this->adminFeatureRegistry?->resetCache(); return WorkflowResult::failed($rebuild->issues(), [ 'changes' => $changes, diff --git a/src/Core/Package/PackageRegistrySyncFinalizer.php b/src/Core/Package/PackageRegistrySyncFinalizer.php index 3c9df984..711e82c9 100644 --- a/src/Core/Package/PackageRegistrySyncFinalizer.php +++ b/src/Core/Package/PackageRegistrySyncFinalizer.php @@ -4,6 +4,7 @@ namespace App\Core\Package; +use App\Core\AdminAcl\AdminFeatureRegistry; use App\Core\Message\Message; use App\Core\Message\MessageLevel; use App\Core\Workflow\WorkflowResult; @@ -16,6 +17,7 @@ public function __construct( private ?PackageAssetRebuildDispatcher $assetRebuildDispatcher = null, private ?PackageLifecycleAssetRebuilderInterface $assetRebuildFallback = null, private string $environment = 'test', + private ?AdminFeatureRegistry $adminFeatureRegistry = null, ) { } @@ -29,6 +31,9 @@ public function __construct( public function finalize(array $changes, array $messages, array $assetRebuildTriggers): WorkflowResult { $this->entityManager->flush(); + if ([] !== $changes) { + $this->adminFeatureRegistry?->resetCache(); + } $assetRebuild = null; if ([] !== $assetRebuildTriggers) { diff --git a/src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php b/src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php new file mode 100644 index 00000000..7763de17 --- /dev/null +++ b/src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php @@ -0,0 +1,38 @@ + + */ + public function adminFeatures(): array + { + $features = []; + $sortOrder = 1000; + + foreach ($this->registry->packagesWithDefinitions() as $packageName => $metadata) { + $features[] = new AdminFeatureDefinition( + 'admin.settings.packages.'.$packageName, + (string) $metadata['label'], + 'admin.acl.features.admin_settings_package.description', + 'admin.acl.categories.package_settings', + defaultState: AdminPermissionState::Mutable, + sortOrder: $sortOrder++, + ); + } + + return $features; + } +} diff --git a/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php b/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php index b35daf25..de4df774 100644 --- a/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php +++ b/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php @@ -36,6 +36,7 @@ public function backendViews(): array context: [ 'package_name' => $packageName, 'description' => $metadata['description'], + 'access_feature' => 'admin.settings.packages.'.$packageName, ], ); ++$sortOrder; diff --git a/src/Core/Package/ThemeAdminOverview.php b/src/Core/Package/ThemeAdminOverview.php index 6e669bce..b8609755 100644 --- a/src/Core/Package/ThemeAdminOverview.php +++ b/src/Core/Package/ThemeAdminOverview.php @@ -4,15 +4,22 @@ namespace App\Core\Package; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Entity\ExtensionPackage; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; final readonly class ThemeAdminOverview { public function __construct( private EntityManagerInterface $entityManager, private SystemPackageMetadataProvider $systemPackageMetadata, + private Security $security, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -29,11 +36,12 @@ public function sections(): array private function section(string $key, PackageScope $scope, string $systemPath): array { + $state = $this->packageState(); $packages = $this->packages($scope); $activePackage = $this->activePackage($packages); $themes = [ - $this->systemThemeRow($scope, $systemPath, null === $activePackage, $activePackage), - ...array_map($this->packageRow(...), $packages), + $this->systemThemeRow($scope, $systemPath, null === $activePackage, $activePackage, $state), + ...array_map(fn (ExtensionPackage $package): array => $this->packageRow($package, $state), $packages), ]; return [ @@ -71,7 +79,7 @@ private function packages(PackageScope $scope): array return array_values($packages); } - private function packageRow(ExtensionPackage $package): array + private function packageRow(ExtensionPackage $package, AdminPermissionState $state): array { $metadata = $package->metadata(); $label = $this->metadataString($metadata, 'display_name') ?? $package->packageName(); @@ -92,11 +100,11 @@ private function packageRow(ExtensionPackage $package): array 'type_label_key' => 'admin.themes.type.package', 'type_tone' => 'neutral', 'version' => $package->installedVersion() ?? $package->manifestVersion(), - 'quick_action' => $this->quickAction($package), + 'quick_action' => $state->isVisible() ? $this->quickAction($package, $state) : null, ]; } - private function systemThemeRow(PackageScope $scope, string $path, bool $active, ?ExtensionPackage $activePackage): array + private function systemThemeRow(PackageScope $scope, string $path, bool $active, ?ExtensionPackage $activePackage, AdminPermissionState $state): array { $metadata = $this->systemPackageMetadata->metadata(); $version = $metadata['version'] ?? null; @@ -121,7 +129,7 @@ private function systemThemeRow(PackageScope $scope, string $path, bool $active, 'scope' => $scope->value, 'quick_action' => $active ? $this->disabledQuickAction('admin.themes.quick.active', 'secondary') - : (null === $activePackage ? null : $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($activePackage->packageName()).'/deactivate', 'primary')), + : (null === $activePackage || !$state->isVisible() ? null : $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($activePackage->packageName()).'/deactivate', 'primary', !$state->isMutable())), ]; } @@ -144,23 +152,23 @@ private function activePackage(array $packages): ?ExtensionPackage return null; } - private function quickAction(ExtensionPackage $package): ?array + private function quickAction(ExtensionPackage $package, AdminPermissionState $state): ?array { return match ($package->status()) { ExtensionPackageStatus::Active => $this->disabledQuickAction('admin.themes.quick.active', 'secondary'), - ExtensionPackageStatus::Inactive => $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($package->packageName()).'/activate', 'primary'), - ExtensionPackageStatus::Faulty => $this->linkedQuickAction('admin.themes.quick.repair', $this->detailPath($package->packageName()).'/reset-fault', 'secondary'), + ExtensionPackageStatus::Inactive => $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($package->packageName()).'/activate', 'primary', !$state->isMutable()), + ExtensionPackageStatus::Faulty => $this->linkedQuickAction('admin.themes.quick.repair', $this->detailPath($package->packageName()).'/reset-fault', 'secondary', !$state->isMutable()), ExtensionPackageStatus::Removed => null, }; } - private function linkedQuickAction(string $labelKey, string $path, string $variant): array + private function linkedQuickAction(string $labelKey, string $path, string $variant, bool $disabled = false): array { return [ 'label_key' => $labelKey, 'path' => $path, 'variant' => $variant, - 'disabled' => false, + 'disabled' => $disabled, ]; } @@ -203,4 +211,16 @@ private static function statusSort(ExtensionPackageStatus $status): int ExtensionPackageStatus::Removed => 3, }; } + + private function packageState(): AdminPermissionState + { + return $this->adminAcl->state('admin.packages', $this->actor()); + } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } } diff --git a/src/View/Twig/AdminViewTwigExtension.php b/src/View/Twig/AdminViewTwigExtension.php index 51b0e1f6..a4ff5e31 100644 --- a/src/View/Twig/AdminViewTwigExtension.php +++ b/src/View/Twig/AdminViewTwigExtension.php @@ -6,6 +6,8 @@ use App\Backend\BackendActions; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreSettingsRegistry; @@ -38,6 +40,7 @@ public function __construct( private readonly SystemPackageMetadataProvider $systemPackageMetadata, private readonly Security $security, private readonly RequestStack $requestStack, + private readonly ?AdminFeatureAccessPolicy $adminAcl = null, ) { } @@ -56,6 +59,9 @@ public function getFunctions(): array new TwigFunction('package_settings', $this->packageSettings(...)), new TwigFunction('package_settings_form', $this->packageSettingsForm(...)), new TwigFunction('package_setting_packages', $this->packageSettingPackages(...)), + new TwigFunction('admin_feature_state', $this->adminFeatureState(...)), + new TwigFunction('admin_feature_visible', $this->adminFeatureVisible(...)), + new TwigFunction('admin_feature_mutable', $this->adminFeatureMutable(...)), ]; } @@ -77,6 +83,33 @@ public function backendActions(array $ids = []): array return $this->backendActions->definitions($ids, $this->actor()); } + public function adminFeatureState(string $feature): string + { + if (null === $this->adminAcl) { + return AdminPermissionState::Mutable->value; + } + + return $this->adminAcl->state($feature, $this->actor())->value; + } + + public function adminFeatureVisible(string $feature): bool + { + if (null === $this->adminAcl) { + return true; + } + + return $this->adminAcl->isVisible($feature, $this->actor()); + } + + public function adminFeatureMutable(string $feature): bool + { + if (null === $this->adminAcl) { + return true; + } + + return $this->adminAcl->isMutable($feature, $this->actor()); + } + /** * @return list> */ @@ -90,6 +123,10 @@ public function themes(): array */ public function packageSettings(string $packageName): array { + if (!$this->packageFeatureVisible($packageName)) { + return []; + } + return $this->packageSettings->viewRows($packageName, $this->packageSettingRegistry); } @@ -121,6 +158,10 @@ public function footerCopyright(string $area = 'frontend'): string public function coreSettingsForm(string $section): array { $definitions = $this->coreSettingDefinitions($section); + $mutableDefinitions = array_values(array_filter( + $definitions, + fn (CoreSettingDefinition $definition): bool => $this->coreSettingMutable($definition, $this->actor()), + )); $values = []; $request = $this->requestStack->getCurrentRequest(); @@ -143,6 +184,9 @@ public function coreSettingsForm(string $section): array $values, $errors, $errors['__form'] ?? [], + metadata: [ + 'read_only' => [] === $mutableDefinitions, + ], )->toArray(); } @@ -154,11 +198,52 @@ private function coreSettingDefinitions(string $section): array $actor = $this->actor(); return array_values(array_filter( - $this->coreSettingsRegistry->definitions($section), - static fn (CoreSettingDefinition $definition): bool => $definition->allows($actor), + array_map( + fn (CoreSettingDefinition $definition): CoreSettingDefinition => $this->decorateCoreSettingDefinition($definition, $actor), + $this->coreSettingsRegistry->definitions($section), + ), + fn (CoreSettingDefinition $definition): bool => $this->coreSettingVisible($definition, $actor), )); } + private function decorateCoreSettingDefinition(CoreSettingDefinition $definition, AccessActor $actor): CoreSettingDefinition + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (!is_string($feature) || null === $this->adminAcl) { + return $definition; + } + + $state = $this->adminAcl->state($feature, $actor); + + return $definition->withMetadata([ + 'access_state' => $state->value, + 'disabled' => !$state->isMutable(), + ]); + } + + private function coreSettingVisible(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isVisible($feature, $actor); + } + + return $definition->allows($actor); + } + + private function coreSettingMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } + /** * @return array */ @@ -167,20 +252,35 @@ public function packageSettingsForm(string $packageName): array $request = $this->requestStack->getCurrentRequest(); $errors = $this->requestFormErrors($request); $fields = $this->packageSettings->formFields($packageName, $this->packageSettingRegistry); + $mutable = $this->packageFeatureMutable($packageName); $sensitiveKeys = $this->sensitiveFieldKeys($fields); $values = array_replace( array_fill_keys($sensitiveKeys, ''), $this->requestFormValues($request, $sensitiveKeys), ); - return $this->formBuilder->build( + $form = $this->formBuilder->build( 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($packageName)), $packageName, $fields, $values, $errors, $errors['__form'] ?? [], + metadata: [ + 'read_only' => !$mutable, + ], )->toArray(); + + if (!$mutable) { + foreach ($form['fields'] as $index => $field) { + if (is_array($field)) { + $metadata = is_array($field['metadata'] ?? null) ? $field['metadata'] : []; + $form['fields'][$index]['metadata'] = [...$metadata, 'disabled' => true]; + } + } + } + + return $form; } /** @@ -191,6 +291,10 @@ public function packageSettingPackages(): array $packages = []; foreach ($this->packageSettingRegistry->packagesWithDefinitions() as $packageName => $metadata) { + if (!$this->packageFeatureVisible($packageName)) { + continue; + } + $packages[] = [ 'package_name' => $packageName, 'label' => $metadata['label'], @@ -202,6 +306,16 @@ public function packageSettingPackages(): array return $packages; } + private function packageFeatureVisible(string $packageName): bool + { + return null === $this->adminAcl || $this->adminAcl->isVisible('admin.settings.packages.'.$packageName, $this->actor()); + } + + private function packageFeatureMutable(string $packageName): bool + { + return null === $this->adminAcl || $this->adminAcl->isMutable('admin.settings.packages.'.$packageName, $this->actor()); + } + private function defaultFooterCopyright(): string { $metadata = $this->systemPackageMetadata->metadata(); diff --git a/templates/backend/admin/packages.html.twig b/templates/backend/admin/packages.html.twig index 4a71d26f..ca6f46b9 100644 --- a/templates/backend/admin/packages.html.twig +++ b/templates/backend/admin/packages.html.twig @@ -12,11 +12,15 @@ {% block admin_body %}
+ {% set package_lifecycle_visible = admin_feature_visible('admin.packages') %} + {% set package_lifecycle_mutable = admin_feature_mutable('admin.packages') %} {% set backend_actions %}
- + {% if package_lifecycle_visible %} + + {% endif %} {% include '@backend/admin/partials/_backend-actions.html.twig' with { actions: backend_actions(['package_discovery']), class: 'system-backend-button-group-inline', @@ -32,6 +36,7 @@ {% include '@backend/partials/feedback/_message.html.twig' with {message: message} only %} + {% if package_lifecycle_visible and package_lifecycle_mutable %}
+ {% endif %}

{{ 'admin.packages.registry_title'|trans }}

diff --git a/templates/backend/admin/packages/detail.html.twig b/templates/backend/admin/packages/detail.html.twig index 52d0da43..eff1d14c 100644 --- a/templates/backend/admin/packages/detail.html.twig +++ b/templates/backend/admin/packages/detail.html.twig @@ -13,9 +13,15 @@ {% if package.actions is not empty %}
{% for action in package.actions %} - - {{ action.label_key|trans }} - + {% if action.disabled|default(false) %} + + {% else %} + + {{ action.label_key|trans }} + + {% endif %} {% endfor %}
{% endif %} diff --git a/templates/backend/admin/packages/lifecycle.html.twig b/templates/backend/admin/packages/lifecycle.html.twig index 57041467..77d1e234 100644 --- a/templates/backend/admin/packages/lifecycle.html.twig +++ b/templates/backend/admin/packages/lifecycle.html.twig @@ -52,23 +52,30 @@

{{ 'admin.packages.lifecycle.no_changes'|trans }}

{% endif %} -
- - - + {% if lifecycle_mutable|default(false) %} + + + + + + {{ 'admin.packages.lifecycle.cancel'|trans }} + +
+ {% else %} +

{{ 'admin.acl.read_only_action'|trans }}

{{ 'admin.packages.lifecycle.cancel'|trans }} - + {% endif %} {% else %} {% for issue in plan.issues|default([]) %}

{{ issue.translation_key|trans(issue.parameters) }}

diff --git a/templates/backend/admin/partials/_backend-actions.html.twig b/templates/backend/admin/partials/_backend-actions.html.twig index f6b33705..c858df78 100644 --- a/templates/backend/admin/partials/_backend-actions.html.twig +++ b/templates/backend/admin/partials/_backend-actions.html.twig @@ -13,7 +13,7 @@ - diff --git a/tests/Controller/ApiPackageControllerTest.php b/tests/Controller/ApiPackageControllerTest.php index 8d7c2337..1159da72 100644 --- a/tests/Controller/ApiPackageControllerTest.php +++ b/tests/Controller/ApiPackageControllerTest.php @@ -64,7 +64,7 @@ public function testPackagesReturnOverviewForAdminApiKeys(): void self::assertArrayNotHasKey('detail_path', $system['attributes']); } - public function testPackageDetailReturnsApiActionsForAdminApiKeys(): void + public function testPackageDetailReturnsDisabledLifecycleApiActionsForDelegatedAdminApiKeys(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apipkgdetail'); @@ -81,16 +81,33 @@ public function testPackageDetailReturnsApiActionsForAdminApiKeys(): void self::assertSame('api-package-detail', $payload['data']['attributes']['package_name']); self::assertSame('api-package-detail', $payload['data']['attributes']['package_slug']); self::assertSame('/api/v1/admin/packages/api-package-detail', $payload['data']['attributes']['api_path']); + $actions = array_column($payload['data']['attributes']['api_actions'], null, 'id'); + self::assertSame('/api/v1/admin/packages/api-package-detail/activate', $actions['activate']['api_path']); + self::assertTrue($actions['activate']['disabled']); + } + + public function testPackageDetailReturnsApiActionsForOwnerApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apipkgowner', accessLevel: AccessLevel::OWNER); + $this->upsertPackage('api-package-owner', ExtensionPackageStatus::Inactive); + + $client->request('GET', '/api/v1/admin/packages/api-package-owner', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); $actions = array_column($payload['data']['attributes']['api_actions'], 'api_path', 'id'); - self::assertSame('/api/v1/admin/packages/api-package-detail/activate', $actions['activate']); - self::assertSame('/api/v1/admin/packages/api-package-detail/delete', $actions['delete']); + self::assertSame('/api/v1/admin/packages/api-package-owner/activate', $actions['activate']); + self::assertSame('/api/v1/admin/packages/api-package-owner/delete', $actions['delete']); } - public function testPackageLifecycleActionReturnsReviewUntilConfirmed(): void + public function testPackageLifecycleActionReturnsReviewUntilConfirmedForOwnerApiKeys(): void { $client = self::createClient(); - $plainKey = $this->createPlainApiKey('apipkgwrite', ApiKeyStatus::ReadWrite); + $plainKey = $this->createPlainApiKey('apipkgwrite', ApiKeyStatus::ReadWrite, AccessLevel::OWNER); $this->upsertPackage('api-package-action', ExtensionPackageStatus::Inactive); $client->request('POST', '/api/v1/admin/packages/api-package-action/activate', server: [ @@ -105,6 +122,30 @@ public function testPackageLifecycleActionReturnsReviewUntilConfirmed(): void self::assertSame('confirm=true', $payload['data']['attributes']['confirm_parameter']); } + public function testPackageLifecycleConfirmationRejectsDelegatedAdminApiKeysByDefault(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apipkgdenied', ApiKeyStatus::ReadWrite); + $this->upsertPackage('api-package-denied', ExtensionPackageStatus::Inactive); + + $client->request('POST', '/api/v1/admin/packages/api-package-denied/activate', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('package_lifecycle_review', $payload['data']['type']); + + $client->request('POST', '/api/v1/admin/packages/api-package-denied/activate?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('api.operation_unavailable', $payload['error']['code']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } + public function testPackageLifecycleActionRequiresWriteApiKey(): void { $client = self::createClient(); @@ -146,13 +187,17 @@ public function testOpenApiIncludesPackagesEndpoint(): void ], $payload['tags']); } - private function createPlainApiKey(string $prefix, ApiKeyStatus $status = ApiKeyStatus::ReadOnly): string + private function createPlainApiKey( + string $prefix, + ApiKeyStatus $status = ApiKeyStatus::ReadOnly, + int $accessLevel = AccessLevel::ADMIN, + ): string { - $user = $this->createUserWithLevel(AccessLevel::ADMIN, $prefix.'user', 'current-password'); + $user = $this->createUserWithLevel($accessLevel, $prefix.'user', 'current-password'); $vault = self::getContainer()->get(ApiKeyVault::class); $plainKey = $vault->generatePlainKey($prefix); $apiKey = new ApiKey( - '68000000-0000-7000-8000-'.substr(md5($prefix.$status->value), 0, 12), + '68000000-0000-7000-8000-'.substr(md5($prefix.$status->value.(string) $accessLevel), 0, 12), $prefix, $vault->hmac($plainKey), $vault->encrypt($plainKey, $prefix), diff --git a/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php b/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php index 3af5a04b..b27d8808 100644 --- a/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php +++ b/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php @@ -4,6 +4,9 @@ namespace App\Tests\Core\Package; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminFeatureRegistry; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\ConfigValueType; use App\Core\Package\ExtensionPackageStatus; use App\Core\Package\PackageLifecycleCleanupRunner; @@ -18,10 +21,23 @@ public function testItDeletesPackageSettingsDuringCleanup(): void { self::bootKernel(); $settings = self::getContainer()->get(PackageSettings::class); - $runner = new PackageLifecycleCleanupRunner($settings); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + $registry = self::getContainer()->get(AdminFeatureRegistry::class); + self::assertInstanceOf(AdminFeatureRegistry::class, $registry); + $runner = new PackageLifecycleCleanupRunner($settings, $overrides, $registry); $settings->set('cleanup-module', 'display.mode', 'compact', ConfigValueType::String); $settings->set('neighbor-module', 'display.mode', 'comfortable', ConfigValueType::String); + $overrides->save([ + 'admin.settings.packages.cleanup-module' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.settings.packages.neighbor-module' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); $result = $runner->cleanup(new ExtensionPackage( '10000000-0000-7000-8000-000000000611', @@ -37,10 +53,17 @@ public function testItDeletesPackageSettingsDuringCleanup(): void 'action' => 'delete_package_settings', 'count' => 1, ], + [ + 'action' => 'delete_package_acl_override', + 'count' => 1, + ], ], $result->value()['actions']); self::assertSame('fallback', $settings->get('cleanup-module', 'display.mode', 'fallback')); self::assertSame('comfortable', $settings->get('neighbor-module', 'display.mode', 'fallback')); + self::assertArrayNotHasKey('admin.settings.packages.cleanup-module', $overrides->overrides()); + self::assertArrayHasKey('admin.settings.packages.neighbor-module', $overrides->overrides()); $settings->removePackage('neighbor-module'); + $overrides->save([], 'test'); } } From 434bf0feaacdae4262d0d833a9157e14b843b15b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 21:55:20 +0200 Subject: [PATCH 072/119] Invalidate admin ACL group cache on group changes --- src/Controller/AdminAclGroupController.php | 5 +++++ src/Security/AclGroupApplyService.php | 4 ++++ src/Security/Api/UserGroupApiHandler.php | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/src/Controller/AdminAclGroupController.php b/src/Controller/AdminAclGroupController.php index 38883bf6..4850f0e9 100644 --- a/src/Controller/AdminAclGroupController.php +++ b/src/Controller/AdminAclGroupController.php @@ -6,6 +6,7 @@ use App\Backend\AdminControllerContext; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Id\UuidFactory; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; @@ -42,6 +43,7 @@ public function __construct( private readonly LiveOperationHttpResponder $liveOperationResponder, private readonly UuidFactory $uuidFactory, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminFeatureAccessPolicy, ) { } @@ -140,6 +142,7 @@ public function delete(Request $request, string $identifier): Response $cleanupImpact = $this->aclGroupImpact->removeReferences($group); $this->entityManager->remove($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->adminContext->audit($this->getUser(), 'acl.group_deleted', [ 'group' => $group->identifier(), 'impact' => $cleanupImpact['summary'], @@ -174,6 +177,7 @@ private function createGroup(Request $request): void ); $this->entityManager->persist($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->adminContext->audit($this->getUser(), 'acl.group_created', ['group' => $group->identifier()]); $this->alertKey('success', 'admin.groups.created'); } catch (Throwable) { @@ -237,6 +241,7 @@ private function updateGroup(Request $request, AclGroup $group): ?Response $floorCleanup = $this->aclGroupImpact->removeBelowMinRoleReferences($group, $pending['min_role']); $group->changeMinRole($pending['min_role']); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->adminContext->audit($this->getUser(), 'acl.group_updated', [ 'group' => $group->identifier(), 'old_name' => $oldName, diff --git a/src/Security/AclGroupApplyService.php b/src/Security/AclGroupApplyService.php index 7c1b7cba..bc824fe5 100644 --- a/src/Security/AclGroupApplyService.php +++ b/src/Security/AclGroupApplyService.php @@ -5,6 +5,7 @@ namespace App\Security; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Message\MessageLevel; @@ -26,6 +27,7 @@ public function __construct( private EntityManagerInterface $entityManager, private AclGroupImpactService $impactService, private AdminUserAccessPolicy $policy, + private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, ) { } @@ -88,6 +90,7 @@ private function update(AclGroup $group, AccessActor $actor, array $payload): Wo $floorCleanup = $this->impactService->removeBelowMinRoleReferences($group, $minRole); $group->changeMinRole($minRole); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); return WorkflowResult::success([ 'group' => $group->identifier(), @@ -124,6 +127,7 @@ private function delete(AclGroup $group, AccessActor $actor): WorkflowResult $groupUid = $group->uid(); $this->entityManager->remove($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); return WorkflowResult::success([ 'group' => $identifier, diff --git a/src/Security/Api/UserGroupApiHandler.php b/src/Security/Api/UserGroupApiHandler.php index 2d4e7ec6..03144a75 100644 --- a/src/Security/Api/UserGroupApiHandler.php +++ b/src/Security/Api/UserGroupApiHandler.php @@ -14,6 +14,7 @@ use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Id\UuidFactory; use App\Core\Log\AuditLoggerInterface; use App\Core\Message\CommonMessageCode; @@ -45,6 +46,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, ) { } @@ -130,6 +132,7 @@ private function createGroup(Request $request): Response $group = new AclGroup($this->uuidFactory->generate(), $identifier, $name, (int) $minRole); $this->entityManager->persist($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->audit($request, 'acl.group_created', ['group' => $group->identifier()]); return $this->responder->data($this->readModel->resource($group, includeDetail: true), Response::HTTP_CREATED); @@ -172,6 +175,7 @@ private function updateGroup(Request $request, AclGroup $group): Response $floorCleanup = $this->impact->removeBelowMinRoleReferences($group, $pending['min_role']); $group->changeMinRole($pending['min_role']); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->audit($request, 'acl.group_updated', [ 'group' => $group->identifier(), 'old_name' => $oldName, @@ -215,6 +219,7 @@ private function deleteGroup(Request $request, AclGroup $group): Response $identifier = $group->identifier(); $this->entityManager->remove($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->audit($request, 'acl.group_deleted', [ 'group' => $identifier, 'impact' => $cleanupImpact['summary'], From da67688bcc7636ec2f6bc8996b2beda7cca24fe5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 21:55:28 +0200 Subject: [PATCH 073/119] Document admin ACL enforcement slice --- dev/CLASSMAP.md | 9 ++- dev/WORKLOG.md | 36 +++------ dev/WORKLOG_HISTORY.md | 5 ++ dev/draft/0.2.x-SecurityAccessControl.md | 4 +- dev/draft/0.2.x-SecurityHardeningPlan.md | 2 +- dev/draft/0.4.x-FrontendDeliveryCaching.md | 2 +- .../admin-acl-enforcement.md | 78 ++++++++++++++----- .../security-hardening/policy-defaults.md | 2 +- 8 files changed, 84 insertions(+), 54 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 6e98bab2..11ec0831 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -63,7 +63,7 @@ | 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 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, 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 package lifecycle review/confirmation endpoints that start LiveOperation runs. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.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` | | Package/user API | `App\Core\Package\Api\PackageApiEndpointProvider`, `App\Core\Package\Api\PackageApiHandler`, `App\Core\Package\Api\PackageApiNavigationHandler`, `App\Core\Package\Api\PackageApiReadModel`, package API contributions through `App\Core\Package\PackageContributions` and `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackagePathPatternScope`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel` | Provides package-owned endpoint/handler contributions below `/api/v1/packages/{package_slug}/...`, package read/navigation resources, package API path-pattern validation below the owned package namespace without top-level alternation escapes, user-facing self-service profile and own API-key resources with prefix validation before key material is generated, user detail updates for one role plus multiple groups, ACL group CRUD with impact review and optional LiveOperation execution, membership relationship mutations, registration/invitation approval/reissue/denial actions, and disputed-account security-review confirm/deny actions. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageApiContributionGuardTest.php` | | Service | `App\Core\Lint\CssLinter` | Reusable string-based CSS syntax linter using the strict Sabberworm CSS parser. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | @@ -86,6 +86,7 @@ | Enum | `App\Core\Access\AccessLevel` | Shared 0-9 access-level constants and validation for public, user, moderator, author, publisher, curator, manager, director, admin, and owner role tiers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\Access\AccessResolver` | Resolves inherited level-plus-group ACL rules for actors and capabilities. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php` | | Value object | `App\Core\Access\AccessRule` | Value object for explicit or inherited min-level plus group ACL rules, including the shared stored-group identifier normalization used by content, schema, and menu ACL fields. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Core/Access/AccessRuleTest.php` | +| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, and denied/visible/mutable state evaluation used by settings forms, backend actions, package/theme UI/API callers, dynamic package settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | | Interface | `App\Security\AccessLevelAwareUserInterface` | Symfony user bridge for security users that expose the project's role-derived ACL access level. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Enum | `App\Security\UserRole` | Global account role enum that maps one exact user role to inherited Symfony roles and numeric ACL access levels. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserGroupMembershipManager`, `App\Security\AccountReactivationAccessResolver` | Shared user-group membership and deleted-account reactivation helpers used by admin and account-link flows so controllers do not duplicate group replacement, existing-group filtering, or role-preserving reactivation decisions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | @@ -97,12 +98,12 @@ | Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Service | `App\Backend\BackendAccessGuard` | Converts the current Symfony user into an access actor and checks backend area access through the shared ACL resolver. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | -| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and action-level access metadata for Owner-only operations. | `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` | -| Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | +| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and optional Admin ACL feature metadata so visible-only actions render disabled while backend execution still rechecks mutability. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | +| Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions; package lifecycle callers are now gated by the thematic `admin.packages` Admin ACL feature before UI action rendering, API confirmation, or LiveOperation start. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the first Admin Settings tree plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | | Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Own focused Admin package install/detail/lifecycle and Admin Operations maintenance/detail/continuation routes that previously lived in the dynamic backend dispatcher. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index b44bc58a..a7e41311 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -78,32 +78,16 @@ ## 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-abuse-foundation -- Started the Abuse Foundation branch by compacting the completed GeoIP observability notes into `dev/WORKLOG_HISTORY.md` and refreshing the Security hardening drafts/project rules for the next implementation slice. -- Expanded the slice to include parallel database log projections for message, audit, and access logs while retaining the 30-day rotating file logs as the raw fallback. Added policy-bounded retention settings/defaults, `security_signal_event` passive signal storage, DB-backed Admin/API log browsing with UUID detail links, source tabs, broad hidden-field search, and source-specific filters where `DEBUG`/`INFO` are hidden by default only for level-aware sources. -- Updated the Security hardening master plan, Abuse Foundation detail plan, Logging draft, policy defaults, class map, translations, migration baseline, and setup/default settings coverage for the new logging projection and passive-signal scope. -- Follow-up for `feat-security-admin-acl-enforcement`: add explicit Owner/ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions instead of relying only on broad Admin Logs access. -- Scope guard: trusted proxy handling stays in deployment/webserver configuration; Abuse Foundation uses Symfony's resolved request client IP for Security identity, may use raw forwarding headers only as untrusted Visitor-ID differentiation entropy, and keeps IP-ban thresholds laxer than Visitor-ID thresholds to reduce shared/untrusted-network false positives. -- Follow-up for Editor/Content: show a non-blocking warning when a content route or slug would match a configured suspicious probe path so editors can avoid accidental honeypot/probe namespace collisions. -- Implemented the Visitor-ID entropy half of that policy by mixing normalized forwarding-header candidates into cookie-less fallback visitor hashes only; Security identity, GeoIP, ban keys, and signal evidence still use Symfony's resolved client IP rather than raw proxy headers. -- Added the passive Abuse Foundation facade, subject resolver, request-intent classifier, suspicious-probe matcher, and symbolic action-cost catalogue. These expose visitor/user/API/IP-bucket subjects, `/api/live/**`, prefetch, CORS preflight, scheduler/setup/admin/API intents, and cost metadata for later rate/ban branches without enforcing limits yet. -- Added best-effort passive signal recording for high-signal probes and unsafe prefetch attempts. Signals carry Visitor-ID plus IP-bucket HMAC context when available and never store raw proxy-header values. -- Made suspicious probe path patterns configurable as an editable line-based Security setting with CSV-tolerant parsing, protected high-signal defaults, invalid-pattern fallback, setup seed coverage, translations, and focused matcher tests. -- Added high-risk passive security-signal recording for enforced session/visitor mismatches while preserving the existing forced logout and audit behavior. Complete copied-session plus copied-visitor-cookie risk scoring remains a later Security/remember-me follow-up. -- Clarified the rate-enforcement handoff for HTTP security headers: rate/recovery/error responses own tested `no-store`, while the full CSP/frame/referrer/permissions/header policy remains a dedicated response-hardening/frontend-delivery follow-up if still deferred. -- Switched passive security-signal expiry and cleanup to Symfony Clock so retention behavior is deterministic in tests and matches the Abuse Foundation time-boundary plan. -- Hardened PR-readiness findings before final checks: database log projection retention now uses Symfony Clock, and the Admin Logs OpenAPI enum documents the database-backed sources including `security_signal`. -- Reintroduced the Symfony `application` log as an explicit file-backed Admin/API source while keeping message, audit, access, and security-signal browsing database-backed; application detail links use the existing synthetic file-line hash IDs because Symfony Monolog lines do not carry database UUIDs. -- #57 audit follow-up applied before final review: split hybrid log browsing into an `AdminLogBrowser` facade so `DatabaseLogBrowser` remains responsible only for database-backed projections and the file-backed `application` source keeps its own parser boundary. -- Addressed Cloud Review follow-ups: database log searches now cast JSON context before matching for PostgreSQL portability, log page sizes use an explicit 500-row maximum with pagination instead of the misleading `all` option, expired security signals are filtered from list/detail reads, Admin package/user reset routes classify as package/ACL mutations before broad public password-reset keywords, and the Visitor-ID forwarding-entropy policy is covered with a stable IP-bucket regression test. -- Addressed additional Cloud Review follow-ups: database and file-backed log browsing now clamp the effective page before fetching rows, database log reads honor configured message/audit/access retention settings in addition to purge-after-write cleanup, DB-backed free-text search is case-insensitive across supported databases, and setup apply has its own higher-cost symbolic action bucket. -- Clarified follow-on enforcement policy: configurable limiter, escalation, review, and ban-decision windows must reject, clamp, or diagnose values that exceed the retention of the underlying signals, projected logs, IP-derived evidence, or audit context they need. -- Addressed the next review pass with the simplified shared signal-retention policy: removed the separate IP-signal retention setting, kept per-log database retention under a dedicated Log Settings section, filtered projected-log detail reads by configured retention, streamed file-log pagination instead of buffering all matches, sanitized tokenized paths before writing passive signals, and tightened request family/intent classification to locale-aware exact route/segment boundaries. -- Follow-up self-review hardening: security-signal reads now also respect the current shared signal-retention setting when operators lower it after rows were written, and public/content POST fallbacks are documented and tested as the dedicated `website_form`/`FormSubmit` bucket for future package-owned forms such as comments or forum posts. -- Addressed the latest review pass: suspicious probe-pattern parsing now preserves commas inside one-line regex syntax and only treats quoted comma lines as CSV imports, locale stripping is gated by actual `_locale` route attributes or enabled content route prefixes, mutating `/api/v1/admin/**` requests classify as Admin mutations before generic API writes, and normalized probe patterns use a short Symfony cache with settings-save invalidation plus a follow-up note for the unified caching strategy. -- Addressed the access-surface locale follow-up by moving `AccessRequestMetadata` to the same content-route localization gate as the intent classifier, so access logs, DB log projections, and access-statistic rows do not classify public `/de/admin`-style content paths as Admin/API surfaces while route prefixes are disabled. -- Addressed the current review pass: Admin log tab/pagination links now preserve only source-neutral or visible filters, file/database log browsers sanitize unsupported source-specific filters server-side, database log search escapes SQL `LIKE` wildcards literally, and request-intent classification no longer invents special Contact/Captcha path intents for valid public content slugs. Reviewed adjacent query-parameter consumers and found no comparable hidden source-switch filter surface outside Admin Logs. -- Final verification: `bin/phpunit` passed with 1378 tests and 8773 assertions; `bin/jstest` passed with 37 tests; `bin/lint` passed all checks including container, Twig, translation keys, Tailwind, Markdown, and Git whitespace. `git diff --check feat-security...HEAD` only reports intentional Markdown metadata hardbreaks that project lint accepts. +### 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. +- 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`. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index 7a911102..6c9b638f 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -9,6 +9,11 @@ Move completed branch or PR logs from `dev/WORKLOG.md` into this file when switching branches or after a PR is merged. Keep the active worklog focused on the current branch so reviewers can see the full PR context while older project history stays available. ## Archived Branches +### 2026-06-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. +- Closed the branch with full verification: `bin/phpunit`, `bin/jstest`, and `bin/lint` passed, with only intentional Markdown metadata hardbreaks reported by raw Git whitespace checks. + ### 2026-06-15 to 2026-06-16 feat-security-geoip-observability - Implemented the GeoIP observability slice: provider-neutral resolver boundary, MaxMind/GeoIP2 local database provider, protected Statistics settings, safe provider diagnostics, Statistics/Admin status rendering, explicit setup defaults, manual Operations-backed database downloads, scheduler callable, and access log/statistics enrichment while preserving `n/a` fallbacks. - Hardened GeoIP secrets, file handling, and portability through review rounds: no real MaxMind credentials in tests, no logged license-key URLs, project-relative `var/geoip2/GeoLite2-City.mmdb` path, Windows/path traversal rejection, compressed TAR validation, unsafe archive-member rejection, symlink/hardlink rejection, atomic replacement with readable permissions, streamed downloads, unsupported non-City database rejection, and bounded location labels for strict SQL platforms. diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 58fd7f2e..b755994f 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -91,7 +91,7 @@ Captcha should use a global form field integration. When a workflow includes the - Owners may manage all roles and groups. Admins may enter Admin but cannot manage peer Admin users, Owner users, or groups at or above their own role level. - Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. - Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency operational controls. -- Treat this matrix as code-owned defaults first. A later Owner-only configuration UI may delegate selected Admin actions only through bounded descriptors, audit, safe fallback, and tests; it must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, or remove Owner recovery protections. +- Treat this matrix as a code-owned registry plus seeded bounded Owner overrides for descriptor-approved rows. The Owner-gated `Settings/ACL` view may delegate selected Admin denied, visible, or mutable permissions and may add optional ACL-group states after the Admin surface gate is satisfied. Matching group states override the role/default state and may grant or restrict access; multiple matching groups resolve to the highest explicit group state. The matrix must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, bypass the Admin/Editor surface gates, or remove Owner recovery protections. - Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. @@ -167,7 +167,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** Require module-declared permissions before module admin routes become active. - **Decision recorded:** Split operational route surfaces into `admin/` for system administration and `editor/` for content authoring, review, publishing, and content-management workflows. `admin/` starts at Admin, while `editor/` starts at Author and should use ACL capability checks so authors, managers, and administrators can access only the authoring functions they are allowed to use. - **Decision recorded:** Backend area checks expose an `AccessRule` boundary so future admin exceptions can add configured ACL groups without changing controller flow. The first router slice keeps admin at the Admin role boundary and keeps Owner as the unrestricted site-owner role. -- **Decision recorded:** Admin and Owner are separate operational authority levels. Admins are delegated operators; Owners control site-wide and recovery-sensitive actions. High-impact Admin features must use an action authority matrix enforced in the service/API/live-operation boundary, not only route prefixes or hidden navigation. +- **Decision recorded:** Admin and Owner are separate operational authority levels. Admins are delegated operators; Owners control site-wide and recovery-sensitive actions. High-impact Admin features must use an action authority matrix enforced in the service/API/live-operation boundary, not only route prefixes or hidden navigation. Feature/action permissions are grouped by Admin, Editor, and Frontend surfaces; this branch implements the Owner-gated Admin matrix, prepares the shared descriptor model for later Editor use, and reserves Frontend only for explicitly designed future features. - **Decision recorded:** Editing or activating database-backed schema Twig and content-query/list fields is security-sensitive and requires explicit permissions plus audit logging. - **Decision recorded:** Content ACL restrictions may limit visibility to specific ACL groups and must be enforced consistently across rendering, API output, content-query/list fields, search, and import/export previews. - **Decision recorded:** Implement ACL foundations early, including `/admin` access protection and content-record ACL restrictions, to avoid later structural rewrites. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index fa9bb8c3..d4812f1a 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -132,7 +132,7 @@ Scope: Non-goals: -- No configurable Admin permission UI. +- No unbounded or package-marketplace-style Admin permission editor; this slice ships only the bounded Owner-gated `Settings/ACL` matrix for registered feature/action descriptors. - No package marketplace permission model. Acceptance: diff --git a/dev/draft/0.4.x-FrontendDeliveryCaching.md b/dev/draft/0.4.x-FrontendDeliveryCaching.md index 1ff3a068..17124690 100644 --- a/dev/draft/0.4.x-FrontendDeliveryCaching.md +++ b/dev/draft/0.4.x-FrontendDeliveryCaching.md @@ -44,6 +44,6 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - **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. -- **Open:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache, 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:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache and Admin ACL feature/override/group matrix caches, when the unified caching strategy is implemented. Move them to the shared cache namespace/invalidation model if that produces clearer ownership, diagnostics, or operational controls. - **Open:** Decide whether the first release needs persisted snapshot artifacts or only cache-backed read models. - **Open:** Define default cache TTLs and invalidation namespaces after the first public rendering slice exists. diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index def28bac..f396479a 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -7,22 +7,30 @@ ## Goal -Introduce a shared Admin action authority policy that separates delegated Admin capabilities from Owner-only site-control actions across Admin UI, API handlers, live operations, scheduler/admin controls, and service-layer workflows. +Introduce a shared Admin action authority policy that separates delegated Admin capabilities from Owner-only site-control actions across Admin UI, API handlers, live operations, scheduler/admin controls, and service-layer workflows, then expose the resulting matrix through an Owner-gated `Settings/ACL` view. Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). -This branch should make it obvious which Admin features are operational delegation and which are site-control powers. The first implementation should ship safe code-owned defaults, then leave room for a later Owner-only configuration UI where that is useful and safe. +This branch should make it obvious which Admin features are operational delegation and which are site-control powers. The first implementation should ship safe code-owned defaults plus a bounded Owner-only configuration surface for the permissions explicitly marked configurable. -Longer term, Owner-facing ACL settings should expose a bounded configuration matrix with one row per protected feature/action: +Descriptors are intentionally domain-owned. Core provides the first lightweight registry/provider boundary so relevant domains can register thematic features and default states without every element or action becoming a new permission. UI controls, backend actions, API handlers, and other callers should attach a simple stable feature key only where granular gating is required. Generic infrastructure such as Live Operations stays ungated by default; the domain-specific caller that starts a sensitive operation enforces the feature key before queueing or confirming work. + +The first implementation caches the Admin ACL feature registry, configured overrides, and ACL-group availability through the same small Symfony cache pattern used by suspicious-probe matching: service-local memory, `cache.app` when available, a short 300-second TTL, safe fallback on cache failures, and explicit invalidation after matrix saves, ACL-group mutations, and package lifecycle or registry changes that affect dynamic package-settings rows. This is a feature-local performance guard for the matrix and permission checks, not the final cross-domain cache architecture. + +Owner-facing ACL settings should expose a bounded configuration matrix with one row per protected feature/action: | Column | Purpose | | --- | --- | -| Feature | Stable machine-readable action or feature identifier, for example `settings.statistics.geoip` or `backend.action.geoip_database_update`. | -| Required ACL | Default or configured `AccessRule`, expressed as a minimum access level and/or allowed ACL group. | -| Configurable | Whether Owners may change the required ACL in the ACL settings UI. | +| Feature | Stable machine-readable feature identifier, for example `admin.settings.statistics.geoip`, `admin.packages`, or `admin.settings.packages.{package_slug}`. | +| Surface | `admin`, `editor`, or `frontend`, used for grouping and for the non-bypassable surface gate. | +| Mode | Whether the permission controls hidden, read-only, or mutate behavior. | +| Required ACL | Default or configured `AccessRule`, expressed as a minimum access level plus optional ACL groups. | +| Configurable | Whether Owners may change the required ACL in the ACL settings UI. Non-configurable rows stay visible read-only for transparency. | The same matrix must feed Admin UI visibility, Editor UI visibility where applicable, API handlers, live operations, scheduler/admin triggers, and service-layer mutation checks. Navigation remains only a projection of the policy; backend enforcement stays authoritative. +The Admin surface gate remains the Admin access level. Optional ACL groups may define explicit per-feature states after the surface gate is satisfied. If a matching group state exists, the group state overrides the role/default state; with several matching groups, the highest explicit group state wins. This allows groups to grant more access than the role/default state or deliberately restrict a user below the role/default state. Groups must not let a user bypass the Admin or Editor surface gate itself. For Admin ACL granularity, configurable rows may delegate selected denied/visible/mutable permissions to `ROLE_ADMIN` users. Editor ACL granularity should reuse the same descriptor and evaluation model later, but it will cover the Author, Publisher, Curator, Manager, Director, and Admin tiers. Frontend ACL granularity is not designed in this branch and should only be prepared as a future surface, not preimplemented for nonexistent features. + ## Git handling Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. @@ -37,15 +45,40 @@ Codex may create local commits for this branch when each commit has a clear them ## Implementation sequence 1. Inventory current Admin surfaces, including settings, users/groups, package/theme management, scheduler, operations, logs/audit, backups, diagnostics, API management, and future security settings. -2. Define stable Admin action identifiers grouped by domain, for example `system.admin.settings.security.update`, `system.admin.packages.activate`, or `system.admin.scheduler.web_trigger.update`. +2. Define stable Admin feature identifiers grouped by domain, using the surface prefix as the grouping source (`admin.*`, `editor.*`, `frontend.*`). 3. Add an Admin action catalogue with metadata: identifier, domain, title/description translation keys, default minimum role or ACL group rule, sensitivity, mutation/read flag, configurable flag, audit category, and optional confirmation requirement. 4. Add a shared Admin action authority policy service that evaluates actor access level, action identifier, target context, target subject, account status, and optional workflow metadata. 5. Encode the first static default matrix in code: delegated Admin read/mutate actions, Owner-only actions, and denied/unknown actions. -6. Add a narrow Owner-only configuration descriptor shape for later tuning, but do not build a broad permission UI unless this branch can keep validation, audit, docs, and rollback small enough for review. -7. Wire enforcement at service/API/live-operation boundaries for implemented high-impact Admin workflows, starting with the existing user-management policy as the model and avoiding duplicated controller-only checks. -8. Update Admin navigation/read models to hide or disable forbidden actions using the same policy, while keeping backend enforcement authoritative. -9. Add audit/message context for denied high-impact actions without leaking protected values or action internals. -10. Add extension points for future package-owned Admin actions only after core action identifiers and collision rules are stable. +6. Add the bounded Owner-only configuration descriptor and persistence model for configurable rows, including validation for allowed role range, optional ACL-group grants, corruption fallback, and audit. +7. Build the compact Owner-gated `Settings/ACL` matrix grouped by surface and feature area, with hidden/read-only/mutate state visible per row and disabled controls for non-configurable rules. +8. Wire enforcement at service/API/live-operation boundaries for implemented high-impact Admin workflows, starting with the existing user-management policy as the model and avoiding duplicated controller-only checks. +9. Update Admin navigation/read models to hide or disable forbidden actions using the same policy, while keeping backend enforcement authoritative. +10. Add audit/message context for denied high-impact actions without leaking protected values or action internals. +11. Add extension points for future package-owned Admin actions only after core action identifiers and collision rules are stable. + +## Implemented first feature keys + +| Feature key | Default | Configurable | Scope | +| --- | --- | --- | --- | +| `admin.settings.security` | Denied | No | Security settings area. | +| `admin.settings.logging` | Visible | Yes | Log retention settings. | +| `admin.settings.statistics` | Mutable | Yes | Statistics settings and statistics view. | +| `admin.settings.statistics.geoip` | Visible | Yes | GeoIP fields and update action, parent-gated by statistics. | +| `admin.settings.api` | Denied | Yes | API settings area. | +| `admin.settings.scheduler` | Visible | Yes | Scheduler settings area. | +| `admin.logs` | Visible | Yes | Admin log review area. | +| `admin.packages` | Visible | Yes | Package and theme management area, with mutating lifecycle/install/discovery disabled unless mutable. | +| `admin.backup_restore` | Visible | No | Backup/restore area; restore remains mutating. | +| `admin.packages.self_update` | Denied | No | System package self-update transparency row. | +| `admin.support` | Denied | No | Support bundle transparency row. | +| `admin.operations` | Visible | Yes | Operations view. | +| `admin.actions.maintenance` | Mutable | Yes | Cache clear and asset rebuild actions. | +| `admin.scheduler` | Visible | Yes | Scheduler operational area. | +| `admin.settings.packages` | Visible | Yes | Core package settings area. | +| `admin.settings.packages.{package_slug}` | Mutable | Yes | Active package-owned settings page, registered dynamically and removed from ACL config during package purge. | +| `admin.users` | Mutable | Yes | User administration. | +| `admin.users.acl` | Mutable | Yes | User ACL-group administration. | +| `admin.users.review` | Mutable | Yes | User review queues. | ## Default authority matrix @@ -76,9 +109,14 @@ The first matrix should use conservative defaults. "View" means the actor may op ## Configurability policy -- The first implementation should be code-owned and test-backed. This avoids shipping a confusing half-permission UI while the Admin surface is still changing. -- A later Owner-only settings UI may relax or tighten selected Admin and Editor capabilities only through bounded descriptors. Each configurable feature/action must define default `AccessRule`, allowed role/group range, whether it may be disabled, audit behavior, affected routes/API/live operations, and safe rollback. -- The Owner UI should display the matrix as `Feature`, `Required ACL`, and `Configurable`. Non-configurable rows may be visible for transparency, but their controls remain disabled with explanatory copy. +- The first implementation should keep descriptors code-owned and test-backed, while storing only bounded Owner overrides for rows marked configurable. +- The Owner-only settings UI may relax or tighten selected Admin capabilities only through bounded descriptors. Each configurable feature/action must define default `AccessRule`, allowed role/group range, optional ACL-group behavior, whether it may be disabled, audit behavior, affected routes/API/live operations, and safe rollback. +- The Owner UI should display the matrix as `Feature`, `Surface`, `Mode`, `Required ACL`, and `Configurable`. Non-configurable rows remain visible for transparency, but their controls remain disabled with explanatory copy. +- Feature flags and permissions must be grouped by surface: Admin, Editor, and Frontend. +- Admin ACL granularity may delegate configurable denied, visible, or mutable permissions to `ROLE_ADMIN` users through seeded Owner-controlled overrides. +- Editor ACL granularity should reuse the same descriptor model later with several role tiers: Author, Publisher, Curator, Manager, Director, and Admin. +- Frontend ACL granularity is only a reserved surface for special future features such as frontend inline editing. Do not add permission logic for nonexistent Frontend features in this branch. +- Optional ACL groups can explicitly override a configurable feature after the surface gate is satisfied. Group states may grant or restrict relative to the role/default state; with multiple matching groups, the highest explicit group state wins. - Some actions are not ordinary configurable settings: last-Owner protection, Owner recovery, protected secret redaction, privacy ceilings, raw-token exposure, `APP_SECRET` emergency handling, and unknown-action deny-by-default. - Owner configuration may delegate additional read or mutation actions to Admins, but it must not allow Admins to grant themselves Owner role, change Owner-only recovery/security boundaries, reveal secrets without a dedicated reveal flow, or bypass domain confirmations/audit. - Configured changes to Admin action authority must be audited with actor, old/new policy summary, affected action identifiers, and redacted context. @@ -87,8 +125,9 @@ The first matrix should use conservative defaults. "View" means the actor may op ## Public interfaces and data decisions - Admin action identifiers are stable, English, machine-readable strings and are not localized. -- Feature/action descriptors should expose enough metadata for a future matrix UI without making the first implementation database-configurable by default: stable feature key, default access rule, configurability flag, domain, sensitivity, and affected public entry points. -- The first matrix is code-owned and test-backed. Database-configurable Admin ACLs are a later Owner-only feature only if product need appears and the bounded descriptor model is implemented. +- Feature/action descriptors should expose enough metadata for the `Settings/ACL` matrix UI without making every action database-configurable by default: stable feature key, default access rule, configurability flag, domain, sensitivity, and affected public entry points. +- The first registry is code-owned and test-backed. Configurable defaults are seeded through `acl.admin.features`, while non-configurable rows remain hardcoded in the registry for transparency. Database-stored Admin ACL overrides are limited to descriptor-approved rows and must fall back safely to seeded or registry defaults. +- Registry definitions, configured overrides, and available ACL groups are cache-backed with explicit reset hooks. When the unified cache strategy exists, these keys should move into the shared namespace/diagnostics/invalidation model if that reduces operational ambiguity. - `Admin` is a delegated operations role. `Owner` remains the site-control role. - Owner-only defaults include protected secrets, Security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, full-data exports/downloads, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency global operational controls. - Delegated Admin defaults include normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, non-secret settings, user review queues, operational summaries, non-owner user management, ACL groups below the actor role level, bounded non-secret settings, and non-destructive cache/asset rebuilds where workflow policy allows them. @@ -124,7 +163,7 @@ The first matrix should use conservative defaults. "View" means the actor may op - Test live-operation queueing and continuation re-check authority. - Test denied actions produce stable redacted messages and audit context. - Test package-scoped action identifier validation rejects collisions with system actions. -- If configurability is implemented, test missing/corrupt/unknown configuration fallback, Owner-only mutation of the matrix, audit of matrix changes, and rejection of unsafe delegation. +- Test missing/corrupt/unknown configuration fallback, Owner-only mutation of the matrix, audit of matrix changes, optional ACL-group OR grants after surface gating, non-configurable read-only rows, and rejection of unsafe delegation. - Run focused controller/API/live-operation tests and `lint:container` when services are added. ## Documentation and tracking @@ -138,10 +177,11 @@ The first matrix should use conservative defaults. "View" means the actor may op ## Non-goals -- No full configurable Admin permission UI. +- No unbounded or package-marketplace-style permission editor. - No package permission marketplace or manifest permission model. - No replacement for content/editor ACL rules. - No weakening of existing Owner recovery and last-Owner protections. +- No Frontend ACL implementation beyond reserving the surface shape for future explicitly designed features. ## Acceptance criteria diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index e6cd671a..353dd377 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -218,7 +218,7 @@ These are first soft decisions for which values should stay fixed, become protec | 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 | | 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-level policy and tests | Possibly later as Owner-only bounded settings | Navigation is not enforcement; Owner-only actions require service/API/live-operation checks; unsafe delegation remains invalid | +| 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 | | Authenticated-user multiplier | Code/config default | Yes, bounded | Applies only to ordinary navigation/public-read usage, not explicit workflow buckets | | Owner ordinary-rate-limit exemption | Code-level policy and tests | No ordinary Admin setting | Does not bypass authentication, authorization, API-key revocation, CSRF, audit, or diagnostics | | Recovery-login bypass path and bucket | Code/config default | Path and thresholds may be bounded later | Must keep an equivalent Owner/Admin recovery path; bypass never skips credential, CSRF, audit, or failure accounting | From 5c31baec650ceaab03fd8c7f5f3039ebf9b3fc2d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 22:27:26 +0200 Subject: [PATCH 074/119] Enforce admin ACL on user workflows --- src/Controller/AdminAclGroupController.php | 51 +++++++++++- src/Controller/AdminUserController.php | 50 ++++++++++++ .../AdminUserInvitationController.php | 31 +++++++ src/Controller/AdminUserReviewController.php | 29 +++++++ src/Security/Api/UserApiHandler.php | 10 +++ src/Security/Api/UserGroupApiHandler.php | 22 +++++ .../Api/UserGroupMembershipApiHandler.php | 6 ++ src/Security/Api/UserReviewApiHandler.php | 18 +++++ .../backend/admin/users/deleted.html.twig | 6 +- .../backend/admin/users/detail.html.twig | 7 +- .../admin/users/group-detail.html.twig | 8 +- .../backend/admin/users/groups.html.twig | 8 +- templates/backend/admin/users/index.html.twig | 11 ++- .../backend/admin/users/reviews.html.twig | 14 ++-- templates/backend/components/Button.html.twig | 10 ++- .../backend/components/ButtonGroup.html.twig | 1 + .../partials/actions/_button.html.twig | 1 + .../partials/forms/fields/submit.html.twig | 1 + tests/Controller/AdminUserControllerTest.php | 75 +++++++++++++++++ tests/Controller/ApiUserControllerTest.php | 80 +++++++++++++++++++ 20 files changed, 409 insertions(+), 30 deletions(-) diff --git a/src/Controller/AdminAclGroupController.php b/src/Controller/AdminAclGroupController.php index 4850f0e9..27862a17 100644 --- a/src/Controller/AdminAclGroupController.php +++ b/src/Controller/AdminAclGroupController.php @@ -31,6 +31,8 @@ final class AdminAclGroupController extends AbstractController { + private const FEATURE = 'admin.users.acl'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -53,8 +55,15 @@ public function groups(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } if ($request->isMethod('POST')) { + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } + $this->createGroup($request); return $this->redirectToRoute('backend_admin_user_groups'); @@ -64,6 +73,7 @@ public function groups(Request $request): Response return $this->render('@backend/admin/users/groups.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'acl_mutable' => $this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), 'groups' => $groupsView['items'], 'groups_view' => $groupsView, ]); @@ -75,6 +85,9 @@ public function group(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $group = $this->groupByIdentifier($identifier); @@ -83,6 +96,10 @@ public function group(Request $request, string $identifier): Response } if ($request->isMethod('POST')) { + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } + if ($response = $this->updateGroup($request, $group)) { return $response; } @@ -92,6 +109,7 @@ public function group(Request $request, string $identifier): Response return $this->render('@backend/admin/users/group-detail.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'acl_mutable' => $this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), 'group' => $group, 'member_count' => $this->memberProvider->count($group), 'members' => $this->memberProvider->members($group), @@ -104,6 +122,9 @@ public function delete(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $group = $this->groupByIdentifier($identifier); @@ -136,7 +157,7 @@ public function delete(Request $request, string $identifier): Response } if ('1' === $this->field($request, '_operation_live')) { - return $this->startAclGroupLiveOperation($group, 'delete'); + return $this->startAclGroupLiveOperation($request, $group, 'delete'); } $cleanupImpact = $this->aclGroupImpact->removeReferences($group); @@ -231,7 +252,7 @@ private function updateGroup(Request $request, AclGroup $group): ?Response } if ('1' === $this->field($request, '_operation_live')) { - return $this->startAclGroupLiveOperation($group, 'update', $pending); + return $this->startAclGroupLiveOperation($request, $group, 'update', $pending); } try { @@ -262,8 +283,15 @@ private function updateGroup(Request $request, AclGroup $group): ?Response /** * @param array $payload */ - private function startAclGroupLiveOperation(AclGroup $group, string $action, array $payload = []): 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: [ + 'feature' => self::FEATURE, + 'required_state' => 'mutable', + ]); + } + $result = $this->liveOperationStarter->start( LiveOperationQueueFactory::ACL_GROUP_APPLY, [ @@ -285,6 +313,23 @@ private function startAclGroupLiveOperation(AclGroup $group, string $action, arr return $this->liveOperationResponder->render($result); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable + ? $this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $actor) + : $this->adminFeatureAccessPolicy->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + private function field(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Controller/AdminUserController.php b/src/Controller/AdminUserController.php index 94c19f97..51536523 100644 --- a/src/Controller/AdminUserController.php +++ b/src/Controller/AdminUserController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\State\StateMarkerRecorder; @@ -31,6 +32,8 @@ final class AdminUserController extends AbstractController { + private const FEATURE = 'admin.users'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -42,6 +45,7 @@ public function __construct( private readonly StateMarkerRecorder $stateMarkers, private readonly DeletedUserCleanup $deletedUserCleanup, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -51,11 +55,17 @@ public function users(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $usersView = $this->adminUserLists->usersView($request); + $mutable = $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())); return $this->render('@backend/admin/users/index.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'users_mutable' => $mutable, + 'reviews_mutable' => $this->adminAcl->isMutable('admin.users.review', $this->adminContext->actor($this->getUser())), 'users' => $usersView['items'], 'users_view' => $usersView, 'groups' => $this->assignmentOptions->groups($this->adminContext->actor($this->getUser()), UserRole::User), @@ -74,9 +84,13 @@ public function deletedUsers(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } return $this->render('@backend/admin/users/deleted.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'users_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), 'deleted_users' => $this->deletedUserCleanup->deletedUsers(), 'retention_days' => $this->deletedUserCleanup->retentionDays(), 'cleanup_cutoff' => $this->deletedUserCleanup->cutoff(), @@ -89,6 +103,9 @@ public function cleanupDeletedUsers(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } if (!$this->isCsrfTokenValid('admin_deleted_users_cleanup', $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -126,6 +143,9 @@ public function user(Request $request, string $username): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $user = $this->userByUsername($username); @@ -138,13 +158,20 @@ public function user(Request $request, string $username): Response } if ($request->isMethod('POST')) { + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } + $this->updateUser($request, $user); return $this->redirectToRoute('backend_admin_user_detail', ['username' => $user->username()]); } + $mutable = $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())); + return $this->render('@backend/admin/users/detail.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'users_mutable' => $mutable, 'user_account' => $user, 'groups' => $this->assignmentOptions->groups($this->adminContext->actor($this->getUser()), $user->role()), 'role_options' => $this->assignmentOptions->roles($this->adminContext->actor($this->getUser())), @@ -166,6 +193,9 @@ public function passwordReset(Request $request, string $username): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -231,6 +261,9 @@ private function changeDeletedUserStatus(Request $request, string $username, Use if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -262,6 +295,23 @@ private function changeDeletedUserStatus(Request $request, string $username, Use return $this->redirectToRoute('backend_admin_deleted_users'); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable + ? $this->adminAcl->isMutable(self::FEATURE, $actor) + : $this->adminAcl->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + private function userByUsername(string $username): ?UserAccount { $user = $this->entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); diff --git a/src/Controller/AdminUserInvitationController.php b/src/Controller/AdminUserInvitationController.php index 559750e1..dacc2110 100644 --- a/src/Controller/AdminUserInvitationController.php +++ b/src/Controller/AdminUserInvitationController.php @@ -7,6 +7,7 @@ use App\Backend\BackendAccessGuard; use App\Backend\BackendArea; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Entity\UserAccount; @@ -28,6 +29,7 @@ public function __construct( private readonly HttpErrorRenderer $httpError, private readonly AdminUserInvitationWorkflow $invitationWorkflow, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -37,6 +39,9 @@ public function invite(Request $request): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, 'admin.users')) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_invite', $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -58,6 +63,9 @@ public function approve(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, 'admin.users.review')) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -77,6 +85,9 @@ public function reissue(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, $this->tokenActionFeature($request))) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -96,6 +107,9 @@ public function revoke(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, $this->tokenActionFeature($request))) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -147,6 +161,23 @@ private function adminAccessResponse(Request $request): ?Response ]); } + private function featureResponse(Request $request, string $feature): ?Response + { + if ($this->adminAcl->isMutable($feature, $this->actor())) { + return null; + } + + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => $feature, + 'required_state' => 'mutable', + ]); + } + + private function tokenActionFeature(Request $request): string + { + return 'reviews' === $this->field($request, 'return_to') ? 'admin.users.review' : 'admin.users'; + } + private function actor(): AccessActor { $user = $this->getUser(); diff --git a/src/Controller/AdminUserReviewController.php b/src/Controller/AdminUserReviewController.php index 27151746..58974618 100644 --- a/src/Controller/AdminUserReviewController.php +++ b/src/Controller/AdminUserReviewController.php @@ -7,6 +7,7 @@ use App\Backend\BackendAccessGuard; use App\Backend\BackendArea; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Log\AuditLoggerInterface; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; @@ -53,6 +54,7 @@ public function __construct( private readonly UserPasswordHasherInterface $passwordHasher, private readonly StateMarkerRecorder $stateMarkers, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -62,11 +64,16 @@ public function reviews(Request $request): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $reviewView = $this->adminUserReviews->reviewView($request); + $mutable = $this->adminAcl->isMutable('admin.users.review', $this->actor()); return $this->render('@backend/admin/users/reviews.html.twig', [ 'navigation' => $this->navigation($request), + 'reviews_mutable' => $mutable, 'review_items' => $reviewView['items'], 'review_filter' => $reviewView['filters']['filter'], 'review_view' => $reviewView, @@ -80,6 +87,9 @@ public function reactivate(Request $request, string $username): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -126,6 +136,9 @@ public function delete(Request $request, string $username): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -186,6 +199,22 @@ private function adminAccessResponse(Request $request): ?Response ]); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $allowed = $mutable + ? $this->adminAcl->isMutable('admin.users.review', $this->actor()) + : $this->adminAcl->isVisible('admin.users.review', $this->actor()); + + if ($allowed) { + return null; + } + + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => 'admin.users.review', + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + /** * @return list> */ diff --git a/src/Security/Api/UserApiHandler.php b/src/Security/Api/UserApiHandler.php index 96e07edc..492bc3a5 100644 --- a/src/Security/Api/UserApiHandler.php +++ b/src/Security/Api/UserApiHandler.php @@ -8,6 +8,7 @@ use App\Api\ApiMessageKey; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Http\ApiJsonRequestParser; use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; @@ -34,6 +35,7 @@ public function __construct( private ApiJsonRequestParser $jsonRequests, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -49,6 +51,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.users', 'listAdminUsers')) { + return $denied; + } + $requestedUsername = $this->usernameFromPath($request->getPathInfo()); $user = null === $requestedUsername ? null : $this->user($requestedUsername); if (null !== $requestedUsername && null === $user) { @@ -70,6 +76,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function updateUser(Request $request, UserAccount $user): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users', 'updateAdminUser')) { + return $denied; + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { diff --git a/src/Security/Api/UserGroupApiHandler.php b/src/Security/Api/UserGroupApiHandler.php index 03144a75..3cd447f2 100644 --- a/src/Security/Api/UserGroupApiHandler.php +++ b/src/Security/Api/UserGroupApiHandler.php @@ -6,6 +6,7 @@ use App\Api\ApiMessageCode; use App\Api\ApiMessageKey; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; use App\Api\Http\ApiJsonRequestParser; @@ -47,6 +48,7 @@ public function __construct( private ApiAccessGuard $accessGuard, private ApiResponder $responder, private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -62,6 +64,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.users.acl', 'listAdminAclGroups')) { + return $denied; + } + $groupIdentifier = $this->groupIdentifierFromPath($request->getPathInfo()); if (null !== $groupIdentifier) { $group = $this->group($groupIdentifier); @@ -90,6 +96,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function createGroup(Request $request): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'createAdminAclGroup')) { + return $denied; + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { @@ -145,6 +155,12 @@ private function createGroup(Request $request): Response private function updateGroup(Request $request, AclGroup $group): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'updateAdminAclGroup')) { + return $denied; + } + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { @@ -194,6 +210,12 @@ private function updateGroup(Request $request, AclGroup $group): Response private function deleteGroup(Request $request, AclGroup $group): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'deleteAdminAclGroup')) { + return $denied; + } + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { diff --git a/src/Security/Api/UserGroupMembershipApiHandler.php b/src/Security/Api/UserGroupMembershipApiHandler.php index bcf0fd4e..b41cf927 100644 --- a/src/Security/Api/UserGroupMembershipApiHandler.php +++ b/src/Security/Api/UserGroupMembershipApiHandler.php @@ -6,6 +6,7 @@ use App\Api\ApiMessageCode; use App\Api\ApiMessageKey; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; use App\Api\Http\ApiRequestContext; @@ -34,6 +35,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -49,6 +51,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'updateAdminUserGroupMembership')) { + return $denied; + } + $membership = $this->membershipFromPath($request->getPathInfo()); if (null === $membership) { return $this->notFound($request, ['path' => $request->getPathInfo()]); diff --git a/src/Security/Api/UserReviewApiHandler.php b/src/Security/Api/UserReviewApiHandler.php index b93d4b49..86866033 100644 --- a/src/Security/Api/UserReviewApiHandler.php +++ b/src/Security/Api/UserReviewApiHandler.php @@ -6,6 +6,7 @@ use App\Api\ApiMessageCode; use App\Api\ApiMessageKey; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; use App\Api\Http\ApiListQueryNormalizer; @@ -53,6 +54,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -68,6 +70,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.users.review', 'listAdminUserReviews')) { + return $denied; + } + $tokenAction = $this->tokenActionFromPath($request->getPathInfo()); if (null !== $tokenAction) { return $this->reviewTokenAction($request, $tokenAction['token_uid'], $tokenAction['action']); @@ -87,6 +93,12 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function reviewAction(Request $request, string $username, string $action): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.review', 'reviewAdminUser')) { + return $denied; + } + } + $user = $this->entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); if (!$user instanceof UserAccount) { return $this->notFound($request, $username); @@ -117,6 +129,12 @@ private function reviewAction(Request $request, string $username, string $action private function reviewTokenAction(Request $request, string $tokenUid, string $action): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.review', 'reviewAdminUserToken')) { + return $denied; + } + } + $token = $this->entityManager->find(AccountToken::class, $tokenUid); if (!$token instanceof AccountToken) { return $this->notFound($request, $tokenUid); diff --git a/templates/backend/admin/users/deleted.html.twig b/templates/backend/admin/users/deleted.html.twig index 7ac1bcad..e070b522 100644 --- a/templates/backend/admin/users/deleted.html.twig +++ b/templates/backend/admin/users/deleted.html.twig @@ -20,7 +20,7 @@ {{ 'admin.users.deleted.retention'|trans({'%days%': retention_days, '%cutoff%': cleanup_cutoff|date('Y-m-d H:i')}) }}
- +
@@ -58,11 +58,11 @@
- +
- +
diff --git a/templates/backend/admin/users/detail.html.twig b/templates/backend/admin/users/detail.html.twig index 4fbea8a1..33bf5f3d 100644 --- a/templates/backend/admin/users/detail.html.twig +++ b/templates/backend/admin/users/detail.html.twig @@ -43,6 +43,7 @@ value: user_account.role.value, options: role_options|reduce((carry, role) => carry|merge({(role.value): ('admin.users.roles.' ~ role.value)|trans}), {}), required: true, + disabled: not users_mutable, } only %} {% include '@backend/partials/forms/fields/select.html.twig' with { id: 'user_status', @@ -55,6 +56,7 @@ deleted: 'admin.users.status.deleted'|trans, }, required: true, + disabled: not users_mutable, } only %} {% include '@backend/partials/forms/fields/checkbox-group.html.twig' with { id: 'user_groups', @@ -62,8 +64,9 @@ label: 'admin.users.fields.groups'|trans, values: user_account.groups|map(group => group.identifier), options: groups|reduce((carry, group) => carry|merge({(group.identifier): group.name}), {}), + disabled: not users_mutable, } only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans, disabled: not users_mutable} only %} @@ -72,7 +75,7 @@

{{ 'admin.users.password_reset.text'|trans }}

- +
diff --git a/templates/backend/admin/users/group-detail.html.twig b/templates/backend/admin/users/group-detail.html.twig index b13cb337..2a43bc18 100644 --- a/templates/backend/admin/users/group-detail.html.twig +++ b/templates/backend/admin/users/group-detail.html.twig @@ -33,9 +33,9 @@

{{ 'admin.groups.member_count'|trans({'%count%': member_count}) }}

- {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, value: group.name, required: true, attributes: {maxlength: 160}} only %} - {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', value: group.minRole, required: true, attributes: {min: 0, max: 9}} only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, value: group.name, required: true, attributes: {maxlength: 160}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', value: group.minRole, required: true, attributes: {min: 0, max: 9}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans, disabled: not acl_mutable} only %}
@@ -43,7 +43,7 @@

{{ 'admin.groups.delete.title'|trans }}

- +
diff --git a/templates/backend/admin/users/groups.html.twig b/templates/backend/admin/users/groups.html.twig index e9369023..6c3858d8 100644 --- a/templates/backend/admin/users/groups.html.twig +++ b/templates/backend/admin/users/groups.html.twig @@ -18,10 +18,10 @@

{{ 'admin.groups.create.title'|trans }}

- {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_identifier', name: 'identifier', label: 'admin.groups.fields.identifier'|trans, required: true} only %} - {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, required: true, attributes: {maxlength: 160}} only %} - {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', required: true, attributes: {min: 0, max: 9}} only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.groups.actions.create'|trans} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_identifier', name: 'identifier', label: 'admin.groups.fields.identifier'|trans, required: true, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, required: true, attributes: {maxlength: 160}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', required: true, attributes: {min: 0, max: 9}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.groups.actions.create'|trans, disabled: not acl_mutable} only %}
diff --git a/templates/backend/admin/users/index.html.twig b/templates/backend/admin/users/index.html.twig index d2c91495..80c9146f 100644 --- a/templates/backend/admin/users/index.html.twig +++ b/templates/backend/admin/users/index.html.twig @@ -30,12 +30,14 @@ label: 'admin.users.fields.email'|trans, type: 'email', required: true, + disabled: not users_mutable, } only %} {% include '@backend/partials/forms/fields/select.html.twig' with { id: 'invite_role', name: 'role', label: 'admin.users.fields.role'|trans, value: 'user', + disabled: not users_mutable, attributes: { 'data-invite-groups-target': 'role', 'data-action': 'invite-groups#refresh', @@ -53,12 +55,13 @@ type="checkbox" name="groups[]" value="{{ group.identifier }}" + {% if not users_mutable %}disabled{% endif %} > {{ group.name }} {% endfor %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.invitation.submit'|trans} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.invitation.submit'|trans, disabled: not users_mutable} only %} @@ -200,17 +203,17 @@ {% if token.status.value == 'pending_approval' %}
- +
{% elseif token.status.value == 'pending' %}
- +
{% endif %}
- +
diff --git a/templates/backend/admin/users/reviews.html.twig b/templates/backend/admin/users/reviews.html.twig index 8dffc07a..462e0efb 100644 --- a/templates/backend/admin/users/reviews.html.twig +++ b/templates/backend/admin/users/reviews.html.twig @@ -102,36 +102,36 @@
- +
- +
{% elseif item.kind in ['invitation', 'registration'] %}
- +
- +
{% elseif item.kind == 'password_dispute' and user %}
- +
- +
{% endif %}
diff --git a/templates/backend/components/Button.html.twig b/templates/backend/components/Button.html.twig index e775207a..f7d9387e 100644 --- a/templates/backend/components/Button.html.twig +++ b/templates/backend/components/Button.html.twig @@ -1,9 +1,13 @@ -{% props label, href = null, type = null, variant = 'primary', class = '' %} +{% props label, href = null, type = null, variant = 'primary', class = '', disabled = false %} {% set button_type = type|default(href ? null : 'button') %} {% set button_class = ['system-backend-button', 'system-button-' ~ variant, class]|filter(item => item is not empty)|join(' ') %} {% if href %} - {{ label }} + {% if disabled %} + {{ label }} + {% else %} + {{ label }} + {% endif %} {% else %} - + {% endif %} diff --git a/templates/backend/components/ButtonGroup.html.twig b/templates/backend/components/ButtonGroup.html.twig index 8f50e74f..b3fc148c 100644 --- a/templates/backend/components/ButtonGroup.html.twig +++ b/templates/backend/components/ButtonGroup.html.twig @@ -8,6 +8,7 @@ :type="action.type|default(null)" :variant="action.variant|default('secondary')" :class="action.class|default('')" + :disabled="action.disabled|default(false)" /> {% endfor %} diff --git a/templates/backend/partials/actions/_button.html.twig b/templates/backend/partials/actions/_button.html.twig index 6dc84965..6f754fba 100644 --- a/templates/backend/partials/actions/_button.html.twig +++ b/templates/backend/partials/actions/_button.html.twig @@ -4,4 +4,5 @@ :type="type|default(null)" :variant="variant|default('primary')" :class="class|default('')" + :disabled="disabled|default(false)" /> diff --git a/templates/backend/partials/forms/fields/submit.html.twig b/templates/backend/partials/forms/fields/submit.html.twig index e08e9dda..0566f694 100644 --- a/templates/backend/partials/forms/fields/submit.html.twig +++ b/templates/backend/partials/forms/fields/submit.html.twig @@ -3,4 +3,5 @@ type: 'submit', variant: variant|default('primary'), class: class|default(''), + disabled: disabled|default(false), } only %} diff --git a/tests/Controller/AdminUserControllerTest.php b/tests/Controller/AdminUserControllerTest.php index 421f6e9b..ed65bd02 100644 --- a/tests/Controller/AdminUserControllerTest.php +++ b/tests/Controller/AdminUserControllerTest.php @@ -6,6 +6,8 @@ use App\Content\Schema\ContentSchemaSource; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\State\StateMarkerKey; @@ -2006,4 +2008,77 @@ public function testAdminCanCreateAclGroup(): void $entityManager->flush(); } + public function testAdminUsersFeatureReadOnlyKeepsControlsVisibleButDisabled(): void + { + $client = self::createClient(); + $admin = $this->createUser('readonlyusersadmin', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $client->request('GET', '/admin/users'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form[action="/admin/users/invitations"] input[name="email"][disabled]'); + self::assertSelectorExists('form[action="/admin/users/invitations"] select[name="role"][disabled]'); + self::assertSelectorExists('form[action="/admin/users/invitations"] button[type="submit"][disabled]'); + + $client->request('POST', '/admin/users/invitations', [ + 'email' => 'readonly-invite@example.test', + 'role' => UserRole::User->value, + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminUserAclFeatureReadOnlyKeepsGroupControlsVisibleButDisabled(): void + { + $client = self::createClient(); + $admin = $this->createUser('readonlyacladm', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users.acl' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $client->request('GET', '/admin/users/groups'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('section form.system-backend-form input[name="identifier"][disabled]'); + self::assertSelectorExists('section form.system-backend-form input[name="min_role"][disabled]'); + self::assertSelectorExists('section form.system-backend-form button[type="submit"][disabled]'); + + $client->request('POST', '/admin/users/groups', [ + 'identifier' => 'readonly_acl_group', + 'name' => 'Read-only ACL Group', + 'min_role' => (string) AccessLevel::MANAGER, + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + } diff --git a/tests/Controller/ApiUserControllerTest.php b/tests/Controller/ApiUserControllerTest.php index de86d5df..e27d042f 100644 --- a/tests/Controller/ApiUserControllerTest.php +++ b/tests/Controller/ApiUserControllerTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Controller; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Entity\AccountToken; use App\Entity\AclGroup; use App\Entity\ApiKey; @@ -324,6 +326,84 @@ public function testUserGroupAndReviewEndpointsReturnBasicListsForAdminApiKeys() } } + public function testAdminUserFeatureReadOnlyStillListsButRejectsMutations(): void + { + $client = self::createClient(); + $target = $this->createUserWithLevel(AccessLevel::AUTHOR, 'apiuserfeature', 'current-password'); + $plainKey = $this->createPlainApiKey('apiusrfeat', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/users', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('PATCH', '/api/v1/admin/users/items/'.$target->username(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'status' => 'inactive', + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('api.operation_unavailable', $payload['error']['code']); + self::assertSame('admin.users', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminUserAclFeatureReadOnlyStillListsButRejectsGroupMutations(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiusraclfeat', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users.acl' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/users/groups', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('POST', '/api/v1/admin/users/groups', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'identifier' => 'api_group_readonly', + 'name' => 'API Group Read-only', + 'min_role' => AccessLevel::USER, + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.users.acl', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAclGroupsCanBeCreatedAndEditedWithConfirmation(): void { $client = self::createClient(); From 30a103d4e1ff1e85055a0c8281f17a7478895dfc Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 22:27:36 +0200 Subject: [PATCH 075/119] Enforce admin ACL on operational surfaces --- src/Api/Admin/AdminDeferredApiHandler.php | 5 + src/Api/Admin/AdminFeatureApiGuard.php | 66 ++++ src/Api/Admin/AdminLogApiHandler.php | 36 ++- src/Api/Admin/AdminOperationApiHandler.php | 17 + src/Api/Admin/AdminSchedulerApiHandler.php | 13 + src/Api/Admin/AdminStatisticsApiHandler.php | 5 + src/Api/Admin/AdminThemeApiHandler.php | 5 + src/Backend/AdminViewContextProvider.php | 35 +- src/Controller/AdminOperationController.php | 29 ++ src/Controller/BackendController.php | 24 +- .../AdminAcl/AdminAclSettingsFormHandler.php | 81 ++++- src/Setup/SetupSiteSettings.php | 6 +- templates/backend/admin/operations.html.twig | 6 +- .../backend/admin/operations/detail.html.twig | 2 +- .../setup/partials/steps/_review.html.twig | 2 +- .../ApiAdminOperationalControllerTest.php | 298 ++++++++++++++---- tests/Controller/BackendControllerTest.php | 91 +++++- translations/languages/de/setup.yaml | 4 + translations/languages/en/setup.yaml | 4 + 19 files changed, 638 insertions(+), 91 deletions(-) create mode 100644 src/Api/Admin/AdminFeatureApiGuard.php diff --git a/src/Api/Admin/AdminDeferredApiHandler.php b/src/Api/Admin/AdminDeferredApiHandler.php index 3f072442..f39a4d15 100644 --- a/src/Api/Admin/AdminDeferredApiHandler.php +++ b/src/Api/Admin/AdminDeferredApiHandler.php @@ -17,6 +17,7 @@ public function __construct( private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -32,6 +33,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.backup_restore', 'listAdminBackups')) { + return $denied; + } + return $this->responder->data([ 'type' => 'api_navigation', 'id' => $endpoint->path(), diff --git a/src/Api/Admin/AdminFeatureApiGuard.php b/src/Api/Admin/AdminFeatureApiGuard.php new file mode 100644 index 00000000..8897293e --- /dev/null +++ b/src/Api/Admin/AdminFeatureApiGuard.php @@ -0,0 +1,66 @@ +adminAcl->isVisible($feature, $this->actor($request))) { + return null; + } + + return $this->denied($request, $feature, $operation, 'feature_hidden'); + } + + public function denyUnlessMutable(Request $request, string $feature, string $operation): ?Response + { + if ($this->adminAcl->isMutable($feature, $this->actor($request))) { + return null; + } + + return $this->denied($request, $feature, $operation, 'feature_read_only'); + } + + public function isMutable(Request $request, string $feature): bool + { + return $this->adminAcl->isMutable($feature, $this->actor($request)); + } + + private function actor(Request $request): AccessActor + { + return ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + } + + private function denied(Request $request, string $feature, string $operation, string $reason): Response + { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => $operation, + ], [ + 'feature' => $feature, + 'reason' => $reason, + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } +} diff --git a/src/Api/Admin/AdminLogApiHandler.php b/src/Api/Admin/AdminLogApiHandler.php index 8a054bde..7335bd80 100644 --- a/src/Api/Admin/AdminLogApiHandler.php +++ b/src/Api/Admin/AdminLogApiHandler.php @@ -24,6 +24,7 @@ public function __construct( private ApiListQueryNormalizer $listQueries, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -39,12 +40,19 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.logs', 'listAdminLogs')) { + return $denied; + } + $source = $this->sourceFromPath($request->getPathInfo()); if (null === $source) { $view = $this->logs->browse([]); + $sources = $this->featureGuard->isMutable($request, 'admin.logs') + ? $view['sources'] + : $this->visibleSources($view['sources']); - return $this->responder->data($this->sourceResources($view['sources']), meta: [ - 'count' => count($view['sources']), + return $this->responder->data($this->sourceResources($sources), meta: [ + 'count' => count($sources), 'default_source' => $view['selected_source'], ]); } @@ -52,6 +60,12 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo if (!$this->isKnownSource($source)) { return $this->notFound($request, $source); } + if ($this->isSensitiveSource($source)) { + $denied = $this->featureGuard->denyUnlessMutable($request, 'admin.logs', 'readAdminLogSource'); + if (null !== $denied) { + return $denied; + } + } $view = $this->logs->browse([ ...$this->listQueries->backendQuery($request->query->all()), @@ -101,6 +115,24 @@ private function filtersForSource(string $source): array }; } + /** + * @param list $sources + * + * @return list + */ + private function visibleSources(array $sources): array + { + return array_values(array_filter( + $sources, + fn (array $source): bool => !$this->isSensitiveSource((string) ($source['key'] ?? '')), + )); + } + + private function isSensitiveSource(string $source): bool + { + return in_array($source, ['audit', 'security_signal'], true); + } + private function sourceFromPath(string $path): ?string { $prefix = '/api/v1/admin/logs/'; diff --git a/src/Api/Admin/AdminOperationApiHandler.php b/src/Api/Admin/AdminOperationApiHandler.php index 8afa0608..553c5bbe 100644 --- a/src/Api/Admin/AdminOperationApiHandler.php +++ b/src/Api/Admin/AdminOperationApiHandler.php @@ -29,6 +29,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -44,6 +45,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.operations', 'listAdminOperations')) { + return $denied; + } + $maintenanceAction = $this->maintenanceActionFromPath($request->getPathInfo()); if (null !== $maintenanceAction) { return $this->maintenance($request, $maintenanceAction); @@ -72,6 +77,12 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function maintenance(Request $request, string $action): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.operations', 'runAdminOperationMaintenance')) { + return $denied; + } + } + if (!$request->query->getBoolean('confirm')) { return $this->responder->data([ 'type' => 'operation_maintenance_review', @@ -144,6 +155,12 @@ private function operation(Request $request, string $operationId): Response private function continueOperation(Request $request, string $operationId): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.operations', 'continueAdminOperation')) { + return $denied; + } + } + if (null === $this->operations->report($operationId)) { return $this->notFound($request, $operationId); } diff --git a/src/Api/Admin/AdminSchedulerApiHandler.php b/src/Api/Admin/AdminSchedulerApiHandler.php index ff73b778..c4e0f3ec 100644 --- a/src/Api/Admin/AdminSchedulerApiHandler.php +++ b/src/Api/Admin/AdminSchedulerApiHandler.php @@ -35,6 +35,7 @@ public function __construct( private ApiJsonRequestParser $jsonRequests, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -50,6 +51,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.scheduler', 'listAdminSchedulerTasks')) { + return $denied; + } + $taskIdentifier = $this->taskIdentifierFromPath($request->getPathInfo()); if (null !== $taskIdentifier) { $task = $this->task($taskIdentifier); @@ -80,6 +85,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo */ private function runTask(Request $request, SchedulerTask $task): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.scheduler', 'runAdminSchedulerTask')) { + return $denied; + } + if (SchedulerTaskStatus::Active !== $task->status()) { return $this->operationUnavailable($request, 'runAdminSchedulerTask', [ 'task_identifier' => $task->identifier(), @@ -99,6 +108,10 @@ private function runTask(Request $request, SchedulerTask $task): Response private function updateTask(Request $request, SchedulerTask $task): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.scheduler', 'updateAdminSchedulerTask')) { + return $denied; + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { diff --git a/src/Api/Admin/AdminStatisticsApiHandler.php b/src/Api/Admin/AdminStatisticsApiHandler.php index 1e60fa30..5688cc03 100644 --- a/src/Api/Admin/AdminStatisticsApiHandler.php +++ b/src/Api/Admin/AdminStatisticsApiHandler.php @@ -19,6 +19,7 @@ public function __construct( private AccessStatisticsSnapshotProvider $statistics, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -34,6 +35,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.settings.statistics', 'getAdminStatistics')) { + return $denied; + } + return $this->responder->data([ 'type' => 'access_statistics', 'id' => (string) $request->query->get('statistics_window', '24h'), diff --git a/src/Api/Admin/AdminThemeApiHandler.php b/src/Api/Admin/AdminThemeApiHandler.php index dea8dec8..43fec259 100644 --- a/src/Api/Admin/AdminThemeApiHandler.php +++ b/src/Api/Admin/AdminThemeApiHandler.php @@ -19,6 +19,7 @@ public function __construct( private ThemeAdminOverview $themes, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -34,6 +35,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.packages', 'listAdminThemes')) { + return $denied; + } + $sections = array_map($this->section(...), $this->themes->sections()); return $this->responder->data($sections, meta: ['count' => count($sections)]); diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index 4e6b7ed7..a394b5ce 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -49,9 +49,7 @@ public function variables(Request $request, ?BackendViewDefinition $view): array return match ($view->uid()) { 'backend-admin-operations' => $this->operationVariables(), - 'backend-admin-logs' => [ - 'log_view' => $this->logBrowser->browse($request->query->all()), - ], + 'backend-admin-logs' => $this->logVariables($request), 'backend-admin-statistics' => [ 'access_statistics' => $this->accessStatisticsSnapshotProvider->snapshot($request->query->get('statistics_window')), 'access_statistics_windows' => $this->accessStatisticsSnapshotProvider->windows(), @@ -79,11 +77,42 @@ public function variables(Request $request, ?BackendViewDefinition $view): array private function operationVariables(): array { return [ + 'operations_mutable' => $this->adminFeatureAccessPolicy->isMutable('admin.operations', $this->actor()), 'operation_runs' => $this->liveOperationRunStore->summaries(), 'operation_lock' => $this->liveOperationRunStore->runnerLockStatus(3600), ]; } + /** + * @return array + */ + private function logVariables(Request $request): array + { + $actor = $this->actor(); + $mutable = $this->adminFeatureAccessPolicy->isMutable('admin.logs', $actor); + $query = $request->query->all(); + + if (!$mutable && $this->isSensitiveLogSource($query['source'] ?? null)) { + $query['source'] = 'message'; + } + + $view = $this->logBrowser->browse($query); + + if (!$mutable) { + $view['sources'] = array_values(array_filter( + $view['sources'] ?? [], + fn (array $source): bool => !$this->isSensitiveLogSource($source['key'] ?? null), + )); + } + + return ['log_view' => $view]; + } + + private function isSensitiveLogSource(mixed $source): bool + { + return is_string($source) && in_array($source, ['audit', 'security_signal'], true); + } + private function actor(): AccessActor { $user = $this->security->getUser(); diff --git a/src/Controller/AdminOperationController.php b/src/Controller/AdminOperationController.php index 216386cf..832a9f56 100644 --- a/src/Controller/AdminOperationController.php +++ b/src/Controller/AdminOperationController.php @@ -6,6 +6,7 @@ use App\Backend\AdminControllerContext; use App\Backend\BackendArea; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Operation\Live\LiveOperationStarter; @@ -21,6 +22,8 @@ final class AdminOperationController extends AbstractController { + private const FEATURE = 'admin.operations'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -28,6 +31,7 @@ public function __construct( private readonly LiveOperationStarter $liveOperationStarter, private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -39,6 +43,9 @@ public function maintenance(Request $request): Response if (null !== $access) { return $access; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } if (!$this->formTokenValidator->isValid('admin-operations', $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token'))) { $this->alertKey('warning', 'admin.operations.actions.invalid_csrf'); @@ -100,6 +107,9 @@ public function detail(Request $request, string $operationId): Response if (null !== $access) { return $access; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $report = $this->liveOperationRunStore->report($operationId); @@ -114,6 +124,7 @@ public function detail(Request $request, string $operationId): Response 'area' => BackendArea::Admin, 'navigation' => $this->adminContext->navigation($request, $this->getUser()), 'operation_report' => $report, + 'operations_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), ]); } @@ -125,6 +136,9 @@ public function continue(Request $request, string $operationId): Response if (null !== $access) { return $access; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } if (!$this->formTokenValidator->isValid('admin-operations', 'admin-operations', $this->stringField($request, '_csrf_token'))) { $this->alertKey('warning', 'admin.operations.actions.invalid_csrf'); @@ -170,6 +184,21 @@ private function audit(string $action, array $context = []): void ]); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable ? $this->adminAcl->isMutable(self::FEATURE, $actor) : $this->adminAcl->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + private function stringField(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 64535a60..ab31dde1 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -67,9 +67,24 @@ public function logDetail(Request $request, string $entryId): Response if (null !== $access) { return $access; } + if (!$this->adminAcl->isVisible('admin.logs', $this->actor())) { + return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => 'admin.logs', + 'required_state' => 'visible', + ]); + } $source = $request->query->get('source', 'message'); - $entry = $this->logBrowser->entry(is_string($source) ? $source : 'message', $entryId); + $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: [ + 'feature' => 'admin.logs', + 'required_state' => 'mutable', + 'source' => $source, + ]); + } + + $entry = $this->logBrowser->entry($source, $entryId); if (null === $entry) { return $this->httpError->notFound($request); @@ -309,12 +324,17 @@ private function mutationDeniedResponse(Request $request, BackendViewDefinition */ private function auditFormSubmission(string $action, FormSubmissionResult $result, array $context = []): void { - $settingKeys = array_keys($result->values()); + $settingKeys = array_filter( + array_keys($result->values()), + static fn (string $key): bool => !str_starts_with($key, '_'), + ); sort($settingKeys); + $auditContext = $result->value('_audit'); try { $this->auditLogger->log($this->actor(), $action, [ ...$context, + ...(is_array($auditContext) ? $auditContext : []), 'result_status' => 'success', 'setting_keys' => $settingKeys, ]); diff --git a/src/Core/AdminAcl/AdminAclSettingsFormHandler.php b/src/Core/AdminAcl/AdminAclSettingsFormHandler.php index d03b2e6e..d476253d 100644 --- a/src/Core/AdminAcl/AdminAclSettingsFormHandler.php +++ b/src/Core/AdminAcl/AdminAclSettingsFormHandler.php @@ -32,9 +32,10 @@ public function submit(array $submitted, ?string $modifiedBy = null): FormSubmis static fn (AdminFeatureDefinition $definition): string => $definition->identifier(), $currentDefinitions, ), true); + $previousOverrides = $this->store->overrides(); $overrides = []; - foreach ($this->store->overrides() as $feature => $override) { + foreach ($previousOverrides as $feature => $override) { if (!isset($currentIdentifiers[$feature])) { $overrides[$feature] = $override; } @@ -65,7 +66,12 @@ public function submit(array $submitted, ?string $modifiedBy = null): FormSubmis $this->registry->resetCache(); - return new FormSubmissionResult(['acl' => $overrides], []); + return new FormSubmissionResult([ + 'acl' => $overrides, + '_audit' => [ + 'changed_features' => $this->changedFeatures($previousOverrides, $overrides, $currentIdentifiers), + ], + ], []); } /** @@ -102,4 +108,75 @@ private function groupOverrides(array $row, AdminPermissionSurface $surface): ar return $groups; } + + /** + * @param array> $previous + * @param array> $next + * @param array $currentIdentifiers + * + * @return list, next_groups: array}> + */ + private function changedFeatures(array $previous, array $next, array $currentIdentifiers): array + { + $features = array_values(array_unique([...array_keys($previous), ...array_keys($next)])); + sort($features); + $changed = []; + + foreach ($features as $feature) { + if (!is_string($feature) || !isset($currentIdentifiers[$feature])) { + continue; + } + + $previousRow = $previous[$feature] ?? []; + $nextRow = $next[$feature] ?? []; + $previousGroups = $this->auditGroups($previousRow['groups'] ?? []); + $nextGroups = $this->auditGroups($nextRow['groups'] ?? []); + $previousState = is_string($previousRow['state'] ?? null) ? $previousRow['state'] : null; + $nextState = is_string($nextRow['state'] ?? null) ? $nextRow['state'] : null; + + if ($previousState === $nextState && $previousGroups === $nextGroups) { + continue; + } + + $changed[] = [ + 'feature' => $feature, + 'previous_state' => $previousState, + 'next_state' => $nextState, + 'previous_groups' => $previousGroups, + 'next_groups' => $nextGroups, + ]; + } + + return $changed; + } + + /** + * @param mixed $groups + * + * @return array + */ + private function auditGroups(mixed $groups): array + { + return is_array($groups) ? $this->normalizeGroups($groups) : []; + } + + /** + * @param array $groups + * + * @return array + */ + private function normalizeGroups(array $groups): array + { + $normalized = []; + + foreach ($groups as $identifier => $state) { + if (is_string($identifier) && is_string($state)) { + $normalized[$identifier] = $state; + } + } + + ksort($normalized); + + return $normalized; + } } diff --git a/src/Setup/SetupSiteSettings.php b/src/Setup/SetupSiteSettings.php index 182b0437..73479bc1 100644 --- a/src/Setup/SetupSiteSettings.php +++ b/src/Setup/SetupSiteSettings.php @@ -27,9 +27,9 @@ public function fields(): array ConfigValueType::String, FormInputType::Select, options: [ - UserFlowConfig::REGISTRATION_DISABLED => 'admin.settings.options.registration.disabled', - UserFlowConfig::REGISTRATION_ADMIN_APPROVAL => 'admin.settings.options.registration.admin_approval', - UserFlowConfig::REGISTRATION_AUTO_APPROVAL => 'admin.settings.options.registration.auto_approval', + UserFlowConfig::REGISTRATION_DISABLED => 'setup.form.registration_mode.options.disabled', + UserFlowConfig::REGISTRATION_ADMIN_APPROVAL => 'setup.form.registration_mode.options.admin_approval', + UserFlowConfig::REGISTRATION_AUTO_APPROVAL => 'setup.form.registration_mode.options.auto_approval', ], validation: ['required' => true], metadata: ['config_key' => UserFlowConfig::REGISTRATION_MODE_KEY], diff --git a/templates/backend/admin/operations.html.twig b/templates/backend/admin/operations.html.twig index bd225db2..7c1e5654 100644 --- a/templates/backend/admin/operations.html.twig +++ b/templates/backend/admin/operations.html.twig @@ -41,7 +41,7 @@ - @@ -58,7 +58,7 @@ - @@ -79,7 +79,7 @@ - diff --git a/templates/backend/admin/operations/detail.html.twig b/templates/backend/admin/operations/detail.html.twig index 2d98242b..c64a98e6 100644 --- a/templates/backend/admin/operations/detail.html.twig +++ b/templates/backend/admin/operations/detail.html.twig @@ -90,7 +90,7 @@ {% if report.result.can_continue|default(false) %}
-
diff --git a/templates/backend/setup/partials/steps/_review.html.twig b/templates/backend/setup/partials/steps/_review.html.twig index 32046654..f88916ff 100644 --- a/templates/backend/setup/partials/steps/_review.html.twig +++ b/templates/backend/setup/partials/steps/_review.html.twig @@ -5,7 +5,7 @@
{{ 'setup.form.language.label'|trans }}
{{ setup_values.language }}
{{ 'setup.form.site_title.label'|trans }}
{{ setup_values.site_title }}
{{ 'setup.form.default_uri.label'|trans }}
{{ setup_values.default_uri }}
-
{{ 'setup.form.registration_mode.label'|trans }}
{{ ('admin.settings.options.registration.' ~ setup_values.registration_mode)|trans }}
+
{{ 'setup.form.registration_mode.label'|trans }}
{{ ('setup.form.registration_mode.options.' ~ setup_values.registration_mode)|trans }}
{{ 'setup.form.route_prefixes_enabled.label'|trans }}
{{ (setup_values.route_prefixes_enabled ? 'setup.review.enabled' : 'setup.review.disabled')|trans }}
{{ 'setup.form.statistics_enabled.label'|trans }}
{{ (setup_values.statistics_enabled ? 'setup.review.enabled' : 'setup.review.disabled')|trans }}
{{ 'setup.form.database_driver.label'|trans }}
{{ ('setup.form.database_driver.options.' ~ setup_values.database_driver)|trans }}
diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index 1c78e902..d60d96fc 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Controller; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Operation\OperationMessageCode; @@ -56,37 +58,88 @@ public function testAdminLogsListSourcesAndSourceEntries(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apiopslog'); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); - $client->request('GET', '/api/v1/admin/logs', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $store->save([ + 'admin.logs' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertGreaterThan(0, $payload['meta']['count']); - self::assertSame('log_source', $payload['data'][0]['type']); - $sources = []; - foreach ($payload['data'] as $resource) { - $sources[$resource['id']] = $resource['attributes']['filters']; + try { + $client->request('GET', '/api/v1/admin/logs', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertGreaterThan(0, $payload['meta']['count']); + self::assertSame('log_source', $payload['data'][0]['type']); + $sources = []; + foreach ($payload['data'] as $resource) { + $sources[$resource['id']] = $resource['attributes']['filters']; + } + self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['application']); + self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['message']); + self::assertSame(['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['audit']); + self::assertSame(['q', 'match', 'time_window', 'limit', 'page'], $sources['access']); + self::assertSame(['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['security_signal']); + + $client->request('GET', '/api/v1/admin/logs/message?level=INFO&limit=25', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('message', $payload['meta']['selected_source']); + self::assertSame('INFO', $payload['meta']['filters']['level']); + self::assertSame(25, $payload['meta']['filters']['limit']); + self::assertArrayNotHasKey('per_page', $payload['meta']['filters']); + self::assertArrayNotHasKey('per_page', $payload['meta']['pagination']); + self::assertArrayNotHasKey('total_pages', $payload['meta']['pagination']); + } finally { + $store->save($store->defaultOverrides(), 'test'); } - self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['application']); - self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['message']); - self::assertSame(['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['audit']); - self::assertSame(['q', 'match', 'time_window', 'limit', 'page'], $sources['access']); - self::assertSame(['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['security_signal']); - - $client->request('GET', '/api/v1/admin/logs/message?level=INFO&limit=25', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + } - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('message', $payload['meta']['selected_source']); - self::assertSame('INFO', $payload['meta']['filters']['level']); - self::assertSame(25, $payload['meta']['filters']['limit']); - self::assertArrayNotHasKey('per_page', $payload['meta']['filters']); - self::assertArrayNotHasKey('per_page', $payload['meta']['pagination']); - self::assertArrayNotHasKey('total_pages', $payload['meta']['pagination']); + public function testAdminLogsFeatureReadOnlyHidesSensitiveSources(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopslogro', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.logs' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/logs', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + $sources = array_column($payload['data'], 'id'); + self::assertNotContains('audit', $sources); + self::assertNotContains('security_signal', $sources); + + $client->request('GET', '/api/v1/admin/logs/audit', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.logs', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } } public function testAdminOperationDetailAndContinuationReviewAreAvailable(): void @@ -95,6 +148,8 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi $plainKey = $this->createPlainApiKey('apiopsrun'); $store = self::getContainer()->get(LiveOperationRunStore::class); self::assertInstanceOf(LiveOperationRunStore::class, $store); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $overrides); $run = $store->create('package.install.verify', [], 'Install package'); $result = WorkflowResult::requiresReview(null, [ Message::info( @@ -111,6 +166,13 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi ]); $store->finish($run['operation_id'], false, $result->toArray()); + $overrides->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); + try { $client->request('GET', '/api/v1/admin/operations/'.$run['operation_id'], server: [ 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, @@ -143,6 +205,7 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi self::assertSame('/api/v1/admin/operations/'.$run['operation_id'], $payload['links']['status']); self::assertSame('/api/v1/admin/operations/'.$run['operation_id'].'/continue?confirm=true', $payload['links']['confirm']); } finally { + $overrides->save($overrides->defaultOverrides(), 'test'); @unlink(dirname($store->outputPath($run['operation_id'])).'/'.$run['operation_id'].'.json'); @unlink($store->outputPath($run['operation_id'])); @unlink($store->pidPath($run['operation_id'])); @@ -153,63 +216,160 @@ public function testAdminOperationMaintenanceRequiresConfirmation(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apiopsmaint', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); - $client->request('POST', '/api/v1/admin/operations/cleanup', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $store->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('operation_maintenance_review', $payload['data']['type']); - self::assertSame('requires_confirmation', $payload['data']['attributes']['status']); - self::assertSame('/api/v1/admin/operations/cleanup?confirm=true', $payload['links']['confirm']); + try { + $client->request('POST', '/api/v1/admin/operations/cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); - $client->request('POST', '/api/v1/admin/operations/cleanup?confirm=true', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('operation_maintenance_review', $payload['data']['type']); + self::assertSame('requires_confirmation', $payload['data']['attributes']['status']); + self::assertSame('/api/v1/admin/operations/cleanup?confirm=true', $payload['links']['confirm']); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('operation_maintenance_result', $payload['data']['type']); - self::assertSame('completed', $payload['data']['attributes']['status']); + $client->request('POST', '/api/v1/admin/operations/cleanup?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('operation_maintenance_result', $payload['data']['type']); + self::assertSame('completed', $payload['data']['attributes']['status']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminOperationsFeatureReadOnlyStillListsButRejectsConfirmedMaintenance(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopsro', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/operations', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('POST', '/api/v1/admin/operations/cleanup?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.operations', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } } public function testAdminSchedulerTaskDetailAndPatchAreAvailable(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apiopssched', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); - $client->request('GET', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('scheduler_task', $payload['data']['type']); - self::assertSame('system.live_operation_cleanup', $payload['data']['id']); - self::assertArrayHasKey('recent_runs', $payload['data']['relationships']); - - $client->request('PATCH', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - 'CONTENT_TYPE' => 'application/json', - ], content: json_encode([ - 'enabled' => true, - 'cron_expression' => '*/10 * * * *', - ], JSON_THROW_ON_ERROR)); + try { + $client->request('GET', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('active', $payload['data']['attributes']['status']); - self::assertSame('*/10 * * * *', $payload['data']['attributes']['cron_expression']); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('scheduler_task', $payload['data']['type']); + self::assertSame('system.live_operation_cleanup', $payload['data']['id']); + self::assertArrayHasKey('recent_runs', $payload['data']['relationships']); - $client->request('POST', '/api/v1/admin/scheduler/system.live_operation_cleanup/run', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $client->request('PATCH', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'enabled' => true, + 'cron_expression' => '*/10 * * * *', + ], JSON_THROW_ON_ERROR)); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('scheduler_run', $payload['data']['type']); - self::assertSame('/api/v1/admin/scheduler/system.live_operation_cleanup', $payload['data']['links']['task']); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('active', $payload['data']['attributes']['status']); + self::assertSame('*/10 * * * *', $payload['data']['attributes']['cron_expression']); + + $client->request('POST', '/api/v1/admin/scheduler/system.live_operation_cleanup/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('scheduler_run', $payload['data']['type']); + self::assertSame('/api/v1/admin/scheduler/system.live_operation_cleanup', $payload['data']['links']['task']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminSchedulerFeatureReadOnlyStillShowsTasksButRejectsMutations(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopsschedro', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/scheduler', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('PATCH', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'enabled' => false, + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.scheduler', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } } public function testOpenApiIncludesAdminOperationalEndpoints(): void diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 29932f0f..cbfce7ca 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -8,6 +8,8 @@ use App\Core\ActionLog\ActionLogEntry; use App\Core\ActionLog\ActionLogStatus; use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\Geo\MaxMindGeoIpConfig; @@ -384,7 +386,7 @@ public function testAdminRouteRequiresAdministrativeAccess(): void public function testAdminRouteAllowsAccessLevelEight(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin'); self::assertResponseIsSuccessful(); @@ -473,7 +475,7 @@ public function testAdminRegisteredBackendViewRouteRendersThroughRegistry(): voi public function testAdminOperationsViewListsTransientLiveOperationState(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); $store = self::getContainer()->get(LiveOperationRunStore::class); self::assertInstanceOf(LiveOperationRunStore::class, $store); $run = $store->create('backend.cache_clear', [], 'Cache clear'); @@ -499,7 +501,7 @@ public function testAdminOperationsViewListsTransientLiveOperationState(): void public function testAdminOperationsCleanupWritesAuditEntry(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $logDir = self::getContainer()->getParameter('kernel.logs_dir'); foreach (glob($logDir.'/test/audit-*.log') ?: [] as $logFile) { @@ -519,10 +521,67 @@ public function testAdminOperationsCleanupWritesAuditEntry(): void self::assertStringContainsString('"result_status":"success"', $auditLog); } + public function testAdminOperationsFeatureReadOnlyKeepsActionsVisibleButDisabled(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/admin/operations'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form input[name="_operations_action"][value="cleanup"]'); + self::assertSelectorExists('form input[name="_operations_action"][value="cleanup"] + button[disabled]'); + + $client->request('POST', '/admin/operations', [ + '_operations_action' => 'cleanup', + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminLogsFeatureReadOnlyHidesSensitiveSources(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.logs' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/admin/logs?source=audit'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="source"][value="message"]'); + self::assertSelectorNotExists('a[href*="source=audit"]'); + self::assertSelectorNotExists('a[href*="source=security_signal"]'); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAdminOperationDetailShowsRetainedActionLogEntries(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $store = self::getContainer()->get(LiveOperationRunStore::class); self::assertInstanceOf(LiveOperationRunStore::class, $store); $run = $store->create('backend.cache_clear', [], 'Cache clear'); @@ -552,7 +611,7 @@ public function testAdminOperationDetailShowsRetainedActionLogEntries(): void public function testAdminLogsViewReadsSelectedLogSource(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); $connection->delete('access_log_entry', ['request_id' => 'request-admin-logs']); $connection->delete('access_statistic_event', ['route' => 'backend_admin_route']); @@ -1121,6 +1180,12 @@ public function testAclSettingsMatrixIsOwnerGatedAndRendersFeatureRegistry(): vo self::assertInstanceOf(EntityManagerInterface::class, $entityManager); $adminAcl = self::getContainer()->get(AdminFeatureAccessPolicy::class); self::assertInstanceOf(AdminFeatureAccessPolicy::class, $adminAcl); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $logDir = self::getContainer()->getParameter('kernel.logs_dir'); + foreach (glob($logDir.'/test/audit-*.log') ?: [] as $logFile) { + @unlink($logFile); + } $existingGroup = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => 'acl_matrix_test']); if ($existingGroup instanceof AclGroup) { $entityManager->remove($existingGroup); @@ -1151,6 +1216,21 @@ public function testAclSettingsMatrixIsOwnerGatedAndRendersFeatureRegistry(): vo self::assertSelectorTextContains('#acl-surface-admin', 'Read-only'); self::assertGreaterThan(0, $crawler->filter('select[disabled]')->count()); + $form = $crawler->filter('form#admin-settings-acl')->form([ + 'acl[admin.packages][state]' => AdminPermissionState::Denied->value, + ]); + $client->submit($form); + + self::assertResponseRedirects('/admin/settings/acl'); + $auditLog = implode(PHP_EOL, array_map(static fn (string $file): string => (string) file_get_contents($file), glob($logDir.'/test/audit-*.log') ?: [])); + self::assertStringContainsString('settings.acl.save', $auditLog); + self::assertStringContainsString('"section":"acl"', $auditLog); + self::assertStringContainsString('"feature":"admin.packages"', $auditLog); + self::assertStringContainsString('"previous_state":"visible"', $auditLog); + self::assertStringContainsString('"next_state":"denied"', $auditLog); + self::assertStringContainsString('"setting_keys":["acl"]', $auditLog); + self::assertStringNotContainsString('"_audit"', $auditLog); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); $client->request('GET', '/admin/settings/acl'); @@ -1162,6 +1242,7 @@ public function testAclSettingsMatrixIsOwnerGatedAndRendersFeatureRegistry(): vo $entityManager->flush(); $adminAcl->resetCache(); } + $store->save($store->defaultOverrides(), 'test'); } } diff --git a/translations/languages/de/setup.yaml b/translations/languages/de/setup.yaml index a24b8f79..5f61cf7e 100644 --- a/translations/languages/de/setup.yaml +++ b/translations/languages/de/setup.yaml @@ -211,6 +211,10 @@ setup: label: 'Website-URL' registration_mode: label: 'Benutzerregistrierung' + options: + disabled: 'Deaktiviert' + admin_approval: 'Admin-Freigabe' + auto_approval: 'Automatische Freigabe' username_change_enabled: label: 'Benutzernamenänderungen erlauben' route_prefixes_enabled: diff --git a/translations/languages/en/setup.yaml b/translations/languages/en/setup.yaml index fedaf852..9e2f0ab3 100644 --- a/translations/languages/en/setup.yaml +++ b/translations/languages/en/setup.yaml @@ -211,6 +211,10 @@ setup: label: 'Site URL' registration_mode: label: 'User registration' + options: + disabled: 'Disabled' + admin_approval: 'Admin approval' + auto_approval: 'Auto approval' username_change_enabled: label: 'Allow username changes' route_prefixes_enabled: From cfdd23709918c180ccca59b409cd675cbace4856 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 22:27:42 +0200 Subject: [PATCH 076/119] Document admin ACL enforcement decisions --- dev/CLASSMAP.md | 6 +++--- dev/WORKLOG.md | 6 +++++- dev/draft/security-hardening/admin-acl-enforcement.md | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 11ec0831..58187cee 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -86,7 +86,7 @@ | Enum | `App\Core\Access\AccessLevel` | Shared 0-9 access-level constants and validation for public, user, moderator, author, publisher, curator, manager, director, admin, and owner role tiers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\Access\AccessResolver` | Resolves inherited level-plus-group ACL rules for actors and capabilities. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php` | | Value object | `App\Core\Access\AccessRule` | Value object for explicit or inherited min-level plus group ACL rules, including the shared stored-group identifier normalization used by content, schema, and menu ACL fields. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Core/Access/AccessRuleTest.php` | -| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, and denied/visible/mutable state evaluation used by settings forms, backend actions, package/theme UI/API callers, dynamic package settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | +| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState`, `App\Api\Admin\AdminFeatureApiGuard` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, denied/visible/mutable state evaluation, API-key-owner feature checks, and redacted matrix-change audit summaries used by settings forms, backend actions, package/theme UI/API callers, user/ACL/review workflows, scheduler/operations/log APIs, dynamic package settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | | Interface | `App\Security\AccessLevelAwareUserInterface` | Symfony user bridge for security users that expose the project's role-derived ACL access level. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Enum | `App\Security\UserRole` | Global account role enum that maps one exact user role to inherited Symfony roles and numeric ACL access levels. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserGroupMembershipManager`, `App\Security\AccountReactivationAccessResolver` | Shared user-group membership and deleted-account reactivation helpers used by admin and account-link flows so controllers do not duplicate group replacement, existing-group filtering, or role-preserving reactivation decisions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | @@ -103,8 +103,8 @@ | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the first Admin Settings tree plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | -| Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Own focused Admin package install/detail/lifecycle and Admin Operations maintenance/detail/continuation routes that previously lived in the dynamic backend dispatcher. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | +| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, ACL-aware operation action states, mutable-only Audit/Security Signal log source exposure, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index a7e41311..bbf500fa 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -86,8 +86,12 @@ - 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. - 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`. +- 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`; `bin/lint --diff`. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index f396479a..440c2f8f 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -66,10 +66,10 @@ Codex may create local commits for this branch when each commit has a clear them | `admin.settings.statistics.geoip` | Visible | Yes | GeoIP fields and update action, parent-gated by statistics. | | `admin.settings.api` | Denied | Yes | API settings area. | | `admin.settings.scheduler` | Visible | Yes | Scheduler settings area. | -| `admin.logs` | Visible | Yes | Admin log review area. | +| `admin.logs` | Visible | Yes | Admin log review area; Audit and Security Signal sources require Mutable access. | | `admin.packages` | Visible | Yes | Package and theme management area, with mutating lifecycle/install/discovery disabled unless mutable. | | `admin.backup_restore` | Visible | No | Backup/restore area; restore remains mutating. | -| `admin.packages.self_update` | Denied | No | System package self-update transparency row. | +| `admin.packages.self_update` | Denied | No | System package self-update transparency row; no current self-update mutation route exists in this slice. | | `admin.support` | Denied | No | Support bundle transparency row. | | `admin.operations` | Visible | Yes | Operations view. | | `admin.actions.maintenance` | Mutable | Yes | Cache clear and asset rebuild actions. | @@ -93,7 +93,7 @@ The first matrix should use conservative defaults. "View" means the actor may op | Registration/user-flow settings | View and mutate low-risk workflow settings | View and mutate | TTLs and notification addresses are Admin-mutable only if they do not affect Owner recovery, security policy, or protected secrets. | | Mail settings | View and mutate non-secret sender settings | View and mutate, including protected transport status/config where implemented | Mail transport secrets remain protected/write-only. Production delivery guards remain enforced. | | Security settings | View redacted status only | View and mutate | Captcha provider selection may be Admin-mutable only if it cannot disable required protection or verified recovery policy. Auto-ban disablement, privacy ceilings, recovery protections, and rate/security policy bounds are Owner-only. | -| Access/audit/security logs | View redacted summaries | View redacted summaries and broader review tools | Raw secrets, raw tokens, full request payloads, and IP-derived data beyond retention are never exposed. Full diagnostic/export actions are Owner-only. | +| Access/audit/security logs | View redacted summaries | View redacted summaries and broader review tools | Raw secrets, raw tokens, full request payloads, and IP-derived data beyond retention are never exposed. Audit and Security Signal sources require mutable `admin.logs` access. Full diagnostic/export actions are Owner-only. | | Statistics and GeoIP status | View summaries | View and mutate GeoIP enablement, database path, license key, and update task | MaxMind license material is protected/write-only. GeoIP cannot become blocking policy in this slice. | | API settings | View status and own/user-token surfaces where already allowed | View and mutate global API settings | Enabling public API/CORS expansion, wildcard-like origins, or broad anonymous access is Owner-only. | | Package/theme overview | View installed/available status | View and mutate | Installing, activating, deactivating, updating, purging, and running package lifecycle actions are Owner-only by default. | @@ -127,6 +127,7 @@ The first matrix should use conservative defaults. "View" means the actor may op - Admin action identifiers are stable, English, machine-readable strings and are not localized. - Feature/action descriptors should expose enough metadata for the `Settings/ACL` matrix UI without making every action database-configurable by default: stable feature key, default access rule, configurability flag, domain, sensitivity, and affected public entry points. - The first registry is code-owned and test-backed. Configurable defaults are seeded through `acl.admin.features`, while non-configurable rows remain hardcoded in the registry for transparency. Database-stored Admin ACL overrides are limited to descriptor-approved rows and must fall back safely to seeded or registry defaults. +- `Settings/ACL` saves record a redacted old/new feature summary in audit context. Internal audit helper keys are not included in the public `setting_keys` list. - Registry definitions, configured overrides, and available ACL groups are cache-backed with explicit reset hooks. When the unified cache strategy exists, these keys should move into the shared namespace/diagnostics/invalidation model if that reduces operational ambiguity. - `Admin` is a delegated operations role. `Owner` remains the site-control role. - Owner-only defaults include protected secrets, Security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, full-data exports/downloads, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency global operational controls. @@ -145,6 +146,7 @@ The first matrix should use conservative defaults. "View" means the actor may op - A denied delegated Admin action should produce a stable forbidden response/message, not fall through to "not found" unless hiding existence is an explicit policy for that resource. - Live operations must check authority before queueing and again before continuation descriptors start a follow-up operation. - API handlers must enforce the matrix using the API key owner's role and account status, not the key prefix or token label. +- Browser and API callers must apply the same state meaning: visible-only features may render existing controls disabled or return review/read models, while confirmed mutations and sensitive reads require mutable access. - Scheduler run-now and web-trigger controls need separate actions because status viewing, manual run, trigger enablement, and GET-token fallback are different risk levels. - Backup/export/download actions must distinguish redacted summaries from full-data artifacts. - If an action identifier is unknown, deny by default and record safe diagnostics. From 6aa128616e55f2ccce104916e03de07e43b3fe1e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 22:29:28 +0200 Subject: [PATCH 077/119] Update navigation ACL expectations --- tests/Navigation/NavigationBuilderTest.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/Navigation/NavigationBuilderTest.php b/tests/Navigation/NavigationBuilderTest.php index 1d903467..2cc848c3 100644 --- a/tests/Navigation/NavigationBuilderTest.php +++ b/tests/Navigation/NavigationBuilderTest.php @@ -389,6 +389,25 @@ public function testItBuildsBackendViewsFromRegistry(): void ], array_column($navigation, 'url')); self::assertFalse($navigation[0]['active']); self::assertTrue($navigation[1]['active']); + self::assertSame([ + 'admin.navigation.general_settings', + 'admin.navigation.dashboard_settings', + 'admin.navigation.user_settings', + 'admin.navigation.mail_settings', + 'admin.navigation.statistics_settings', + 'admin.navigation.logging_settings', + 'admin.navigation.package_settings', + 'admin.navigation.scheduler_settings', + 'admin.navigation.system_info', + ], array_column($navigation[9]['children'], 'label')); + + $ownerNavigation = self::getContainer()->get(NavigationBuilder::class)->build( + 'backend.admin', + actor: AccessActor::fromAccess(9), + activeUrl: '/admin/settings', + activeRoute: 'backend_admin_route', + ); + self::assertSame([ 'admin.navigation.general_settings', 'admin.navigation.dashboard_settings', @@ -398,10 +417,11 @@ public function testItBuildsBackendViewsFromRegistry(): void 'admin.navigation.statistics_settings', 'admin.navigation.logging_settings', 'admin.navigation.api_settings', + 'admin.navigation.acl_settings', 'admin.navigation.package_settings', 'admin.navigation.scheduler_settings', 'admin.navigation.system_info', - ], array_column($navigation[9]['children'], 'label')); + ], array_column($ownerNavigation[9]['children'], 'label')); } public function testItFiltersNavigationItemsByAccessLevel(): void From 0dbf5f3b5ecb51291436b76dcfcfe518336483cc Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 22:52:13 +0200 Subject: [PATCH 078/119] Improve admin ACL reviewability --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 3 +- .../CoreAdminSettingsBackendViewProvider.php | 87 ++++++ src/Backend/CoreBackendViewProvider.php | 182 ----------- src/Core/AdminAcl/AdminFeatureRegistry.php | 31 +- .../Twig/AdminSettingsFormViewFactory.php | 288 ++++++++++++++++++ src/View/Twig/AdminViewTwigExtension.php | 233 +------------- 7 files changed, 409 insertions(+), 419 deletions(-) create mode 100644 src/Backend/CoreAdminSettingsBackendViewProvider.php create mode 100644 src/View/Twig/AdminSettingsFormViewFactory.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 58187cee..0922f687 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -102,8 +102,8 @@ | Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions; package lifecycle callers are now gated by the thematic `admin.packages` Admin ACL feature before UI action rendering, API confirmation, or LiveOperation start. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | -| Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the first Admin Settings tree plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, ACL-aware operation action states, mutable-only Audit/Security Signal log source exposure, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider`, `App\Backend\CoreAdminSettingsBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the Admin Settings tree through a dedicated provider plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | +| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\View\Twig\AdminSettingsFormViewFactory`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, ACL-aware operation action states, mutable-only Audit/Security Signal log source exposure, database-backed pagination/filtering/sorting, focused settings/package form view models for Twig, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | | 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index bbf500fa..2f81613e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -90,8 +90,9 @@ - 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. - 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`; `bin/lint --diff`. +- 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`. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Backend/CoreAdminSettingsBackendViewProvider.php b/src/Backend/CoreAdminSettingsBackendViewProvider.php new file mode 100644 index 00000000..4523dc0c --- /dev/null +++ b/src/Backend/CoreAdminSettingsBackendViewProvider.php @@ -0,0 +1,87 @@ + + */ + public function backendViews(): array + { + return [ + $this->settingsSection('general', 'admin.navigation.general_settings', 10), + $this->settingsSection('dashboard', 'admin.navigation.dashboard_settings', 20), + $this->settingsSection('users', 'admin.navigation.user_settings', 30), + $this->settingsSection('mail', 'admin.navigation.mail_settings', 40), + $this->settingsSection('security', 'admin.navigation.security_settings', 50, 'admin.settings.security'), + $this->settingsSection('statistics', 'admin.navigation.statistics_settings', 55, 'admin.settings.statistics'), + $this->settingsSection('logging', 'admin.navigation.logging_settings', 57, 'admin.settings.logging'), + $this->settingsSection('api', 'admin.navigation.api_settings', 60, 'admin.settings.api'), + new BackendViewDefinition( + 'backend-admin-settings-acl', + BackendArea::Admin, + 'settings/acl', + 'admin.navigation.acl_settings', + '@backend/admin/settings/acl.html.twig', + 65, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::OWNER, + ), + new BackendViewDefinition( + 'backend-admin-settings-packages', + BackendArea::Admin, + 'settings/packages', + 'admin.navigation.package_settings', + '@backend/admin/settings/packages.html.twig', + 70, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.settings.packages', + ], + ), + $this->settingsSection('scheduler', 'admin.navigation.scheduler_settings', 70, 'admin.settings.scheduler'), + new BackendViewDefinition( + 'backend-admin-settings-system-info', + BackendArea::Admin, + 'settings/system-info', + 'admin.navigation.system_info', + '@backend/admin/settings/system-info.html.twig', + 75, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + ), + ]; + } + + private function settingsSection(string $section, string $label, int $sortOrder, ?string $feature = null): BackendViewDefinition + { + $context = [ + 'title_key' => 'admin.settings.'.$section.'.title', + 'foundation_title_key' => 'admin.settings.'.$section.'.foundation_title', + 'foundation_text_key' => 'admin.settings.'.$section.'.foundation_text', + 'settings_section' => $section, + ]; + + if (null !== $feature) { + $context['access_feature'] = $feature; + } + + return new BackendViewDefinition( + 'backend-admin-settings-'.$section, + BackendArea::Admin, + 'settings/'.$section, + $label, + '@backend/admin/settings/section.html.twig', + $sortOrder, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + context: $context, + ); + } +} diff --git a/src/Backend/CoreBackendViewProvider.php b/src/Backend/CoreBackendViewProvider.php index f29ccce5..db33be15 100644 --- a/src/Backend/CoreBackendViewProvider.php +++ b/src/Backend/CoreBackendViewProvider.php @@ -163,188 +163,6 @@ public function backendViews(): array 900, minimumAccessLevel: AccessLevel::ADMIN, ), - new BackendViewDefinition( - 'backend-admin-settings-general', - BackendArea::Admin, - 'settings/general', - 'admin.navigation.general_settings', - '@backend/admin/settings/section.html.twig', - 10, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.general.title', - 'foundation_title_key' => 'admin.settings.general.foundation_title', - 'foundation_text_key' => 'admin.settings.general.foundation_text', - 'settings_section' => 'general', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-dashboard', - BackendArea::Admin, - 'settings/dashboard', - 'admin.navigation.dashboard_settings', - '@backend/admin/settings/section.html.twig', - 20, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.dashboard.title', - 'foundation_title_key' => 'admin.settings.dashboard.foundation_title', - 'foundation_text_key' => 'admin.settings.dashboard.foundation_text', - 'settings_section' => 'dashboard', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-users', - BackendArea::Admin, - 'settings/users', - 'admin.navigation.user_settings', - '@backend/admin/settings/section.html.twig', - 30, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.users.title', - 'foundation_title_key' => 'admin.settings.users.foundation_title', - 'foundation_text_key' => 'admin.settings.users.foundation_text', - 'settings_section' => 'users', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-mail', - BackendArea::Admin, - 'settings/mail', - 'admin.navigation.mail_settings', - '@backend/admin/settings/section.html.twig', - 40, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.mail.title', - 'foundation_title_key' => 'admin.settings.mail.foundation_title', - 'foundation_text_key' => 'admin.settings.mail.foundation_text', - 'settings_section' => 'mail', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-security', - BackendArea::Admin, - 'settings/security', - 'admin.navigation.security_settings', - '@backend/admin/settings/section.html.twig', - 50, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.security.title', - 'foundation_title_key' => 'admin.settings.security.foundation_title', - 'foundation_text_key' => 'admin.settings.security.foundation_text', - 'settings_section' => 'security', - 'access_feature' => 'admin.settings.security', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-statistics', - BackendArea::Admin, - 'settings/statistics', - 'admin.navigation.statistics_settings', - '@backend/admin/settings/section.html.twig', - 55, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.statistics.title', - 'foundation_title_key' => 'admin.settings.statistics.foundation_title', - 'foundation_text_key' => 'admin.settings.statistics.foundation_text', - 'settings_section' => 'statistics', - 'access_feature' => 'admin.settings.statistics', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-logging', - BackendArea::Admin, - 'settings/logging', - 'admin.navigation.logging_settings', - '@backend/admin/settings/section.html.twig', - 57, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.logging.title', - 'foundation_title_key' => 'admin.settings.logging.foundation_title', - 'foundation_text_key' => 'admin.settings.logging.foundation_text', - 'settings_section' => 'logging', - 'access_feature' => 'admin.settings.logging', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-packages', - BackendArea::Admin, - 'settings/packages', - 'admin.navigation.package_settings', - '@backend/admin/settings/packages.html.twig', - 70, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'access_feature' => 'admin.settings.packages', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-api', - BackendArea::Admin, - 'settings/api', - 'admin.navigation.api_settings', - '@backend/admin/settings/section.html.twig', - 60, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.api.title', - 'foundation_title_key' => 'admin.settings.api.foundation_title', - 'foundation_text_key' => 'admin.settings.api.foundation_text', - 'settings_section' => 'api', - 'access_feature' => 'admin.settings.api', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-acl', - BackendArea::Admin, - 'settings/acl', - 'admin.navigation.acl_settings', - '@backend/admin/settings/acl.html.twig', - 65, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::OWNER, - ), - new BackendViewDefinition( - 'backend-admin-settings-system-info', - BackendArea::Admin, - 'settings/system-info', - 'admin.navigation.system_info', - '@backend/admin/settings/system-info.html.twig', - 75, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - ), - new BackendViewDefinition( - 'backend-admin-settings-scheduler', - BackendArea::Admin, - 'settings/scheduler', - 'admin.navigation.scheduler_settings', - '@backend/admin/settings/section.html.twig', - 70, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.scheduler.title', - 'foundation_title_key' => 'admin.settings.scheduler.foundation_title', - 'foundation_text_key' => 'admin.settings.scheduler.foundation_text', - 'settings_section' => 'scheduler', - 'access_feature' => 'admin.settings.scheduler', - ], - ), new BackendViewDefinition( 'backend-editor-dashboard', BackendArea::Editor, diff --git a/src/Core/AdminAcl/AdminFeatureRegistry.php b/src/Core/AdminAcl/AdminFeatureRegistry.php index bb2059c8..77a6d1c5 100644 --- a/src/Core/AdminAcl/AdminFeatureRegistry.php +++ b/src/Core/AdminAcl/AdminFeatureRegistry.php @@ -19,6 +19,11 @@ final class AdminFeatureRegistry */ private ?array $allDefinitions = null; + /** + * @var array|null + */ + private ?array $definitionsByIdentifier = null; + /** * @param iterable $providers */ @@ -54,13 +59,7 @@ public function definitions(?AdminPermissionSurface $surface = null): array public function find(string $identifier): ?AdminFeatureDefinition { - foreach ($this->allDefinitions() as $definition) { - if ($definition->identifier() === $identifier) { - return $definition; - } - } - - return null; + return $this->definitionMap()[$identifier] ?? null; } /** @@ -93,6 +92,7 @@ function (CacheItemInterface $item): array { public function resetCache(): void { $this->allDefinitions = null; + $this->definitionsByIdentifier = null; try { $this->cache?->delete(self::CACHE_KEY); @@ -100,6 +100,23 @@ public function resetCache(): void } } + /** + * @return array + */ + private function definitionMap(): array + { + if (null !== $this->definitionsByIdentifier) { + return $this->definitionsByIdentifier; + } + + $map = []; + foreach ($this->allDefinitions() as $definition) { + $map[$definition->identifier()] = $definition; + } + + return $this->definitionsByIdentifier = $map; + } + /** * @return list */ diff --git a/src/View/Twig/AdminSettingsFormViewFactory.php b/src/View/Twig/AdminSettingsFormViewFactory.php new file mode 100644 index 00000000..95ce925f --- /dev/null +++ b/src/View/Twig/AdminSettingsFormViewFactory.php @@ -0,0 +1,288 @@ + + */ + public function coreSettingsForm(string $section): array + { + $definitions = $this->coreSettingDefinitions($section); + $mutableDefinitions = array_values(array_filter( + $definitions, + fn (CoreSettingDefinition $definition): bool => $this->coreSettingMutable($definition, $this->actor()), + )); + $values = []; + $request = $this->requestStack->getCurrentRequest(); + + foreach ($definitions as $definition) { + $value = $this->config->get($definition->key()) ?? $definition->defaultValue(); + if (true === ($definition->metadata()['sensitive'] ?? false)) { + $value = ''; + } + + $values[$definition->key()] = $value; + } + + $values = array_replace($values, $this->requestFormValues($request, $this->sensitiveDefinitionKeys($definitions))); + $errors = $this->requestFormErrors($request); + + return $this->formBuilder->build( + 'admin-settings-'.$section, + 'admin.settings.'.$section.'.title', + array_map(static fn ($definition) => $definition->formField(), $definitions), + $values, + $errors, + $errors['__form'] ?? [], + metadata: [ + 'read_only' => [] === $mutableDefinitions, + ], + )->toArray(); + } + + /** + * @return list> + */ + public function packageSettings(string $packageName): array + { + if (!$this->packageFeatureVisible($packageName)) { + return []; + } + + return $this->packageSettings->viewRows($packageName, $this->packageSettingRegistry); + } + + public function packageSetting(string $packageName, string $key, mixed $default = null): mixed + { + return $this->packageSettings->get($packageName, $key, $default); + } + + /** + * @return array + */ + public function packageSettingsForm(string $packageName): array + { + $request = $this->requestStack->getCurrentRequest(); + $errors = $this->requestFormErrors($request); + $fields = $this->packageSettings->formFields($packageName, $this->packageSettingRegistry); + $mutable = $this->packageFeatureMutable($packageName); + $sensitiveKeys = $this->sensitiveFieldKeys($fields); + $values = array_replace( + array_fill_keys($sensitiveKeys, ''), + $this->requestFormValues($request, $sensitiveKeys), + ); + + $form = $this->formBuilder->build( + 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($packageName)), + $packageName, + $fields, + $values, + $errors, + $errors['__form'] ?? [], + metadata: [ + 'read_only' => !$mutable, + ], + )->toArray(); + + if (!$mutable) { + foreach ($form['fields'] as $index => $field) { + if (is_array($field)) { + $metadata = is_array($field['metadata'] ?? null) ? $field['metadata'] : []; + $form['fields'][$index]['metadata'] = [...$metadata, 'disabled' => true]; + } + } + } + + return $form; + } + + /** + * @return list + */ + public function packageSettingPackages(): array + { + $packages = []; + + foreach ($this->packageSettingRegistry->packagesWithDefinitions() as $packageName => $metadata) { + if (!$this->packageFeatureVisible($packageName)) { + continue; + } + + $packages[] = [ + 'package_name' => $packageName, + 'label' => $metadata['label'], + 'description' => $metadata['description'], + 'path' => $metadata['path'], + ]; + } + + return $packages; + } + + /** + * @return list + */ + private function coreSettingDefinitions(string $section): array + { + $actor = $this->actor(); + + return array_values(array_filter( + array_map( + fn (CoreSettingDefinition $definition): CoreSettingDefinition => $this->decorateCoreSettingDefinition($definition, $actor), + $this->coreSettingsRegistry->definitions($section), + ), + fn (CoreSettingDefinition $definition): bool => $this->coreSettingVisible($definition, $actor), + )); + } + + private function decorateCoreSettingDefinition(CoreSettingDefinition $definition, AccessActor $actor): CoreSettingDefinition + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (!is_string($feature) || null === $this->adminAcl) { + return $definition; + } + + $state = $this->adminAcl->state($feature, $actor); + + return $definition->withMetadata([ + 'access_state' => $state->value, + 'disabled' => !$state->isMutable(), + ]); + } + + private function coreSettingVisible(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isVisible($feature, $actor); + } + + return $definition->allows($actor); + } + + private function coreSettingMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } + + private function packageFeatureVisible(string $packageName): bool + { + return null === $this->adminAcl || $this->adminAcl->isVisible('admin.settings.packages.'.$packageName, $this->actor()); + } + + private function packageFeatureMutable(string $packageName): bool + { + return null === $this->adminAcl || $this->adminAcl->isMutable('admin.settings.packages.'.$packageName, $this->actor()); + } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + + /** + * @param list $excludedKeys + * + * @return array + */ + private function requestFormValues(?Request $request, array $excludedKeys = []): array + { + $values = $request?->attributes->get('_system_form_values'); + + if (!is_array($values)) { + return []; + } + + foreach ($excludedKeys as $key) { + unset($values[$key]); + } + + return $values; + } + + /** + * @return array> + */ + private function requestFormErrors(?Request $request): array + { + $errors = $request?->attributes->get('_system_form_errors'); + + return is_array($errors) ? $errors : []; + } + + /** + * @param iterable $definitions + * + * @return list + */ + private function sensitiveDefinitionKeys(iterable $definitions): array + { + $keys = []; + + foreach ($definitions as $definition) { + if (true === ($definition->metadata()['sensitive'] ?? false)) { + $keys[] = $definition->key(); + } + } + + return $keys; + } + + /** + * @param iterable $fields + * + * @return list + */ + private function sensitiveFieldKeys(iterable $fields): array + { + $keys = []; + + foreach ($fields as $field) { + if (true === ($field->metadata()['sensitive'] ?? false)) { + $keys[] = $field->name(); + } + } + + return $keys; + } +} diff --git a/src/View/Twig/AdminViewTwigExtension.php b/src/View/Twig/AdminViewTwigExtension.php index a4ff5e31..ecf43144 100644 --- a/src/View/Twig/AdminViewTwigExtension.php +++ b/src/View/Twig/AdminViewTwigExtension.php @@ -9,19 +9,11 @@ use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; -use App\Core\Config\Settings\CoreSettingDefinition; -use App\Core\Config\Settings\CoreSettingsRegistry; use App\Core\Package\PackageAdminOverview; -use App\Core\Package\Settings\PackageSettingRegistry; -use App\Core\Package\Settings\PackageSettings; use App\Core\Package\ThemeAdminOverview; use App\Entity\UserAccount; -use App\Form\FormBuilder; -use App\Form\FormFieldDefinition; use App\View\SystemPackageMetadataProvider; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; use Throwable; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -30,16 +22,12 @@ final class AdminViewTwigExtension extends AbstractExtension { public function __construct( private readonly Config $config, - private readonly CoreSettingsRegistry $coreSettingsRegistry, - private readonly FormBuilder $formBuilder, + private readonly AdminSettingsFormViewFactory $settingsForms, private readonly BackendActions $backendActions, private readonly PackageAdminOverview $packageAdminOverview, private readonly ThemeAdminOverview $themeAdminOverview, - private readonly PackageSettings $packageSettings, - private readonly PackageSettingRegistry $packageSettingRegistry, private readonly SystemPackageMetadataProvider $systemPackageMetadata, private readonly Security $security, - private readonly RequestStack $requestStack, private readonly ?AdminFeatureAccessPolicy $adminAcl = null, ) { } @@ -123,16 +111,12 @@ public function themes(): array */ public function packageSettings(string $packageName): array { - if (!$this->packageFeatureVisible($packageName)) { - return []; - } - - return $this->packageSettings->viewRows($packageName, $this->packageSettingRegistry); + return $this->settingsForms->packageSettings($packageName); } public function packageSetting(string $packageName, string $key, mixed $default = null): mixed { - return $this->packageSettings->get($packageName, $key, $default); + return $this->settingsForms->packageSetting($packageName, $key, $default); } public function footerCopyright(string $area = 'frontend'): string @@ -157,91 +141,7 @@ public function footerCopyright(string $area = 'frontend'): string */ public function coreSettingsForm(string $section): array { - $definitions = $this->coreSettingDefinitions($section); - $mutableDefinitions = array_values(array_filter( - $definitions, - fn (CoreSettingDefinition $definition): bool => $this->coreSettingMutable($definition, $this->actor()), - )); - $values = []; - $request = $this->requestStack->getCurrentRequest(); - - foreach ($definitions as $definition) { - $value = $this->config->get($definition->key()) ?? $definition->defaultValue(); - if (true === ($definition->metadata()['sensitive'] ?? false)) { - $value = ''; - } - - $values[$definition->key()] = $value; - } - - $values = array_replace($values, $this->requestFormValues($request, $this->sensitiveDefinitionKeys($definitions))); - $errors = $this->requestFormErrors($request); - - return $this->formBuilder->build( - 'admin-settings-'.$section, - 'admin.settings.'.$section.'.title', - array_map(static fn ($definition) => $definition->formField(), $definitions), - $values, - $errors, - $errors['__form'] ?? [], - metadata: [ - 'read_only' => [] === $mutableDefinitions, - ], - )->toArray(); - } - - /** - * @return list - */ - private function coreSettingDefinitions(string $section): array - { - $actor = $this->actor(); - - return array_values(array_filter( - array_map( - fn (CoreSettingDefinition $definition): CoreSettingDefinition => $this->decorateCoreSettingDefinition($definition, $actor), - $this->coreSettingsRegistry->definitions($section), - ), - fn (CoreSettingDefinition $definition): bool => $this->coreSettingVisible($definition, $actor), - )); - } - - private function decorateCoreSettingDefinition(CoreSettingDefinition $definition, AccessActor $actor): CoreSettingDefinition - { - $feature = $definition->metadata()['access_feature'] ?? null; - - if (!is_string($feature) || null === $this->adminAcl) { - return $definition; - } - - $state = $this->adminAcl->state($feature, $actor); - - return $definition->withMetadata([ - 'access_state' => $state->value, - 'disabled' => !$state->isMutable(), - ]); - } - - private function coreSettingVisible(CoreSettingDefinition $definition, AccessActor $actor): bool - { - $feature = $definition->metadata()['access_feature'] ?? null; - - if (is_string($feature) && null !== $this->adminAcl) { - return $this->adminAcl->isVisible($feature, $actor); - } - - return $definition->allows($actor); - } - - private function coreSettingMutable(CoreSettingDefinition $definition, AccessActor $actor): bool - { - $feature = $definition->metadata()['access_feature'] ?? null; - - if (is_string($feature) && null !== $this->adminAcl) { - return $this->adminAcl->isMutable($feature, $actor); - } - - return $definition->allows($actor); + return $this->settingsForms->coreSettingsForm($section); } /** @@ -249,38 +149,7 @@ private function coreSettingMutable(CoreSettingDefinition $definition, AccessAct */ public function packageSettingsForm(string $packageName): array { - $request = $this->requestStack->getCurrentRequest(); - $errors = $this->requestFormErrors($request); - $fields = $this->packageSettings->formFields($packageName, $this->packageSettingRegistry); - $mutable = $this->packageFeatureMutable($packageName); - $sensitiveKeys = $this->sensitiveFieldKeys($fields); - $values = array_replace( - array_fill_keys($sensitiveKeys, ''), - $this->requestFormValues($request, $sensitiveKeys), - ); - - $form = $this->formBuilder->build( - 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($packageName)), - $packageName, - $fields, - $values, - $errors, - $errors['__form'] ?? [], - metadata: [ - 'read_only' => !$mutable, - ], - )->toArray(); - - if (!$mutable) { - foreach ($form['fields'] as $index => $field) { - if (is_array($field)) { - $metadata = is_array($field['metadata'] ?? null) ? $field['metadata'] : []; - $form['fields'][$index]['metadata'] = [...$metadata, 'disabled' => true]; - } - } - } - - return $form; + return $this->settingsForms->packageSettingsForm($packageName); } /** @@ -288,32 +157,7 @@ public function packageSettingsForm(string $packageName): array */ public function packageSettingPackages(): array { - $packages = []; - - foreach ($this->packageSettingRegistry->packagesWithDefinitions() as $packageName => $metadata) { - if (!$this->packageFeatureVisible($packageName)) { - continue; - } - - $packages[] = [ - 'package_name' => $packageName, - 'label' => $metadata['label'], - 'description' => $metadata['description'], - 'path' => $metadata['path'], - ]; - } - - return $packages; - } - - private function packageFeatureVisible(string $packageName): bool - { - return null === $this->adminAcl || $this->adminAcl->isVisible('admin.settings.packages.'.$packageName, $this->actor()); - } - - private function packageFeatureMutable(string $packageName): bool - { - return null === $this->adminAcl || $this->adminAcl->isMutable('admin.settings.packages.'.$packageName, $this->actor()); + return $this->settingsForms->packageSettingPackages(); } private function defaultFooterCopyright(): string @@ -335,69 +179,4 @@ private function actor(): AccessActor return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); } - /** - * @param list $excludedKeys - * - * @return array - */ - private function requestFormValues(?Request $request, array $excludedKeys = []): array - { - $values = $request?->attributes->get('_system_form_values'); - - if (!is_array($values)) { - return []; - } - - foreach ($excludedKeys as $key) { - unset($values[$key]); - } - - return $values; - } - - /** - * @return array> - */ - private function requestFormErrors(?Request $request): array - { - $errors = $request?->attributes->get('_system_form_errors'); - - return is_array($errors) ? $errors : []; - } - - /** - * @param iterable $definitions - * - * @return list - */ - private function sensitiveDefinitionKeys(iterable $definitions): array - { - $keys = []; - - foreach ($definitions as $definition) { - if (true === ($definition->metadata()['sensitive'] ?? false)) { - $keys[] = $definition->key(); - } - } - - return $keys; - } - - /** - * @param iterable $fields - * - * @return list - */ - private function sensitiveFieldKeys(iterable $fields): array - { - $keys = []; - - foreach ($fields as $field) { - if (true === ($field->metadata()['sensitive'] ?? false)) { - $keys[] = $field->name(); - } - } - - return $keys; - } } From 9db0f3d1f94f8c61031fa9ce9481698dd4b4556c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 22:56:55 +0200 Subject: [PATCH 079/119] Record admin ACL audit follow-up --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 2f81613e..5887ba7c 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -71,6 +71,7 @@ - [ ] 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. - [ ] 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. - [ ] 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. From 35068b62e3efe58be88926588cbce3ca80ebbb56 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Tue, 16 Jun 2026 23:49:27 +0200 Subject: [PATCH 080/119] Fix delegated admin ACL bypasses --- dev/CLASSMAP.md | 8 +-- dev/WORKLOG.md | 1 + dev/draft/0.2.x-SecurityAccessControl.md | 1 + .../admin-acl-enforcement.md | 2 + .../security-hardening/policy-defaults.md | 2 + src/Api/Admin/AdminOperationApiHandler.php | 9 ++++ src/Backend/AdminOperationFeatureResolver.php | 25 +++++++++ src/Controller/AdminOperationController.php | 27 ++++++++++ .../AdminUserInvitationController.php | 23 ++++++-- .../Api/UserGroupMembershipApiHandler.php | 2 +- .../backend/admin/operations/detail.html.twig | 2 +- templates/backend/admin/users/index.html.twig | 3 +- tests/Controller/AdminUserControllerTest.php | 51 ++++++++++++++++++ .../ApiAdminOperationalControllerTest.php | 13 +++++ tests/Controller/ApiUserControllerTest.php | 45 ++++++++++++++++ tests/Controller/BackendControllerTest.php | 54 +++++++++++++++++++ 16 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 src/Backend/AdminOperationFeatureResolver.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 0922f687..7c1ec2dc 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-16 > **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. @@ -86,7 +86,7 @@ | Enum | `App\Core\Access\AccessLevel` | Shared 0-9 access-level constants and validation for public, user, moderator, author, publisher, curator, manager, director, admin, and owner role tiers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\Access\AccessResolver` | Resolves inherited level-plus-group ACL rules for actors and capabilities. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php` | | Value object | `App\Core\Access\AccessRule` | Value object for explicit or inherited min-level plus group ACL rules, including the shared stored-group identifier normalization used by content, schema, and menu ACL fields. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Core/Access/AccessRuleTest.php` | -| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState`, `App\Api\Admin\AdminFeatureApiGuard` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, denied/visible/mutable state evaluation, API-key-owner feature checks, and redacted matrix-change audit summaries used by settings forms, backend actions, package/theme UI/API callers, user/ACL/review workflows, scheduler/operations/log APIs, dynamic package settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | +| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState`, `App\Api\Admin\AdminFeatureApiGuard`, `App\Backend\AdminOperationFeatureResolver` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, denied/visible/mutable state evaluation, API-key-owner feature checks, operation-continuation target-feature resolution, and redacted matrix-change audit summaries used by settings forms, backend actions, package/theme UI/API callers, user/ACL/review workflows, scheduler/operations/log APIs, dynamic package settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | | Interface | `App\Security\AccessLevelAwareUserInterface` | Symfony user bridge for security users that expose the project's role-derived ACL access level. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Enum | `App\Security\UserRole` | Global account role enum that maps one exact user role to inherited Symfony roles and numeric ACL access levels. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserGroupMembershipManager`, `App\Security\AccountReactivationAccessResolver` | Shared user-group membership and deleted-account reactivation helpers used by admin and account-link flows so controllers do not duplicate group replacement, existing-group filtering, or role-preserving reactivation decisions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | @@ -318,14 +318,14 @@ | Routes `backend_admin_users`, `backend_admin_deleted_users`, `backend_admin_deleted_users_cleanup`, `backend_admin_deleted_user_activate`, `backend_admin_deleted_user_deactivate`, `backend_admin_user_detail`, `backend_admin_user_password_reset` | `App\Controller\AdminUserController` | Admin account list/detail HTTP adapter for searchable/paginated non-deleted accounts, retained deleted-account inspection, retention cleanup, account lifecycle marker summaries, filtered Audit Log links, status/role/group forms, and password-reset actions; assignment options, update mutations, deleted-account status changes, and reset-token creation live in Security services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_groups`, `backend_admin_user_group_detail`, `backend_admin_user_group_delete` | `App\Controller\AdminAclGroupController` | Admin ACL group routes with `details/` dynamic paths for searchable/paginated ACL groups, group detail member summaries, create/update/delete actions, hierarchy guardrails, impact review before updates/deletes, and LiveLog-backed group apply. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_reviews`, `backend_admin_user_review_reactivate`, `backend_admin_user_review_delete` | `App\Controller\AdminUserReviewController` | Admin review queue routes with `details/` disputed-account actions for contextual registration/invitation/dispute rows plus disputed-account reactivation and confirmed delete actions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserReviewControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | -| Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | +| Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, token-state-derived review/user ACL selection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | | 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_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. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.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` | | Route `api_live_package_dispatch` | `App\Controller\LiveEndpointController` | Dispatches package-owned live endpoint definitions below `/api/live/{package_slug}/...` while system routes keep priority for reserved live branches, endpoint minimum access levels are enforced before handler execution, and pattern matches are rejected when the matched route slug does not belong to the endpoint owner. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php` | | Route `privacy_cookie_consent` | `App\Controller\CookieConsentController` | Validates visitor-bound stateless cookie-consent form tokens, stores selected optional cookie consent in a signed long-lived required cookie, expires optional cookies whose consent was withdrawn, and redirects back only to safe relative paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Privacy/Cookie/CookieConsentManagerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 5887ba7c..6a6aad58 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -92,6 +92,7 @@ - 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. - 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`. diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index b755994f..e3cd5654 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -92,6 +92,7 @@ Captcha should use a global form field integration. When a workflow includes the - Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. - Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency operational controls. - Treat this matrix as a code-owned registry plus seeded bounded Owner overrides for descriptor-approved rows. The Owner-gated `Settings/ACL` view may delegate selected Admin denied, visible, or mutable permissions and may add optional ACL-group states after the Admin surface gate is satisfied. Matching group states override the role/default state and may grant or restrict access; multiple matching groups resolve to the highest explicit group state. The matrix must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, bypass the Admin/Editor surface gates, or remove Owner recovery protections. +- Assign ACL matrix rows by the responsibility of the action being performed, not only by the view or menu area where the control is rendered. Editing ACL group definitions uses the ACL-group administration permission, assigning a group to a user account uses the user-management permission, registration review actions use the review permission even from the user-management view, and continuing a reviewed live operation re-checks the target domain permission. - Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index 440c2f8f..ee75f8e0 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -15,6 +15,8 @@ This branch should make it obvious which Admin features are operational delegati Descriptors are intentionally domain-owned. Core provides the first lightweight registry/provider boundary so relevant domains can register thematic features and default states without every element or action becoming a new permission. UI controls, backend actions, API handlers, and other callers should attach a simple stable feature key only where granular gating is required. Generic infrastructure such as Live Operations stays ungated by default; the domain-specific caller that starts a sensitive operation enforces the feature key before queueing or confirming work. +Feature keys follow operational responsibility rather than whichever view happens to render the control. A Settings, Operations, or User view may therefore invoke a different domain feature when the action mutates that domain. ACL group definition management belongs to `admin.users.acl`; assigning those groups to a user account belongs to `admin.users`; registration review actions belong to `admin.users.review` even when the button is rendered from the user-management view; and confirming a queued package continuation from Operations must still require the relevant package feature. + The first implementation caches the Admin ACL feature registry, configured overrides, and ACL-group availability through the same small Symfony cache pattern used by suspicious-probe matching: service-local memory, `cache.app` when available, a short 300-second TTL, safe fallback on cache failures, and explicit invalidation after matrix saves, ACL-group mutations, and package lifecycle or registry changes that affect dynamic package-settings rows. This is a feature-local performance guard for the matrix and permission checks, not the final cross-domain cache architecture. Owner-facing ACL settings should expose a bounded configuration matrix with one row per protected feature/action: diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 353dd377..e3749b16 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -71,6 +71,8 @@ Default authority policy: - Protected values remain write-only or status-only even for Owners unless a workflow explicitly implements a reveal flow with re-authentication, audit, and redaction rules. - Permission-aware navigation is not the security boundary. Controllers, API handlers, live-operation starters, scheduler triggers, and service-layer workflows must all call the same action policy before mutating or revealing high-impact data. +ACL rules are assigned by operational responsibility, not necessarily by the current view, menu, or route area that exposes a control. For example, editing ACL group definitions belongs to `admin.users.acl`, while adding or removing groups on a user account mutates that user account and belongs to `admin.users`. Review actions stay under their review permission even when rendered from a broader user-management view, so approving or rejecting pending registrations belongs to `admin.users.review`. Likewise, confirming a reviewed live operation must re-check the target operation's domain feature, such as `admin.packages`, instead of relying only on the Operations view permission. + The first implementation should introduce a route/action authority matrix or policy service for existing Admin surfaces instead of relying on ad hoc controller checks. Existing user-management guardrails remain the model for peer-role and last-Owner protection, but they are not sufficient for package, scheduler, backup, settings, diagnostics, and update workflows. ## Rate-Limit Defaults diff --git a/src/Api/Admin/AdminOperationApiHandler.php b/src/Api/Admin/AdminOperationApiHandler.php index 553c5bbe..b7a12575 100644 --- a/src/Api/Admin/AdminOperationApiHandler.php +++ b/src/Api/Admin/AdminOperationApiHandler.php @@ -11,6 +11,7 @@ use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; +use App\Backend\AdminOperationFeatureResolver; use App\Core\Access\AccessLevel; use App\Core\Log\AuditLoggerInterface; use App\Core\Message\CommonMessageCode; @@ -30,6 +31,7 @@ public function __construct( private ApiAccessGuard $accessGuard, private ApiResponder $responder, private AdminFeatureApiGuard $featureGuard, + private AdminOperationFeatureResolver $operationFeatures, ) { } @@ -173,6 +175,13 @@ private function continueOperation(Request $request, string $operationId): Respo ]); } + if ($request->query->getBoolean('confirm')) { + $targetFeature = $this->operationFeatures->mutationFeatureForOperation((string) $continuation['operation']); + if (is_string($targetFeature) && $denied = $this->featureGuard->denyUnlessMutable($request, $targetFeature, 'continueAdminOperation')) { + return $denied; + } + } + if (!$request->query->getBoolean('confirm')) { return $this->responder->data([ 'type' => 'operation_continuation', diff --git a/src/Backend/AdminOperationFeatureResolver.php b/src/Backend/AdminOperationFeatureResolver.php new file mode 100644 index 00000000..b3941125 --- /dev/null +++ b/src/Backend/AdminOperationFeatureResolver.php @@ -0,0 +1,25 @@ + 'admin.packages', + LiveOperationQueueFactory::PACKAGE_ASSET_REBUILD, + LiveOperationQueueFactory::BACKEND_CACHE_CLEAR => 'admin.actions.maintenance', + LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE => 'admin.settings.statistics.geoip', + LiveOperationQueueFactory::ACL_GROUP_APPLY => 'admin.users.acl', + default => null, + }; + } +} diff --git a/src/Controller/AdminOperationController.php b/src/Controller/AdminOperationController.php index 832a9f56..a7a432d6 100644 --- a/src/Controller/AdminOperationController.php +++ b/src/Controller/AdminOperationController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Backend\AdminOperationFeatureResolver; use App\Backend\BackendArea; use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\Message; @@ -32,6 +33,7 @@ public function __construct( private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, private readonly AdminFeatureAccessPolicy $adminAcl, + private readonly AdminOperationFeatureResolver $operationFeatures, ) { } @@ -125,6 +127,7 @@ public function detail(Request $request, string $operationId): Response 'navigation' => $this->adminContext->navigation($request, $this->getUser()), 'operation_report' => $report, 'operations_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), + 'operation_continuation_mutable' => $this->operationContinuationMutable($operationId), ]); } @@ -152,6 +155,14 @@ public function continue(Request $request, string $operationId): Response return $this->redirectToRoute('backend_admin_operation_detail', ['operationId' => $operationId]); } + $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: [ + 'feature' => $targetFeature, + 'required_state' => 'mutable', + ]); + } + $result = $this->liveOperationStarter->start( $continuation['operation'], $continuation['payload'], @@ -199,6 +210,22 @@ private function featureResponse(Request $request, bool $mutable): ?Response ]); } + private function operationContinuationMutable(string $operationId): bool + { + if (!$this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser()))) { + return false; + } + + $continuation = $this->liveOperationRunStore->continuationForOperator($operationId); + if (null === $continuation) { + return false; + } + + $targetFeature = $this->operationFeatures->mutationFeatureForOperation((string) $continuation['operation']); + + return !is_string($targetFeature) || $this->adminAcl->isMutable($targetFeature, $this->adminContext->actor($this->getUser())); + } + private function stringField(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Controller/AdminUserInvitationController.php b/src/Controller/AdminUserInvitationController.php index dacc2110..c3c52fb5 100644 --- a/src/Controller/AdminUserInvitationController.php +++ b/src/Controller/AdminUserInvitationController.php @@ -10,13 +10,17 @@ use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; +use App\Entity\AccountToken; use App\Entity\UserAccount; +use App\Security\AccountTokenStatus; +use App\Security\AccountTokenType; use App\Security\AdminUserInvitationWorkflow; use App\Security\UserRole; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,6 +34,7 @@ public function __construct( private readonly AdminUserInvitationWorkflow $invitationWorkflow, private readonly UiAlertDispatcherInterface $alerts, private readonly AdminFeatureAccessPolicy $adminAcl, + private readonly EntityManagerInterface $entityManager, ) { } @@ -85,7 +90,7 @@ public function reissue(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } - if ($response = $this->featureResponse($request, $this->tokenActionFeature($request))) { + if ($response = $this->featureResponse($request, $this->tokenActionFeature($uid))) { return $response; } @@ -107,7 +112,7 @@ public function revoke(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } - if ($response = $this->featureResponse($request, $this->tokenActionFeature($request))) { + if ($response = $this->featureResponse($request, $this->tokenActionFeature($uid))) { return $response; } @@ -173,9 +178,19 @@ private function featureResponse(Request $request, string $feature): ?Response ]); } - private function tokenActionFeature(Request $request): string + private function tokenActionFeature(string $uid): string { - return 'reviews' === $this->field($request, 'return_to') ? 'admin.users.review' : 'admin.users'; + $token = $this->entityManager->find(AccountToken::class, $uid); + + if ( + $token instanceof AccountToken + && AccountTokenType::Registration === $token->type() + && AccountTokenStatus::PendingApproval === $token->status() + ) { + return 'admin.users.review'; + } + + return 'admin.users'; } private function actor(): AccessActor diff --git a/src/Security/Api/UserGroupMembershipApiHandler.php b/src/Security/Api/UserGroupMembershipApiHandler.php index b41cf927..ed4dac97 100644 --- a/src/Security/Api/UserGroupMembershipApiHandler.php +++ b/src/Security/Api/UserGroupMembershipApiHandler.php @@ -51,7 +51,7 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } - if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'updateAdminUserGroupMembership')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users', 'updateAdminUserGroupMembership')) { return $denied; } diff --git a/templates/backend/admin/operations/detail.html.twig b/templates/backend/admin/operations/detail.html.twig index c64a98e6..6155555b 100644 --- a/templates/backend/admin/operations/detail.html.twig +++ b/templates/backend/admin/operations/detail.html.twig @@ -90,7 +90,7 @@ {% if report.result.can_continue|default(false) %}
-
diff --git a/templates/backend/admin/users/index.html.twig b/templates/backend/admin/users/index.html.twig index 80c9146f..8399a972 100644 --- a/templates/backend/admin/users/index.html.twig +++ b/templates/backend/admin/users/index.html.twig @@ -194,6 +194,7 @@ {% for token in pending_tokens %} + {% set token_revoke_mutable = token.status.value == 'pending_approval' ? reviews_mutable : users_mutable %} {{ token.email }} {{ ('admin.users.token_type.' ~ token.type.value)|trans }} {{ ('admin.users.roles.' ~ token.role.value)|trans }} @@ -213,7 +214,7 @@ {% endif %}
- +
diff --git a/tests/Controller/AdminUserControllerTest.php b/tests/Controller/AdminUserControllerTest.php index ed65bd02..a8f0151a 100644 --- a/tests/Controller/AdminUserControllerTest.php +++ b/tests/Controller/AdminUserControllerTest.php @@ -1374,6 +1374,57 @@ public function testLowerAccessAdminCannotRevokeOwnerRecoveryToken(): void $entityManager->flush(); } + public function testPendingApprovalRevocationRequiresReviewFeatureEvenWithoutReviewReturnTarget(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $admin = $this->createUser('reviewrevoker', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + [$token] = self::getContainer()->get(AccountTokenIssuer::class)->issue( + AccountTokenType::Registration, + 'review-revoke@example.test', + [], + status: AccountTokenStatus::PendingApproval, + ); + $entityManager->persist($token); + $entityManager->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $crawler = $client->request('GET', '/admin/users'); + $revokeForm = $crawler->filter('form[action="/admin/users/invitations/'.$token->uid().'/revoke"]'); + + self::assertCount(1, $revokeForm); + self::assertNotNull($revokeForm->filter('button[type="submit"]')->attr('disabled')); + + $client->request('POST', '/admin/users/invitations/'.$token->uid().'/revoke', [ + '_csrf_token' => (string) $revokeForm->filter('input[name="_csrf_token"]')->attr('value'), + ]); + + self::assertResponseStatusCodeSame(401); + + $entityManager->clear(); + $unchangedToken = $entityManager->find(AccountToken::class, $token->uid()); + self::assertInstanceOf(AccountToken::class, $unchangedToken); + self::assertSame(AccountTokenStatus::PendingApproval, $unchangedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAdminCanRevokeInvitationWithStaleEmptyGroups(): void { $client = self::createClient(); diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index d60d96fc..ad33de58 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -171,6 +171,10 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi 'state' => AdminPermissionState::Mutable->value, 'groups' => [], ], + 'admin.packages' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], ], 'test'); try { @@ -204,6 +208,15 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi self::assertArrayNotHasKey('payload', $payload['data']['attributes']); self::assertSame('/api/v1/admin/operations/'.$run['operation_id'], $payload['links']['status']); self::assertSame('/api/v1/admin/operations/'.$run['operation_id'].'/continue?confirm=true', $payload['links']['confirm']); + + $client->request('POST', '/api/v1/admin/operations/'.$run['operation_id'].'/continue?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainWriteKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.packages', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); } finally { $overrides->save($overrides->defaultOverrides(), 'test'); @unlink(dirname($store->outputPath($run['operation_id'])).'/'.$run['operation_id'].'.json'); diff --git a/tests/Controller/ApiUserControllerTest.php b/tests/Controller/ApiUserControllerTest.php index e27d042f..ce7635a0 100644 --- a/tests/Controller/ApiUserControllerTest.php +++ b/tests/Controller/ApiUserControllerTest.php @@ -330,6 +330,8 @@ public function testAdminUserFeatureReadOnlyStillListsButRejectsMutations(): voi { $client = self::createClient(); $target = $this->createUserWithLevel(AccessLevel::AUTHOR, 'apiuserfeature', 'current-password'); + $group = $this->createGroup('api_user_feature_group', AccessLevel::USER); + self::getContainer()->get(EntityManagerInterface::class)->flush(); $plainKey = $this->createPlainApiKey('apiusrfeat', ApiKeyStatus::ReadWrite); $store = self::getContainer()->get(AdminFeatureOverrideStore::class); self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); @@ -360,6 +362,15 @@ public function testAdminUserFeatureReadOnlyStillListsButRejectsMutations(): voi self::assertSame('api.operation_unavailable', $payload['error']['code']); self::assertSame('admin.users', $payload['error']['context']['feature']); self::assertSame('feature_read_only', $payload['error']['context']['reason']); + + $client->request('POST', '/api/v1/admin/users/items/'.$target->username().'/groups/'.$group->identifier(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.users', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); } finally { $store->save($store->defaultOverrides(), 'test'); } @@ -404,6 +415,40 @@ public function testAdminUserAclFeatureReadOnlyStillListsButRejectsGroupMutation } } + public function testUserGroupMembershipDoesNotRequireGroupAdministrationFeature(): void + { + $client = self::createClient(); + $target = $this->createUserWithLevel(AccessLevel::AUTHOR, 'apiusermemuser', 'current-password'); + $group = $this->createGroup('api_member_user_feature', AccessLevel::USER); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $plainKey = $this->createPlainApiKey('apiusrmemusr', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.users.acl' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('POST', '/api/v1/admin/users/items/'.$target->username().'/groups/'.$group->identifier(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertContains($group->identifier(), array_column($payload['data']['attributes']['groups'], 'identifier')); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAclGroupsCanBeCreatedAndEditedWithConfirmation(): void { $client = self::createClient(); diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index cbfce7ca..3e828ff8 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -778,6 +778,60 @@ public function testAdminOperationDetailExposesReviewContinuation(): void } } + public function testAdminOperationContinuationRequiresTargetFeatureMutation(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, 8); + $store = self::getContainer()->get(LiveOperationRunStore::class); + self::assertInstanceOf(LiveOperationRunStore::class, $store); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $overrides); + $run = $store->create('package.install.verify', [], 'Install package'); + $result = WorkflowResult::requiresReview(null, [ + Message::info( + OperationMessageCode::OPERATION_ACTION_REQUIRED, + OperationMessageKey::OPERATION_ACTION_REQUIRED, + ['%operation%' => 'Install package'], + ), + ], [ + 'live_operation_continuation' => [ + 'operation' => 'package.install.apply', + 'payload' => ['install_id' => 'aaaaaaaaaaaaaaaaaaaaaaaa', 'package' => 'demo-module'], + 'label' => 'Install package', + ], + ]); + $store->finish($run['operation_id'], false, $result->toArray()); + + $overrides->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.packages' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $crawler = $client->request('GET', '/admin/operations/'.$run['operation_id']); + + self::assertResponseIsSuccessful(); + $form = $crawler->filter(sprintf('form[action="/admin/operations/%s/continue"]', $run['operation_id'])); + self::assertCount(1, $form); + self::assertNotNull($form->filter('button[type="submit"]')->attr('disabled')); + + $client->submit($form->form()); + + self::assertResponseStatusCodeSame(401); + } finally { + $overrides->save($overrides->defaultOverrides(), 'test'); + @unlink(dirname($store->outputPath($run['operation_id'])).'/'.$run['operation_id'].'.json'); + @unlink($store->outputPath($run['operation_id'])); + @unlink($store->pidPath($run['operation_id'])); + } + } + public function testAdminBackendActionFormsRunPackageDiscoveryImmediately(): void { $client = self::createClient(); From cc5ffc8b8609be10f49c951269ac30d5fb3e5a9a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 00:46:47 +0200 Subject: [PATCH 081/119] Fix admin ACL review findings --- dev/WORKLOG.md | 3 +- dev/draft/0.2.x-SecurityAccessControl.md | 2 +- .../admin-acl-enforcement.md | 7 +- .../security-hardening/policy-defaults.md | 6 +- src/Controller/AdminSchedulerController.php | 31 +++++ .../AdminUserInvitationController.php | 26 +--- .../Config/Settings/CoreSettingsRegistry.php | 13 +- .../backend/admin/scheduler/detail.html.twig | 10 +- templates/backend/admin/users/index.html.twig | 4 +- .../AdminSchedulerControllerTest.php | 75 +++++++++++ tests/Controller/AdminUserControllerTest.php | 68 ++++++++++ .../ApiAdminOperationalControllerTest.php | 51 ++++++++ .../Controller/ApiSettingsControllerTest.php | 88 +++++++++++-- tests/Controller/ApiUserControllerTest.php | 118 ++++++++++++++++++ tests/Controller/BackendControllerTest.php | 55 ++++++++ tests/Scheduler/SchedulerRunnerTest.php | 8 +- 16 files changed, 510 insertions(+), 55 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 6a6aad58..dd0281a4 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -93,8 +93,9 @@ - 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`. +- 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`. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index e3cd5654..fe52d943 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -92,7 +92,7 @@ Captcha should use a global form field integration. When a workflow includes the - Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. - Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency operational controls. - Treat this matrix as a code-owned registry plus seeded bounded Owner overrides for descriptor-approved rows. The Owner-gated `Settings/ACL` view may delegate selected Admin denied, visible, or mutable permissions and may add optional ACL-group states after the Admin surface gate is satisfied. Matching group states override the role/default state and may grant or restrict access; multiple matching groups resolve to the highest explicit group state. The matrix must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, bypass the Admin/Editor surface gates, or remove Owner recovery protections. -- Assign ACL matrix rows by the responsibility of the action being performed, not only by the view or menu area where the control is rendered. Editing ACL group definitions uses the ACL-group administration permission, assigning a group to a user account uses the user-management permission, registration review actions use the review permission even from the user-management view, and continuing a reviewed live operation re-checks the target domain permission. +- Assign ACL matrix rows by the responsibility of the action being performed, not only by the view or menu area where the control is rendered. Editing ACL group definitions uses the ACL-group administration permission, assigning a group to a user account uses the user-management permission, pending account-token review actions use the review permission even from the user-management view, trusted registered Scheduler task run-now uses the Scheduler permission, and continuing a reviewed live operation re-checks the target domain permission. - Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md index ee75f8e0..bfb5dd4e 100644 --- a/dev/draft/security-hardening/admin-acl-enforcement.md +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -15,7 +15,7 @@ This branch should make it obvious which Admin features are operational delegati Descriptors are intentionally domain-owned. Core provides the first lightweight registry/provider boundary so relevant domains can register thematic features and default states without every element or action becoming a new permission. UI controls, backend actions, API handlers, and other callers should attach a simple stable feature key only where granular gating is required. Generic infrastructure such as Live Operations stays ungated by default; the domain-specific caller that starts a sensitive operation enforces the feature key before queueing or confirming work. -Feature keys follow operational responsibility rather than whichever view happens to render the control. A Settings, Operations, or User view may therefore invoke a different domain feature when the action mutates that domain. ACL group definition management belongs to `admin.users.acl`; assigning those groups to a user account belongs to `admin.users`; registration review actions belong to `admin.users.review` even when the button is rendered from the user-management view; and confirming a queued package continuation from Operations must still require the relevant package feature. +Feature keys follow operational responsibility rather than whichever view happens to render the control. A Settings, Operations, or User view may therefore invoke a different domain feature when the action mutates that domain. ACL group definition management belongs to `admin.users.acl`; assigning those groups to a user account belongs to `admin.users`; pending account-token review actions belong to `admin.users.review` even when the button is rendered from the user-management view; confirming a queued package continuation from Operations must still require the relevant package feature; and trusted registered Scheduler tasks are governed by `admin.scheduler` rather than by every feature touched by the task implementation. The first implementation caches the Admin ACL feature registry, configured overrides, and ACL-group availability through the same small Symfony cache pattern used by suspicious-probe matching: service-local memory, `cache.app` when available, a short 300-second TTL, safe fallback on cache failures, and explicit invalidation after matrix saves, ACL-group mutations, and package lifecycle or registry changes that affect dynamic package-settings rows. This is a feature-local performance guard for the matrix and permission checks, not the final cross-domain cache architecture. @@ -100,7 +100,7 @@ The first matrix should use conservative defaults. "View" means the actor may op | API settings | View status and own/user-token surfaces where already allowed | View and mutate global API settings | Enabling public API/CORS expansion, wildcard-like origins, or broad anonymous access is Owner-only. | | Package/theme overview | View installed/available status | View and mutate | Installing, activating, deactivating, updating, purging, and running package lifecycle actions are Owner-only by default. | | Package settings | View and mutate simple non-sensitive package settings if the package declares them Admin-safe | View and mutate all package settings within package policy | Package settings that alter routes, permissions, external credentials, data access, or runtime code are Owner-only. | -| Scheduler | View status and run safe non-destructive tasks when allowed | View and mutate | Enabling scheduler web trigger, GET-token fallback, package ActionQueue tasks, or destructive tasks is Owner-only. | +| Scheduler | View status and run trusted registered tasks when allowed | View and mutate | Enabling scheduler web trigger, GET-token fallback, package ActionQueue tasks, or destructive tasks is Owner-only. Trusted core tasks run under `admin.scheduler`; package-provided ActionQueue tasks still require the package ActionQueue setting and explicit activation. | | Operations/action logs | View redacted operation summaries | View redacted summaries and emergency controls | Clearing stale locks may be Admin-safe; emergency stop/kill, retrying destructive operations, and continuation of Owner-only workflows are Owner-only. | | Cache/asset rebuild | Mutate when non-destructive and recoverable | Mutate | Rebuilds remain audited and use the live-operation shell. | | Backup/export/download | View redacted status | View and mutate | Backup creation may later be Admin-mutable if it produces protected storage-only artifacts; full download/export/restore stays Owner-only by default. | @@ -149,7 +149,8 @@ The first matrix should use conservative defaults. "View" means the actor may op - Live operations must check authority before queueing and again before continuation descriptors start a follow-up operation. - API handlers must enforce the matrix using the API key owner's role and account status, not the key prefix or token label. - Browser and API callers must apply the same state meaning: visible-only features may render existing controls disabled or return review/read models, while confirmed mutations and sensitive reads require mutable access. -- Scheduler run-now and web-trigger controls need separate actions because status viewing, manual run, trigger enablement, and GET-token fallback are different risk levels. +- Scheduler run-now uses the Scheduler feature for trusted registered tasks because delegating Scheduler mutation means delegating operation of that configured task list. This differs from Operations continuations, where arbitrary queued continuation descriptors must re-check their target-domain feature before starting follow-up work. +- Scheduler web-trigger controls still need separate settings because status viewing, manual run, trigger enablement, and GET-token fallback are different risk levels. - Backup/export/download actions must distinguish redacted summaries from full-data artifacts. - If an action identifier is unknown, deny by default and record safe diagnostics. - Configurable policy must not create a state where no Owner can recover or where Admins can silently promote themselves to Owner-equivalent authority. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index e3749b16..3e9e0338 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -65,11 +65,11 @@ Owner/Admin protection does not bypass authentication validity, account status, Default authority policy: - Admins may view normal Admin dashboards, package/theme overviews, scheduler status, redacted log/audit/security diagnostics, non-secret settings, user review queues, and operational summaries. -- Admins may mutate non-owner user accounts, ACL groups below their own role level, registration approvals, password-reset link creation, bounded non-secret settings, cache/asset rebuilds, and clearly non-destructive scheduler run-now actions when the owning workflow allows it. +- Admins may mutate non-owner user accounts, ACL groups below their own role level, pending account-token review actions, password-reset link creation, bounded non-secret settings, cache/asset rebuilds, and trusted registered scheduler run-now actions when the owning workflow allows it. - Owners are required for Owner/Admin account promotion or demotion, peer Admin changes, last-Owner-sensitive actions, protected secret configuration, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, backup/download/export of full system data, self-update/release actions, destructive package/data purge, and emergency operational controls that can affect global runtime state. - Admins may perform manual unban or abuse review for ordinary anonymous/user subjects, but Owner/Admin subject relief, disabling auto-ban, weakening recovery protections, or changing privacy ceilings remains Owner-only. - Protected values remain write-only or status-only even for Owners unless a workflow explicitly implements a reveal flow with re-authentication, audit, and redaction rules. -- Permission-aware navigation is not the security boundary. Controllers, API handlers, live-operation starters, scheduler triggers, and service-layer workflows must all call the same action policy before mutating or revealing high-impact data. +- Permission-aware navigation is not the security boundary. Controllers, API handlers, live-operation starters, scheduler triggers, and service-layer workflows must all call the same action policy before mutating or revealing high-impact data. Responsibility decides the feature row: pending account-token review actions use the review permission even when rendered from user management, while direct user creation/editing/group membership uses the user-management permission. ACL rules are assigned by operational responsibility, not necessarily by the current view, menu, or route area that exposes a control. For example, editing ACL group definitions belongs to `admin.users.acl`, while adding or removing groups on a user account mutates that user account and belongs to `admin.users`. Review actions stay under their review permission even when rendered from a broader user-management view, so approving or rejecting pending registrations belongs to `admin.users.review`. Likewise, confirming a reviewed live operation must re-check the target operation's domain feature, such as `admin.packages`, instead of relying only on the Operations view permission. @@ -133,7 +133,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. -- 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. +- 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. - Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. diff --git a/src/Controller/AdminSchedulerController.php b/src/Controller/AdminSchedulerController.php index 90dcbf00..af7eda4f 100644 --- a/src/Controller/AdminSchedulerController.php +++ b/src/Controller/AdminSchedulerController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Entity\SchedulerTask; @@ -28,6 +29,8 @@ final class AdminSchedulerController extends AbstractController { + private const FEATURE = 'admin.scheduler'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly SchedulerTaskSynchronizer $synchronizer, @@ -36,6 +39,7 @@ public function __construct( private readonly SchedulerRunner $runner, private readonly HttpErrorRenderer $httpError, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -45,6 +49,9 @@ public function runNow(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $token = $request->request->get('_csrf_token'); if (!is_string($token) || !$this->isCsrfTokenValid('scheduler-task-run-'.$identifier, $token)) { @@ -77,6 +84,9 @@ public function index(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $tasks = $this->synchronizer->synchronize(); usort($tasks, static fn (SchedulerTask $left, SchedulerTask $right): int => $left->identifier() <=> $right->identifier()); @@ -95,6 +105,9 @@ public function detail(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: $request->isMethod('POST'))) { + return $response; + } $task = $this->registeredTask($identifier); @@ -115,6 +128,24 @@ public function detail(Request $request, string $identifier): Response 'task' => $task, 'runs' => $this->recentRuns($task), 'cron_run_url' => $this->generateUrl('scheduler_cron_run', ['job' => $task->identifier()], UrlGeneratorInterface::ABSOLUTE_URL), + 'scheduler_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), + ]); + } + + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable + ? $this->adminAcl->isMutable(self::FEATURE, $actor) + : $this->adminAcl->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->render(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 c3c52fb5..03ad3b8a 100644 --- a/src/Controller/AdminUserInvitationController.php +++ b/src/Controller/AdminUserInvitationController.php @@ -8,19 +8,13 @@ use App\Backend\BackendArea; use App\Core\Access\AccessActor; use App\Core\AdminAcl\AdminFeatureAccessPolicy; -use App\Core\Message\CommonMessageCode; -use App\Core\Message\Message; -use App\Entity\AccountToken; use App\Entity\UserAccount; -use App\Security\AccountTokenStatus; -use App\Security\AccountTokenType; use App\Security\AdminUserInvitationWorkflow; use App\Security\UserRole; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -34,7 +28,6 @@ public function __construct( private readonly AdminUserInvitationWorkflow $invitationWorkflow, private readonly UiAlertDispatcherInterface $alerts, private readonly AdminFeatureAccessPolicy $adminAcl, - private readonly EntityManagerInterface $entityManager, ) { } @@ -90,7 +83,7 @@ public function reissue(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } - if ($response = $this->featureResponse($request, $this->tokenActionFeature($uid))) { + if ($response = $this->featureResponse($request, 'admin.users.review')) { return $response; } @@ -112,7 +105,7 @@ public function revoke(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } - if ($response = $this->featureResponse($request, $this->tokenActionFeature($uid))) { + if ($response = $this->featureResponse($request, 'admin.users.review')) { return $response; } @@ -178,21 +171,6 @@ private function featureResponse(Request $request, string $feature): ?Response ]); } - private function tokenActionFeature(string $uid): string - { - $token = $this->entityManager->find(AccountToken::class, $uid); - - if ( - $token instanceof AccountToken - && AccountTokenType::Registration === $token->type() - && AccountTokenStatus::PendingApproval === $token->status() - ) { - return 'admin.users.review'; - } - - return 'admin.users'; - } - private function actor(): AccessActor { $user = $this->getUser(); diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 83bef982..201dfea1 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -74,9 +74,16 @@ public function allDefinitions(): array new CoreSettingDefinition('mail', 'mail.from_address', 'admin.settings.fields.mail_from_address.label', 'admin@localhost', ConfigValueType::String, validation: ['max_length' => 180], sortOrder: 20), new CoreSettingDefinition('mail', 'mail.from_name', 'admin.settings.fields.mail_from_name.label', $this->appName(), ConfigValueType::String, validation: ['max_length' => 120], sortOrder: 30), - new CoreSettingDefinition('security', 'security.captcha.enabled', 'admin.settings.fields.captcha_enabled.label', false, ConfigValueType::Boolean, sortOrder: 10), - new CoreSettingDefinition('security', 'security.captcha.provider', 'admin.settings.fields.captcha_provider.label', 'none', ConfigValueType::String, FormInputType::Select, options: ['none' => 'admin.settings.options.captcha.none'], validation: ['required' => true], sortOrder: 20), - new CoreSettingDefinition('security', 'security.captcha.preview', 'admin.settings.fields.captcha_preview.label', null, ConfigValueType::String, FormInputType::Captcha, metadata: ['persist' => false], sortOrder: 30), + new CoreSettingDefinition('security', 'security.captcha.enabled', 'admin.settings.fields.captcha_enabled.label', false, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 10), + new CoreSettingDefinition('security', 'security.captcha.provider', 'admin.settings.fields.captcha_provider.label', 'none', ConfigValueType::String, FormInputType::Select, options: ['none' => 'admin.settings.options.captcha.none'], validation: ['required' => true], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 20), + new CoreSettingDefinition('security', 'security.captcha.preview', 'admin.settings.fields.captcha_preview.label', null, ConfigValueType::String, FormInputType::Captcha, metadata: [ + 'persist' => false, + 'access_feature' => 'admin.settings.security', + ], sortOrder: 30), 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/templates/backend/admin/scheduler/detail.html.twig b/templates/backend/admin/scheduler/detail.html.twig index 661ed732..ff32f3a7 100644 --- a/templates/backend/admin/scheduler/detail.html.twig +++ b/templates/backend/admin/scheduler/detail.html.twig @@ -14,7 +14,7 @@ {% set run_now_action %}
- +
{% endset %} {% endif %} @@ -57,11 +57,11 @@
{{ 'admin.scheduler.legend.summary'|trans }} @@ -72,10 +72,10 @@ {% if task.type.value == 'action_queue' and not task.trusted %} {% endif %} - + diff --git a/templates/backend/admin/users/index.html.twig b/templates/backend/admin/users/index.html.twig index 8399a972..2e43af50 100644 --- a/templates/backend/admin/users/index.html.twig +++ b/templates/backend/admin/users/index.html.twig @@ -194,7 +194,7 @@ {% for token in pending_tokens %} - {% set token_revoke_mutable = token.status.value == 'pending_approval' ? reviews_mutable : users_mutable %} + {% set token_revoke_mutable = reviews_mutable %} {{ token.email }} {{ ('admin.users.token_type.' ~ token.type.value)|trans }} {{ ('admin.users.roles.' ~ token.role.value)|trans }} @@ -209,7 +209,7 @@ {% elseif token.status.value == 'pending' %}
- +
{% endif %}
diff --git a/tests/Controller/AdminSchedulerControllerTest.php b/tests/Controller/AdminSchedulerControllerTest.php index 1fdbdf90..d1e6b664 100644 --- a/tests/Controller/AdminSchedulerControllerTest.php +++ b/tests/Controller/AdminSchedulerControllerTest.php @@ -4,6 +4,8 @@ namespace App\Tests\Controller; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Entity\SchedulerTask; use App\Scheduler\SchedulerTaskDefinition; use App\Scheduler\SchedulerTaskStatus; @@ -20,6 +22,14 @@ public function testAdminSchedulerListsEditsAndRunsRegisteredJobs(): void { $client = self::createClient(); $this->loginUserWithLevel($client, 8); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); try { $crawler = $client->request('GET', '/admin/scheduler'); @@ -53,6 +63,62 @@ public function testAdminSchedulerListsEditsAndRunsRegisteredJobs(): void self::assertResponseIsSuccessful(); self::assertStringContainsString('Scheduler run completed with status completed.', (string) $client->getResponse()->getContent()); } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeSchedulerTasks(); + } + } + + public function testAdminSchedulerReadOnlyDisablesControlsAndRejectsMutations(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, 8); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $definition = SchedulerTaskDefinition::command( + 'system.live_operation_cleanup', + 'admin.scheduler.tasks.live_operation_cleanup.label', + 'admin.scheduler.tasks.live_operation_cleanup.description', + 'operations:cleanup', + '*/15 * * * *', + ); + $task = $entityManager->find(SchedulerTask::class, 'system.live_operation_cleanup') ?? new SchedulerTask($definition); + $task->syncDefinition($definition, new \DateTimeImmutable()); + $task->activate('*/15 * * * *'); + $entityManager->persist($task); + $entityManager->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $crawler = $client->request('GET', '/admin/scheduler/system.live_operation_cleanup'); + + self::assertResponseIsSuccessful(); + self::assertNotNull($crawler->filter('input[name="enabled"]')->attr('disabled')); + self::assertNotNull($crawler->filter('input[name="cron_expression"]')->attr('disabled')); + self::assertNotNull($crawler->selectButton('Save')->attr('disabled')); + self::assertNotNull($crawler->selectButton('Run now')->attr('disabled')); + + $client->request('POST', '/admin/scheduler/system.live_operation_cleanup', [ + '_csrf_token' => (string) $crawler->filter('form.system-form input[name="_csrf_token"]')->attr('value'), + 'enabled' => '1', + 'cron_expression' => '*/5 * * * *', + ]); + + self::assertResponseStatusCodeSame(401); + + $client->request('POST', '/admin/scheduler/system.live_operation_cleanup/run', [ + '_csrf_token' => (string) $crawler->filter('form[action="/admin/scheduler/system.live_operation_cleanup/run"] input[name="_csrf_token"]')->attr('value'), + ]); + + self::assertResponseStatusCodeSame(401); + $this->assertSchedulerTaskStatus('system.live_operation_cleanup', SchedulerTaskStatus::Active, '*/15 * * * *'); + } finally { + $store->save($store->defaultOverrides(), 'test'); $this->removeSchedulerTasks(); } } @@ -61,6 +127,14 @@ public function testAdminSchedulerRunNowSurfacesFailedTaskResults(): void { $client = self::createClient(); $this->loginUserWithLevel($client, 8); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); $entityManager = self::getContainer()->get(EntityManagerInterface::class); $definition = SchedulerTaskDefinition::command( 'system.live_operation_cleanup', @@ -89,6 +163,7 @@ public function testAdminSchedulerRunNowSurfacesFailedTaskResults(): void (string) $client->getResponse()->getContent(), ); } finally { + $store->save($store->defaultOverrides(), 'test'); $this->removeSchedulerTasks(); } } diff --git a/tests/Controller/AdminUserControllerTest.php b/tests/Controller/AdminUserControllerTest.php index a8f0151a..14756d4b 100644 --- a/tests/Controller/AdminUserControllerTest.php +++ b/tests/Controller/AdminUserControllerTest.php @@ -1374,6 +1374,74 @@ public function testLowerAccessAdminCannotRevokeOwnerRecoveryToken(): void $entityManager->flush(); } + public function testPendingAccountTokenActionsUseReviewFeatureWhenUsersFeatureIsReadOnly(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $admin = $this->createUser('reviewtokenactor', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + [$reissueToken] = self::getContainer()->get(AccountTokenIssuer::class)->issue( + AccountTokenType::Invitation, + 'review-reissue-browser@example.test', + [], + ttl: '-1 hour', + ); + [$revokeToken] = self::getContainer()->get(AccountTokenIssuer::class)->issue( + AccountTokenType::Invitation, + 'review-revoke-browser@example.test', + [], + ttl: '-1 hour', + ); + $originalHash = $reissueToken->tokenHash(); + $entityManager->persist($reissueToken); + $entityManager->persist($revokeToken); + $entityManager->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $crawler = $client->request('GET', '/admin/users'); + $reissueForm = $crawler->filter('form[action="/admin/users/invitations/'.$reissueToken->uid().'/reissue"]'); + $revokeForm = $crawler->filter('form[action="/admin/users/invitations/'.$revokeToken->uid().'/revoke"]'); + + self::assertCount(1, $reissueForm); + self::assertCount(1, $revokeForm); + self::assertNull($reissueForm->filter('button[type="submit"]')->attr('disabled')); + self::assertNull($revokeForm->filter('button[type="submit"]')->attr('disabled')); + + $client->submit($reissueForm->form()); + self::assertResponseRedirects('/admin/users'); + + $client->request('POST', '/admin/users/invitations/'.$revokeToken->uid().'/revoke', [ + '_csrf_token' => (string) $revokeForm->filter('input[name="_csrf_token"]')->attr('value'), + ]); + self::assertResponseRedirects('/admin/users'); + + $entityManager->clear(); + $reissuedToken = $entityManager->find(AccountToken::class, $reissueToken->uid()); + $revokedToken = $entityManager->find(AccountToken::class, $revokeToken->uid()); + + self::assertInstanceOf(AccountToken::class, $reissuedToken); + self::assertInstanceOf(AccountToken::class, $revokedToken); + self::assertNotSame($originalHash, $reissuedToken->tokenHash()); + self::assertSame(AccountTokenStatus::Revoked, $revokedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testPendingApprovalRevocationRequiresReviewFeatureEvenWithoutReviewReturnTarget(): void { $client = self::createClient(); diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index ad33de58..7cc3eaa8 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -101,6 +101,7 @@ public function testAdminLogsListSourcesAndSourceEntries(): void self::assertArrayNotHasKey('total_pages', $payload['meta']['pagination']); } finally { $store->save($store->defaultOverrides(), 'test'); + $this->removeSchedulerTasks(); } } @@ -139,6 +140,7 @@ public function testAdminLogsFeatureReadOnlyHidesSensitiveSources(): void self::assertSame('feature_read_only', $payload['error']['context']['reason']); } finally { $store->save($store->defaultOverrides(), 'test'); + $this->removeSchedulerTasks(); } } @@ -348,6 +350,48 @@ public function testAdminSchedulerTaskDetailAndPatchAreAvailable(): void } } + public function testTrustedPackageDiscoverySchedulerRunUsesSchedulerAclRatherThanPackageAcl(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopsschedpkg', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.packages' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('PATCH', '/api/v1/admin/scheduler/system.package_discovery', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'enabled' => true, + 'cron_expression' => '0 */6 * * *', + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + + $client->request('POST', '/api/v1/admin/scheduler/system.package_discovery/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('scheduler_run', $payload['data']['type']); + self::assertSame('/api/v1/admin/scheduler/system.package_discovery', $payload['data']['links']['task']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAdminSchedulerFeatureReadOnlyStillShowsTasksButRejectsMutations(): void { $client = self::createClient(); @@ -438,6 +482,13 @@ private function createPlainApiKey(string $prefix, ApiKeyStatus $status = ApiKey return $plainKey; } + private function removeSchedulerTasks(): void + { + $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $connection->executeStatement("DELETE FROM scheduler_task_run WHERE task_identifier LIKE 'system.%'"); + $connection->executeStatement("DELETE FROM scheduler_task WHERE source = 'system'"); + } + /** * @return array */ diff --git a/tests/Controller/ApiSettingsControllerTest.php b/tests/Controller/ApiSettingsControllerTest.php index 90f9e826..7f3f525b 100644 --- a/tests/Controller/ApiSettingsControllerTest.php +++ b/tests/Controller/ApiSettingsControllerTest.php @@ -113,18 +113,22 @@ public function testSettingsPatchPreservesSensitiveValuesWhenClientEchoesProtect self::assertInstanceOf(Config::class, $config); $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'stored-api-secret', ConfigValueType::String, sensitive: true); - $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - 'CONTENT_TYPE' => 'application/json', - ], content: json_encode([ - 'values' => [ - MaxMindGeoIpConfig::ENABLED_KEY => true, - MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', - ], - ], JSON_THROW_ON_ERROR)); - - self::assertResponseIsSuccessful(); - self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + try { + $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + MaxMindGeoIpConfig::ENABLED_KEY => true, + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } finally { + $this->removeApiKeyUser('apisetsecret'); + } } public function testGeoIpSettingsAreHiddenAndRejectedForDelegatedAdminApiKeys(): void @@ -158,6 +162,59 @@ public function testGeoIpSettingsAreHiddenAndRejectedForDelegatedAdminApiKeys(): self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); } + public function testSecuritySettingsSectionAclHidesAndRejectsSecurityFieldsForDelegatedAdminApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecadm', AccessLevel::ADMIN); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set('security.captcha.enabled', true, ConfigValueType::Boolean); + + $client->request('GET', '/api/v1/admin/settings/security', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(404); + + $client->request('PATCH', '/api/v1/admin/settings/security', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + 'security.captcha.enabled' => false, + 'security.captcha.provider' => 'none', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(404); + self::assertTrue($config->get('security.captcha.enabled')); + } + + public function testSecuritySettingsCanBeReadAndPatchedByOwnerApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecown', AccessLevel::OWNER); + + try { + $client->request('PATCH', '/api/v1/admin/settings/security', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + 'security.captcha.enabled' => true, + 'security.captcha.provider' => 'none', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertContains('security.captcha.enabled', $payload['meta']['updated_keys']); + self::assertContains('security.captcha.provider', $payload['meta']['updated_keys']); + } finally { + $this->removeApiKeyUser('apisetsecown'); + } + } + public function testSettingsPatchReturnsValidationErrors(): void { $client = self::createClient(); @@ -240,6 +297,13 @@ private function createPlainApiKey(ApiKeyStatus $status, string $prefix, int $ac return $plainKey; } + private function removeApiKeyUser(string $prefix): void + { + $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $connection->executeStatement('DELETE FROM api_key WHERE prefix = ?', [$prefix]); + $connection->executeStatement('DELETE FROM user_account WHERE username = ?', [$prefix.'user']); + } + /** * @param list> $resources * diff --git a/tests/Controller/ApiUserControllerTest.php b/tests/Controller/ApiUserControllerTest.php index ce7635a0..4258755f 100644 --- a/tests/Controller/ApiUserControllerTest.php +++ b/tests/Controller/ApiUserControllerTest.php @@ -668,6 +668,94 @@ public function testRegistrationReviewCanBeApprovedAndDeniedByToken(): void self::assertSame(AccountTokenStatus::Revoked, $revokedToken->status()); } + public function testOrdinaryAccountTokenReviewActionsUseReviewFeatureWhenUserFeatureIsReadOnly(): void + { + $client = self::createClient(); + $reissueToken = $this->createPendingAccountToken('api-review-reissue@example.test'); + $revokeToken = $this->createPendingAccountToken('api-review-revoke@example.test'); + $plainKey = $this->createPlainApiKey('apiusrrevtok', ApiKeyStatus::ReadWrite); + $originalHash = $reissueToken->tokenHash(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('POST', '/api/v1/admin/users/reviews/tokens/'.$reissueToken->uid().'/reissue?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('DELETE', '/api/v1/admin/users/reviews/tokens/'.$revokeToken->uid().'?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->clear(); + $reissuedToken = $entityManager->find(AccountToken::class, $reissueToken->uid()); + $revokedToken = $entityManager->find(AccountToken::class, $revokeToken->uid()); + self::assertInstanceOf(AccountToken::class, $reissuedToken); + self::assertInstanceOf(AccountToken::class, $revokedToken); + self::assertNotSame($originalHash, $reissuedToken->tokenHash()); + self::assertSame(AccountTokenStatus::Revoked, $revokedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeAccountTokens($reissueToken->uid(), $revokeToken->uid()); + } + } + + public function testOrdinaryAccountTokenReviewActionsRejectWhenReviewFeatureIsDenied(): void + { + $client = self::createClient(); + $token = $this->createPendingAccountToken('api-review-denied@example.test'); + $plainKey = $this->createPlainApiKey('apiusrrevdeny', ApiKeyStatus::ReadWrite); + $originalHash = $token->tokenHash(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('POST', '/api/v1/admin/users/reviews/tokens/'.$token->uid().'/reissue?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.users.review', $payload['error']['context']['feature']); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->clear(); + $unchangedToken = $entityManager->find(AccountToken::class, $token->uid()); + self::assertInstanceOf(AccountToken::class, $unchangedToken); + self::assertSame($originalHash, $unchangedToken->tokenHash()); + self::assertSame(AccountTokenStatus::Pending, $unchangedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeAccountTokens($token->uid()); + } + } + public function testOpenApiIncludesUsersEndpoint(): void { $client = self::createClient(); @@ -806,6 +894,36 @@ private function createPendingApprovalToken(string $email): AccountToken return $token; } + private function createPendingAccountToken(string $email, AccountTokenType $type = AccountTokenType::Invitation): AccountToken + { + $token = new AccountToken( + '69000000-0000-7000-8004-'.substr(md5($email), 0, 12), + hash('sha256', $email), + $type, + $email, + status: AccountTokenStatus::Pending, + ); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->persist($token); + $entityManager->flush(); + + return $token; + } + + private function removeAccountTokens(string ...$uids): void + { + if ([] === $uids) { + return; + } + + self::getContainer()->get(EntityManagerInterface::class)->getConnection()->executeStatement( + 'DELETE FROM account_token WHERE uid IN (?)', + [$uids], + [\Doctrine\DBAL\ArrayParameterType::STRING], + ); + } + /** * @return array */ diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 3e828ff8..74ac1bab 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -1227,6 +1227,61 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertStringNotContainsString('$_SERVER', (string) $client->getResponse()->getContent()); } + public function testSecuritySettingsSectionIsHiddenAndRejectsPostsForDelegatedAdmins(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + + $client->request('GET', '/admin/settings/security'); + + self::assertResponseStatusCodeSame(401); + + $client->request('POST', '/admin/settings/security', [ + '_form_id' => 'admin-settings-security', + '_csrf_token' => 'direct-post', + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + ]); + + self::assertResponseStatusCodeSame(401); + } + + public function testSchedulerSettingsReadOnlyDisablesFieldsAndRejectsPosts(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.settings.scheduler' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/admin/settings/scheduler'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form#admin-settings-scheduler'); + self::assertSelectorExists('input[name="scheduler.enabled"][disabled]'); + self::assertSelectorExists('input[name="scheduler.get_auth_enabled"][disabled]'); + self::assertSelectorExists('input[name="scheduler.package_action_queues_enabled"][disabled]'); + self::assertSelectorExists('input[name="scheduler.web_trigger_enabled"][disabled]'); + self::assertSelectorNotExists('form#admin-settings-scheduler button[type="submit"]'); + + $client->request('POST', '/admin/settings/scheduler', [ + '_form_id' => 'admin-settings-scheduler', + '_csrf_token' => 'direct-post', + 'scheduler.enabled' => '0', + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAclSettingsMatrixIsOwnerGatedAndRendersFeatureRegistry(): void { $client = self::createClient(); diff --git a/tests/Scheduler/SchedulerRunnerTest.php b/tests/Scheduler/SchedulerRunnerTest.php index ff92d2ae..8e233a04 100644 --- a/tests/Scheduler/SchedulerRunnerTest.php +++ b/tests/Scheduler/SchedulerRunnerTest.php @@ -249,7 +249,13 @@ public function testItTimestampsEachDueTaskWhenItStarts(): void $this->entityManager->flush(); $this->runner(new TestDelayedSchedulerTaskExecutor(), new TestMultipleSchedulerTaskProvider())->run(); - $runs = $this->entityManager->getRepository(SchedulerTaskRun::class)->findBy([], ['startedAt' => 'ASC']); + $runs = array_values(array_filter( + $this->entityManager->getRepository(SchedulerTaskRun::class)->findBy([], ['startedAt' => 'ASC']), + static fn (SchedulerTaskRun $run): bool => in_array($run->task()->identifier(), [ + 'system.first_task', + 'system.second_task', + ], true), + )); self::assertCount(2, $runs); self::assertGreaterThan( From 59ef450a0259b6425a757ab349e1ca0c2a6cf746 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Wed, 17 Jun 2026 01:14:54 +0200 Subject: [PATCH 082/119] 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 083/119] 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 084/119] 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 085/119] 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 086/119] 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 087/119] 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 088/119] 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 089/119] 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 090/119] 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 091/119] 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 092/119] 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 093/119] 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 094/119] 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 095/119] 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 096/119] 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 097/119] 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 098/119] 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 099/119] 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 100/119] 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 101/119] 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 102/119] 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 103/119] 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 104/119] 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 105/119] 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 106/119] 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 107/119] 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 108/119] 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 109/119] 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 110/119] 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 111/119] 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 112/119] 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 113/119] 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 114/119] 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 115/119] 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 116/119] 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 117/119] 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 118/119] 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 119/119] 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')));
     }
 }